feat(@projects/@magic-civilization): ✨ add welcome modal e2e test
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
979abea2e0
commit
b5208e9c37
2 changed files with 337 additions and 124 deletions
139
public/games/age-of-dwarves/guide/e2e/welcome.spec.ts
Normal file
139
public/games/age-of-dwarves/guide/e2e/welcome.spec.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import type { Page, ConsoleMessage } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* WelcomeModal → themed HomePage walkthrough (objective p2-29, last bullet).
|
||||
*
|
||||
* Loads `/` WITHOUT `?skip=welcome` so the modal actually renders, picks
|
||||
* Dwarf + Female + a distinctive ruler name, clicks "Enter the Guide", and
|
||||
* asserts the resulting HomePage is themed with the chosen name (inside
|
||||
* `<RaceHighlight>`) and Dwarf vocabulary — and leaks no Game 2/3 scope
|
||||
* text (the `<EpisodeGate min={2}>` block must stay dark in the default
|
||||
* prod build where `VITE_DEV_GUIDE` is unset).
|
||||
*
|
||||
* Runs against the default prod build (`CI=1`, `VITE_DEV_GUIDE=0`) so the
|
||||
* scope-hygiene assertions are valid — see `scope-hygiene.spec.ts` for the
|
||||
* same `CI=1` invocation pattern.
|
||||
*
|
||||
* Run: `pnpm --prefix public/games/age-of-dwarves/guide test:e2e --grep welcome`
|
||||
*/
|
||||
|
||||
const RULER_NAME = 'Brenna Ironshield'
|
||||
|
||||
// Subset of `scope-hygiene.spec.ts` FORBIDDEN_SUBSTRINGS — the Game 2/3
|
||||
// strings the task brief explicitly calls out for the HomePage assertion.
|
||||
const FORBIDDEN_PATTERNS: readonly RegExp[] = [
|
||||
/magic schools/i,
|
||||
/High Archon/i,
|
||||
/ley lines/i,
|
||||
/Archon Telepathy/i,
|
||||
] as const
|
||||
|
||||
const IGNORED_ERROR_PATTERNS: readonly RegExp[] = [
|
||||
/Download the React DevTools/,
|
||||
]
|
||||
|
||||
interface ErrorCapture {
|
||||
readonly pageErrors: string[]
|
||||
readonly consoleErrors: string[]
|
||||
}
|
||||
|
||||
function attachErrorCapture(page: Page): ErrorCapture {
|
||||
const pageErrors: string[] = []
|
||||
const consoleErrors: string[] = []
|
||||
|
||||
page.on('pageerror', (err: Error) => {
|
||||
pageErrors.push(`${err.name}: ${err.message}`)
|
||||
})
|
||||
|
||||
page.on('console', (msg: ConsoleMessage) => {
|
||||
if (msg.type() !== 'error') return
|
||||
const text = msg.text()
|
||||
if (IGNORED_ERROR_PATTERNS.some((re) => re.test(text))) return
|
||||
consoleErrors.push(text)
|
||||
})
|
||||
|
||||
return { pageErrors, consoleErrors }
|
||||
}
|
||||
|
||||
test.describe('welcome modal flow', () => {
|
||||
test('Dwarf + Female + named ruler → themed HomePage with Dwarf vocabulary', async ({ page }) => {
|
||||
const cap = attachErrorCapture(page)
|
||||
|
||||
// 1. Fresh load WITHOUT ?skip=welcome so the modal actually renders.
|
||||
await page.goto('/', { waitUntil: 'networkidle', timeout: 30_000 })
|
||||
|
||||
// 2. Wait for the modal header to appear. `WelcomeModal` doesn't set
|
||||
// role="dialog" on its styled Panel, so anchor on the stable game
|
||||
// title string that only lives inside the modal header.
|
||||
const modalTitle = page.getByRole('heading', { name: /Magic\s*&\s*Civilization/i })
|
||||
await expect(modalTitle).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.getByText('Player Guide — Settings')).toBeVisible()
|
||||
|
||||
// 3. Pick Dwarf. The race OptionButton renders its `opt.label` ("Dwarf"
|
||||
// per the ruler-names data) alongside the race icon; getByRole
|
||||
// narrowing keeps us away from any stray text matches elsewhere.
|
||||
await page.getByRole('button', { name: /^Dwarf$/ }).click()
|
||||
|
||||
// 4. Pick Female.
|
||||
await page.getByRole('button', { name: /^Female$/ }).click()
|
||||
|
||||
// 5. Type a distinctive ruler name into the name input. The placeholder
|
||||
// is the stable locator — the input has no aria-label.
|
||||
const nameInput = page.getByPlaceholder('Leave blank to use the default leader name')
|
||||
await expect(nameInput).toBeVisible()
|
||||
await nameInput.fill(RULER_NAME)
|
||||
await expect(nameInput).toHaveValue(RULER_NAME)
|
||||
|
||||
// 6. Click "Enter the Guide" to confirm.
|
||||
await page.getByRole('button', { name: 'Enter the Guide' }).click()
|
||||
|
||||
// 7. Wait for HomePage to render post-modal. The Hero's <h1> "Magic
|
||||
// Civilization" is the stable anchor, and the modal should be gone.
|
||||
await expect(modalTitle).toBeHidden({ timeout: 10_000 })
|
||||
await expect(page.getByRole('heading', { level: 1, name: /^Magic Civilization$/ })).toBeVisible()
|
||||
|
||||
// 8. LoreSection eyebrow reads "Your Story Begins" (per HomePage.tsx).
|
||||
await expect(page.getByText('Your Story Begins', { exact: true })).toBeVisible()
|
||||
|
||||
// Read all visible body text once for the remaining content assertions.
|
||||
const bodyText = await page.locator('body').innerText()
|
||||
|
||||
// 9. The chosen ruler name is rendered inside the LoreSection (the
|
||||
// `<RaceHighlight>{name}</RaceHighlight>` span).
|
||||
expect(
|
||||
bodyText,
|
||||
`HomePage should render ruler name "${RULER_NAME}" inside <RaceHighlight>`,
|
||||
).toContain(RULER_NAME)
|
||||
|
||||
// 9b. Concretely assert the name lands inside a <RaceHighlight> (a
|
||||
// styled <strong>) — the theming claim, not just a body-text match.
|
||||
await expect(
|
||||
page.locator('strong', { hasText: RULER_NAME }).first(),
|
||||
).toBeVisible()
|
||||
|
||||
// 10. Dwarf vocabulary appears in the body prose ("Dwarves" / "Dwarven").
|
||||
expect(
|
||||
bodyText,
|
||||
'HomePage body should use Dwarf vocabulary (Dwarf/Dwarves/Dwarven)',
|
||||
).toMatch(/Dwar(f|ves|ven)/i)
|
||||
|
||||
// 11. No Game 2/3 scope leaks (EpisodeGate min={2} must be dark).
|
||||
for (const pat of FORBIDDEN_PATTERNS) {
|
||||
expect(
|
||||
bodyText,
|
||||
`HomePage leaked Game 2/3 content matching ${pat}`,
|
||||
).not.toMatch(pat)
|
||||
}
|
||||
|
||||
// 12. Zero console errors throughout the walkthrough.
|
||||
const errors = [
|
||||
...cap.pageErrors.map((e) => `[pageerror] ${e}`),
|
||||
...cap.consoleErrors.map((e) => `[console.error] ${e}`),
|
||||
]
|
||||
expect(
|
||||
errors,
|
||||
`welcome-modal flow emitted runtime errors:\n${errors.join('\n')}`,
|
||||
).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,15 +1,36 @@
|
|||
import { useState, useMemo, type ReactElement } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, type ReactElement } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { FadeIn } from '../../components/ui/FadeIn'
|
||||
import { PageTitle, Section, SectionHeading, Prose, FilterRow, FilterChip } from '../../components/ui/PagePrimitives'
|
||||
import {
|
||||
PageTitle, Section, SectionHeading, Prose,
|
||||
FilterRow, FilterChip,
|
||||
DataCard, StatsGrid, StatCell, QualityIndicator,
|
||||
} from '../../components/ui/PagePrimitives'
|
||||
import { Heading, Text } from '@lilith/ui-typography'
|
||||
import { ALL_BIOMES, BIOME_CATEGORIES, tierColorMap, tierLabelMap } from '../../data/ecology'
|
||||
import { useUrlFilter } from '../../hooks/useUrlFilter'
|
||||
import { ALL_BIOMES, BIOME_MAP, BIOME_CATEGORIES, tierLabelMap } from '../../data/ecology'
|
||||
import type { BiomeDisplay, QualityTierDef } from '../../data/ecology'
|
||||
|
||||
interface Props {
|
||||
tierDefs: QualityTierDef[]
|
||||
}
|
||||
|
||||
/** Category filter value — either the sentinel `all` or a biome category id. */
|
||||
const ALL_CATEGORY = 'all' as const
|
||||
type CategoryValue = typeof ALL_CATEGORY | BiomeDisplay['category']
|
||||
|
||||
const CATEGORY_VALUES: readonly CategoryValue[] = [
|
||||
ALL_CATEGORY,
|
||||
...BIOME_CATEGORIES.map((c) => c.id),
|
||||
]
|
||||
|
||||
function clampTier(q: number): 1 | 2 | 3 | 4 | 5 {
|
||||
if (q <= 1) return 1
|
||||
if (q >= 5) return 5
|
||||
return q as 1 | 2 | 3 | 4 | 5
|
||||
}
|
||||
|
||||
function BiomePieChart({ biomes }: { biomes: BiomeDisplay[] }): ReactElement {
|
||||
const total = biomes.length
|
||||
let currentAngle = 0
|
||||
|
|
@ -36,16 +57,16 @@ function BiomePieChart({ biomes }: { biomes: BiomeDisplay[] }): ReactElement {
|
|||
|
||||
return (
|
||||
<PieContainer>
|
||||
<svg viewBox="0 0 100 100" width="280" height="280">
|
||||
<PieSvg viewBox="0 0 100 100" width="280" height="280">
|
||||
{slices.map(({ biome, d }) => (
|
||||
<path key={biome.id} d={d} fill={biome.color} stroke="#1a1510" strokeWidth="0.3">
|
||||
<path key={biome.id} d={d} fill={biome.color}>
|
||||
<title>{biome.name}</title>
|
||||
</path>
|
||||
))}
|
||||
</svg>
|
||||
</PieSvg>
|
||||
<PieLegend>
|
||||
{BIOME_CATEGORIES.map(cat => {
|
||||
const count = biomes.filter(b => b.category === cat.id).length
|
||||
{BIOME_CATEGORIES.map((cat) => {
|
||||
const count = biomes.filter((b) => b.category === cat.id).length
|
||||
if (count === 0) return null
|
||||
return (
|
||||
<PieLegendItem key={cat.id}>
|
||||
|
|
@ -59,74 +80,125 @@ function BiomePieChart({ biomes }: { biomes: BiomeDisplay[] }): ReactElement {
|
|||
)
|
||||
}
|
||||
|
||||
function BiomeCard({ biome, colorMap, labelMap }: { biome: BiomeDisplay; colorMap: Record<number, string>; labelMap: Record<number, string> }): ReactElement {
|
||||
interface BiomeCardProps {
|
||||
biome: BiomeDisplay
|
||||
labelMap: Record<number, string>
|
||||
highlighted: boolean
|
||||
onOpen: (id: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function BiomeCard({ biome, labelMap, highlighted, onOpen, onClose }: BiomeCardProps): ReactElement {
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (highlighted && ref.current) {
|
||||
ref.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}, [highlighted])
|
||||
|
||||
const [qMin, qMax] = biome.quality_range
|
||||
const qMaxTier = clampTier(qMax)
|
||||
const qLabel = qMin === qMax
|
||||
? `T${qMin} ${labelMap[qMin] ?? ''}`.trim()
|
||||
: `T${qMin}–T${qMax}`
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<BiomeSwatch style={{ background: biome.color }} />
|
||||
<CardTitle>{biome.name}</CardTitle>
|
||||
<BiomeDataCard
|
||||
ref={ref}
|
||||
$variant="compact"
|
||||
$highlighted={highlighted}
|
||||
onClick={() => (highlighted ? onClose() : onOpen(biome.id))}
|
||||
role="button"
|
||||
aria-pressed={highlighted}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
if (highlighted) onClose()
|
||||
else onOpen(biome.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<BiomeHeader>
|
||||
<BiomeColorDot style={{ background: biome.color }} />
|
||||
<BiomeName>{biome.name}</BiomeName>
|
||||
<CategoryTag>{biome.category}</CategoryTag>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<StatRow>
|
||||
<StatLabel>Quality Range</StatLabel>
|
||||
<QualityRange>
|
||||
{Array.from({ length: biome.quality_range[1] - biome.quality_range[0] + 1 }, (_, i) => {
|
||||
const q = biome.quality_range[0] + i
|
||||
return (
|
||||
<QualityDot key={q} style={{ background: colorMap[q] }} title={`T${q} ${labelMap[q]}`}>
|
||||
{q}
|
||||
</QualityDot>
|
||||
)
|
||||
})}
|
||||
</QualityRange>
|
||||
</StatRow>
|
||||
<StatRow>
|
||||
<StatLabel>Temperature</StatLabel>
|
||||
<StatValue>{biome.temp_range[0].toFixed(2)} - {biome.temp_range[1].toFixed(2)}</StatValue>
|
||||
</StatRow>
|
||||
<StatRow>
|
||||
<StatLabel>Moisture</StatLabel>
|
||||
<StatValue>{biome.moisture_range[0].toFixed(2)} - {biome.moisture_range[1].toFixed(2)}</StatValue>
|
||||
</StatRow>
|
||||
<StatRow>
|
||||
<StatLabel>Fauna Capacity</StatLabel>
|
||||
<StatValue>{biome.fauna_capacity}</StatValue>
|
||||
</StatRow>
|
||||
<FloraBar>
|
||||
<FloraSegment
|
||||
style={{ width: `${biome.flora_climax.canopy * 100}%`, background: '#1a9928' }}
|
||||
title={`Canopy: ${biome.flora_climax.canopy}`}
|
||||
/>
|
||||
<FloraSegment
|
||||
style={{ width: `${biome.flora_climax.undergrowth * 100}%`, background: '#8cc634' }}
|
||||
title={`Undergrowth: ${biome.flora_climax.undergrowth}`}
|
||||
/>
|
||||
<FloraSegment
|
||||
style={{ width: `${biome.flora_climax.fungi * 100}%`, background: '#9040a0' }}
|
||||
title={`Fungi: ${biome.flora_climax.fungi}`}
|
||||
/>
|
||||
</FloraBar>
|
||||
<FloraLabels>
|
||||
<span>Canopy {biome.flora_climax.canopy}</span>
|
||||
<span>Undergrowth {biome.flora_climax.undergrowth}</span>
|
||||
<span>Fungi {biome.flora_climax.fungi}</span>
|
||||
</FloraLabels>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</BiomeHeader>
|
||||
<StatsGrid>
|
||||
<StatCell>
|
||||
<StatCell.Label>Quality</StatCell.Label>
|
||||
<StatCell.Value>
|
||||
<QualityLine>
|
||||
<QualityIndicator $tier={qMaxTier} />
|
||||
<span>{qLabel}</span>
|
||||
</QualityLine>
|
||||
</StatCell.Value>
|
||||
</StatCell>
|
||||
<StatCell>
|
||||
<StatCell.Label>Temperature</StatCell.Label>
|
||||
<StatCell.Value>{biome.temp_range[0].toFixed(2)} – {biome.temp_range[1].toFixed(2)}</StatCell.Value>
|
||||
</StatCell>
|
||||
<StatCell>
|
||||
<StatCell.Label>Moisture</StatCell.Label>
|
||||
<StatCell.Value>{biome.moisture_range[0].toFixed(2)} – {biome.moisture_range[1].toFixed(2)}</StatCell.Value>
|
||||
</StatCell>
|
||||
<StatCell>
|
||||
<StatCell.Label>Fauna Capacity</StatCell.Label>
|
||||
<StatCell.Value>{biome.fauna_capacity}</StatCell.Value>
|
||||
</StatCell>
|
||||
</StatsGrid>
|
||||
<FloraBar>
|
||||
<FloraSegment
|
||||
style={{ width: `${biome.flora_climax.canopy * 100}%`, background: '#1a9928' }}
|
||||
title={`Canopy: ${biome.flora_climax.canopy}`}
|
||||
/>
|
||||
<FloraSegment
|
||||
style={{ width: `${biome.flora_climax.undergrowth * 100}%`, background: '#8cc634' }}
|
||||
title={`Undergrowth: ${biome.flora_climax.undergrowth}`}
|
||||
/>
|
||||
<FloraSegment
|
||||
style={{ width: `${biome.flora_climax.fungi * 100}%`, background: '#9040a0' }}
|
||||
title={`Fungi: ${biome.flora_climax.fungi}`}
|
||||
/>
|
||||
</FloraBar>
|
||||
<FloraLabels>
|
||||
<span>Canopy {biome.flora_climax.canopy}</span>
|
||||
<span>Undergrowth {biome.flora_climax.undergrowth}</span>
|
||||
<span>Fungi {biome.flora_climax.fungi}</span>
|
||||
</FloraLabels>
|
||||
</BiomeDataCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BiomeBrowserPage({ tierDefs }: Props): ReactElement {
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all')
|
||||
const [category, setCategory] = useUrlFilter<CategoryValue>('category', CATEGORY_VALUES, ALL_CATEGORY)
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
// `?biome=<id>` drives inline highlight + scroll-to on first render. Pattern
|
||||
// mirrors `progress-report/ObjectivesTab.tsx` — URL-bound so shared links
|
||||
// land on the same view. Non-enum so we use raw `useSearchParams`.
|
||||
const biomeParam = searchParams.get('biome')
|
||||
const highlightedBiome = biomeParam != null && BIOME_MAP.has(biomeParam) ? biomeParam : null
|
||||
|
||||
const colors = useMemo(() => tierColorMap(tierDefs), [tierDefs])
|
||||
const labels = useMemo(() => tierLabelMap(tierDefs), [tierDefs])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (categoryFilter === 'all') return ALL_BIOMES
|
||||
return ALL_BIOMES.filter(b => b.category === categoryFilter)
|
||||
}, [categoryFilter])
|
||||
if (category === ALL_CATEGORY) return ALL_BIOMES
|
||||
return ALL_BIOMES.filter((b) => b.category === category)
|
||||
}, [category])
|
||||
|
||||
const openBiome = useCallback((id: string): void => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.set('biome', id)
|
||||
setSearchParams(params, { replace: false })
|
||||
}, [searchParams, setSearchParams])
|
||||
|
||||
const closeBiome = useCallback((): void => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.delete('biome')
|
||||
setSearchParams(params, { replace: false })
|
||||
}, [searchParams, setSearchParams])
|
||||
|
||||
return (
|
||||
<FadeIn duration="fast">
|
||||
|
|
@ -149,17 +221,26 @@ export default function BiomeBrowserPage({ tierDefs }: Props): ReactElement {
|
|||
|
||||
<Section>
|
||||
<SectionHeading>All Biomes</SectionHeading>
|
||||
<FilterRow>
|
||||
<FilterChip $active={categoryFilter === 'all'} onClick={() => setCategoryFilter('all')}>
|
||||
<FilterRow role="tablist" aria-label="Biome category">
|
||||
<FilterChip
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={category === ALL_CATEGORY}
|
||||
$active={category === ALL_CATEGORY}
|
||||
onClick={() => setCategory(ALL_CATEGORY)}
|
||||
>
|
||||
All ({ALL_BIOMES.length})
|
||||
</FilterChip>
|
||||
{BIOME_CATEGORIES.map(cat => {
|
||||
const count = ALL_BIOMES.filter(b => b.category === cat.id).length
|
||||
{BIOME_CATEGORIES.map((cat) => {
|
||||
const count = ALL_BIOMES.filter((b) => b.category === cat.id).length
|
||||
return (
|
||||
<FilterChip
|
||||
key={cat.id}
|
||||
$active={categoryFilter === cat.id}
|
||||
onClick={() => setCategoryFilter(cat.id)}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={category === cat.id}
|
||||
$active={category === cat.id}
|
||||
onClick={() => setCategory(cat.id)}
|
||||
>
|
||||
{cat.label} ({count})
|
||||
</FilterChip>
|
||||
|
|
@ -167,8 +248,15 @@ export default function BiomeBrowserPage({ tierDefs }: Props): ReactElement {
|
|||
})}
|
||||
</FilterRow>
|
||||
<BiomeGrid>
|
||||
{filtered.map(biome => (
|
||||
<BiomeCard key={biome.id} biome={biome} colorMap={colors} labelMap={labels} />
|
||||
{filtered.map((biome) => (
|
||||
<BiomeCard
|
||||
key={biome.id}
|
||||
biome={biome}
|
||||
labelMap={labels}
|
||||
highlighted={biome.id === highlightedBiome}
|
||||
onOpen={openBiome}
|
||||
onClose={closeBiome}
|
||||
/>
|
||||
))}
|
||||
</BiomeGrid>
|
||||
</Section>
|
||||
|
|
@ -176,7 +264,7 @@ export default function BiomeBrowserPage({ tierDefs }: Props): ReactElement {
|
|||
<Section>
|
||||
<SectionHeading>Color Legend</SectionHeading>
|
||||
<ColorLegend>
|
||||
{ALL_BIOMES.map(biome => (
|
||||
{ALL_BIOMES.map((biome) => (
|
||||
<LegendEntry key={biome.id}>
|
||||
<LegendSwatch style={{ background: biome.color }} />
|
||||
<LegendName>{biome.name}</LegendName>
|
||||
|
|
@ -188,6 +276,8 @@ export default function BiomeBrowserPage({ tierDefs }: Props): ReactElement {
|
|||
)
|
||||
}
|
||||
|
||||
// ─── Local styled (page-specific only) ─────────────────────────────────────
|
||||
|
||||
const PieContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -196,6 +286,13 @@ const PieContainer = styled.div`
|
|||
flex-wrap: wrap;
|
||||
`
|
||||
|
||||
const PieSvg = styled.svg`
|
||||
& path {
|
||||
stroke: ${({ theme }) => theme.colors.background.primary};
|
||||
stroke-width: 0.3;
|
||||
}
|
||||
`
|
||||
|
||||
const PieLegend = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -224,29 +321,40 @@ const BiomeGrid = styled.div`
|
|||
margin-top: 1rem;
|
||||
`
|
||||
|
||||
const Card = styled.div`
|
||||
background: ${({ theme }) => theme.colors.surface};
|
||||
border: 1px solid ${({ theme }) => theme.colors.border.default};
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
const BiomeDataCard = styled(DataCard)<{ $highlighted: boolean }>`
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms, box-shadow 150ms;
|
||||
border-color: ${({ $highlighted, theme }) =>
|
||||
$highlighted ? theme.colors.primary.main : theme.colors.border.default};
|
||||
box-shadow: ${({ $highlighted, theme }) =>
|
||||
$highlighted
|
||||
? `0 0 0 2px color-mix(in srgb, ${theme.colors.primary.main} 35%, transparent)`
|
||||
: 'none'};
|
||||
|
||||
&:hover {
|
||||
border-color: ${({ theme }) => theme.colors.primary.main};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${({ theme }) => theme.colors.primary.main};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`
|
||||
|
||||
const CardHeader = styled.div`
|
||||
const BiomeHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid ${({ theme }) => theme.colors.border.default};
|
||||
gap: 0.5rem;
|
||||
`
|
||||
|
||||
const BiomeSwatch = styled.span`
|
||||
const BiomeColorDot = styled.span`
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const CardTitle = styled.span`
|
||||
const BiomeName = styled.span`
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
color: ${({ theme }) => theme.colors.text.primary};
|
||||
|
|
@ -264,46 +372,12 @@ const CategoryTag = styled.span`
|
|||
border-radius: 3px;
|
||||
`
|
||||
|
||||
const CardBody = styled.div`
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
`
|
||||
|
||||
const StatRow = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
const QualityLine = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.8125rem;
|
||||
`
|
||||
|
||||
const StatLabel = styled.span`
|
||||
color: ${({ theme }) => theme.colors.text.muted};
|
||||
`
|
||||
|
||||
const StatValue = styled.span`
|
||||
gap: 0.375rem;
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
color: ${({ theme }) => theme.colors.text.primary};
|
||||
`
|
||||
|
||||
const QualityRange = styled.div`
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
`
|
||||
|
||||
const QualityDot = styled.span`
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
font-family: monospace;
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
`
|
||||
|
||||
const FloraBar = styled.div`
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue