feat: implement global abacus display configuration and remove client-side SVG generation

- Add React Context for app-wide abacus styling (localStorage persistence)
- Create AbacusDisplayDropdown with style controls in nav bar
- Add AppNavBar component with adaptive behavior (minimal for games)
- Remove duplicate navigation headers from guide and home pages
- Delete client-side SorobanSVG component - use only server-side Python/Typst
- Clean up generateSorobanSVG and generateMockSorobanSVG functions
- All abacus displays now use consistent global configuration
- Settings persist across page reloads and browser sessions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-09-14 20:45:55 -05:00
parent bca15c54c7
commit 5c3231c170
18 changed files with 1312 additions and 513 deletions

View File

@@ -62,7 +62,12 @@ export async function POST(request: NextRequest) {
console.error(`❌ Failed to generate SVG for number ${number}:`, error instanceof Error ? error.message : error)
samples.push({
number,
front: generateMockSorobanSVG(number),
front: `<svg width="200" height="300" viewBox="0 0 200 300" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="10" width="180" height="280" fill="none" stroke="#ccc" stroke-width="2"/>
<line x1="20" y1="150" x2="180" y2="150" stroke="#ccc" stroke-width="2"/>
<text x="100" y="160" text-anchor="middle" font-size="24" fill="#666">SVG Error</text>
<text x="100" y="180" text-anchor="middle" font-size="16" fill="#999">${number}</text>
</svg>`,
back: number.toString()
})
}
@@ -144,63 +149,17 @@ function getMockPreviewData(config: any) {
count: numbers.length,
samples: numbers.map(number => ({
number,
front: generateMockSorobanSVG(number),
front: `<svg width="200" height="300" viewBox="0 0 200 300" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="10" width="180" height="280" fill="none" stroke="#ccc" stroke-width="2"/>
<line x1="20" y1="150" x2="180" y2="150" stroke="#ccc" stroke-width="2"/>
<text x="100" y="160" text-anchor="middle" font-size="24" fill="#666">Preview Error</text>
<text x="100" y="180" text-anchor="middle" font-size="16" fill="#999">${number}</text>
</svg>`,
back: number.toString()
}))
}
}
// Generate a simple mock soroban SVG for preview
function generateMockSorobanSVG(number: number): string {
const width = 200
const height = 300
const rodWidth = 4
const beadRadius = 8
const heavenBeadHeight = 40
const earthBeadHeight = 40
const rods = 3 // Show 3 rods for preview
let svg = `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">`
// Frame
svg += `<rect x="10" y="10" width="${width-20}" height="${height-20}" fill="none" stroke="#8B4513" stroke-width="3"/>`
// Crossbar (divider between heaven and earth)
const crossbarY = height / 2
svg += `<line x1="15" y1="${crossbarY}" x2="${width-15}" y2="${crossbarY}" stroke="#8B4513" stroke-width="3"/>`
// Generate rods and beads based on the number
const digits = number.toString().padStart(rods, '0').split('').map(d => parseInt(d))
for (let i = 0; i < rods; i++) {
const rodX = 40 + i * 50
const digit = digits[rods - 1 - i] // Rightmost digit first
// Rod
svg += `<line x1="${rodX}" y1="20" x2="${rodX}" y2="${height-20}" stroke="#654321" stroke-width="${rodWidth}"/>`
// Calculate bead positions for this digit
const heavenValue = digit >= 5 ? 1 : 0
const earthValue = digit % 5
// Heaven bead (worth 5)
const heavenY = heavenValue > 0 ? crossbarY - 15 : 30
const heavenColor = heavenValue > 0 ? '#FF6B6B' : '#DDD'
svg += `<circle cx="${rodX}" cy="${heavenY}" r="${beadRadius}" fill="${heavenColor}" stroke="#333" stroke-width="1"/>`
// Earth beads (worth 1 each)
for (let j = 0; j < 4; j++) {
const isActive = j < earthValue
const earthY = crossbarY + 20 + j * 25
const earthColor = isActive ? '#4ECDC4' : '#DDD'
svg += `<circle cx="${rodX}" cy="${earthY}" r="${beadRadius}" fill="${earthColor}" stroke="#333" stroke-width="1"/>`
}
}
svg += '</svg>'
return svg
}
// Health check endpoint
export async function GET() {
return NextResponse.json({

View File

@@ -10,6 +10,7 @@ import { ConfigurationFormWithoutGenerate } from '@/components/ConfigurationForm
import { LivePreview } from '@/components/LivePreview'
import { GenerationProgress } from '@/components/GenerationProgress'
import { StyleControls } from '@/components/StyleControls'
import { useAbacusConfig } from '@/contexts/AbacusDisplayContext'
// Complete, validated configuration ready for generation
export interface FlashcardConfig {
@@ -106,6 +107,7 @@ type GenerationStatus = 'idle' | 'generating' | 'error'
export default function CreatePage() {
const [generationStatus, setGenerationStatus] = useState<GenerationStatus>('idle')
const [error, setError] = useState<string | null>(null)
const globalConfig = useAbacusConfig()
const form = useForm<FlashcardFormState>({
defaultValues: {
@@ -122,11 +124,12 @@ export default function CreatePage() {
fontSize: '48pt',
columns: 'auto',
showEmptyColumns: false,
hideInactiveBeads: false,
beadShape: 'diamond',
colorScheme: 'place-value',
coloredNumerals: false,
scaleFactor: 0.9,
// Use global config for abacus display settings
hideInactiveBeads: globalConfig.hideInactiveBeads,
beadShape: globalConfig.beadShape,
colorScheme: globalConfig.colorScheme,
coloredNumerals: globalConfig.coloredNumerals,
scaleFactor: globalConfig.scaleFactor,
format: 'pdf'
}
})
@@ -183,55 +186,6 @@ export default function CreatePage() {
return (
<div className={css({ minHeight: '100vh', bg: 'gray.50' })}>
{/* Header */}
<header className={css({ bg: 'white', shadow: 'card', position: 'sticky', top: 0, zIndex: 10 })}>
<div className={container({ maxW: '7xl', px: '4', py: '4' })}>
<div className={hstack({ justify: 'space-between', alignItems: 'center' })}>
<Link
href="/"
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: 'brand.800',
textDecoration: 'none'
})}
>
🧮 Soroban Generator
</Link>
<div className={hstack({ gap: '3' })}>
<Link
href="/guide"
className={css({
px: '4',
py: '2',
color: 'brand.600',
fontWeight: 'medium',
rounded: 'lg',
transition: 'all',
_hover: { bg: 'brand.50' }
})}
>
Guide
</Link>
<Link
href="/games"
className={css({
px: '4',
py: '2',
color: 'brand.600',
fontWeight: 'medium',
rounded: 'lg',
transition: 'all',
_hover: { bg: 'brand.50' }
})}
>
Games
</Link>
</div>
</div>
</div>
</header>
{/* Main Content */}
<div className={container({ maxW: '7xl', px: '4', py: '8' })}>

View File

@@ -12,41 +12,6 @@ export default function GuidePage() {
const [activeTab, setActiveTab] = useState<TabType>('reading')
return (
<div className={css({ minHeight: '100vh', bg: 'gray.50' })}>
{/* Header */}
<header className={css({ bg: 'white', shadow: 'card', position: 'sticky', top: 0, zIndex: 10 })}>
<div className={container({ maxW: '7xl', px: '4', py: '4' })}>
<div className={hstack({ justify: 'space-between', alignItems: 'center' })}>
<Link
href="/"
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: 'brand.800',
textDecoration: 'none'
})}
>
🧮 Soroban Generator
</Link>
<div className={hstack({ gap: '3' })}>
<Link
href="/create"
className={css({
px: '4',
py: '2',
color: 'brand.600',
fontWeight: 'medium',
rounded: 'lg',
transition: 'all',
_hover: { bg: 'brand.50' }
})}
>
Create Flashcards
</Link>
</div>
</div>
</div>
</header>
{/* Hero Section */}
<div className={css({

View File

@@ -1,5 +1,7 @@
import type { Metadata } from 'next'
import './globals.css'
import { AbacusDisplayProvider } from '@/contexts/AbacusDisplayContext'
import { AppNavBar } from '@/components/AppNavBar'
export const metadata: Metadata = {
title: 'Soroban Flashcard Generator',
@@ -13,7 +15,12 @@ export default function RootLayout({
}) {
return (
<html lang="en">
<body>{children}</body>
<body>
<AbacusDisplayProvider>
<AppNavBar />
{children}
</AbacusDisplayProvider>
</body>
</html>
)
}

View File

@@ -7,65 +7,6 @@ import Link from 'next/link'
export default function HomePage() {
return (
<div className={css({ minHeight: '100vh', bg: 'gradient-to-br from-brand.50 to-brand.100' })}>
{/* Header */}
<header className={css({ py: '6', px: '4' })}>
<div className={container({ maxW: '7xl' })}>
<nav className={hstack({ justify: 'space-between', alignItems: 'center' })}>
<h1 className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: 'brand.800'
})}>
🧮 Soroban Generator
</h1>
<div className={hstack({ gap: '4' })}>
<Link
href="/guide"
className={css({
px: '4',
py: '2',
color: 'brand.600',
rounded: 'lg',
fontWeight: 'medium',
transition: 'all',
_hover: { bg: 'brand.50' }
})}
>
Guide
</Link>
<Link
href="/games"
className={css({
px: '4',
py: '2',
color: 'brand.600',
rounded: 'lg',
fontWeight: 'medium',
transition: 'all',
_hover: { bg: 'brand.50' }
})}
>
Games
</Link>
<Link
href="/create"
className={css({
px: '4',
py: '2',
bg: 'brand.600',
color: 'white',
rounded: 'lg',
fontWeight: 'medium',
transition: 'all',
_hover: { bg: 'brand.700', transform: 'translateY(-1px)' }
})}
>
Create Flashcards
</Link>
</div>
</nav>
</div>
</header>
{/* Hero Section */}
<main className={container({ maxW: '6xl', px: '4' })}>

View File

@@ -0,0 +1,286 @@
'use client'
import { useState } from 'react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import * as Label from '@radix-ui/react-label'
import * as RadioGroup from '@radix-ui/react-radio-group'
import * as Switch from '@radix-ui/react-switch'
import { css } from '../../styled-system/css'
import { stack, hstack } from '../../styled-system/patterns'
import { useAbacusDisplay, ColorScheme, BeadShape } from '@/contexts/AbacusDisplayContext'
export function AbacusDisplayDropdown() {
const [open, setOpen] = useState(false)
const { config, updateConfig, resetToDefaults } = useAbacusDisplay()
return (
<DropdownMenu.Root open={open} onOpenChange={setOpen}>
<DropdownMenu.Trigger asChild>
<button
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
px: '3',
py: '2',
fontSize: 'sm',
fontWeight: 'medium',
color: 'gray.600',
bg: 'white',
border: '1px solid',
borderColor: 'gray.200',
rounded: 'lg',
shadow: 'sm',
transition: 'all',
cursor: 'pointer',
_hover: {
bg: 'gray.50',
borderColor: 'gray.300'
},
_focus: {
outline: 'none',
ring: '2px',
ringColor: 'brand.500',
ringOffset: '1px'
}
})}
>
<span className={css({ fontSize: 'lg' })}>🧮</span>
<span>Style</span>
<svg
className={css({
w: '4',
h: '4',
transition: 'transform',
transform: open ? 'rotate(180deg)' : 'rotate(0deg)'
})}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className={css({
bg: 'white',
rounded: 'xl',
shadow: 'modal',
border: '1px solid',
borderColor: 'gray.200',
p: '6',
minW: '320px',
maxW: '400px',
zIndex: 50,
animation: 'fadeIn 0.2s ease-out'
})}
sideOffset={8}
align="end"
>
<div className={stack({ gap: '6' })}>
{/* Header */}
<div className={stack({ gap: '1' })}>
<div className={hstack({ justify: 'space-between', alignItems: 'center' })}>
<h3 className={css({
fontSize: 'lg',
fontWeight: 'semibold',
color: 'gray.900'
})}>
🎨 Abacus Style
</h3>
<button
onClick={resetToDefaults}
className={css({
fontSize: 'xs',
color: 'gray.500',
_hover: { color: 'gray.700' }
})}
>
Reset
</button>
</div>
<p className={css({
fontSize: 'sm',
color: 'gray.600'
})}>
Configure display across the entire app
</p>
</div>
{/* Color Scheme */}
<FormField label="Color Scheme">
<RadioGroupField
value={config.colorScheme}
onValueChange={(value) => updateConfig({ colorScheme: value as ColorScheme })}
options={[
{ value: 'monochrome', label: 'Monochrome' },
{ value: 'place-value', label: 'Place Value' },
{ value: 'heaven-earth', label: 'Heaven-Earth' },
{ value: 'alternating', label: 'Alternating' }
]}
/>
</FormField>
{/* Bead Shape */}
<FormField label="Bead Shape">
<RadioGroupField
value={config.beadShape}
onValueChange={(value) => updateConfig({ beadShape: value as BeadShape })}
options={[
{ value: 'diamond', label: '💎 Diamond' },
{ value: 'circle', label: '⭕ Circle' },
{ value: 'square', label: '⬜ Square' }
]}
/>
</FormField>
{/* Toggle Options */}
<div className={stack({ gap: '4' })}>
<FormField label="Hide Inactive Beads">
<SwitchField
checked={config.hideInactiveBeads}
onCheckedChange={(checked) => updateConfig({ hideInactiveBeads: checked })}
/>
</FormField>
<FormField label="Colored Numerals">
<SwitchField
checked={config.coloredNumerals}
onCheckedChange={(checked) => updateConfig({ coloredNumerals: checked })}
/>
</FormField>
</div>
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)
}
// Helper Components (simplified versions of StyleControls components)
function FormField({
label,
children
}: {
label: string
children: React.ReactNode
}) {
return (
<div className={stack({ gap: '2' })}>
<Label.Root className={css({
fontSize: 'sm',
fontWeight: 'medium',
color: 'gray.900'
})}>
{label}
</Label.Root>
{children}
</div>
)
}
function SwitchField({
checked,
onCheckedChange
}: {
checked: boolean
onCheckedChange: (checked: boolean) => void
}) {
return (
<Switch.Root
checked={checked}
onCheckedChange={onCheckedChange}
className={css({
w: '11',
h: '6',
bg: checked ? 'brand.600' : 'gray.300',
rounded: 'full',
position: 'relative',
transition: 'all',
cursor: 'pointer',
_hover: { bg: checked ? 'brand.700' : 'gray.400' }
})}
>
<Switch.Thumb
className={css({
display: 'block',
w: '5',
h: '5',
bg: 'white',
rounded: 'full',
shadow: 'sm',
transition: 'transform 0.2s',
transform: checked ? 'translateX(20px)' : 'translateX(0px)',
willChange: 'transform'
})}
/>
</Switch.Root>
)
}
function RadioGroupField({
value,
onValueChange,
options
}: {
value: string
onValueChange: (value: string) => void
options: Array<{ value: string; label: string }>
}) {
return (
<RadioGroup.Root
value={value}
onValueChange={onValueChange}
className={stack({ gap: '2' })}
>
{options.map((option) => (
<div key={option.value} className={hstack({ gap: '3', alignItems: 'center' })}>
<RadioGroup.Item
value={option.value}
className={css({
w: '4',
h: '4',
rounded: 'full',
border: '2px solid',
borderColor: 'gray.300',
bg: 'white',
cursor: 'pointer',
transition: 'all',
_hover: { borderColor: 'brand.400' },
'&[data-state=checked]': { borderColor: 'brand.600' }
})}
>
<RadioGroup.Indicator
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
w: 'full',
h: 'full',
position: 'relative',
_after: {
content: '""',
display: 'block',
w: '1.5',
h: '1.5',
rounded: 'full',
bg: 'brand.600'
}
})}
/>
</RadioGroup.Item>
<label className={css({
fontSize: 'sm',
color: 'gray.900',
cursor: 'pointer'
})}>
{option.label}
</label>
</div>
))}
</RadioGroup.Root>
)
}

View File

@@ -0,0 +1,121 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { css } from '../../styled-system/css'
import { container, hstack } from '../../styled-system/patterns'
import { AbacusDisplayDropdown } from './AbacusDisplayDropdown'
interface AppNavBarProps {
variant?: 'full' | 'minimal'
}
export function AppNavBar({ variant = 'full' }: AppNavBarProps) {
const pathname = usePathname()
const isGamePage = pathname?.startsWith('/games')
// Auto-detect variant based on context
const actualVariant = variant === 'full' && isGamePage ? 'minimal' : variant
if (actualVariant === 'minimal') {
return (
<header className={css({
position: 'fixed',
top: '4',
right: '4',
zIndex: 40,
// Make it less prominent during games
opacity: '0.8',
_hover: { opacity: '1' },
transition: 'opacity'
})}>
<div className={hstack({ gap: '2' })}>
<AbacusDisplayDropdown />
</div>
</header>
)
}
return (
<header className={css({
bg: 'white',
shadow: 'sm',
borderBottom: '1px solid',
borderColor: 'gray.200',
position: 'sticky',
top: 0,
zIndex: 30
})}>
<div className={container({ maxW: '7xl', px: '4', py: '3' })}>
<div className={hstack({ justify: 'space-between', alignItems: 'center' })}>
{/* Logo */}
<Link
href="/"
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: 'brand.800',
textDecoration: 'none',
_hover: { color: 'brand.900' }
})}
>
🧮 Soroban Generator
</Link>
<div className={hstack({ gap: '6', alignItems: 'center' })}>
{/* Navigation Links */}
<nav className={hstack({ gap: '4' })}>
<NavLink href="/create" currentPath={pathname}>
Create
</NavLink>
<NavLink href="/guide" currentPath={pathname}>
Guide
</NavLink>
<NavLink href="/games" currentPath={pathname}>
Games
</NavLink>
</nav>
{/* Abacus Style Dropdown */}
<AbacusDisplayDropdown />
</div>
</div>
</div>
</header>
)
}
function NavLink({
href,
currentPath,
children
}: {
href: string
currentPath: string | null
children: React.ReactNode
}) {
const isActive = currentPath === href || (href !== '/' && currentPath?.startsWith(href))
return (
<Link
href={href}
className={css({
px: '3',
py: '2',
fontSize: 'sm',
fontWeight: 'medium',
color: isActive ? 'brand.700' : 'gray.600',
bg: isActive ? 'brand.50' : 'transparent',
rounded: 'lg',
transition: 'all',
textDecoration: 'none',
_hover: {
color: isActive ? 'brand.800' : 'gray.900',
bg: isActive ? 'brand.100' : 'gray.50'
}
})}
>
{children}
</Link>
)
}

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import { css } from '../../styled-system/css'
import { useAbacusConfig } from '@/contexts/AbacusDisplayContext'
interface ServerSorobanSVGProps {
number: number
@@ -15,13 +16,19 @@ interface ServerSorobanSVGProps {
export function ServerSorobanSVG({
number,
colorScheme = 'place-value',
hideInactiveBeads = false,
beadShape = 'diamond',
colorScheme,
hideInactiveBeads,
beadShape,
width = 240,
height = 320,
className = ''
}: ServerSorobanSVGProps) {
const globalConfig = useAbacusConfig()
// Use global config as defaults, allow props to override
const actualColorScheme = colorScheme ?? globalConfig.colorScheme
const actualHideInactiveBeads = hideInactiveBeads ?? globalConfig.hideInactiveBeads
const actualBeadShape = beadShape ?? globalConfig.beadShape
const [svgContent, setSvgContent] = useState<string>('')
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -34,9 +41,9 @@ export function ServerSorobanSVG({
try {
const config = {
range: number.toString(),
colorScheme,
hideInactiveBeads,
beadShape,
colorScheme: actualColorScheme,
hideInactiveBeads: actualHideInactiveBeads,
beadShape: actualBeadShape,
format: 'svg'
}
@@ -69,7 +76,7 @@ export function ServerSorobanSVG({
}
generateSVG()
}, [number, colorScheme, hideInactiveBeads, beadShape])
}, [number, actualColorScheme, actualHideInactiveBeads, actualBeadShape])
if (isLoading) {
return (

View File

@@ -1,156 +0,0 @@
'use client'
import { css } from '../../styled-system/css'
interface SorobanSVGProps {
number: number
colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating'
width?: number
height?: number
hideInactiveBeads?: boolean
beadShape?: 'diamond' | 'circle' | 'square'
className?: string
}
export function SorobanSVG({
number,
colorScheme = 'place-value',
width = 240,
height = 320,
hideInactiveBeads = false,
beadShape = 'diamond',
className = ''
}: SorobanSVGProps) {
const svg = generateSorobanSVG({
number,
colorScheme,
width,
height,
hideInactiveBeads,
beadShape
})
return (
<div
className={className}
dangerouslySetInnerHTML={{ __html: svg }}
/>
)
}
function generateSorobanSVG({
number,
colorScheme,
width,
height,
hideInactiveBeads,
beadShape
}: {
number: number
colorScheme: string
width: number
height: number
hideInactiveBeads: boolean
beadShape: string
}): string {
const rodWidth = 4
const beadSize = beadShape === 'circle' ? 10 : 8
const heavenBeadHeight = 40
const earthBeadHeight = 40
// Determine number of rods needed
const numString = Math.abs(number).toString()
const rods = Math.max(numString.length, 2) // At least 2 rods for display
// Adjust width based on number of rods
const actualWidth = Math.max(width, 80 + rods * 50)
let svg = `<svg width="${actualWidth}" height="${height}" viewBox="0 0 ${actualWidth} ${height}" xmlns="http://www.w3.org/2000/svg">`
// Frame
svg += `<rect x="10" y="10" width="${actualWidth-20}" height="${height-20}" fill="none" stroke="#8B4513" stroke-width="3"/>`
// Crossbar (divider between heaven and earth)
const crossbarY = height / 2
svg += `<line x1="15" y1="${crossbarY}" x2="${actualWidth-15}" y2="${crossbarY}" stroke="#8B4513" stroke-width="3"/>`
// Generate digits with proper padding
const digits = numString.padStart(rods, '0').split('').map(d => parseInt(d))
for (let i = 0; i < rods; i++) {
const rodX = 40 + i * 50
const digit = digits[i]
const placeValue = Math.pow(10, rods - 1 - i) // 100s, 10s, 1s place etc.
// Rod
svg += `<line x1="${rodX}" y1="20" x2="${rodX}" y2="${height-20}" stroke="#654321" stroke-width="${rodWidth}"/>`
// Calculate bead positions for this digit
const heavenValue = digit >= 5 ? 1 : 0
const earthValue = digit % 5
// Get colors based on scheme and place value
const colors = getBeadColors(colorScheme, placeValue)
// Heaven bead (worth 5)
const heavenActive = heavenValue > 0
const heavenY = heavenActive ? crossbarY - 15 : 30
const heavenColor = heavenActive ? colors.heaven : (hideInactiveBeads ? 'transparent' : '#E5E5E5')
const heavenStroke = heavenActive || !hideInactiveBeads ? '#333' : 'transparent'
if (heavenColor !== 'transparent') {
svg += createBead(rodX, heavenY, beadSize, heavenColor, heavenStroke, beadShape)
}
// Earth beads (worth 1 each)
for (let j = 0; j < 4; j++) {
const isActive = j < earthValue
const earthY = crossbarY + 20 + j * 25
const earthColor = isActive ? colors.earth : (hideInactiveBeads ? 'transparent' : '#E5E5E5')
const earthStroke = isActive || !hideInactiveBeads ? '#333' : 'transparent'
if (earthColor !== 'transparent') {
svg += createBead(rodX, earthY, beadSize, earthColor, earthStroke, beadShape)
}
}
}
svg += '</svg>'
return svg
}
function createBead(x: number, y: number, size: number, fill: string, stroke: string, shape: string): string {
switch (shape) {
case 'circle':
return `<circle cx="${x}" cy="${y}" r="${size}" fill="${fill}" stroke="${stroke}" stroke-width="1"/>`
case 'square':
return `<rect x="${x - size}" y="${y - size}" width="${size * 2}" height="${size * 2}" fill="${fill}" stroke="${stroke}" stroke-width="1"/>`
case 'diamond':
default:
const points = `${x},${y - size} ${x + size},${y} ${x},${y + size} ${x - size},${y}`
return `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="1"/>`
}
}
function getBeadColors(colorScheme: string, placeValue: number): { heaven: string; earth: string } {
switch (colorScheme) {
case 'monochrome':
return { heaven: '#666', earth: '#666' }
case 'heaven-earth':
return { heaven: '#FF6B6B', earth: '#4ECDC4' }
case 'alternating':
return placeValue % 2 === 0
? { heaven: '#FF6B6B', earth: '#4ECDC4' }
: { heaven: '#4ECDC4', earth: '#FF6B6B' }
case 'place-value':
default:
// Colors based on place value (ones, tens, hundreds, etc.)
if (placeValue >= 1000) return { heaven: '#9B59B6', earth: '#8E44AD' } // Purple for thousands
if (placeValue >= 100) return { heaven: '#E74C3C', earth: '#C0392B' } // Red for hundreds
if (placeValue >= 10) return { heaven: '#3498DB', earth: '#2980B9' } // Blue for tens
return { heaven: '#2ECC71', earth: '#27AE60' } // Green for ones
}
}

View File

@@ -7,12 +7,23 @@ import * as Switch from '@radix-ui/react-switch'
import { css } from '../../styled-system/css'
import { stack, hstack, grid } from '../../styled-system/patterns'
import { FlashcardFormState } from '@/app/create/page'
import { useAbacusDisplay } from '@/contexts/AbacusDisplayContext'
import { useEffect } from 'react'
interface StyleControlsProps {
form: FormApi<FlashcardFormState>
}
export function StyleControls({ form }: StyleControlsProps) {
const { config, updateConfig } = useAbacusDisplay()
// Sync form values with global context
useEffect(() => {
form.setFieldValue('colorScheme', config.colorScheme)
form.setFieldValue('beadShape', config.beadShape)
form.setFieldValue('hideInactiveBeads', config.hideInactiveBeads)
form.setFieldValue('coloredNumerals', config.coloredNumerals)
}, [config, form])
return (
<div className={stack({ gap: '4' })}>
<FormField
@@ -23,7 +34,10 @@ export function StyleControls({ form }: StyleControlsProps) {
{(field) => (
<RadioGroupField
value={field.state.value || 'place-value'}
onValueChange={(value) => field.handleChange(value as any)}
onValueChange={(value) => {
field.handleChange(value as any)
updateConfig({ colorScheme: value as any })
}}
options={[
{ value: 'monochrome', label: 'Monochrome', desc: 'Classic black and white' },
{ value: 'place-value', label: 'Place Value', desc: 'Colors by digit position' },
@@ -43,7 +57,10 @@ export function StyleControls({ form }: StyleControlsProps) {
{(field) => (
<RadioGroupField
value={field.state.value || 'diamond'}
onValueChange={(value) => field.handleChange(value as any)}
onValueChange={(value) => {
field.handleChange(value as any)
updateConfig({ beadShape: value as any })
}}
options={[
{ value: 'diamond', label: '💎 Diamond', desc: 'Realistic 3D appearance' },
{ value: 'circle', label: '⭕ Circle', desc: 'Traditional round beads' },
@@ -63,7 +80,10 @@ export function StyleControls({ form }: StyleControlsProps) {
{(field) => (
<SwitchField
checked={field.state.value || false}
onCheckedChange={field.handleChange}
onCheckedChange={(checked) => {
field.handleChange(checked)
updateConfig({ coloredNumerals: checked })
}}
/>
)}
</form.Field>
@@ -77,7 +97,10 @@ export function StyleControls({ form }: StyleControlsProps) {
{(field) => (
<SwitchField
checked={field.state.value || false}
onCheckedChange={field.handleChange}
onCheckedChange={(checked) => {
field.handleChange(checked)
updateConfig({ hideInactiveBeads: checked })
}}
/>
)}
</form.Field>

View File

@@ -0,0 +1,131 @@
'use client'
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react'
// Abacus display configuration types
export type ColorScheme = 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating'
export type BeadShape = 'diamond' | 'circle' | 'square'
export interface AbacusDisplayConfig {
colorScheme: ColorScheme
beadShape: BeadShape
hideInactiveBeads: boolean
coloredNumerals: boolean
scaleFactor: number
}
export interface AbacusDisplayContextType {
config: AbacusDisplayConfig
updateConfig: (updates: Partial<AbacusDisplayConfig>) => void
resetToDefaults: () => void
}
// Default configuration - matches current create page defaults
const DEFAULT_CONFIG: AbacusDisplayConfig = {
colorScheme: 'place-value',
beadShape: 'diamond',
hideInactiveBeads: false,
coloredNumerals: false,
scaleFactor: 1.0 // Normalized for display, can be scaled per component
}
const STORAGE_KEY = 'soroban-abacus-display-config'
// Load config from localStorage with fallback to defaults
function loadConfigFromStorage(): AbacusDisplayConfig {
if (typeof window === 'undefined') return DEFAULT_CONFIG
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored)
// Validate that all required fields are present and have valid values
return {
colorScheme: ['monochrome', 'place-value', 'heaven-earth', 'alternating'].includes(parsed.colorScheme)
? parsed.colorScheme : DEFAULT_CONFIG.colorScheme,
beadShape: ['diamond', 'circle', 'square'].includes(parsed.beadShape)
? parsed.beadShape : DEFAULT_CONFIG.beadShape,
hideInactiveBeads: typeof parsed.hideInactiveBeads === 'boolean'
? parsed.hideInactiveBeads : DEFAULT_CONFIG.hideInactiveBeads,
coloredNumerals: typeof parsed.coloredNumerals === 'boolean'
? parsed.coloredNumerals : DEFAULT_CONFIG.coloredNumerals,
scaleFactor: typeof parsed.scaleFactor === 'number' && parsed.scaleFactor > 0
? parsed.scaleFactor : DEFAULT_CONFIG.scaleFactor
}
}
} catch (error) {
console.warn('Failed to load abacus config from localStorage:', error)
}
return DEFAULT_CONFIG
}
// Save config to localStorage
function saveConfigToStorage(config: AbacusDisplayConfig): void {
if (typeof window === 'undefined') return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
} catch (error) {
console.warn('Failed to save abacus config to localStorage:', error)
}
}
const AbacusDisplayContext = createContext<AbacusDisplayContextType | null>(null)
export function useAbacusDisplay() {
const context = useContext(AbacusDisplayContext)
if (!context) {
throw new Error('useAbacusDisplay must be used within an AbacusDisplayProvider')
}
return context
}
interface AbacusDisplayProviderProps {
children: ReactNode
initialConfig?: Partial<AbacusDisplayConfig>
}
export function AbacusDisplayProvider({
children,
initialConfig = {}
}: AbacusDisplayProviderProps) {
const [config, setConfig] = useState<AbacusDisplayConfig>(() => {
const stored = loadConfigFromStorage()
return { ...stored, ...initialConfig }
})
// Save to localStorage whenever config changes
useEffect(() => {
saveConfigToStorage(config)
}, [config])
const updateConfig = useCallback((updates: Partial<AbacusDisplayConfig>) => {
setConfig(prev => {
const newConfig = { ...prev, ...updates }
return newConfig
})
}, [])
const resetToDefaults = useCallback(() => {
setConfig(DEFAULT_CONFIG)
}, [])
const value: AbacusDisplayContextType = {
config,
updateConfig,
resetToDefaults
}
return (
<AbacusDisplayContext.Provider value={value}>
{children}
</AbacusDisplayContext.Provider>
)
}
// Convenience hook for components that need specific config values
export function useAbacusConfig() {
const { config } = useAbacusDisplay()
return config
}

View File

@@ -40,6 +40,14 @@
color: var(--colors-brand-600)
}
.justify_space-between {
justify-content: space-between
}
.gap_10px {
gap: 10px
}
.fs_3xl {
font-size: var(--font-sizes-3xl)
}
@@ -108,6 +116,10 @@
padding-inline: var(--spacing-6)
}
.py_4 {
padding-block: var(--spacing-4)
}
.bg_brand\.600 {
background: var(--colors-brand-600)
}
@@ -230,10 +242,6 @@
transition-duration: var(--transition-duration, 150ms)
}
.py_4 {
padding-block: var(--spacing-4)
}
.pos_relative {
position: relative
}
@@ -254,30 +262,6 @@
padding-block: var(--spacing-8)
}
.justify_space-between {
justify-content: space-between
}
.gap_10px {
gap: 10px
}
.justify_center {
justify-content: center
}
.items_center {
align-items: center
}
.gap_3 {
gap: var(--spacing-3)
}
.flex_row {
flex-direction: row
}
.mb_8 {
margin-bottom: var(--spacing-8)
}
@@ -298,10 +282,6 @@
gap: var(--spacing-6)
}
.d_flex {
display: flex
}
.flex_column {
flex-direction: column
}
@@ -326,6 +306,26 @@
align-items: start
}
.justify_center {
justify-content: center
}
.d_flex {
display: flex
}
.items_center {
align-items: center
}
.gap_3 {
gap: var(--spacing-3)
}
.flex_row {
flex-direction: row
}
.hover\:bg_brand\.700:is(:hover, [data-hover]) {
background: var(--colors-brand-700)
}
@@ -346,7 +346,7 @@
background: var(--colors-red-700)
}
@media screen and (min-width: 64em) {
.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\) {
.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\) {
grid-template-columns: repeat(3, minmax(0, 1fr))
}

View File

@@ -938,7 +938,7 @@
color: var(--colors-gray-800)
}
@media screen and (min-width: 48em) {
.md\:px_4,.md\:px_4,.md\:px_4,.md\:px_4 {
.md\:px_4,.md\:px_4,.md\:px_4,.md\:px_4,.md\:px_4,.md\:px_4,.md\:px_4 {
padding-inline: var(--spacing-4)
}

View File

@@ -28,6 +28,18 @@
padding-block: var(--spacing-2)
}
.justify_space-between {
justify-content: space-between
}
.gap_10px {
gap: 10px
}
.gap_3 {
gap: var(--spacing-3)
}
.bg_linear-gradient\(135deg\,_\#667eea_0\%\,_\#764ba2_100\%\) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%)
}
@@ -72,6 +84,10 @@
background: var(--colors-transparent)
}
.py_4 {
padding-block: var(--spacing-4)
}
.border-b_2px_solid {
border-bottom: 2px solid
}
@@ -527,10 +543,6 @@
height: 120px
}
.py_4 {
padding-block: var(--spacing-4)
}
.max-w_4xl {
max-width: var(--sizes-4xl)
}
@@ -559,18 +571,6 @@
padding-block: var(--spacing-12)
}
.justify_space-between {
justify-content: space-between
}
.gap_10px {
gap: 10px
}
.gap_3 {
gap: var(--spacing-3)
}
.gap_0 {
gap: var(--spacing-0)
}

View File

@@ -36,6 +36,18 @@
font-weight: var(--font-weights-medium)
}
.max-w_7xl {
max-width: var(--sizes-7xl)
}
.justify_space-between {
justify-content: space-between
}
.gap_10px {
gap: 10px
}
.leading_tight {
line-height: var(--line-heights-tight)
}
@@ -162,10 +174,6 @@
line-height: var(--line-heights-relaxed)
}
.max-w_7xl {
max-width: var(--sizes-7xl)
}
.pos_relative {
position: relative
}
@@ -182,12 +190,36 @@
padding-inline: var(--spacing-4)
}
.justify_space-between {
justify-content: space-between
.gap_12 {
gap: var(--spacing-12)
}
.gap_10px {
gap: 10px
.py_16 {
padding-block: var(--spacing-16)
}
.text_center {
text-align: center
}
.flex_column {
flex-direction: column
}
.gap_6 {
gap: var(--spacing-6)
}
.max-w_4xl {
max-width: var(--sizes-4xl)
}
.d_flex {
display: flex
}
.items_center {
align-items: center
}
.justify_center {
@@ -206,38 +238,6 @@
margin-top: var(--spacing-8)
}
.items_center {
align-items: center
}
.gap_12 {
gap: var(--spacing-12)
}
.py_16 {
padding-block: var(--spacing-16)
}
.text_center {
text-align: center
}
.d_flex {
display: flex
}
.flex_column {
flex-direction: column
}
.gap_6 {
gap: var(--spacing-6)
}
.max-w_4xl {
max-width: var(--sizes-4xl)
}
.hover\:bg_brand\.50:is(:hover, [data-hover]) {
background: var(--colors-brand-50)
}
@@ -279,10 +279,6 @@
grid-template-columns: repeat(3, minmax(0, 1fr))
}
.md\:px_6 {
padding-inline: var(--spacing-6)
}
.md\:fs_6xl {
font-size: var(--font-sizes-6xl)
}

View File

@@ -0,0 +1,295 @@
@layer utilities {
.px_3 {
padding-inline: var(--spacing-3)
}
.py_2 {
padding-block: var(--spacing-2)
}
.rounded_lg {
border-radius: var(--radii-lg)
}
.transform_rotate\(180deg\) {
transform: rotate(180deg)
}
.transform_rotate\(0deg\) {
transform: rotate(0deg)
}
.transition_transform {
transition-property: var(--transition-prop, transform);
transition-timing-function: var(--transition-easing, cubic-bezier(0.4, 0, 0.2, 1));
transition-duration: var(--transition-duration, 150ms)
}
.rounded_xl {
border-radius: var(--radii-xl)
}
.shadow_modal {
box-shadow: var(--shadows-modal)
}
.border_1px_solid {
border: 1px solid
}
.border_gray\.200 {
border-color: var(--colors-gray-200)
}
.p_6 {
padding: var(--spacing-6)
}
.min-w_320px {
min-width: 320px
}
.max-w_400px {
max-width: 400px
}
.z_50 {
z-index: 50
}
.animation_fadeIn_0\.2s_ease-out {
animation: fadeIn 0.2s ease-out
}
.fs_lg {
font-size: var(--font-sizes-lg)
}
.font_semibold {
font-weight: var(--font-weights-semibold)
}
.fs_xs {
font-size: var(--font-sizes-xs)
}
.text_gray\.500 {
color: var(--colors-gray-500)
}
.text_gray\.600 {
color: var(--colors-gray-600)
}
.font_medium {
font-weight: var(--font-weights-medium)
}
.bg_brand\.600 {
background: var(--colors-brand-600)
}
.bg_gray\.300 {
background: var(--colors-gray-300)
}
.w_11 {
width: var(--sizes-11)
}
.h_6 {
height: var(--sizes-6)
}
.transform_translateX\(20px\) {
transform: translateX(20px)
}
.transform_translateX\(0px\) {
transform: translateX(0px)
}
.d_block {
display: block
}
.w_5 {
width: var(--sizes-5)
}
.h_5 {
height: var(--sizes-5)
}
.shadow_sm {
box-shadow: var(--shadows-sm)
}
.transition_transform_0\.2s {
transition: transform 0.2s
}
.will-change_transform {
will-change: transform
}
.w_4 {
width: var(--sizes-4)
}
.h_4 {
height: var(--sizes-4)
}
.rounded_full {
border-radius: var(--radii-full)
}
.border_2px_solid {
border: 2px solid
}
.border_gray\.300 {
border-color: var(--colors-gray-300)
}
.bg_white {
background: var(--colors-white)
}
.transition_all {
transition-property: var(--transition-prop, all);
transition-timing-function: var(--transition-easing, cubic-bezier(0.4, 0, 0.2, 1));
transition-duration: var(--transition-duration, 150ms)
}
.\[\&\[data-state\=checked\]\]\:border_brand\.600[data-state=checked] {
border-color: var(--colors-brand-600)
}
.justify_center {
justify-content: center
}
.w_full {
width: var(--sizes-full)
}
.h_full {
height: var(--sizes-full)
}
.pos_relative {
position: relative
}
.after\:content_\"\"::after {
content: ""
}
.after\:d_block::after {
display: block
}
.after\:w_1\.5::after {
width: var(--sizes-1\.5)
}
.after\:h_1\.5::after {
height: var(--sizes-1\.5)
}
.after\:rounded_full::after {
border-radius: var(--radii-full)
}
.after\:bg_brand\.600::after {
background: var(--colors-brand-600)
}
.fs_sm {
font-size: var(--font-sizes-sm)
}
.text_gray\.900 {
color: var(--colors-gray-900)
}
.cursor_pointer {
cursor: pointer
}
.gap_6 {
gap: var(--spacing-6)
}
.gap_1 {
gap: var(--spacing-1)
}
.gap_4 {
gap: var(--spacing-4)
}
.flex_column {
flex-direction: column
}
.gap_2 {
gap: var(--spacing-2)
}
.justify_space-between {
justify-content: space-between
}
.gap_10px {
gap: 10px
}
.d_flex {
display: flex
}
.items_center {
align-items: center
}
.gap_3 {
gap: var(--spacing-3)
}
.flex_row {
flex-direction: row
}
.focus\:ring_2px:is(:focus, [data-focus]) {
outline: 2px
}
.focus\:ring_brand\.500:is(:focus, [data-focus]) {
outline-color: var(--colors-brand-500)
}
.focus\:ring_1px:is(:focus, [data-focus]) {
outline-offset: 1px
}
.hover\:bg_gray\.50:is(:hover, [data-hover]) {
background: var(--colors-gray-50)
}
.hover\:border_gray\.300:is(:hover, [data-hover]) {
border-color: var(--colors-gray-300)
}
.hover\:text_gray\.700:is(:hover, [data-hover]) {
color: var(--colors-gray-700)
}
.hover\:bg_brand\.700:is(:hover, [data-hover]) {
background: var(--colors-brand-700)
}
.hover\:bg_gray\.400:is(:hover, [data-hover]) {
background: var(--colors-gray-400)
}
.hover\:border_brand\.400:is(:hover, [data-hover]) {
border-color: var(--colors-brand-400)
}
}

View File

@@ -0,0 +1,190 @@
@layer utilities {
.pos_fixed {
position: fixed
}
.top_4 {
top: var(--spacing-4)
}
.right_4 {
right: var(--spacing-4)
}
.z_40 {
z-index: 40
}
.opacity_0\.8 {
opacity: 0.8
}
.transition_opacity {
transition-property: var(--transition-prop, opacity);
transition-timing-function: var(--transition-easing, cubic-bezier(0.4, 0, 0.2, 1));
transition-duration: var(--transition-duration, 150ms)
}
.bg_white {
background: var(--colors-white)
}
.shadow_sm {
box-shadow: var(--shadows-sm)
}
.border-b_1px_solid {
border-bottom: 1px solid
}
.border_gray\.200 {
border-color: var(--colors-gray-200)
}
.pos_sticky {
position: sticky
}
.top_0 {
top: var(--spacing-0)
}
.z_30 {
z-index: 30
}
.fs_xl {
font-size: var(--font-sizes-xl)
}
.font_bold {
font-weight: var(--font-weights-bold)
}
.text_brand\.800 {
color: var(--colors-brand-800)
}
.text_brand\.700 {
color: var(--colors-brand-700)
}
.text_gray\.600 {
color: var(--colors-gray-600)
}
.bg_brand\.50 {
background: var(--colors-brand-50)
}
.bg_transparent {
background: var(--colors-transparent)
}
.px_3 {
padding-inline: var(--spacing-3)
}
.py_2 {
padding-block: var(--spacing-2)
}
.fs_sm {
font-size: var(--font-sizes-sm)
}
.font_medium {
font-weight: var(--font-weights-medium)
}
.rounded_lg {
border-radius: var(--radii-lg)
}
.transition_all {
transition-property: var(--transition-prop, all);
transition-timing-function: var(--transition-easing, cubic-bezier(0.4, 0, 0.2, 1));
transition-duration: var(--transition-duration, 150ms)
}
.text-decor_none {
text-decoration: none
}
.gap_2 {
gap: var(--spacing-2)
}
.justify_space-between {
justify-content: space-between
}
.gap_10px {
gap: 10px
}
.gap_6 {
gap: var(--spacing-6)
}
.d_flex {
display: flex
}
.items_center {
align-items: center
}
.gap_4 {
gap: var(--spacing-4)
}
.flex_row {
flex-direction: row
}
.pos_relative {
position: relative
}
.max-w_7xl {
max-width: var(--sizes-7xl)
}
.mx_auto {
margin-inline: auto
}
.px_4 {
padding-inline: var(--spacing-4)
}
.py_3 {
padding-block: var(--spacing-3)
}
.hover\:opacity_1:is(:hover, [data-hover]) {
opacity: 1
}
.hover\:text_brand\.900:is(:hover, [data-hover]) {
color: var(--colors-brand-900)
}
.hover\:text_brand\.800:is(:hover, [data-hover]) {
color: var(--colors-brand-800)
}
.hover\:text_gray\.900:is(:hover, [data-hover]) {
color: var(--colors-gray-900)
}
.hover\:bg_brand\.100:is(:hover, [data-hover]) {
background: var(--colors-brand-100)
}
.hover\:bg_gray\.50:is(:hover, [data-hover]) {
background: var(--colors-gray-50)
}
}

View File

@@ -321,10 +321,6 @@
border: 3px solid #5f3dc4
}
.opacity_0\.8 {
opacity: 0.8
}
.backface_hidden {
backface-visibility: hidden;
-webkit-backface-visibility: hidden
@@ -483,10 +479,6 @@
animation: blink 1s infinite
}
.pos_fixed {
position: fixed
}
.left_0 {
left: var(--spacing-0)
}
@@ -823,14 +815,6 @@
space: y-2
}
.pos_sticky {
position: sticky
}
.top_0 {
top: var(--spacing-0)
}
.bg_linear-gradient\(135deg\,_\#667eea_0\%\,_\#764ba2_100\%\) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%)
}
@@ -847,10 +831,6 @@
opacity: 0.95
}
.border-b_1px_solid {
border-bottom: 1px solid
}
.border_transparent {
border-color: var(--colors-transparent)
}
@@ -979,10 +959,6 @@
color: var(--colors-red-700)
}
.shadow_sm {
box-shadow: var(--shadows-sm)
}
.mb_6 {
margin-bottom: var(--spacing-6)
}
@@ -1047,10 +1023,6 @@
color: var(--colors-purple-600)
}
.text-decor_none {
text-decoration: none
}
.w_120 {
width: 120px
}
@@ -1107,10 +1079,6 @@
padding-inline: var(--spacing-8)
}
.text_brand\.700 {
color: var(--colors-brand-700)
}
.border_brand\.200 {
border-color: var(--colors-brand-200)
}
@@ -1131,22 +1099,10 @@
margin-bottom: var(--spacing-4)
}
.max-w_7xl {
max-width: var(--sizes-7xl)
}
.max-w_6xl {
max-width: var(--sizes-6xl)
}
.mx_auto {
margin-inline: auto
}
.mt_8 {
margin-top: var(--spacing-8)
}
.gap_12 {
gap: var(--spacing-12)
}
@@ -1159,6 +1115,94 @@
max-width: var(--sizes-4xl)
}
.mt_8 {
margin-top: var(--spacing-8)
}
.transform_rotate\(180deg\) {
transform: rotate(180deg)
}
.transform_rotate\(0deg\) {
transform: rotate(0deg)
}
.transition_transform {
transition-property: var(--transition-prop, transform);
transition-timing-function: var(--transition-easing, cubic-bezier(0.4, 0, 0.2, 1));
transition-duration: var(--transition-duration, 150ms)
}
.max-w_400px {
max-width: 400px
}
.animation_fadeIn_0\.2s_ease-out {
animation: fadeIn 0.2s ease-out
}
.after\:w_1\.5::after {
width: var(--sizes-1\.5)
}
.after\:h_1\.5::after {
height: var(--sizes-1\.5)
}
.pos_fixed {
position: fixed
}
.z_40 {
z-index: 40
}
.opacity_0\.8 {
opacity: 0.8
}
.transition_opacity {
transition-property: var(--transition-prop, opacity);
transition-timing-function: var(--transition-easing, cubic-bezier(0.4, 0, 0.2, 1));
transition-duration: var(--transition-duration, 150ms)
}
.shadow_sm {
box-shadow: var(--shadows-sm)
}
.border-b_1px_solid {
border-bottom: 1px solid
}
.pos_sticky {
position: sticky
}
.top_0 {
top: var(--spacing-0)
}
.z_30 {
z-index: 30
}
.text_brand\.700 {
color: var(--colors-brand-700)
}
.text-decor_none {
text-decoration: none
}
.max-w_7xl {
max-width: var(--sizes-7xl)
}
.mx_auto {
margin-inline: auto
}
.align_start {
align: start
}
@@ -1914,6 +1958,18 @@
flex-direction: row
}
.focus\:ring_2px:is(:focus, [data-focus]) {
outline: 2px
}
.focus\:ring_brand\.500:is(:focus, [data-focus]) {
outline-color: var(--colors-brand-500)
}
.focus\:ring_1px:is(:focus, [data-focus]) {
outline-offset: 1px
}
.focus\:ring_none:is(:focus, [data-focus]) {
outline: var(--borders-none)
}
@@ -1950,10 +2006,6 @@
background: var(--colors-blue-700)
}
.hover\:bg_gray\.50:is(:hover, [data-hover]) {
background: var(--colors-gray-50)
}
.hover\:text_brand\.600:is(:hover, [data-hover]) {
color: var(--colors-brand-600)
}
@@ -1970,6 +2022,34 @@
transform: translateY(-4px)
}
.hover\:border_gray\.300:is(:hover, [data-hover]) {
border-color: var(--colors-gray-300)
}
.hover\:text_gray\.700:is(:hover, [data-hover]) {
color: var(--colors-gray-700)
}
.hover\:opacity_1:is(:hover, [data-hover]) {
opacity: 1
}
.hover\:text_brand\.900:is(:hover, [data-hover]) {
color: var(--colors-brand-900)
}
.hover\:text_brand\.800:is(:hover, [data-hover]) {
color: var(--colors-brand-800)
}
.hover\:bg_brand\.100:is(:hover, [data-hover]) {
background: var(--colors-brand-100)
}
.hover\:bg_gray\.50:is(:hover, [data-hover]) {
background: var(--colors-gray-50)
}
.hover\:text_gray\.900:is(:hover, [data-hover]) {
color: var(--colors-gray-900)
}
@@ -2035,7 +2115,7 @@
}
@media screen and (min-width: 48em) {
.md\:px_4,.md\:px_4,.md\:px_4,.md\:px_4 {
.md\:px_4,.md\:px_4,.md\:px_4,.md\:px_4,.md\:px_4,.md\:px_4,.md\:px_4 {
padding-inline: var(--spacing-4)
}
@@ -2081,7 +2161,7 @@
}
@media screen and (min-width: 64em) {
.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\) {
.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\),.lg\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\) {
grid-template-columns: repeat(3, minmax(0, 1fr))
}