Compare commits

...

22 Commits

Author SHA1 Message Date
semantic-release-bot
3c00ebfe2f chore(release): 2.2.0 [skip ci]
## [2.2.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.3...v2.2.0) (2025-10-07)

### Features

* remove typst-related code and routes ([be6fb1a](be6fb1a881))
2025-10-07 15:42:43 +00:00
Thomas Hallock
be6fb1a881 feat: remove typst-related code and routes
Remove all typst-related files, API routes, and components.
This completes the typst dependency removal.

Removed:
- apps/web/src/app/api/typst-svg/route.ts
- apps/web/src/app/api/typst-template/route.ts
- apps/web/src/lib/typst-soroban.ts
- apps/web/src/components/TypstSoroban.tsx
- apps/web/src/app/test-typst/
- apps/web/src/app/typst-gallery/
- apps/web/src/app/typst-playground/

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:41:44 -05:00
semantic-release-bot
e157bbff43 chore(release): 2.1.3 [skip ci]
## [2.1.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.2...v2.1.3) (2025-10-07)

### Bug Fixes

* remove .npmrc from Dockerfile COPY ([e71c2b4](e71c2b4da8))
2025-10-07 15:37:01 +00:00
Thomas Hallock
e71c2b4da8 fix: remove .npmrc from Dockerfile COPY
.npmrc no longer exists after reverting to default pnpm mode.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:36:10 -05:00
semantic-release-bot
40cbe96385 chore(release): 2.1.2 [skip ci]
## [2.1.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.1...v2.1.2) (2025-10-07)

### Bug Fixes

* revert to default pnpm mode for Docker compatibility ([bd0092e](bd0092e69a))
2025-10-07 15:34:15 +00:00
Thomas Hallock
bd0092e69a fix: revert to default pnpm mode for Docker compatibility
Hoisted mode is incompatible with Docker's overlay filesystem.
Remove .npmrc and regenerate lockfile with default isolated mode.

This maintains semantic-release functionality while allowing
Docker builds to succeed.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:33:26 -05:00
semantic-release-bot
f9262a2c83 chore(release): 2.1.1 [skip ci]
## [2.1.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.0...v2.1.1) (2025-10-07)

### Bug Fixes

* ignore all node_modules in Docker ([4792dde](4792dde1be))
2025-10-07 15:28:57 +00:00
Thomas Hallock
4792dde1be fix: ignore all node_modules in Docker
Docker overlay filesystem conflicts with local node_modules structure,
regardless of whether it's hoisted mode or not. Ignore all node_modules
and rely on the base stage installation.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:28:09 -05:00
semantic-release-bot
f91248b0bb chore(release): 2.1.0 [skip ci]
## [2.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.7...v2.1.0) (2025-10-07)

### Features

* remove typst dependencies ([eedce28](eedce28572))
2025-10-07 15:23:47 +00:00
Thomas Hallock
eedce28572 feat: remove typst dependencies
Remove @myriaddreamin/typst-* packages that are no longer needed.
This eliminates Docker overlay conflicts with hoisted node_modules.

Removed packages (-365):
- @myriaddreamin/typst-all-in-one.ts
- @myriaddreamin/typst-ts-renderer
- @myriaddreamin/typst-ts-web-compiler
- @myriaddreamin/typst.ts

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:22:48 -05:00
semantic-release-bot
d84bf9c845 chore(release): 2.0.7 [skip ci]
## [2.0.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.6...v2.0.7) (2025-10-07)

### Bug Fixes

* preserve workspace node_modules in Docker for hoisted mode ([4f8aaf0](4f8aaf04aa))
2025-10-07 15:15:43 +00:00
Thomas Hallock
4f8aaf04aa fix: preserve workspace node_modules in Docker for hoisted mode
With hoisted mode, each workspace needs its own node_modules folder
(containing symlinks). Only ignore root /node_modules.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:14:47 -05:00
semantic-release-bot
a43c8654e1 chore(release): 2.0.6 [skip ci]
## [2.0.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.5...v2.0.6) (2025-10-07)

### Bug Fixes

* ignore nested node_modules in Docker ([f554592](f554592272))
2025-10-07 15:11:09 +00:00
Thomas Hallock
f554592272 fix: ignore nested node_modules in Docker
Add **/node_modules pattern to prevent Docker overlay conflicts
when hoisted mode creates nested symlink structures.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:10:23 -05:00
semantic-release-bot
b073b9e1ec chore(release): 2.0.5 [skip ci]
## [2.0.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.4...v2.0.5) (2025-10-07)

### Bug Fixes

* use .npmrc in Docker for hoisted mode consistency ([2df8cdc](2df8cdc88e))
2025-10-07 15:06:45 +00:00
Thomas Hallock
2df8cdc88e fix: use .npmrc in Docker for hoisted mode consistency
The pnpm lockfile was generated with hoisted mode, so Docker must
also use hoisted mode to match the module resolution paths.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:05:56 -05:00
semantic-release-bot
e73afdb913 chore(release): 2.0.4 [skip ci]
## [2.0.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.3...v2.0.4) (2025-10-07)

### Bug Fixes

* remove .npmrc in Docker to avoid hoisted mode issues ([2a77d75](2a77d755b7))
2025-10-07 15:02:36 +00:00
Thomas Hallock
2a77d755b7 fix: remove .npmrc in Docker to avoid hoisted mode issues
Docker builds should use default pnpm isolated mode, not hoisted mode
which causes tsup module resolution failures.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:01:39 -05:00
semantic-release-bot
f4ab0ff9ba chore(release): 2.0.3 [skip ci]
## [2.0.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.2...v2.0.3) (2025-10-07)

### Bug Fixes

* remove duplicate PlayerStatusBar story file from arcade ([4e721f7](4e721f765a))
2025-10-07 14:58:20 +00:00
Thomas Hallock
4e721f765a fix: remove duplicate PlayerStatusBar story file from arcade
Remove apps/web/src/app/arcade/matching/components/PlayerStatusBar.stories.tsx
to fix Storybook build error about duplicate story IDs.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:57:17 -05:00
semantic-release-bot
8bd6d6d8b7 chore(release): 2.0.2 [skip ci]
## [2.0.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.1...v2.0.2) (2025-10-07)

### Bug Fixes

* update Dockerfile pnpm version and fix TypeScript config ([43077a8](43077a80e2))
2025-10-07 14:54:44 +00:00
Thomas Hallock
43077a80e2 fix: update Dockerfile pnpm version and fix TypeScript config
- Upgrade Dockerfile from pnpm 8.0.0 to 9.15.4 for lockfile compatibility
- Add "types": [] to abacus-react tsconfig to prevent implicit @types includes
- Fixes Docker build lockfile incompatibility
- Fixes TypeScript error looking for @types/minimatch

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:54:04 -05:00
16 changed files with 84 additions and 2628 deletions

View File

@@ -1,5 +1,7 @@
# Ignore development files
# Ignore all node_modules to prevent Docker overlay conflicts
node_modules
**/node_modules
.next
.git
.github

1
.npmrc
View File

@@ -1 +0,0 @@
node-linker=hoisted

View File

