| name | frontend-dev |
| description | Frontend development for the Bashkirtseff AstroJS PWA. Use when building UI features, fixing layout issues, adding components, modifying pages, or working on the reading experience. |
| allowed-tools | Read, Write, Edit, Grep, Glob, Bash, WebSearch, AskUserQuestion |
Frontend Developer
You develop and maintain the AstroJS Progressive Web App for reading Marie Bashkirtseff's diary at https://bashkirtseff.org.
Architecture Overview
Stack: AstroJS 5.x (static site generation) + Vue 3 islands + Tailwind CSS v4 + Pinia + PWA (Workbox)
The frontend generates 11,000+ static HTML pages from markdown content in /content/. Interactive features use Vue 3 components hydrated as Astro islands. The site supports multiple content languages (Czech translation, French original) and multiple UI languages (cs, en, fr, uk).
src/frontend/src/
โโโ pages/ # Route pages (Astro, auto-generates URLs)
โ โโโ [lang]/ # Unified diary routes (cz, original, en, uk, fr)
โ โ โโโ index.astro # Year overview (1873-1884)
โ โ โโโ [year]/index.astro # Carnets in a year
โ โ โโโ [carnet]/index.astro # Entries in a carnet
โ โ โโโ [carnet]/[entry].astro # Individual diary entry
โ โ โโโ 000/index.astro # Preface (special carnet)
โ โ โโโ carnets.astro # Flat carnet list (translations only)
โ โ โโโ glossary/ # Glossary (index, [id], [letter])
โ โโโ home/[lang].astro # Homepage
โ โโโ [lang]/about.astro # About page
โ โโโ [lang]/marie.astro # Biography
โ โโโ api/glossary/[id].json.ts # Glossary API endpoint
โ โโโ offline.astro # PWA offline fallback
โโโ components/
โ โโโ reading/ # Core reading: FlipParagraph, ParagraphMenu, LanguageSwitcher, ReadingSettings, BackToTop
โ โโโ filter/ # Tag filtering: FilterOverlay
โ โโโ glossary/ # GlossarySearch, GlossaryCategoryBrowser
โ โโโ layout/ # Header, HeaderNav, Footer, UnifiedMenu, LocaleSwitcher, HistoryTracker
โ โโโ pwa/ # InstallPrompt, OfflineDownload, OfflineStatus
โ โโโ home/ # ThisDayEntry
โโโ layouts/ # BaseLayout.astro (root HTML), ReadingLayout.astro (diary pages)
โโโ lib/ # Utilities
โ โโโ content.ts # Content loading engine (entries, carnets, glossary)
โ โโโ diary-lang-config.ts # Language registry (DIARY_LANGUAGES, helpers)
โ โโโ glossary-categories.ts # Category icons, colors, subcategory definitions
โ โโโ offline.ts # Download/cache utilities
โโโ stores/ # Pinia stores
โ โโโ filter.ts # Tag filtering state (AND/OR, selectedTags, matchingEntries)
โ โโโ offline.ts # Download manager (scope-based caching)
โ โโโ preferences.ts # Theme, font size, UI language (currently unused by components)
โโโ i18n/
โ โโโ index.ts # Vue composable: useI18n(), locale management
โ โโโ astro.ts # SSR: createT(locale) for Astro pages
โ โโโ locales/ # cs.json, en.json, fr.json, uk.json
โโโ types/
โ โโโ filter-index.ts # FilterEntryRecord, FilterCategory, FilterTag, FilterIndex
โโโ scripts/
โ โโโ footnote-popover.ts # Footnote hover/click behavior
โโโ styles/
โ โโโ global.css # Tailwind v4 imports, theme variables, utility classes
โโโ vue-app.ts # Vue entry point (installs Pinia)
Two Language Code Systems
CRITICAL โ the app uses two distinct language code systems:
| System | Czech | French | English | Original French |
|---|
| UI Locale (ISO 639-1) | cs | fr | en | N/A |
| Content Path (URLs) | cz | fr | en | original |
URLs use /cz/ (not /cs/) to avoid breaking existing links. The original path maps to _original content directory.
Helpers (src/i18n/index.ts):
localeToContentPath('cs')
contentPathToLocale('cz')
Multi-Language Routing
All diary pages use [lang] parameterized routes driven by diary-lang-config.ts:
interface DiaryLanguageConfig {
urlPath: string;
contentPath: string;
uiLocale: SupportedLocale;
dateLocale: string;
contentLangAttr: string;
isTranslation: boolean;
}
isTranslation controls rendering:
true โ FlipParagraph (original/translation toggle), progress bars, FR badges, translation stats
false โ Plain <p lang="fr"> text, no translation UI
Helper functions:
diaryUrl(lang, ...segments) โ e.g., /cz/001/1873-01-11
glossaryUrl(lang, id) โ e.g., /cz/glossary/MARIE_BASHKIRTSEFF
toGlossaryId(name) โ MARIE_BASHKIRTSEFF
Each page's getStaticPaths() iterates DIARY_LANGUAGES to generate paths for all configured languages. Currently active: cz, original, en, uk, fr.
Content Loading (lib/content.ts)
All content is loaded at build time from /content/ directory (two levels up). Key functions:
getCarnets(language): CarnetInfo[]
getEntry(carnetId, entryId, language): DiaryEntry | null
getEntryNavigation(carnetId, entryDate, language): { prev, next }
getYears(language): YearInfo[]
getGlossaryEntryWithFallback(id, language): GlossaryEntry | null
getMergedGlossaryEntries(language): GlossaryEntry[]
searchGlossary(query, language): GlossaryEntry[]
buildGlossaryUsageCounts(): Record<string, number>
getCarnet000Merged(language): DiaryEntry | null
isCarnetCrossYear(carnetId, language): { crossYear, years }
Content parsing handles:
- Paragraph IDs:
%% 001.0001 %% markers
- Glossary tags:
[#Name](../_glossary/path/ID.md) โ GlossaryTag[]
- Footnotes:
[^id]: definition โ popover on click
- Foreign text:
==highlighted== โ <span class="foreign-text">
- Original text extraction: for FlipParagraph translation/original toggle
Theme System
Three themes: light, sepia, dark. Controlled via data-theme attribute on <html>.
[data-theme="light"] { --bg-primary: #FFF8F0; --text-primary: #2C1810; }
[data-theme="sepia"] { --bg-primary: #F5E6D3; ... }
[data-theme="dark"] { --bg-primary: #1a1a1a; --text-primary: #e5e5e5; }
Pre-paint script in BaseLayout.astro reads localStorage before first render to prevent flash:
localStorage['reading-theme'] โ data-theme
localStorage['reading-font-scale'] โ --reading-font-scale
localStorage['ui-language'] โ i18n locale
localStorage['sidebar-pinned'] โ sidebar-pinned class on <html>
Theme/font changes are managed directly via localStorage + DOM in ReadingSettings.vue and UnifiedMenu.vue (the preferences Pinia store exists but is currently unused by components).
Key Components
Reading Experience
| Component | Hydration | Purpose |
|---|
FlipParagraph.vue | client:visible | 3D flip card: front = translation, back = original. Language icon button triggers flip. |
ParagraphMenu.vue | client:visible | ::: button โ bottom sheet with share, copy link, glossary tags, filter shortcuts |
LanguageSwitcher.vue | client:load | Switch between available content languages on entry pages; preserves scroll position via paragraph tracking. Shows CZ, EN, UK, FR and globe icon for Original. |
ContentLanguageSwitcher.vue | client:load | Switch between content languages on browsing pages (year overview, year detail, carnet detail). Simpler than LanguageSwitcher โ just swaps the lang prefix in the URL. |
ContinueReading.vue | client:load | Shows "Continue reading" button when user has a saved reading position (from history store) for the current carnet or year. Links to last-read paragraph. |
| (contents/sidebar) | โ | Entry list + calendar live in UnifiedMenu.vue (standalone BookSidebar.vue was dead code, removed 2026-07-03) |
ReadingSettings.vue | โ | Font size slider + theme buttons (embedded in UnifiedMenu) |
BackToTop.vue | client:visible | Scroll-to-top button |
Navigation & Layout
| Component | Type | Purpose |
|---|
Header.astro | Static | Logo, nav links, slots for Vue islands |
UnifiedMenu.vue | client:load | Combined sidebar: reading settings + entry navigation + filter panel |
LocaleSwitcher.vue | client:load | UI language switcher (cs/en/fr/uk). Changes UI text only, NOT content language. Saves preference to localStorage and reloads page. |
HeaderNav.vue | via Header.astro | Header nav links incl. mobile nav (there is no separate MobileMenu/FilterButton component โ both were phantoms removed from these docs 2026-07-06) |
HistoryTracker.vue | via ReadingLayout.astro | Listens for paragraph-read/glossary-visit events, records them to the history Pinia store |
| (filter UI) | โ | Category tree with search, AND/OR toggle โ an inline section of UnifiedMenu.vue (the standalone FilterPanel.vue was dead code, removed 2026-07-03) |
Filter System
FilterOverlay.vue (client:load) applies DOM-level filtering on carnet/entry lists:
- Loads
/data/filter-index.json (~330KB raw, ~50-80KB gzipped)
- Tags selected in the UnifiedMenu filter section โ stored in
filter Pinia store โ persisted to localStorage['filter-tags']
- CSS classes:
.filter-hidden (hide), .filter-dimmed (opacity: 0.2), .filter-match (accent left border)
data-filter-carnet, data-filter-entry attributes on list items enable DOM targeting
Glossary
| Component | Purpose |
|---|
GlossarySearch.vue | Fuzzy search with scoring (exact > starts-with > contains), debounced 300ms |
GlossaryCategoryBrowser.vue | Hierarchical category tree with usage counts, expand/collapse |
PWA
| Component | Purpose |
|---|
InstallPrompt.vue | "Add to Home Screen" prompt |
OfflineDownload.vue | Download year/carnet for offline reading (batch fetch + Cache API) |
OfflineStatus.vue | Online/offline indicator |
i18n Pattern
Server-side (Astro pages):
const t = createT(lang.uiLocale);
const label = t('diary.notebook');
const text = t('diary.completed', { percent: 85 });
Client-side (Vue components):
const { t, locale, setLocale } = useI18n();
Translation keys use dot-separated paths: header.siteTitle, diary.notebook, filter.and, glossary.search.
Header/Footer i18n: Header.astro and Footer.astro accept an optional locale prop. ReadingLayout.astro derives the locale from its lang prop (via contentPathToLocale()) and passes it to both. All [lang] pages pass lang to ReadingLayout, so header/footer render in the correct language. The home/[lang].astro page passes locale directly.
localStorage Keys
| Key | Values | Used By |
|---|
reading-theme | light / sepia / dark | BaseLayout, UnifiedMenu, ReadingSettings |
reading-font-scale | number (0.8-1.3) | BaseLayout, UnifiedMenu |
ui-language | cs / en / fr / uk | BaseLayout, LocaleSwitcher, useI18n |
sidebar-pinned | true / absent | BaseLayout (legacy of removed BookSidebar) |
filter-tags | JSON Record<string, string[]> | filter store |
offline-downloads | JSON download records | offline store |
Paragraph ID Format
CCC.PPPP โ 3-digit carnet + 4-digit sequential paragraph (never resets within carnet).
Example: Carnet 002 runs 002.0001 to 002.2453.
HTML anchors: #p-002-0001 (dots replaced with dashes).
Glossary ID Format
CAPITAL_ASCII โ uppercase letters, underscores, no spaces or accents.
Example: MARIE_BASHKIRTSEFF, PROMENADE_DES_ANGLAIS
Categories: people/core/, people/family/, places/cities/, culture/art/, culture/daily_life/ (full list: content/_original/_glossary/_categories.yaml โ there is no top-level themes/; themes live under culture/themes/)
Link format in content: [#Marie Bashkirtseff](../_glossary/people/core/MARIE_BASHKIRTSEFF.md)
Build & Development
just fe-dev
just fe-build
just fe-preview
Config files:
astro.config.mjs โ redirects, PWA manifest, workbox caching, Vite plugins
tailwind.config.mjs โ Tailwind v4 config
vue-app.ts โ Vue entry point (installs Pinia only)
Build output: All routes pre-rendered to static HTML. Service worker (Workbox) precaches shell, runtime-caches diary entries (NetworkFirst, 90 days), fonts (CacheFirst, 1 year).
Common Development Tasks
Add a new Vue component
- Create
.vue file in appropriate components/ subdirectory
- Use Composition API with
<script setup lang="ts">
- Import in Astro page with appropriate hydration directive
- For i18n:
const { t } = useI18n()
- For theme-aware styling: use CSS custom properties (
var(--text-primary), var(--bg-primary))
Add a new diary page template
- Create under
pages/[lang]/ using the parameterized routing pattern
- Define
getStaticPaths() iterating DIARY_LANGUAGES
- Accept
lang: DiaryLanguageConfig as prop
- Use
createT(lang.uiLocale) for translations
- Conditional rendering: check
lang.isTranslation for translation-specific UI
Add new i18n keys
- Add key to all 4 locale files:
cs.json, en.json, fr.json, uk.json
- Czech (
cs.json) is the primary โ add the real translation
- Other locales: translate or use Czech as placeholder
- Use
t('your.key') in Astro (SSR) or useI18n().t('your.key') in Vue (client)
Modify the theme
- Edit CSS custom properties in
src/styles/global.css
- All three theme variants:
[data-theme="light"], [data-theme="sepia"], [data-theme="dark"]
- Use semantic variables (
--bg-primary, --text-primary, --color-accent) not hardcoded colors
Add a new content language
- Add entry to
DIARY_LANGUAGES array in diary-lang-config.ts
- Pages auto-generate when content exists (empty carnets = no pages)
- No other code changes needed โ templates are fully parameterized
Design Principles
- Reading first: Diary content is the star. UI should fade into the background.
- Marie's aesthetic: Elegant 19th-century palette (warm parchment, amber accent, Crimson Pro serif) but modern and clean.
- Performance: 11,000+ static pages must build fast. Keep JS minimal โ use Astro static HTML where possible.
- Accessibility: Screen readers, keyboard nav, high contrast support.
- Offline-capable: PWA with service worker caching for reading without internet.
Pitfalls to Avoid
-
[year]/index.astro and [carnet]/index.astro are near-twins โ summary
sidebars, location links, and heading structures are byte-similar. A fix
applied to one almost always belongs in the other; grep for the sibling
before considering the fix done (missed twice in one week, both caught only
by external review).
-
Verify a component is actually imported before investing work in it โ
FilterPanel.vue and BookSidebar.vue sat in these docs and the codebase as
apparent live components while UnifiedMenu's inline sections had superseded
them; a full dialog-a11y migration was applied to dead code before a
reviewer caught it. grep -rn "ComponentName" src/ first.
-
Get an external review pass (e.g. /codex:rescue) on substantial frontend
changes before pushing โ across the a11y wave, 4 of 5 review rounds found
real defects the implementer + automated gates missed.
-
Don't shadow lang โ page templates receive lang: DiaryLanguageConfig as prop. Inside .map() callbacks, use different variable names (e.g., l, langCode).
-
Astro redirects with dynamic params don't work as config entries โ create redirect pages with getStaticPaths() instead.
-
glossaryUrl(lang, id) must be used for all glossary links to ensure correct /{urlPath}/glossary/{id} paths.
-
Route conflicts โ if [lang] pages and fixed-path pages both generate the same URL, Astro warns. Delete old fixed-path files when migrating to [lang].
-
SSR vs client i18n โ Header.astro and Footer.astro accept locale prop from ReadingLayout. All [lang] pages pass lang so SSR renders the correct locale. Only pages that don't pass lang (if any) fall back to Czech.
-
ThisDayEntry links โ When an entry doesn't exist in the current translation, the "read full entry" link falls back to /original/ instead of linking to a non-existent translation page (which would redirect). The date is also a clickable link to the entry.