AgentSkillsCN

frontend-dev

为Bashkirtseff AstroJS PWA开发前端。在构建UI功能、修复布局问题、添加组件、修改页面,或优化阅读体验时,可使用此技能。

SKILL.md
--- frontmatter
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).

code
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, BookSidebar, ReadingSettings, BackToTop
│   ├── filter/      # Tag filtering: FilterOverlay
│   ├── glossary/    # GlossarySearch, GlossaryCategoryBrowser
│   ├── layout/      # Header, Footer, UnifiedMenu, LocaleSwitcher, MobileMenu, FilterPanel, FilterButton
│   ├── 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:

SystemCzechFrenchEnglishOriginal French
UI Locale (ISO 639-1)csfrenN/A
Content Path (URLs)czfrenoriginal

URLs use /cz/ (not /cs/) to avoid breaking existing links. The original path maps to _original content directory.

Helpers (src/i18n/index.ts):

typescript
localeToContentPath('cs')  // → 'cz'
contentPathToLocale('cz')  // → 'cs'

Multi-Language Routing

All diary pages use [lang] parameterized routes driven by diary-lang-config.ts:

typescript
interface DiaryLanguageConfig {
  urlPath: string;         // 'cz', 'original'
  contentPath: string;     // 'cz', '_original'
  uiLocale: SupportedLocale; // 'cs' (SSR i18n)
  dateLocale: string;      // 'cs-CZ', 'fr-FR'
  contentLangAttr: string; // HTML lang attr: 'cs', 'fr'
  isTranslation: boolean;  // false only for 'original'
}

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 only cz and original are active; others are commented out in the config.

Content Loading (lib/content.ts)

All content is loaded at build time from /content/ directory (two levels up). Key functions:

typescript
// Navigation
getCarnets(language): CarnetInfo[]
getEntry(carnetId, entryId, language): DiaryEntry | null
getEntryNavigation(carnetId, entryDate, language): { prev, next }
getYears(language): YearInfo[]

// Glossary (with content fallback for translations)
getGlossaryEntryWithFallback(id, language): GlossaryEntry | null
getMergedGlossaryEntries(language): GlossaryEntry[]  // union of translated + original
searchGlossary(query, language): GlossaryEntry[]
buildGlossaryUsageCounts(): Record<string, number>   // cached

// Preface (special carnet 000)
getCarnet000Merged(language): DiaryEntry | null  // merges 000-01, 000-02, etc.

// Cross-year carnets
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>.