@@ -1,3 +1,80 @@
## [2.2.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.3...v2.2.0) (2025-10-07)
### Features
* remove typst-related code and routes ([be6fb1a](https://github.com/antialias/soroban-abacus-flashcards/commit/be6fb1a881b983f9830d36c079b7b41f35153b8a))
## [2.1.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.2...v2.1.3) (2025-10-07)
### Bug Fixes
* remove .npmrc from Dockerfile COPY ([e71c2b4](https://github.com/antialias/soroban-abacus-flashcards/commit/e71c2b4da85076dfc97401fc170cd88cb0aa4375))
## [2.1.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.1...v2.1.2) (2025-10-07)
### Bug Fixes
* revert to default pnpm mode for Docker compatibility ([bd0092e](https://github.com/antialias/soroban-abacus-flashcards/commit/bd0092e69ac4f74ea89b8d31399cf72f57484cbb))
## [2.1.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.0...v2.1.1) (2025-10-07)
### Bug Fixes
* ignore all node_modules in Docker ([4792dde](https://github.com/antialias/soroban-abacus-flashcards/commit/4792dde1beef9c6cb84a27bc6bb6acfa43919a72))
## [2.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.7...v2.1.0) (2025-10-07)
### Features
* remove typst dependencies ([eedce28](https://github.com/antialias/soroban-abacus-flashcards/commit/eedce28572035897001f6b8a08f79beaa2360d44))
## [2.0.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.6...v2.0.7) (2025-10-07)
### Bug Fixes
* preserve workspace node_modules in Docker for hoisted mode ([4f8aaf0](https://github.com/antialias/soroban-abacus-flashcards/commit/4f8aaf04aadda11ce9ec470dec44f78062929e77))
## [2.0.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.5...v2.0.6) (2025-10-07)
### Bug Fixes
* ignore nested node_modules in Docker ([f554592](https://github.com/antialias/soroban-abacus-flashcards/commit/f554592272c2e92d7f1ec6550211518de9c3242f))
## [2.0.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.4...v2.0.5) (2025-10-07)
### Bug Fixes
* use .npmrc in Docker for hoisted mode consistency ([2df8cdc](https://github.com/antialias/soroban-abacus-flashcards/commit/2df8cdc88ed03b6b04642a3441e17c6fda11d2a5))
## [2.0.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.3...v2.0.4) (2025-10-07)
### Bug Fixes
* remove .npmrc in Docker to avoid hoisted mode issues ([2a77d75](https://github.com/antialias/soroban-abacus-flashcards/commit/2a77d755b7820b5b6b52ea99db418e6d071d726e))
## [2.0.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.2...v2.0.3) (2025-10-07)
### Bug Fixes
* remove duplicate PlayerStatusBar story file from arcade ([4e721f7](https://github.com/antialias/soroban-abacus-flashcards/commit/4e721f765a29fe8628d4e34ef94cdf5728eea3dc))
## [2.0.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.1...v2.0.2) (2025-10-07)
### Bug Fixes
* update Dockerfile pnpm version and fix TypeScript config ([43077a8](https://github.com/antialias/soroban-abacus-flashcards/commit/43077a80e271a793c88f100874914ae6f3c515b5))
## [2.0.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.0...v2.0.1) (2025-10-07)

View File

@@ -5,7 +5,7 @@ FROM node:18-alpine AS base
RUN apk add --no-cache python3 py3-setuptools make g++
# Install pnpm and turbo
RUN npm install -g pnpm@8.0.0 turbo@1.10.0
RUN npm install -g pnpm@9.15.4 turbo@1.10.0
WORKDIR /app
@@ -17,7 +17,7 @@ COPY packages/core/client/typescript/package.json ./packages/core/client/typescr
COPY packages/abacus-react/package.json ./packages/abacus-react/
COPY packages/templates/package.json ./packages/templates/
# Install dependencies
# Install dependencies (will use .npmrc with hoisted mode)
RUN pnpm install --frozen-lockfile
# Builder stage

View File

@@ -23,10 +23,6 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@myriaddreamin/typst-all-in-one.ts": "0.6.1-rc3",
"@myriaddreamin/typst-ts-renderer": "0.6.1-rc3",
"@myriaddreamin/typst-ts-web-compiler": "0.6.1-rc3",
"@myriaddreamin/typst.ts": "0.6.1-rc3",
"@number-flow/react": "^0.5.10",
"@pandacss/dev": "^0.20.0",
"@paralleldrive/cuid2": "^2.2.2",

View File

@@ -1,154 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { $typst } from '@myriaddreamin/typst.ts/dist/esm/contrib/snippet.mjs'
import fs from 'fs'
import path from 'path'
export interface TypstSVGRequest {
number: number
beadShape?: 'diamond' | 'circle' | 'square'
colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating'
colorPalette?: 'default' | 'colorblind' | 'mnemonic' | 'grayscale' | 'nature'
hideInactiveBeads?: boolean
showEmptyColumns?: boolean
columns?: number | 'auto'
scaleFactor?: number
width?: string
height?: string
fontSize?: string
fontFamily?: string
transparent?: boolean
coloredNumerals?: boolean
}
// Cache for template content
let flashcardsTemplate: string | null = null
async function getFlashcardsTemplate(): Promise<string> {
if (flashcardsTemplate) {
return flashcardsTemplate
}
try {
const { getTemplatePath } = require('@soroban/templates')
const templatePath = getTemplatePath('flashcards.typ')
flashcardsTemplate = fs.readFileSync(templatePath, 'utf-8')
return flashcardsTemplate
} catch (error) {
console.error('Failed to load flashcards template:', error)
throw new Error('Template loading failed')
}
}
function processBeadAnnotations(svg: string): string {
const { extractBeadAnnotations } = require('@soroban/templates')
const result = extractBeadAnnotations(svg)
if (result.warnings.length > 0) {
console.log(' SVG bead processing warnings:', result.warnings)
}
console.log(`🔗 Processed ${result.count} bead links into data attributes`)
return result.processedSVG
}
function createTypstContent(config: TypstSVGRequest, template: string): string {
const {
number,
beadShape = 'diamond',
colorScheme = 'place-value',
colorPalette = 'default',
hideInactiveBeads = false,
showEmptyColumns = false,
columns = 'auto',
scaleFactor = 1.0,
width = '120pt',
height = '160pt',
fontSize = '48pt',
fontFamily = 'DejaVu Sans',
transparent = false,
coloredNumerals = false
} = config
return `
${template}
#set page(
width: ${width},
height: ${height},
margin: 0pt,
fill: ${transparent ? 'none' : 'white'}
)
#set text(font: "${fontFamily}", size: ${fontSize}, fallback: true)
#align(center + horizon)[
#box(
width: ${width} - 2 * (${width} * 0.05),
height: ${height} - 2 * (${height} * 0.05)
)[
#align(center + horizon)[
#scale(x: ${scaleFactor * 100}%, y: ${scaleFactor * 100}%)[
#draw-soroban(
${number},
columns: ${columns},
show-empty: ${showEmptyColumns},
hide-inactive: ${hideInactiveBeads},
bead-shape: "${beadShape}",
color-scheme: "${colorScheme}",
color-palette: "${colorPalette}",
base-size: 1.0
)
]
]
]
]
`
}
export async function POST(request: NextRequest) {
try {
const config: TypstSVGRequest = await request.json()
console.log('🎨 Generating typst.ts SVG for number:', config.number)
// Load template
const template = await getFlashcardsTemplate()
// Create typst content
const typstContent = createTypstContent(config, template)
// Generate SVG using typst.ts
const rawSvg = await $typst.svg({ mainContent: typstContent })
// Post-process to convert bead annotations to data attributes
const svg = processBeadAnnotations(rawSvg)
console.log('✅ Generated and processed typst.ts SVG, length:', svg.length)
return NextResponse.json({
svg,
success: true,
number: config.number
})
} catch (error) {
console.error('❌ Typst SVG generation failed:', error)
return NextResponse.json(
{
error: error instanceof Error ? error.message : 'Unknown error',
success: false
},
{ status: 500 }
)
}
}
// Health check
export async function GET() {
return NextResponse.json({
status: 'healthy',
endpoint: 'typst-svg',
message: 'Typst.ts SVG generation API is running'
})
}

View File

@@ -1,25 +0,0 @@
import { NextResponse } from 'next/server'
import fs from 'fs'
import { getTemplatePath } from '@soroban/templates'
// API endpoint to serve the flashcards.typ template content
export async function GET() {
try {
const templatePath = getTemplatePath('flashcards.typ');
const flashcardsTemplate = fs.readFileSync(templatePath, 'utf-8')
return NextResponse.json({
template: flashcardsTemplate,
success: true
})
} catch (error) {
console.error('Failed to load typst template:', error)
return NextResponse.json(
{
error: 'Failed to load template',
success: false
},
{ status: 500 }
)
}
}

View File

@@ -1,455 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react'
import React, { useEffect } from 'react'
import { css } from '../../../../../styled-system/css'
import { gamePlurals } from '../../../../utils/pluralization'
// Inject the celebration animations for Storybook
const celebrationAnimations = `
@keyframes gentle-pulse {
0%, 100% {
box-shadow: 0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.3), 0 12px 32px rgba(0,0,0,0.1);
}
50% {
box-shadow: 0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.5), 0 12px 32px rgba(0,0,0,0.2);
}
}
@keyframes gentle-bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
@keyframes gentle-sway {
0%, 100% { transform: rotate(-2deg) scale(1); }
50% { transform: rotate(2deg) scale(1.05); }
}
@keyframes breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.03); }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-6px); }
}
@keyframes turn-entrance {
0% {
transform: scale(0.8) rotate(-10deg);
opacity: 0.6;
}
50% {
transform: scale(1.1) rotate(5deg);
opacity: 1;
}
100% {
transform: scale(1.08) rotate(0deg);
opacity: 1;
}
}
@keyframes streak-pulse {
0%, 100% {
opacity: 0.9;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.05);
}
}
@keyframes great-celebration {
0% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #22c55e40, 0 12px 32px rgba(0,0,0,0.2);
}
50% {
transform: scale(1.12) translateY(-6px);
box-shadow: 0 0 0 2px white, 0 0 0 8px #22c55e60, 0 15px 35px rgba(34,197,94,0.3);
}
100% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #22c55e40, 0 12px 32px rgba(0,0,0,0.2);
}
}
@keyframes epic-celebration {
0% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #f97316, 0 12px 32px rgba(0,0,0,0.2);
}
25% {
transform: scale(1.15) translateY(-8px) rotate(2deg);
box-shadow: 0 0 0 3px white, 0 0 0 10px #f97316, 0 18px 40px rgba(249,115,22,0.4);
}
75% {
transform: scale(1.15) translateY(-8px) rotate(-2deg);
box-shadow: 0 0 0 3px white, 0 0 0 10px #f97316, 0 18px 40px rgba(249,115,22,0.4);
}
100% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #f97316, 0 12px 32px rgba(0,0,0,0.2);
}
}
@keyframes legendary-celebration {
0% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2);
}
20% {
transform: scale(1.2) translateY(-12px) rotate(5deg);
box-shadow: 0 0 0 4px gold, 0 0 0 12px #a855f7, 0 25px 50px rgba(168,85,247,0.5);
}
40% {
transform: scale(1.18) translateY(-10px) rotate(-3deg);
box-shadow: 0 0 0 3px gold, 0 0 0 10px #a855f7, 0 20px 45px rgba(168,85,247,0.4);
}
60% {
transform: scale(1.22) translateY(-14px) rotate(3deg);
box-shadow: 0 0 0 4px gold, 0 0 0 12px #a855f7, 0 25px 50px rgba(168,85,247,0.5);
}
80% {
transform: scale(1.15) translateY(-8px) rotate(-1deg);
box-shadow: 0 0 0 3px gold, 0 0 0 8px #a855f7, 0 18px 40px rgba(168,85,247,0.3);
}
100% {
transform: scale(1.08) translateY(-4px);
box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2);
}
}
`
// Component to inject animations
const AnimationProvider = ({ children }: { children: React.ReactNode }) => {
useEffect(() => {
if (typeof document !== 'undefined' && !document.getElementById('celebration-animations')) {
const style = document.createElement('style')
style.id = 'celebration-animations'
style.textContent = celebrationAnimations
document.head.appendChild(style)
}
}, [])
return <>{children}</>
}
const meta: Meta = {
title: 'Games/Matching/PlayerStatusBar',
parameters: {
layout: 'centered',
docs: {
description: {
component: `
The PlayerStatusBar component displays the current state of players in the matching game.
It shows different layouts for single player vs multiplayer modes and includes escalating
celebration effects for consecutive matching pairs.
## Features
- Single player mode with epic styling
- Multiplayer mode with competitive grid layout
- Escalating celebration animations based on consecutive matches:
- 2+ matches: Great celebration (green)
- 3+ matches: Epic celebration (orange)
- 5+ matches: Legendary celebration (purple with gold accents)
- Real-time turn indicators
- Score tracking and progress display
- Responsive design for mobile and desktop
## Animation Preview
The animations demonstrate different celebration levels that activate when players get consecutive matches.
`
}
}
},
decorators: [
(Story) => (
<AnimationProvider>
<div className={css({
width: '800px',
maxWidth: '90vw',
padding: '20px',
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
minHeight: '400px'
})}>
<Story />
</div>
</AnimationProvider>
)
]
}
export default meta
type Story = StoryObj<typeof meta>
// Create a mock player card component that showcases the animations
const MockPlayerCard = ({
emoji,
name,
score,
consecutiveMatches,
isCurrentPlayer = true,
celebrationLevel
}: {
emoji: string
name: string
score: number
consecutiveMatches: number
isCurrentPlayer?: boolean
celebrationLevel: 'normal' | 'great' | 'epic' | 'legendary'
}) => {
const playerColor = celebrationLevel === 'legendary' ? '#a855f7' :
celebrationLevel === 'epic' ? '#f97316' :
celebrationLevel === 'great' ? '#22c55e' : '#3b82f6'
return (
<div className={css({
display: 'flex',
alignItems: 'center',
gap: { base: '3', md: '4' },
p: isCurrentPlayer ? { base: '4', md: '6' } : { base: '2', md: '3' },
rounded: isCurrentPlayer ? '2xl' : 'lg',
background: isCurrentPlayer
? `linear-gradient(135deg, ${playerColor}15, ${playerColor}25, ${playerColor}15)`
: 'white',
border: isCurrentPlayer ? '4px solid' : '2px solid',
borderColor: isCurrentPlayer ? playerColor : 'gray.200',
boxShadow: isCurrentPlayer
? `0 0 0 2px white, 0 0 0 6px ${playerColor}40, 0 12px 32px rgba(0,0,0,0.2)`
: '0 2px 4px rgba(0,0,0,0.1)',
transition: 'all 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
position: 'relative',
transform: isCurrentPlayer ? 'scale(1.08) translateY(-4px)' : 'scale(1)',
zIndex: isCurrentPlayer ? 10 : 1,
animation: isCurrentPlayer
? (celebrationLevel === 'legendary' ? 'legendary-celebration 0.8s ease-out, turn-entrance 0.6s ease-out'
: celebrationLevel === 'epic' ? 'epic-celebration 0.7s ease-out, turn-entrance 0.6s ease-out'
: celebrationLevel === 'great' ? 'great-celebration 0.6s ease-out, turn-entrance 0.6s ease-out'
: 'turn-entrance 0.6s ease-out')
: 'none'
})}>
{/* Player emoji */}
<div className={css({
fontSize: isCurrentPlayer ? { base: '3xl', md: '5xl' } : { base: 'lg', md: 'xl' },
flexShrink: 0,
animation: isCurrentPlayer
? 'float 3s ease-in-out infinite'
: 'breathe 5s ease-in-out infinite',
transform: isCurrentPlayer ? 'scale(1.2)' : 'scale(1)',
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
textShadow: isCurrentPlayer ? '0 0 20px currentColor' : 'none'
})}>
{emoji}
</div>
{/* Player info */}
<div className={css({
flex: 1,
minWidth: 0
})}>
<div className={css({
fontSize: isCurrentPlayer ? { base: 'md', md: 'lg' } : { base: 'xs', md: 'sm' },
fontWeight: 'black',
color: isCurrentPlayer ? 'gray.900' : 'gray.700',
textShadow: isCurrentPlayer ? '0 0 10px currentColor' : 'none'
})}>
{name}
</div>
<div className={css({
fontSize: isCurrentPlayer ? { base: 'sm', md: 'md' } : { base: '2xs', md: 'xs' },
color: isCurrentPlayer ? playerColor : 'gray.500',
fontWeight: isCurrentPlayer ? 'black' : 'semibold'
})}>
{gamePlurals.pair(score)}
{isCurrentPlayer && (
<span className={css({
color: 'red.600',
fontWeight: 'black',
fontSize: isCurrentPlayer ? { base: 'sm', md: 'lg' } : 'inherit',
textShadow: '0 0 15px currentColor'
})}>
{' • Your turn'}
</span>
)}
{consecutiveMatches > 1 && (
<div className={css({
fontSize: { base: '2xs', md: 'xs' },
color: celebrationLevel === 'legendary' ? 'purple.600' :
celebrationLevel === 'epic' ? 'orange.600' :
celebrationLevel === 'great' ? 'green.600' : 'gray.500',
fontWeight: 'black',
animation: isCurrentPlayer ? 'streak-pulse 1s ease-in-out infinite' : 'none',
textShadow: isCurrentPlayer ? '0 0 10px currentColor' : 'none'
})}>
🔥 {consecutiveMatches} streak!
</div>
)}
</div>
</div>
{/* Epic score display */}
{isCurrentPlayer && (
<div className={css({
background: 'linear-gradient(135deg, #ff6b6b, #ee5a24)',
color: 'white',
px: { base: '3', md: '4' },
py: { base: '2', md: '3' },
rounded: 'xl',
fontSize: { base: 'lg', md: 'xl' },
fontWeight: 'black',
boxShadow: '0 4px 15px rgba(238, 90, 36, 0.4)',
animation: 'gentle-bounce 1.5s ease-in-out infinite',
textShadow: '0 0 10px rgba(255,255,255,0.8)'
})}>
{score}
</div>
)}
</div>
)
}
// Normal celebration level
export const NormalPlayer: Story = {
render: () => (
<MockPlayerCard
emoji="🚀"
name="Solo Champion"
score={3}
consecutiveMatches={0}
celebrationLevel="normal"
/>
)
}
// Great celebration level
export const GreatStreak: Story = {
render: () => (
<MockPlayerCard
emoji="🎯"
name="Streak Master"
score={5}
consecutiveMatches={2}
celebrationLevel="great"
/>
)
}
// Epic celebration level
export const EpicStreak: Story = {
render: () => (
<MockPlayerCard
emoji="🔥"
name="Epic Matcher"
score={7}
consecutiveMatches={4}
celebrationLevel="epic"
/>
)
}
// Legendary celebration level
export const LegendaryStreak: Story = {
render: () => (
<MockPlayerCard
emoji="👑"
name="Legend"
score={8}
consecutiveMatches={6}
celebrationLevel="legendary"
/>
)
}
// All levels showcase
export const AllCelebrationLevels: Story = {
render: () => (
<div className={css({ display: 'flex', flexDirection: 'column', gap: '20px' })}>
<h3 className={css({ textAlign: 'center', fontSize: '24px', fontWeight: 'bold', marginBottom: '20px' })}>
Consecutive Match Celebration Levels
</h3>
<div className={css({ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(380px, 1fr))', gap: '20px' })}>
{/* Normal */}
<div>
<h4 className={css({ textAlign: 'center', marginBottom: '10px', fontSize: '16px', fontWeight: 'bold' })}>
Normal (0-1 matches)
</h4>
<MockPlayerCard
emoji="🚀"
name="Solo Champion"
score={3}
consecutiveMatches={0}
celebrationLevel="normal"
/>
</div>
{/* Great */}
<div>
<h4 className={css({ textAlign: 'center', marginBottom: '10px', color: 'green.600', fontSize: '16px', fontWeight: 'bold' })}>
Great (2+ matches)
</h4>
<MockPlayerCard
emoji="🎯"
name="Streak Master"
score={5}
consecutiveMatches={2}
celebrationLevel="great"
/>
</div>
{/* Epic */}
<div>
<h4 className={css({ textAlign: 'center', marginBottom: '10px', color: 'orange.600', fontSize: '16px', fontWeight: 'bold' })}>
Epic (3+ matches)
</h4>
<MockPlayerCard
emoji="🔥"
name="Epic Matcher"
score={7}
consecutiveMatches={4}
celebrationLevel="epic"
/>
</div>
{/* Legendary */}
<div>
<h4 className={css({ textAlign: 'center', marginBottom: '10px', color: 'purple.600', fontSize: '16px', fontWeight: 'bold' })}>
Legendary (5+ matches)
</h4>
<MockPlayerCard
emoji="👑"
name="Legend"
score={8}
consecutiveMatches={6}
celebrationLevel="legendary"
/>
</div>
</div>
<div className={css({
textAlign: 'center',
marginTop: '20px',
padding: '16px',
background: 'rgba(255,255,255,0.8)',
borderRadius: '12px',
border: '1px solid rgba(0,0,0,0.1)'
})}>
<p className={css({ fontSize: '14px', color: 'gray.700', margin: 0 })}>
These animations trigger when a player gets consecutive matching pairs in the memory matching game.
The celebrations get more intense as the streak grows, providing visual feedback and excitement!
</p>
</div>
</div>
),
parameters: {
layout: 'fullscreen'
}
}

View File

@@ -1,260 +0,0 @@
'use client'
import { useState } from 'react'
import { TypstSoroban } from '@/components/TypstSoroban'
import { css } from '../../../styled-system/css'
import { container, stack, grid, hstack } from '../../../styled-system/patterns'
export default function TestTypstPage() {
const [selectedNumber, setSelectedNumber] = useState(23)
const [generationCount, setGenerationCount] = useState(0)
const [errorCount, setErrorCount] = useState(0)
const testNumbers = [5, 23, 67, 123, 456]
return (
<div className={css({ minH: 'screen', bg: 'gray.50', py: '8' })}>
<div className={container({ maxW: '6xl', px: '4' })}>
<div className={stack({ gap: '8' })}>
{/* Header */}
<div className={stack({ gap: '4', textAlign: 'center' })}>
<h1 className={css({
fontSize: '3xl',
fontWeight: 'bold',
color: 'gray.900'
})}>
Typst.ts Integration Test
</h1>
<p className={css({
fontSize: 'lg',
color: 'gray.600'
})}>
Testing browser-only Soroban SVG generation (no server fallback)
</p>
<div className={hstack({ gap: '6', justify: 'center' })}>
<div className={css({
px: '3',
py: '1',
bg: 'green.100',
color: 'green.700',
rounded: 'full',
fontSize: 'sm'
})}>
Generated: {generationCount}
</div>
<div className={css({
px: '3',
py: '1',
bg: errorCount > 0 ? 'red.100' : 'gray.100',
color: errorCount > 0 ? 'red.700' : 'gray.700',
rounded: 'full',
fontSize: 'sm'
})}>
Errors: {errorCount}
</div>
</div>
</div>
{/* Number Selector */}
<div className={css({
bg: 'white',
rounded: 'xl',
p: '6',
shadow: 'card'
})}>
<h3 className={css({
fontSize: 'lg',
fontWeight: 'semibold',
mb: '4'
})}>
Select Number to Generate
</h3>
<div className={hstack({ gap: '3', flexWrap: 'wrap' })}>
{testNumbers.map((num) => (
<button
key={num}
onClick={() => setSelectedNumber(num)}
className={css({
px: '4',
py: '2',
rounded: 'lg',
border: '2px solid',
borderColor: selectedNumber === num ? 'brand.600' : 'gray.200',
bg: selectedNumber === num ? 'brand.50' : 'white',
color: selectedNumber === num ? 'brand.700' : 'gray.700',
fontWeight: 'medium',
transition: 'all',
_hover: {
borderColor: 'brand.400',
bg: 'brand.25'
}
})}
>
{num}
</button>
))}
<input
type="number"
value={selectedNumber}
onChange={(e) => setSelectedNumber(parseInt(e.target.value) || 0)}
className={css({
w: '20',
px: '3',
py: '2',
border: '2px solid',
borderColor: 'gray.200',
rounded: 'lg',
fontSize: 'sm',
_focus: {
borderColor: 'brand.400',
outline: 'none'
}
})}
min="0"
max="9999"
/>
</div>
</div>
{/* Generated Soroban */}
<div className={css({
bg: 'white',
rounded: 'xl',
p: '6',
shadow: 'card'
})}>
<h3 className={css({
fontSize: 'lg',
fontWeight: 'semibold',
mb: '4'
})}>
Generated Soroban (Number: {selectedNumber})
</h3>
<div className={css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minH: '300px',
bg: 'gray.50',
rounded: 'lg',
border: '1px solid',
borderColor: 'gray.200'
})}>
<TypstSoroban
number={selectedNumber}
width="200pt"
height="250pt"
enableServerFallback={false}
lazy={false}
onSuccess={() => setGenerationCount(prev => prev + 1)}
onError={() => setErrorCount(prev => prev + 1)}
className={css({
maxW: 'sm',
maxH: '400px'
})}
/>
</div>
</div>
{/* Test Grid */}
<div className={css({
bg: 'white',
rounded: 'xl',
p: '6',
shadow: 'card'
})}>
<h3 className={css({
fontSize: 'lg',
fontWeight: 'semibold',
mb: '4'
})}>
Test Grid (Multiple Numbers)
</h3>
<div className={grid({
columns: { base: 2, md: 3, lg: 5 },
gap: '4'
})}>
{testNumbers.map((num, index) => (
<div
key={num}
className={css({
aspectRatio: '3/4',
bg: 'gray.50',
rounded: 'lg',
border: '1px solid',
borderColor: 'gray.200',
overflow: 'hidden'
})}
>
<div className={css({
p: '2',
bg: 'white',
borderBottom: '1px solid',
borderColor: 'gray.200',
textAlign: 'center',
fontSize: 'sm',
fontWeight: 'medium',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '1'
})}>
{num}
{index > 1 && <span className={css({ fontSize: 'xs', color: 'blue.600' })}>lazy</span>}
</div>
<div className={css({ p: '2', h: 'full' })}>
<TypstSoroban
number={num}
width="100pt"
height="120pt"
enableServerFallback={false}
lazy={index > 1} // Make the last 3 components lazy
onSuccess={() => setGenerationCount(prev => prev + 1)}
onError={() => setErrorCount(prev => prev + 1)}
className={css({ w: 'full', h: 'full' })}
/>
</div>
</div>
))}
</div>
</div>
{/* Info */}
<div className={css({
bg: 'blue.50',
border: '1px solid',
borderColor: 'blue.200',
rounded: 'xl',
p: '6'
})}>
<h3 className={css({
fontSize: 'lg',
fontWeight: 'semibold',
color: 'blue.900',
mb: '3'
})}>
About This Test
</h3>
<div className={stack({ gap: '2' })}>
<p className={css({ color: 'blue.800', fontSize: 'sm' })}>
This page tests the typst.ts integration for generating Soroban SVGs directly in the browser
</p>
<p className={css({ color: 'blue.800', fontSize: 'sm' })}>
No Python bridge required - everything runs natively in TypeScript/WebAssembly
</p>
<p className={css({ color: 'blue.800', fontSize: 'sm' })}>
WASM preloading starts automatically in background for better performance
</p>
<p className={css({ color: 'blue.800', fontSize: 'sm' })}>
Lazy loading demo: Last 3 grid items show placeholders until clicked (progressive enhancement)
</p>
<p className={css({ color: 'blue.800', fontSize: 'sm' })}>
Global abacus display settings are automatically applied
</p>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,232 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
interface TemplateExample {
id: string;
title: string;
description: string;
number: number;
config: {
bead_shape?: 'diamond' | 'circle' | 'square';
color_scheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating';
base_size?: number;
columns?: number | 'auto';
show_empty?: boolean;
hide_inactive?: boolean;
};
}
const examples: TemplateExample[] = [
{
id: 'basic-5',
title: 'Basic Number 5',
description: 'Simple representation of 5 with default settings',
number: 5,
config: {}
},
{
id: 'diamond-123',
title: 'Diamond Beads - 123',
description: 'Number 123 with diamond beads and place-value colors',
number: 123,
config: {
bead_shape: 'diamond',
color_scheme: 'place-value',
base_size: 1.2
}
},
{
id: 'circle-1234',
title: 'Circle Beads - 1234',
description: 'Larger number with circular beads and heaven-earth colors',
number: 1234,
config: {
bead_shape: 'circle',
color_scheme: 'heaven-earth',
base_size: 1.0
}
},
{
id: 'large-scale-42',
title: 'Large Scale - 42',
description: 'Number 42 with larger scale for detail work',
number: 42,
config: {
bead_shape: 'diamond',
color_scheme: 'place-value',
base_size: 2.0
}
},
{
id: 'minimal-999',
title: 'Minimal - 999',
description: 'Compact rendering with hidden inactive beads',
number: 999,
config: {
bead_shape: 'square',
color_scheme: 'monochrome',
hide_inactive: true,
base_size: 0.8
}
},
{
id: 'alternating-colors-567',
title: 'Alternating Colors - 567',
description: 'Mid-range number with alternating color scheme',
number: 567,
config: {
bead_shape: 'circle',
color_scheme: 'alternating',
base_size: 1.5
}
}
];
export default function TypstGallery() {
const [renderings, setRenderings] = useState<Record<string, string>>({});
const [loading, setLoading] = useState<Record<string, boolean>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const generateSvg = async (example: TemplateExample) => {
setLoading(prev => ({ ...prev, [example.id]: true }));
setErrors(prev => ({ ...prev, [example.id]: '' }));
try {
const response = await fetch('/api/typst-svg', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
number: example.number,
...example.config
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setRenderings(prev => ({ ...prev, [example.id]: data.svg }));
} catch (error) {
console.error('Error generating SVG:', error);
setErrors(prev => ({
...prev,
[example.id]: error instanceof Error ? error.message : 'Unknown error'
}));
} finally {
setLoading(prev => ({ ...prev, [example.id]: false }));
}
};
const generateAll = async () => {
for (const example of examples) {
await generateSvg(example);
// Small delay between requests to avoid overwhelming the server
await new Promise(resolve => setTimeout(resolve, 100));
}
};
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
🧮 Soroban Template Gallery
</h1>
<p className="text-lg text-gray-600 mb-6">
Interactive preview of soroban template renderings with different configurations
</p>
<button
onClick={generateAll}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-medium transition-colors"
>
Generate All Examples
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{examples.map((example) => (
<div key={example.id} className="bg-white rounded-lg shadow-md overflow-hidden">
<div className="p-6">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
{example.title}
</h3>
<p className="text-gray-600 text-sm mb-4">
{example.description}
</p>
</div>
<button
onClick={() => generateSvg(example)}
disabled={loading[example.id]}
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-3 py-1 rounded text-sm font-medium transition-colors"
>
{loading[example.id] ? '🔄' : '🎨'}
</button>
</div>
{/* Configuration Details */}
<div className="mb-4 p-3 bg-gray-50 rounded text-xs">
<div className="font-medium text-gray-700 mb-1">Configuration:</div>
<div className="text-gray-600">
<div>Number: <code>{example.number}</code></div>
{Object.entries(example.config).map(([key, value]) => (
<div key={key}>
{key}: <code>{String(value)}</code>
</div>
))}
</div>
</div>
{/* Rendering Area */}
<div className="border rounded-lg p-4 bg-gray-50 min-h-[200px] flex items-center justify-center">
{loading[example.id] && (
<div className="text-center">
<div className="animate-spin text-2xl mb-2">🔄</div>
<div className="text-gray-600">Generating...</div>
</div>
)}
{errors[example.id] && (
<div className="text-center text-red-600">
<div className="text-2xl mb-2"></div>
<div className="text-sm">{errors[example.id]}</div>
</div>
)}
{renderings[example.id] && !loading[example.id] && (
<div
dangerouslySetInnerHTML={{ __html: renderings[example.id] }}
className="max-w-full"
/>
)}
{!loading[example.id] && !errors[example.id] && !renderings[example.id] && (
<div className="text-center text-gray-500">
<div className="text-2xl mb-2">🧮</div>
<div className="text-sm">Click generate to render</div>
</div>
)}
</div>
</div>
</div>
))}
</div>
{/* Footer */}
<div className="text-center mt-12 text-gray-600">
<p>
Powered by <strong>@soroban/templates</strong> and <strong>typst.ts</strong>
</p>
<p className="text-sm mt-2">
This gallery helps visualize different template configurations before implementation
</p>
</div>
</div>
</div>
);
}

View File

@@ -1,335 +0,0 @@
'use client';
import { useState, useCallback } from 'react';
interface TypstConfig {
number: number;
bead_shape: 'diamond' | 'circle' | 'square';
color_scheme: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating';
color_palette: 'default' | 'colorblind' | 'mnemonic' | 'grayscale' | 'nature';
base_size: number;
columns: number | 'auto';
show_empty: boolean;
hide_inactive: boolean;
}
const defaultConfig: TypstConfig = {
number: 123,
bead_shape: 'diamond',
color_scheme: 'place-value',
color_palette: 'default',
base_size: 1.0,
columns: 'auto',
show_empty: false,
hide_inactive: false,
};
const presets: Record<string, Partial<TypstConfig>> = {
'Classic': { bead_shape: 'diamond', color_scheme: 'monochrome', base_size: 1.0 },
'Colorful': { bead_shape: 'circle', color_scheme: 'place-value', color_palette: 'default', base_size: 1.2 },
'Educational': { bead_shape: 'circle', color_scheme: 'heaven-earth', show_empty: true, base_size: 1.5 },
'Minimal': { bead_shape: 'square', color_scheme: 'monochrome', hide_inactive: true, base_size: 0.8 },
'Accessible': { bead_shape: 'circle', color_scheme: 'place-value', color_palette: 'colorblind', base_size: 1.3 },
};
export default function TypstPlayground() {
const [config, setConfig] = useState<TypstConfig>(defaultConfig);
const [svg, setSvg] = useState<string>('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [lastGenerated, setLastGenerated] = useState<string>('');
const generateSvg = useCallback(async (currentConfig: TypstConfig) => {
setLoading(true);
setError('');
try {
const response = await fetch('/api/typst-svg', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(currentConfig),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setSvg(data.svg);
setLastGenerated(new Date().toLocaleTimeString());
} catch (error) {
console.error('Error generating SVG:', error);
setError(error instanceof Error ? error.message : 'Unknown error');
} finally {
setLoading(false);
}
}, []);
const updateConfig = (key: keyof TypstConfig, value: any) => {
const newConfig = { ...config, [key]: value };
setConfig(newConfig);
// Auto-generate on config change (debounced)
setTimeout(() => generateSvg(newConfig), 500);
};
const applyPreset = (presetName: string) => {
const newConfig = { ...config, ...presets[presetName] };
setConfig(newConfig);
generateSvg(newConfig);
};
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
🛠 Soroban Template Playground
</h1>
<p className="text-lg text-gray-600">
Interactive tool for testing soroban template configurations in real-time
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Configuration Panel */}
<div className="lg:col-span-1">
<div className="bg-white rounded-lg shadow-md p-6 sticky top-8">
<h2 className="text-xl font-semibold mb-6">Configuration</h2>
{/* Presets */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-3">
Quick Presets
</label>
<div className="grid grid-cols-2 gap-2">
{Object.keys(presets).map((preset) => (
<button
key={preset}
onClick={() => applyPreset(preset)}
className="px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded transition-colors"
>
{preset}
</button>
))}
</div>
</div>
{/* Number Input */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Number
</label>
<input
type="number"
value={config.number}
onChange={(e) => updateConfig('number', parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
min="0"
max="99999"
/>
</div>
{/* Bead Shape */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Bead Shape
</label>
<select
value={config.bead_shape}
onChange={(e) => updateConfig('bead_shape', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="diamond">Diamond</option>
<option value="circle">Circle</option>
<option value="square">Square</option>
</select>
</div>
{/* Color Scheme */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Color Scheme
</label>
<select
value={config.color_scheme}
onChange={(e) => updateConfig('color_scheme', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="monochrome">Monochrome</option>
<option value="place-value">Place Value</option>
<option value="heaven-earth">Heaven & Earth</option>
<option value="alternating">Alternating</option>
</select>
</div>
{/* Color Palette */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Color Palette
</label>
<select
value={config.color_palette}
onChange={(e) => updateConfig('color_palette', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="default">Default</option>
<option value="colorblind">Colorblind Friendly</option>
<option value="mnemonic">Mnemonic</option>
<option value="grayscale">Grayscale</option>
<option value="nature">Nature</option>
</select>
</div>
{/* Base Size */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Scale: {config.base_size}x
</label>
<input
type="range"
min="0.5"
max="3.0"
step="0.1"
value={config.base_size}
onChange={(e) => updateConfig('base_size', parseFloat(e.target.value))}
className="w-full"
/>
</div>
{/* Columns */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Columns
</label>
<select
value={config.columns}
onChange={(e) => updateConfig('columns', e.target.value === 'auto' ? 'auto' : parseInt(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="auto">Auto</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
</div>
{/* Toggles */}
<div className="mb-4">
<label className="flex items-center">
<input
type="checkbox"
checked={config.show_empty}
onChange={(e) => updateConfig('show_empty', e.target.checked)}
className="mr-2"
/>
<span className="text-sm text-gray-700">Show empty columns</span>
</label>
</div>
<div className="mb-6">
<label className="flex items-center">
<input
type="checkbox"
checked={config.hide_inactive}
onChange={(e) => updateConfig('hide_inactive', e.target.checked)}
className="mr-2"
/>
<span className="text-sm text-gray-700">Hide inactive beads</span>
</label>
</div>
{/* Generate Button */}
<button
onClick={() => generateSvg(config)}
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white py-3 px-4 rounded-md font-medium transition-colors"
>
{loading ? 'Generating...' : 'Generate Now'}
</button>
{lastGenerated && (
<p className="text-xs text-gray-500 mt-2 text-center">
Last updated: {lastGenerated}
</p>
)}
</div>
</div>
{/* Preview Panel */}
<div className="lg:col-span-2">
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold">Live Preview</h2>
<div className="text-sm text-gray-600">
Number: <code className="bg-gray-100 px-2 py-1 rounded">{config.number}</code>
</div>
</div>
{/* Preview Area */}
<div className="border-2 border-dashed border-gray-200 rounded-lg p-8 min-h-[400px] flex items-center justify-center bg-gradient-to-br from-gray-50 to-white">
{loading && (
<div className="text-center">
<div className="animate-spin text-4xl mb-4">🔄</div>
<div className="text-gray-600">Generating soroban...</div>
</div>
)}
{error && (
<div className="text-center text-red-600">
<div className="text-4xl mb-4"></div>
<div className="font-medium">Generation Error</div>
<div className="text-sm mt-2">{error}</div>
</div>
)}
{svg && !loading && (
<div
dangerouslySetInnerHTML={{ __html: svg }}
className="max-w-full max-h-full"
/>
)}
{!svg && !loading && !error && (
<div className="text-center text-gray-500">
<div className="text-4xl mb-4">🧮</div>
<div className="font-medium">Configure and Generate</div>
<div className="text-sm mt-2">Adjust settings and click generate to see your soroban</div>
</div>
)}
</div>
{/* Configuration Summary */}
{svg && (
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
<h3 className="font-medium text-gray-900 mb-2">Current Configuration</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-600">Shape:</span>{' '}
<span className="font-mono">{config.bead_shape}</span>
</div>
<div>
<span className="text-gray-600">Colors:</span>{' '}
<span className="font-mono">{config.color_scheme}</span>
</div>
<div>
<span className="text-gray-600">Scale:</span>{' '}
<span className="font-mono">{config.base_size}x</span>
</div>
<div>
<span className="text-gray-600">Columns:</span>{' '}
<span className="font-mono">{config.columns}</span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,405 +0,0 @@
'use client'
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { generateSorobanSVG, getWasmStatus, triggerWasmPreload, type SorobanConfig } from '@/lib/typst-soroban'
import { css } from '../../styled-system/css'
import { useAbacusConfig } from '@soroban/abacus-react'
interface TypstSorobanProps {
number: number
width?: string
height?: string
className?: string
onError?: (error: string) => void
onSuccess?: () => void
enableServerFallback?: boolean
lazy?: boolean // New prop for lazy loading
transparent?: boolean // New prop for transparent background
}
export function TypstSoroban({
number,
width = '120pt',
height = '160pt',
className,
onError,
onSuccess,
enableServerFallback = false,
lazy = false,
transparent = false
}: TypstSorobanProps) {
const [svg, setSvg] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(!lazy) // Don't start loading if lazy
const [error, setError] = useState<string | null>(null)
const [shouldLoad, setShouldLoad] = useState(!lazy) // Control when loading starts
const globalConfig = useAbacusConfig()
const abortControllerRef = useRef<AbortController | null>(null)
const currentConfigRef = useRef<any>(null)
// Memoize the config to prevent unnecessary re-renders
const stableConfig = useMemo(() => ({
beadShape: globalConfig.beadShape,
colorScheme: globalConfig.colorScheme,
hideInactiveBeads: globalConfig.hideInactiveBeads,
coloredNumerals: globalConfig.coloredNumerals,
scaleFactor: globalConfig.scaleFactor
}), [
globalConfig.beadShape,
globalConfig.colorScheme,
globalConfig.hideInactiveBeads,
globalConfig.coloredNumerals,
globalConfig.scaleFactor
])
useEffect(() => {
if (!shouldLoad) return
console.log(`🔄 TypstSoroban useEffect triggered for number ${number}, hasExistingSVG: ${!!svg}`)
async function generateSVG() {
// Create current config signature
const currentConfig = {
number,
width,
height,
beadShape: stableConfig.beadShape,
colorScheme: stableConfig.colorScheme,
hideInactiveBeads: stableConfig.hideInactiveBeads,
coloredNumerals: stableConfig.coloredNumerals,
scaleFactor: stableConfig.scaleFactor,
transparent,
enableServerFallback
}
// Check if config changed since last render
const configChanged = JSON.stringify(currentConfig) !== JSON.stringify(currentConfigRef.current)
// Don't regenerate if we already have an SVG for this exact config
if (svg && !error && !configChanged) {
console.log(`✅ Skipping regeneration for ${number} - already have SVG with same config`)
return
}
if (configChanged) {
console.log(`🔄 Config changed for ${number}, regenerating SVG`)
// Clear existing SVG to show fresh loading state for config changes
setSvg(null)
}
// Update config ref
currentConfigRef.current = currentConfig
// Cancel any previous generation
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
abortControllerRef.current = new AbortController()
const signal = abortControllerRef.current.signal
// Check cache quickly before showing loading state
const cacheKey = JSON.stringify({
number,
width,
height,
beadShape: stableConfig.beadShape,
colorScheme: stableConfig.colorScheme,
hideInactiveBeads: stableConfig.hideInactiveBeads,
coloredNumerals: stableConfig.coloredNumerals,
scaleFactor: stableConfig.scaleFactor,
transparent,
enableServerFallback
})
setError(null)
try {
// Try generation immediately to see if it's cached
const config: SorobanConfig = {
number,
width,
height,
beadShape: stableConfig.beadShape,
colorScheme: stableConfig.colorScheme,
hideInactiveBeads: stableConfig.hideInactiveBeads,
coloredNumerals: stableConfig.coloredNumerals,
scaleFactor: stableConfig.scaleFactor,
transparent,
enableServerFallback
}
// Set loading only after a delay if generation is slow
const loadingTimeout = setTimeout(() => {
if (!signal.aborted) {
setIsLoading(true)
}
}, 100)
const generatedSvg = await generateSorobanSVG(config)
// Clear timeout since we got result quickly
clearTimeout(loadingTimeout)
if (signal.aborted) return
// Crop the SVG to remove whitespace around abacus
const croppedSvg = cropSVGToContent(generatedSvg)
setSvg(croppedSvg)
setTimeout(() => onSuccess?.(), 0)
} catch (err) {
if (signal.aborted) return
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
setError(errorMessage)
console.error('TypstSoroban generation error:', err)
setTimeout(() => onError?.(errorMessage), 0)
} finally {
if (!signal.aborted) {
setIsLoading(false)
}
}
}
generateSVG()
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
}
}, [shouldLoad, number, width, height, stableConfig, enableServerFallback, transparent])
// Handler to trigger loading on user interaction
const handleLoadTrigger = useCallback(() => {
if (!shouldLoad && !isLoading && !svg) {
setShouldLoad(true)
// Also trigger WASM preload if not already started
triggerWasmPreload()
}
}, [shouldLoad, isLoading, svg])
// Show lazy loading placeholder
if (lazy && !shouldLoad && !svg) {
const wasmStatus = getWasmStatus()
return (
<div
className={className}
onClick={handleLoadTrigger}
onMouseEnter={handleLoadTrigger}
>
<div className={css({
w: 'full',
h: 'full',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bg: wasmStatus.isLoaded ? 'green.25' : 'gray.50',
rounded: 'md',
minH: '200px',
cursor: 'pointer',
transition: 'all',
_hover: {
bg: wasmStatus.isLoaded ? 'green.50' : 'gray.100',
transform: 'scale(1.02)'
}
})}>
<div className={css({
fontSize: '4xl',
opacity: '0.6',
transition: 'all',
_hover: { opacity: '0.8' }
})}>
{wasmStatus.isLoaded ? '🚀' : '🧮'}
</div>
</div>
</div>
)
}
if (isLoading) {
return (
<div className={className}>
<div className={css({
w: 'full',
h: 'full',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bg: 'blue.25',
rounded: 'md',
minH: '200px'
})}>
<div className={css({
w: '6',
h: '6',
border: '2px solid',
borderColor: 'blue.200',
borderTopColor: 'blue.500',
rounded: 'full',
animation: 'spin 1s linear infinite'
})} />
</div>
</div>
)
}
if (error) {
return (
<div className={className}>
<div className={css({
w: 'full',
h: 'full',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bg: 'red.25',
rounded: 'md',
minH: '200px'
})}>
<div className={css({ fontSize: '3xl', opacity: '0.6' })}></div>
</div>
</div>
)
}
if (!svg) {
return (
<div className={className}>
<div className={css({
w: 'full',
h: 'full',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bg: 'gray.50',
rounded: 'md',
minH: '200px'
})}>
<div className={css({ color: 'gray.500', fontSize: 'sm' })}>
No SVG generated
</div>
</div>
</div>
)
}
return (
<div className={className}>
<div
className={css({
w: 'full',
h: 'full',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
'& svg': {
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain'
}
})}
dangerouslySetInnerHTML={{ __html: svg }}
/>
</div>
)
}
// Optional: Create a hook for easier usage
export function useTypstSoroban(config: SorobanConfig) {
const [svg, setSvg] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const generate = async () => {
setIsLoading(true)
setError(null)
setSvg(null)
try {
const generatedSvg = await generateSorobanSVG(config)
setSvg(generatedSvg)
return generatedSvg
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
setError(errorMessage)
throw err
} finally {
setIsLoading(false)
}
}
return {
svg,
isLoading,
error,
generate
}
}
// SVG cropping function to remove whitespace around abacus content
function cropSVGToContent(svgContent: string): string {
try {
const parser = new DOMParser()
const svgDoc = parser.parseFromString(svgContent, 'image/svg+xml')
const svgElement = svgDoc.documentElement
if (svgElement.tagName !== 'svg') {
return svgContent
}
// Get all visible elements and calculate their combined bounding box
const elements = svgElement.querySelectorAll('path, circle, rect, line, polygon, ellipse')
const bounds: { x: number; y: number; width: number; height: number }[] = []
elements.forEach(element => {
try {
const bbox = (element as SVGGraphicsElement).getBBox()
if (bbox.width > 0 && bbox.height > 0) {
bounds.push(bbox)
}
} catch (e) {
// Skip elements that can't be measured
}
})
if (bounds.length === 0) {
return svgContent // No measurable content found
}
// Calculate the combined bounding box
const minX = Math.min(...bounds.map(b => b.x))
const maxX = Math.max(...bounds.map(b => b.x + b.width))
const minY = Math.min(...bounds.map(b => b.y))
const maxY = Math.max(...bounds.map(b => b.y + b.height))
// Add minimal padding
const padding = 5
const newX = minX - padding
const newY = minY - padding
const newWidth = (maxX - minX) + (padding * 2)
const newHeight = (maxY - minY) + (padding * 2)
// Create the new viewBox
const newViewBox = `${newX} ${newY} ${newWidth} ${newHeight}`
// Update the SVG viewBox while keeping original width/height
let croppedSvg = svgContent.replace(/viewBox="[^"]*"/, `viewBox="${newViewBox}"`)
// Ensure preserveAspectRatio is set for proper scaling
if (!croppedSvg.includes('preserveAspectRatio')) {
croppedSvg = croppedSvg.replace('<svg', '<svg preserveAspectRatio="xMidYMid meet"')
}
console.log(`🎯 SVG cropped: ${newWidth.toFixed(1)}x${newHeight.toFixed(1)} content in original SVG`)
return croppedSvg
} catch (error) {
console.warn('Failed to crop SVG:', error)
return svgContent // Return original if cropping fails
}
}

View File

@@ -1,703 +0,0 @@
// TypeScript module for generating Soroban SVGs using typst.ts
// This replaces the Python bridge with a browser-native solution
// Browser-side typst.ts rendering
let $typst: any = null
let isLoading = false
// Promise to track the initialization process
let typstInitializationPromise: Promise<any> | null = null
// Preloading state
let isPreloading = false
let preloadStartTime: number | null = null
// Start preloading WASM as soon as this module is imported
if (typeof window !== 'undefined') {
setTimeout(() => {
preloadTypstWasm()
}, 100) // Small delay to avoid blocking initial render
}
// SVG viewBox optimization - crops SVG to actual content bounds
function optimizeSvgViewBox(svgString: string): string {
try {
console.log('🔍 Starting SVG viewBox optimization...')
// Parse SVG to analyze content bounds
const parser = new DOMParser()
const doc = parser.parseFromString(svgString, 'image/svg+xml')
const svgElement = doc.querySelector('svg')
if (!svgElement) {
console.warn('❌ No SVG element found, returning original')
return svgString
}
// Extract original viewBox and dimensions for debugging
const originalViewBox = svgElement.getAttribute('viewBox')
const originalWidth = svgElement.getAttribute('width')
const originalHeight = svgElement.getAttribute('height')
console.log(`📊 Original SVG - viewBox: ${originalViewBox}, width: ${originalWidth}, height: ${originalHeight}`)
// Create a temporary element to measure bounds
const tempDiv = document.createElement('div')
tempDiv.style.position = 'absolute'
tempDiv.style.visibility = 'hidden'
tempDiv.style.top = '-9999px'
tempDiv.style.left = '-9999px'
tempDiv.innerHTML = svgString
document.body.appendChild(tempDiv)
const tempSvg = tempDiv.querySelector('svg')
if (!tempSvg) {
document.body.removeChild(tempDiv)
console.warn('❌ Could not create temp SVG element')
return svgString
}
// Get the bounding box of all content
try {
// Try multiple methods to get content bounds
let bbox: DOMRect | SVGRect
// Method 1: Try to get bbox of visible content elements (paths, circles, rects, etc.)
try {
const contentElements = tempSvg.querySelectorAll('path, circle, rect, line, polygon, polyline, text, g[stroke], g[fill]')
if (contentElements.length > 0) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
let foundValidBounds = false
contentElements.forEach(element => {
try {
const elementBBox = (element as SVGGraphicsElement).getBBox()
if (elementBBox.width > 0 && elementBBox.height > 0) {
minX = Math.min(minX, elementBBox.x)
minY = Math.min(minY, elementBBox.y)
maxX = Math.max(maxX, elementBBox.x + elementBBox.width)
maxY = Math.max(maxY, elementBBox.y + elementBBox.height)
foundValidBounds = true
}
} catch (e) {
// Skip elements that can't provide bbox
}
})
if (foundValidBounds) {
bbox = {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY
} as SVGRect
console.log(`📦 Content elements bbox successful: x=${bbox.x}, y=${bbox.y}, width=${bbox.width}, height=${bbox.height}`)
} else {
throw new Error('No valid content elements found')
}
} else {
throw new Error('No content elements found')
}
} catch (contentError) {
console.warn('⚠️ Content elements bbox failed, trying SVG getBBox():', contentError)
// Method 2: Try getBBox() on the SVG element
try {
bbox = tempSvg.getBBox()
console.log(`📦 SVG getBBox() successful: x=${bbox.x}, y=${bbox.y}, width=${bbox.width}, height=${bbox.height}`)
} catch (getBBoxError) {
console.warn('⚠️ SVG getBBox() failed, trying getBoundingClientRect():', getBBoxError)
// Method 3: Use getBoundingClientRect() as fallback
const clientRect = tempSvg.getBoundingClientRect()
bbox = {
x: 0,
y: 0,
width: clientRect.width || 200,
height: clientRect.height || 280
} as SVGRect
console.log(`📦 getBoundingClientRect() fallback: width=${bbox.width}, height=${bbox.height}`)
}
}
document.body.removeChild(tempDiv)
// Validate bounding box
if (!bbox || bbox.width <= 0 || bbox.height <= 0) {
console.warn(`❌ Invalid bounding box: ${JSON.stringify(bbox)}, returning original`)
return svgString
}
// Much more aggressive cropping - minimal padding only
const paddingX = Math.max(2, bbox.width * 0.01) // 1% padding, minimum 2 units
const paddingY = Math.max(2, bbox.height * 0.01)
const newX = Math.max(0, bbox.x - paddingX)
const newY = Math.max(0, bbox.y - paddingY)
const newWidth = bbox.width + (2 * paddingX)
const newHeight = bbox.height + (2 * paddingY)
// Failsafe: If the cropped SVG still has too much wasted space,
// use a more aggressive crop based on actual content ratio
const originalViewBox = svgElement.getAttribute('viewBox')
const originalDimensions = originalViewBox ? originalViewBox.split(' ').map(Number) : [0, 0, 400, 400]
const originalWidth = originalDimensions[2]
const originalHeight = originalDimensions[3]
const contentRatioX = bbox.width / originalWidth
const contentRatioY = bbox.height / originalHeight
// If content takes up less than 30% of original space, be even more aggressive
if (contentRatioX < 0.3 || contentRatioY < 0.3) {
console.log(`🔥 Ultra-aggressive crop: content only ${(contentRatioX*100).toFixed(1)}% x ${(contentRatioY*100).toFixed(1)}% of original`)
// Remove almost all padding for tiny content
const ultraPaddingX = Math.max(1, bbox.width * 0.005)
const ultraPaddingY = Math.max(1, bbox.height * 0.005)
const ultraX = Math.max(0, bbox.x - ultraPaddingX)
const ultraY = Math.max(0, bbox.y - ultraPaddingY)
const ultraWidth = bbox.width + (2 * ultraPaddingX)
const ultraHeight = bbox.height + (2 * ultraPaddingY)
const ultraViewBox = `${ultraX.toFixed(2)} ${ultraY.toFixed(2)} ${ultraWidth.toFixed(2)} ${ultraHeight.toFixed(2)}`
let ultraOptimizedSvg = svgString
.replace(/viewBox="[^"]*"/, `viewBox="${ultraViewBox}"`)
.replace(/<svg[^>]*width="[^"]*"/, (match) => match.replace(/width="[^"]*"/, ''))
.replace(/<svg[^>]*height="[^"]*"/, (match) => match.replace(/height="[^"]*"/, ''))
console.log(`✅ Ultra-optimized SVG: ${bbox.width.toFixed(1)}×${bbox.height.toFixed(1)} content → viewBox="${ultraViewBox}"`)
return ultraOptimizedSvg
}
// Update the viewBox to crop to content bounds
const newViewBox = `${newX.toFixed(2)} ${newY.toFixed(2)} ${newWidth.toFixed(2)} ${newHeight.toFixed(2)}`
// Replace viewBox and remove fixed dimensions to allow CSS scaling
let optimizedSvg = svgString
.replace(/viewBox="[^"]*"/, `viewBox="${newViewBox}"`)
.replace(/<svg[^>]*width="[^"]*"/, (match) => match.replace(/width="[^"]*"/, ''))
.replace(/<svg[^>]*height="[^"]*"/, (match) => match.replace(/height="[^"]*"/, ''))
console.log(`✅ SVG optimized: ${bbox.width.toFixed(1)}×${bbox.height.toFixed(1)} content → viewBox="${newViewBox}"`)
return optimizedSvg
} catch (bboxError) {
document.body.removeChild(tempDiv)
console.warn('❌ Could not measure SVG content bounds, returning original:', bboxError)
return svgString
}
} catch (error) {
console.error('❌ SVG optimization failed completely, returning original:', error)
return svgString
}
}
// Preload WASM and template without blocking - starts in background
async function preloadTypstWasm() {
if ($typst || isPreloading || typstInitializationPromise) return
if (typeof window === 'undefined') return
isPreloading = true
preloadStartTime = performance.now()
console.log('🔄 Starting background WASM and template preload...')
try {
// Preload both WASM and template in parallel
await Promise.all([
getTypstRenderer(),
getFlashcardsTemplate()
])
const loadTime = Math.round(performance.now() - (preloadStartTime || 0))
console.log(`✅ WASM and template preloaded successfully in ${loadTime}ms - ready for instant generation!`)
} catch (error) {
console.warn('⚠️ Preload failed (will retry on demand):', error)
} finally {
isPreloading = false
}
}
async function getTypstRenderer() {
if ($typst) return $typst
// Return the existing initialization promise if one is in progress
if (typstInitializationPromise) {
return await typstInitializationPromise
}
// Check if we're in a browser environment
if (typeof window === 'undefined') {
throw new Error('Not in browser environment')
}
// Create and cache the initialization promise
typstInitializationPromise = initializeTypstRenderer()
try {
return await typstInitializationPromise
} catch (error) {
// Clear the promise on failure so we can retry
typstInitializationPromise = null
throw error
}
}
async function initializeTypstRenderer() {
console.log('🚀 Loading typst.ts WASM in browser...')
const startTime = performance.now()
try {
// Import the all-in-one typst package with timeout
console.log('📦 Importing typst all-in-one package...')
const typstModule = await Promise.race([
import('@myriaddreamin/typst-all-in-one.ts'),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('WASM module load timeout')), 30000) // 30 second timeout
)
]) as any
$typst = typstModule.$typst
if (!$typst) {
throw new Error('typst.ts renderer not found in module')
}
// Test the renderer with a minimal example
console.log('🧪 Testing typst.ts renderer...')
await $typst.svg({ mainContent: '#set page(width: 10pt, height: 10pt)\n' })
const loadTime = Math.round(performance.now() - startTime)
console.log(`✅ typst.ts WASM loaded and tested successfully in ${loadTime}ms!`)
return $typst
} catch (error) {
console.error('❌ Failed to load typst.ts WASM:', error)
$typst = null
throw new Error(`Browser typst.ts initialization failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}
// We'll load the template content via an API endpoint or inline it here
// For now, let's create a minimal template with the draw-soroban function
export interface SorobanConfig {
number: number
beadShape?: 'diamond' | 'circle' | 'square'
colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating'
colorPalette?: 'default' | 'colorblind' | 'mnemonic' | 'grayscale' | 'nature'
hideInactiveBeads?: boolean
showEmptyColumns?: boolean
columns?: number | 'auto'
scaleFactor?: number
width?: string
height?: string
fontSize?: string
fontFamily?: string
transparent?: boolean
coloredNumerals?: boolean
enableServerFallback?: boolean
}
// Cache for compiled templates to avoid recompilation
const templateCache = new Map<string, Promise<string>>()
// Suspense resource for WASM loading
class TypstResource {
private promise: Promise<any> | null = null
private renderer: any = null
private error: Error | null = null
read() {
if (this.error) {
throw this.error
}
if (this.renderer) {
return this.renderer
}
if (!this.promise) {
this.promise = this.loadTypst()
}
throw this.promise
}
private async loadTypst() {
try {
const renderer = await getTypstRenderer()
this.renderer = renderer
return renderer
} catch (error) {
this.error = error instanceof Error ? error : new Error('WASM loading failed')
throw this.error
}
}
reset() {
this.promise = null
this.renderer = null
this.error = null
}
}
// Global resource instance
const typstResource = new TypstResource()
export function resetTypstResource() {
typstResource.reset()
}
export function useTypstRenderer() {
return typstResource.read()
}
// Lazy-loaded template content
let flashcardsTemplate: string | null = null
let templateLoadPromise: Promise<string> | null = null
async function getFlashcardsTemplate(): Promise<string> {
if (flashcardsTemplate) {
return flashcardsTemplate
}
// Return the existing promise if already loading
if (templateLoadPromise) {
return await templateLoadPromise
}
// Create and cache the loading promise
templateLoadPromise = loadTemplateFromAPI()
try {
const template = await templateLoadPromise
flashcardsTemplate = template
return template
} catch (error) {
// Clear the promise on failure so we can retry
templateLoadPromise = null
throw error
}
}
async function loadTemplateFromAPI(): Promise<string> {
console.log('📥 Loading typst template from API...')
try {
const response = await fetch('/api/typst-template')
const data = await response.json()
if (data.success) {
console.log('✅ Template loaded successfully')
return data.template
} else {
throw new Error(data.error || 'Failed to load template')
}
} catch (error) {
console.error('❌ Failed to fetch typst template:', error)
throw new Error('Template loading failed')
}
}
async function getTypstTemplate(config: SorobanConfig): Promise<string> {
const template = await getFlashcardsTemplate()
const {
number,
beadShape = 'diamond',
colorScheme = 'place-value',
colorPalette = 'default',
hideInactiveBeads = false,
showEmptyColumns = false,
columns = 'auto',
scaleFactor = 1.0,
width = '120pt',
height = '160pt',
fontSize = '48pt',
fontFamily = 'DejaVu Sans',
transparent = false,
coloredNumerals = false,
enableServerFallback = false
} = config
return `
${template}
#set page(
width: ${width},
height: ${height},
margin: 0pt,
fill: ${transparent ? 'none' : 'white'}
)
#set text(font: "${fontFamily}", size: ${fontSize}, fallback: true)
#align(center + horizon)[
#box(
width: ${width},
height: ${height}
)[
#align(center + horizon)[
#scale(x: ${scaleFactor * 100}%, y: ${scaleFactor * 100}%)[
#draw-soroban(
${number},
columns: ${columns},
show-empty: ${showEmptyColumns},
hide-inactive: ${hideInactiveBeads},
bead-shape: "${beadShape}",
color-scheme: "${colorScheme}",
color-palette: "${colorPalette}",
base-size: 1.0
)
]
]
]
]
`
}
export async function generateSorobanSVG(config: SorobanConfig): Promise<string> {
try {
// Create a cache key based on the configuration
const cacheKey = JSON.stringify(config)
// Check if we have a cached result
if (templateCache.has(cacheKey)) {
return await templateCache.get(cacheKey)!
}
// Try browser-side generation first, fallback to server if it fails
const generationPromise = generateSVGWithFallback(config)
// Cache the promise to prevent duplicate generations
templateCache.set(cacheKey, generationPromise)
// Clean up the cache if it gets too large (keep last 50 entries)
if (templateCache.size > 50) {
const entries = Array.from(templateCache.entries())
const toKeep = entries.slice(-25) // Keep last 25
templateCache.clear()
toKeep.forEach(([key, value]) => templateCache.set(key, value))
}
return await generationPromise
} catch (error) {
console.error('Failed to generate Soroban SVG with typst.ts:', error)
throw new Error(`SVG generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}
// Track if browser-side generation has been attempted and failed
let browserGenerationAvailable: boolean | null = null
// Function to reset browser availability detection (useful for debugging)
export function resetBrowserGenerationStatus() {
browserGenerationAvailable = null
$typst = null
isLoading = false
console.log('🔄 Reset browser generation status - will retry on next generation')
}
// Export preloading utilities
export function getWasmStatus() {
return {
isLoaded: !!$typst,
isPreloading,
isInitializing: !!typstInitializationPromise && !$typst,
browserGenerationAvailable
}
}
export function triggerWasmPreload() {
if (!isPreloading && !$typst) {
preloadTypstWasm()
}
}
async function generateSVGWithFallback(config: SorobanConfig): Promise<string> {
console.log('🔍 generateSVGWithFallback called for number:', config.number)
console.log('🔍 browserGenerationAvailable status:', browserGenerationAvailable)
console.log('🔍 enableServerFallback:', config.enableServerFallback)
// If we know browser generation is available, always use it
if (browserGenerationAvailable === true) {
console.log('🎯 Using confirmed browser-side generation')
return await generateSVGInBrowser(config)
}
// If we know browser generation is not available and server fallback is disabled, throw error
if (browserGenerationAvailable === false && !config.enableServerFallback) {
console.error('❌ Browser-side generation unavailable and server fallback disabled')
throw new Error('Browser-side SVG generation failed and server fallback is disabled. Enable server fallback or fix browser WASM loading.')
}
// If we know browser generation is not available, skip to server (only if fallback enabled)
if (browserGenerationAvailable === false && config.enableServerFallback) {
console.log('🔄 Using server fallback (browser unavailable)')
return await generateSVGOnServer(config)
}
// First attempt - try browser-side generation
try {
console.log('🚀 Attempting browser-side generation for number:', config.number)
const result = await generateSVGInBrowser(config)
browserGenerationAvailable = true
console.log('✅ Browser-side generation successful! Will use for future requests.')
return result
} catch (browserError) {
console.warn('❌ Browser-side generation failed for number:', config.number, browserError)
browserGenerationAvailable = false
// Only fall back to server if explicitly enabled
if (config.enableServerFallback) {
try {
console.log('🔄 Falling back to server-side generation for number:', config.number)
return await generateSVGOnServer(config)
} catch (serverError) {
console.error('❌ Both browser and server generation failed for number:', config.number)
throw new Error(`SVG generation failed: ${serverError instanceof Error ? serverError.message : 'Unknown error'}`)
}
} else {
console.error('❌ Browser-side generation failed and server fallback disabled for number:', config.number)
throw new Error(`Browser-side SVG generation failed: ${browserError instanceof Error ? browserError.message : 'Unknown error'}. Enable server fallback or fix browser WASM loading.`)
}
}
}
async function generateSVGInBrowser(config: SorobanConfig): Promise<string> {
// Load typst.ts renderer
const $typst = await getTypstRenderer()
// Get the template content
const template = await getFlashcardsTemplate()
// Create the complete Typst document
const typstContent = await getTypstTemplate(config)
console.log('🎨 Generating SVG in browser for number:', config.number)
// Generate SVG using typst.ts in the browser
const svg = await $typst.svg({ mainContent: typstContent })
console.log('✅ Generated browser SVG, length:', svg.length)
// Optimize viewBox to crop to actual content bounds
const optimizedSvg = optimizeSvgViewBox(svg)
// Process bead annotations to convert links to data attributes
const annotatedSvg = processBeadAnnotations(optimizedSvg)
return annotatedSvg
}
// Function to process bead annotations - converts link elements to data attributes
function processBeadAnnotations(svg: string): string {
console.log('🏷️ Processing bead annotations...')
const processedSvg = svg.replace(
/<a[^>]*xlink:href="bead:\/\/([^"]*)"[^>]*>(.*?)<\/a>/gs,
(match, beadId, content) => {
// Parse the bead ID to extract metadata
const parts = beadId.split('-')
let beadType = 'unknown'
let column = '0'
let position = ''
let active = '0'
// Parse heaven beads: "heaven-col0-active1"
if (parts[0] === 'heaven' && parts.length >= 3) {
beadType = 'heaven'
const colMatch = parts[1].match(/col(\d+)/)
if (colMatch) column = colMatch[1]
const activeMatch = parts[2].match(/active(\d+)/)
if (activeMatch) active = activeMatch[1]
}
// Parse earth beads: "earth-col0-pos1-active1"
else if (parts[0] === 'earth' && parts.length >= 4) {
beadType = 'earth'
const colMatch = parts[1].match(/col(\d+)/)
if (colMatch) column = colMatch[1]
const posMatch = parts[2].match(/pos(\d+)/)
if (posMatch) position = posMatch[1]
const activeMatch = parts[3].match(/active(\d+)/)
if (activeMatch) active = activeMatch[1]
}
// Construct data attributes
const dataAttrs = `data-bead-type="${beadType}" data-bead-column="${column}"${position ? ` data-bead-position="${position}"` : ''} data-bead-active="${active}"`
// Add data attributes to shapes within the content and remove the link wrapper
const processedContent = content.replace(
/<(circle|path|rect|polygon|ellipse)([^>]*>)/g,
`<$1 ${dataAttrs}$2`
)
return processedContent
}
)
console.log('✅ Bead annotations processed')
return processedSvg
}
async function generateSVGOnServer(config: SorobanConfig): Promise<string> {
// Fallback to server-side API generation
const response = await fetch('/api/typst-svg', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(config),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(errorData.error || 'Server SVG generation failed')
}
const data = await response.json()
if (!data.success) {
throw new Error(data.error || 'Server SVG generation failed')
}
console.log('🔄 Generated SVG on server, length:', data.svg.length)
// Optimize viewBox to crop to actual content bounds
const optimizedSvg = optimizeSvgViewBox(data.svg)
return optimizedSvg
}
export async function generateSorobanPreview(
numbers: number[],
config: Omit<SorobanConfig, 'number'>
): Promise<Array<{ number: number; svg: string }>> {
const results = await Promise.allSettled(
numbers.map(async (number) => ({
number,
svg: await generateSorobanSVG({ ...config, number })
}))
)
return results.map((result, index) => {
if (result.status === 'fulfilled') {
return result.value
} else {
console.error(`Failed to generate SVG for number ${numbers[index]}:`, result.reason)
return {
number: numbers[index],
svg: `<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">Generation Error</text>
<text x="100" y="180" text-anchor="middle" font-size="16" fill="#999">${numbers[index]}</text>
</svg>`
}
}
})
}

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "2.0.1",
"version": "2.2.0",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [

View File

@@ -16,7 +16,8 @@
"jsx": "react-jsx",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
"rootDir": "./src",
"types": []
},
"include": [
"src/**/*"

50
pnpm-lock.yaml generated
View File

@@ -59,18 +59,6 @@ importers:
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@18.3.1)
'@myriaddreamin/typst-all-in-one.ts':
specifier: 0.6.1-rc3
version: 0.6.1-rc3
'@myriaddreamin/typst-ts-renderer':
specifier: 0.6.1-rc3
version: 0.6.1-rc3
'@myriaddreamin/typst-ts-web-compiler':
specifier: 0.6.1-rc3
version: 0.6.1-rc3
'@myriaddreamin/typst.ts':
specifier: 0.6.1-rc3
version: 0.6.1-rc3(@myriaddreamin/typst-ts-renderer@0.6.1-rc3)(@myriaddreamin/typst-ts-web-compiler@0.6.1-rc3)
'@number-flow/react':
specifier: ^0.5.10
version: 0.5.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -1945,32 +1933,12 @@ packages:
'@types/react': '>=16'
react: '>=16'
'@myriaddreamin/typst-all-in-one.ts@0.6.1-rc3':
resolution: {integrity: sha512-IFkPEDTfE8/IYEpJxeNMRCOqeQ0NP9+30VHk1EmLIGHMiDcqkMJ04MEF1eHdPX5L8XjAuyKI4jPZQvHSXaQRnA==}
'@myriaddreamin/typst-ts-renderer@0.6.0':
resolution: {integrity: sha512-56Mids4E5Ob6LeEeXDedvmsVnEWnLmc1qeUOeUSruL/zI3S9QXleF/c3Os1FXwJmLuCFbWTEIq8Quh2cXlnxKw==}
'@myriaddreamin/typst-ts-renderer@0.6.1-rc3':
resolution: {integrity: sha512-m4OHUXYvxDcE6yEvTIl+s7Y721RMWpVTUDXRN2o08P2ckkgxmtloVwHkdCAbrV5w19b5f/fcSaCW2xbxaw+XnA==}
'@myriaddreamin/typst-ts-web-compiler@0.6.0':
resolution: {integrity: sha512-P/eIJ5RnfElj0NYzn5PI296t/IwWtgqUyyTMi5Jm5X3V5kZfskkH+LI7mSQe8tEyxwgCvxbxvFe5adinA3K8Gg==}
'@myriaddreamin/typst-ts-web-compiler@0.6.1-rc3':
resolution: {integrity: sha512-6KD2QKUI1KVcBpKxh/LQX02224NnjMWlRQy/IqtROekDKUdoCoSlf5PV7aETYyI4bA1mtNrzMkBGdCeum/4frA==}
'@myriaddreamin/typst.ts@0.6.1-rc3':
resolution: {integrity: sha512-pGzaJ5SV0JjrNWn14Bicy0nPTtfD5+4kHwGUDYSebSMbfAtNHfi+Et7k4PiXqunwRm7Obkk5iZufiRM2jOlfbg==}
peerDependencies:
'@myriaddreamin/typst-ts-renderer': ^0.6.1-rc3
'@myriaddreamin/typst-ts-web-compiler': ^0.6.1-rc3
peerDependenciesMeta:
'@myriaddreamin/typst-ts-renderer':
optional: true
'@myriaddreamin/typst-ts-web-compiler':
optional: true
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@@ -6121,9 +6089,6 @@ packages:
peerDependencies:
postcss: ^8.1.0
idb@7.1.1:
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -10792,23 +10757,10 @@ snapshots:
'@types/react': 18.3.26
react: 18.3.1
'@myriaddreamin/typst-all-in-one.ts@0.6.1-rc3': {}
'@myriaddreamin/typst-ts-renderer@0.6.0': {}
'@myriaddreamin/typst-ts-renderer@0.6.1-rc3': {}
'@myriaddreamin/typst-ts-web-compiler@0.6.0': {}
'@myriaddreamin/typst-ts-web-compiler@0.6.1-rc3': {}
'@myriaddreamin/typst.ts@0.6.1-rc3(@myriaddreamin/typst-ts-renderer@0.6.1-rc3)(@myriaddreamin/typst-ts-web-compiler@0.6.1-rc3)':
dependencies:
idb: 7.1.1
optionalDependencies:
'@myriaddreamin/typst-ts-renderer': 0.6.1-rc3
'@myriaddreamin/typst-ts-web-compiler': 0.6.1-rc3
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.5.0
@@ -16047,8 +15999,6 @@ snapshots:
dependencies:
postcss: 8.5.6
idb@7.1.1: {}
ieee754@1.2.1: {}
ignore@5.3.2: {}