css
/* global.css */
[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

ComponentHydrationPurpose
FlipParagraph.vueclient:visible3D flip card: front = translation, back = original. Language icon button triggers flip.
ParagraphMenu.vueclient:visible::: button → bottom sheet with share, copy link, glossary tags, filter shortcuts
LanguageSwitcher.vueclient:loadSwitch between available entry languages; preserves scroll position by detecting visible paragraph
BookSidebar.vueclient:loadCollapsible sidebar: entry list, calendar, search. Pinned state in localStorage
ReadingSettings.vueFont size slider + theme buttons (embedded in UnifiedMenu)
BackToTop.vueclient:visibleScroll-to-top button

Navigation & Layout

ComponentTypePurpose
Header.astroStaticLogo, nav links, slots for Vue islands
UnifiedMenu.vueclient:loadCombined sidebar: reading settings + entry navigation + filter panel
LocaleSwitcher.vueclient:loadUI language buttons (cs/en/fr/uk), saves to localStorage
MobileMenu.vueclient:loadMobile hamburger nav drawer
FilterButton.vueclient:loadShows active filter count badge
FilterPanel.vueCategory tree with search, AND/OR toggle (embedded in UnifiedMenu)

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 FilterPanel → 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

ComponentPurpose
GlossarySearch.vueFuzzy search with scoring (exact > starts-with > contains), debounced 300ms
GlossaryCategoryBrowser.vueHierarchical category tree with usage counts, expand/collapse

PWA

ComponentPurpose
InstallPrompt.vue"Add to Home Screen" prompt
OfflineDownload.vueDownload year/carnet for offline reading (batch fetch + Cache API)
OfflineStatus.vueOnline/offline indicator

i18n Pattern

Server-side (Astro pages):

typescript
const t = createT(lang.uiLocale);  // from diary-lang-config
const label = t('diary.notebook');
const text = t('diary.completed', { percent: 85 });

Client-side (Vue components):

typescript
const { t, locale, setLocale } = useI18n();

Translation keys use dot-separated paths: header.siteTitle, diary.notebook, filter.and, glossary.search.

Limitation: Static pages without [lang] param (like Header.astro, Footer.astro) use t from i18n/astro.ts which always returns Czech. Vue islands correct this on hydration from localStorage.

localStorage Keys

KeyValuesUsed By
reading-themelight / sepia / darkBaseLayout, UnifiedMenu, ReadingSettings
reading-font-scalenumber (0.8-1.3)BaseLayout, UnifiedMenu
ui-languagecs / en / fr / ukBaseLayout, LocaleSwitcher, useI18n
sidebar-pinnedtrue / absentBaseLayout, BookSidebar
filter-tagsJSON Record<string, string[]>filter store
offline-downloadsJSON download recordsoffline 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/arts/, themes/daily_life/

Link format in content: [#Marie Bashkirtseff](../_glossary/people/core/MARIE_BASHKIRTSEFF.md)

Build & Development

bash
just fe-dev       # Dev server with hot reload
just fe-build     # Production build (~3 min, 11,000+ pages)
just fe-preview   # Preview production build

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

  1. Create .vue file in appropriate components/ subdirectory
  2. Use Composition API with <script setup lang="ts">
  3. Import in Astro page with appropriate hydration directive
  4. For i18n: const { t } = useI18n()
  5. For theme-aware styling: use CSS custom properties (var(--text-primary), var(--bg-primary))

Add a new diary page template

  1. Create under pages/[lang]/ using the parameterized routing pattern
  2. Define getStaticPaths() iterating DIARY_LANGUAGES
  3. Accept lang: DiaryLanguageConfig as prop
  4. Use createT(lang.uiLocale) for translations
  5. Conditional rendering: check lang.isTranslation for translation-specific UI

Add new i18n keys

  1. Add key to all 4 locale files: cs.json, en.json, fr.json, uk.json
  2. Czech (cs.json) is the primary — add the real translation
  3. Other locales: translate or use Czech as placeholder
  4. Use t('your.key') in Astro (SSR) or useI18n().t('your.key') in Vue (client)

Modify the theme

  1. Edit CSS custom properties in src/styles/global.css
  2. All three theme variants: [data-theme="light"], [data-theme="sepia"], [data-theme="dark"]
  3. Use semantic variables (--bg-primary, --text-primary, --color-accent) not hardcoded colors

Add a new content language

  1. Add entry to DIARY_LANGUAGES array in diary-lang-config.ts
  2. Pages auto-generate when content exists (empty carnets = no pages)
  3. No other code changes needed — templates are fully parameterized

Design Principles

  1. Reading first: Diary content is the star. UI should fade into the background.
  2. Marie's aesthetic: Elegant 19th-century palette (warm parchment, amber accent, Crimson Pro serif) but modern and clean.
  3. Performance: 11,000+ static pages must build fast. Keep JS minimal — use Astro static HTML where possible.
  4. Accessibility: Screen readers, keyboard nav, high contrast support.
  5. Offline-capable: PWA with service worker caching for reading without internet.

Pitfalls to Avoid

  • 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 i18nHeader.astro and Footer.astro render Czech on server. Vue islands fix this on hydration but there's a brief flash for non-Czech users.
  • preferences store is unused — theme/font are managed directly via localStorage in components. Don't add consumers without migrating existing code.
  • vue-i18n package is installed but unused — the app uses a custom i18n system (src/i18n/index.ts + astro.ts), not the vue-i18n library. Don't import from vue-i18n.