fix: board rotation now properly fills height in portrait mode

Fixed board rotation issues when guide is docked:
- Board now prioritizes HEIGHT when in portrait orientation
- Removed CSS constraints (maxWidth, maxHeight, aspectRatio) that were
  interfering with explicit dimensions during rotation
- Board properly fills container whether guide is docked or not
- Smooth transition between portrait and landscape orientations

Technical changes:
- Set maxWidth/maxHeight to 'none' when svgDimensions are explicit
- Set aspectRatio to 'auto' when rotating to prevent override
- Cleaned up debug logging

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-11-02 19:06:29 -06:00
parent f9bc1fb0b8
commit b5a96eaeb1
12 changed files with 2298 additions and 656 deletions

View File

@@ -0,0 +1,943 @@
# Platform Integration Roadmap
**Project:** Soroban Abacus Flashcards - Arcade Games Platform
**Focus Areas:** Educational Platform SSO & Game Distribution Channels
**Document Version:** 1.0
**Last Updated:** 2025-11-02
---
## Executive Summary
This roadmap outlines integration strategies for four major platform categories to expand reach and player acquisition:
1. **Google Classroom Integration** - Direct access to teachers and students
2. **Clever/ClassLink SSO** - K-12 institution single sign-on (massive reach)
3. **Game Distribution Portals** - CrazyGames, Poki, Kongregate (casual player discovery)
4. **Steam Distribution** - Educational game marketplace with social features
**Estimated Total Timeline:** 6-9 months for full implementation
**Estimated Total Cost:** $600-1,500 (app fees + hosting)
**Expected Impact:** 10x-100x increase in player discovery reach
---
## Platform #1: Google Classroom Integration
### Overview
Google Classroom API enables teachers to:
- Import game rooms directly into their classes
- Auto-create student accounts with SSO
- Track student progress and scores
- Assign abacus practice as homework
**Target Audience:** 150+ million students and teachers using Google Classroom globally
### Technical Requirements
**Prerequisites:**
- Google Cloud Platform (GCP) project
- OAuth 2.0 implementation
- Google Classroom API access
- HTTPS endpoints
**API Capabilities Needed:**
- Courses API (read class rosters)
- Coursework API (create assignments)
- Students API (manage student accounts)
- Submissions API (track game completion)
### Implementation Phases
#### Phase 1: Basic OAuth & SSO (2-3 weeks)
**Tasks:**
1. Set up GCP project and enable Classroom API
2. Implement OAuth 2.0 "Sign in with Google" flow
3. Add Classroom scope permissions (`classroom.courses.readonly`, `classroom.rosters.readonly`)
4. Create user account mapping (Google ID → Internal User ID)
5. Test with sandbox Google Workspace account
**Deliverables:**
- "Sign in with Google" button on login page
- Auto-account creation for Google users
- Basic profile sync (name, email, photo)
**Effort:** 40-60 hours
**Cost:** $0 (Google API is free for educational use)
#### Phase 2: Class Import & Roster Sync (3-4 weeks)
**Tasks:**
1. Build UI for teachers to import Classroom rosters
2. Implement Courses API integration (list teacher's classes)
3. Create room auto-provisioning from class data
4. Set up automatic roster synchronization (daily sync)
5. Handle student account creation/deactivation
6. Add class management dashboard for teachers
**Deliverables:**
- "Import from Google Classroom" feature
- Teacher dashboard showing all imported classes
- Automatic student account provisioning
- Daily roster sync (new students, removed students)
**Effort:** 80-100 hours
**Cost:** $0
#### Phase 3: Assignment Integration (3-4 weeks)
**Tasks:**
1. Build "Assign to Classroom" feature for game rooms
2. Implement Coursework API integration
3. Create assignment templates for each game type
4. Build grade sync (game scores → Classroom grades)
5. Add completion tracking and submission API integration
6. Create teacher analytics dashboard
**Deliverables:**
- Teachers can push game assignments to Classroom
- Students see assignments in Classroom feed
- Automatic grade passback on game completion
- Teacher analytics (who played, scores, time spent)
**Effort:** 100-120 hours
**Cost:** $0
#### Phase 4: Deep Integration & Polish (2-3 weeks)
**Tasks:**
1. Add "Share Turn" notifications (via Classroom API announcements)
2. Implement progress tracking dashboard
3. Create teacher resource library (lesson plans, tutorials)
4. Add student portfolio (history of games played)
5. Build reporting exports (CSV, PDF)
6. Classroom API webhook integration for real-time updates
**Deliverables:**
- Comprehensive teacher dashboard
- Student progress portfolios
- Automated reporting
- Real-time roster updates
**Effort:** 60-80 hours
**Cost:** $0
### Total Timeline: 10-14 weeks
### Total Effort: 280-360 hours
### Total Cost: $0
### Dependencies
- Existing user authentication system (✓ NextAuth in place)
- Room management system (✓ exists)
- Score/progress tracking (✓ exists per game)
- Email notification system (partially implemented)
### Risks & Mitigation
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| OAuth complexity | Medium | Medium | Use official Google client libraries |
| API rate limits | Low | Low | Implement caching and batch requests |
| Grade sync errors | High | Medium | Add retry logic and error notifications |
| Teacher adoption | High | High | Create detailed onboarding videos |
| Privacy compliance (COPPA) | Critical | Medium | Implement parent consent workflows |
### Success Metrics
- **Adoption:** 100+ teachers in first 3 months
- **Usage:** 50+ classes imported
- **Retention:** 70%+ of teachers assign games monthly
- **Referrals:** 20% teacher-to-teacher referrals
---
## Platform #2: Clever & ClassLink SSO
### Overview
**Clever:** SSO platform serving 75% of U.S. K-12 schools (13,000+ districts)
**ClassLink:** SSO platform serving 20+ million students globally
Both provide instant access to school rosters without manual setup.
**Key Benefits:**
- Zero teacher setup (IT manages access)
- Automatic roster sync
- Instant student login (no passwords)
- District-wide deployment capability
### Technical Requirements
**Clever Requirements:**
- OAuth 2.0 implementation
- REST API integration
- HTTPS endpoints
- District SSO certification
**ClassLink Requirements:**
- SAML 2.0 support (or OAuth/OpenID Connect)
- OneRoster API integration (for roster sync)
- ClassLink Management Console access
### Implementation Phases
#### Phase 1: Clever District SSO (4-5 weeks)
**Tasks:**
1. Register as Clever developer (dev.clever.com)
2. Implement OAuth 2.0 authorization grant flow
3. Add "Log in with Clever" button
4. Integrate Clever Data API v3.1
- Districts API
- Schools API
- Students/Teachers API
- Sections API (classes)
5. Build user account mapping (Clever ID → Internal ID)
6. Create development environment with sandbox district
7. Implement district admin dashboard
8. Apply for District SSO certification
**API Scopes Required:**
- `read:district_admins_basic`
- `read:school_admins_basic`
- `read:students_basic`
- `read:teachers_basic`
- `read:user_id`
- `read:sis` (for full roster data)
**Deliverables:**
- "Log in with Clever" SSO
- Automatic account creation
- District/school/class hierarchy sync
- Certified for live district connections
**Effort:** 100-120 hours
**Cost:** $0 (Clever is free for developers)
#### Phase 2: ClassLink SAML Integration (3-4 weeks)
**Tasks:**
1. Set up SAML 2.0 service provider
2. Register with ClassLink SSO Library
3. Implement SAML authentication flow
4. Add "Log in with ClassLink" button
5. Integrate OneRoster API for roster sync
6. Build automated roster update system
7. Create ClassLink admin dashboard
8. Submit to ClassLink SSO Library (6,000+ app directory)
**Deliverables:**
- "Log in with ClassLink" SSO
- OneRoster-based roster sync
- Listed in ClassLink SSO Library
**Effort:** 80-100 hours
**Cost:** $0
#### Phase 3: Advanced Features (2-3 weeks)
**Tasks:**
1. Implement shared device support (session override)
2. Add UTF-8 character support for names
3. Build district admin analytics dashboard
4. Create automated onboarding emails for admins
5. Add Clever/ClassLink badge to marketing site
6. Build compliance documentation (FERPA, COPPA)
**Deliverables:**
- Production-ready SSO for both platforms
- District admin tools
- Compliance documentation
**Effort:** 60-80 hours
**Cost:** $0
### Total Timeline: 9-12 weeks
### Total Effort: 240-300 hours
### Total Cost: $0
### Dependencies
- SAML library (e.g., `passport-saml` for Node.js)
- OAuth 2.0 implementation (can reuse from Google Classroom)
- Secure session management
- Database schema for district/school/class hierarchy
### Risks & Mitigation
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Certification delays | High | Medium | Start certification process early |
| SAML complexity | Medium | Medium | Use established libraries (passport-saml) |
| District adoption | High | Medium | Partner with early adopter districts |
| Privacy regulations | Critical | Medium | Legal review of terms/privacy policy |
| Competing apps | Medium | High | Emphasize unique abacus/math focus |
### Success Metrics
- **Listings:** Published in both app directories
- **Certification:** Clever District SSO certified
- **Districts:** 10+ districts in first 6 months
- **Students:** 5,000+ student logins in first year
- **Retention:** 60%+ district renewal rate
---
## Platform #3: Game Distribution Portals
### Overview
Three major browser game portals with complementary audiences:
1. **CrazyGames:** Open platform, 35M+ monthly users, SDK monetization
2. **Poki:** Curated platform, 50M+ monthly users, 50/50 revenue share, web exclusivity
3. **Kongregate:** Legacy platform, 20M+ monthly users, now requires pre-approval
**Target Audience:** Casual gamers discovering educational games through play
### Platform Comparison
| Feature | CrazyGames | Poki | Kongregate |
|---------|------------|------|------------|
| **Selectivity** | Moderate | High (curated) | High (email approval) |
| **File Size** | Strict limits | <8MB initial | ~1100x700px max |
| **Exclusivity** | None | Web exclusive | None |
| **Revenue** | SDK ads | 50/50 split | Ads + Kreds |
| **SDK Required** | Yes | Yes | Yes |
| **Approval Time** | 1-2 weeks | 2-4 weeks | Varies |
### Technical Requirements (Common)
**All Platforms:**
- HTML5/WebGL build
- Save system (localStorage/cloud)
- Ad integration (SDK provided)
- Mobile responsive design
- HTTPS hosting
- No external monetization UI
**CrazyGames Specific:**
- CrazyGames SDK integration
- File size optimization
- Ad placements (banner, interstitial, rewarded)
- Leaderboard integration
**Poki Specific:**
- Poki SDK integration
- <8MB initial download (critical!)
- Auto-detect mobile devices
- Remove any IAP UI elements
- Web exclusivity agreement
**Kongregate Specific:**
- Pre-approval via BD@kongregate.com
- Kongregate API integration
- Badge system integration
- Kreds (virtual currency) support optional
- "initialized" stat tracking
### Implementation Phases
#### Phase 1: Game Optimization & SDK Prep (3-4 weeks)
**Tasks:**
1. Audit current build sizes (check all games)
2. Implement code splitting and lazy loading
3. Optimize assets (image compression, audio formats)
4. Create lightweight "portal build" configuration
5. Build asset CDN strategy
6. Implement progress save system (if not present)
7. Add mobile detection and responsive layouts
8. Create build pipeline for portal releases
**Target Build Sizes:**
- Initial load: <5MB (to meet Poki's <8MB requirement)
- Full game: <20MB
- Individual game assets: lazy loaded
**Deliverables:**
- Optimized builds for each game
- Build pipeline for portal releases
- Mobile-responsive UI for all games
**Effort:** 80-100 hours
**Cost:** $0
#### Phase 2: CrazyGames Integration (2-3 weeks)
**Tasks:**
1. Register at CrazyGames developer portal
2. Integrate CrazyGames SDK
- Ad placements (banner, interstitial, rewarded)
- Game analytics
- User progression tracking
3. Submit best-performing games:
- Matching Pairs Battle (most casual-friendly)
- Complement Race (fast-paced)
- Card Sorting (quick sessions)
4. Create game pages with descriptions/screenshots
5. Pass technical review and QA
6. Launch and monitor metrics
**Deliverables:**
- 3 games live on CrazyGames
- SDK integration complete
- Ad monetization active
**Effort:** 60-80 hours
**Cost:** $0
#### Phase 3: Poki Integration (3-4 weeks)
**Tasks:**
1. Submit application via Poki game submission form
2. Wait for curator review (2-4 weeks)
3. If approved, integrate Poki SDK
- Ad integration (Poki manages monetization)
- Analytics tracking
- Commerce API (if applicable)
4. Agree to web exclusivity terms
5. Optimize games to <8MB initial load (critical!)
6. Remove any competing monetization UI
7. Submit games for final review
8. Launch on Poki
**Deliverables:**
- 2-3 games live on Poki (start with best performers)
- Poki SDK integration
- Revenue share active (50/50 split)
**Effort:** 80-100 hours
**Cost:** $0
**Note:** Approval not guaranteed - Poki is highly selective
#### Phase 4: Kongregate Pre-Approval (2-3 weeks)
**Tasks:**
1. Email BD@kongregate.com with game portfolio
2. Prepare pitch deck:
- Game trailers/screenshots
- Unique value proposition (abacus education + multiplayer)
- Target audience data
- Existing player metrics
3. Wait for response (varies)
4. If approved, integrate Kongregate API
- Stats and achievements (badges)
- Optional: Kreds integration
5. Submit games for review
6. Launch on Kongregate
**Deliverables:**
- Pre-approval from Kongregate
- 1-2 games live on platform
- Badge achievements implemented
**Effort:** 60-80 hours
**Cost:** $0
**Note:** Approval required before development
### Total Timeline: 10-14 weeks
### Total Effort: 280-360 hours
### Total Cost: $0
### Game Prioritization for Submission
**Tier 1 (Submit first):**
1. **Matching Pairs Battle** - Most casual-friendly, clear mechanics
2. **Complement Race** - Fast-paced, competitive, good for short sessions
**Tier 2 (Submit if Tier 1 succeeds):**
3. **Card Sorting** - Simple, quick gameplay
4. **Memory Quiz** - Educational but approachable
**Tier 3 (Hold back):**
5. **Rithmomachia** - Too complex for casual portals, better for Steam
### Dependencies
- Current games must be playable without login (guest mode)
- Mobile responsive design
- Build optimization pipeline
- CDN for asset hosting
- Analytics integration
### Risks & Mitigation
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Poki rejection | Medium | High | Focus on CrazyGames first, apply with best metrics |
| File size requirements | High | Medium | Aggressive asset optimization, code splitting |
| Ad integration conflicts | Medium | Medium | Separate builds per platform |
| Revenue lower than expected | Medium | High | Treat as marketing channel, not primary revenue |
| Web exclusivity limits Steam | High | Low | Don't submit Rithmomachia to Poki |
| Multiplayer sync issues | High | Medium | Add offline/single-player modes |
### Success Metrics
**CrazyGames:**
- **Plays:** 10,000+ plays/month per game
- **Retention:** 30%+ day-1 return rate
- **Revenue:** $200+ monthly from ads
**Poki:**
- **Plays:** 50,000+ plays/month per game
- **Revenue:** $500+ monthly (50/50 split)
- **Rating:** 4.0+ stars
**Kongregate:**
- **Plays:** 5,000+ plays/month
- **Badges:** 3+ achievements per game
- **Revenue:** $100+ monthly
---
## Platform #4: Steam Distribution
### Overview
**Why Steam for Educational Games:**
- 120+ million active users
- Strong social features (friends, achievements, leaderboards)
- Educational games perform well (Kerbal Space Program, Human Resource Machine, etc.)
- One-time purchase model fits premium educational content
- Community features drive multiplayer engagement
**Package Concept:** "Soroban Academy: Mathematical Strategy Games"
- Bundle all games into single Steam app
- Include exclusive Steam features (achievements, leaderboards, cloud saves)
- Highlight Rithmomachia as flagship historical strategy game
- Price point: $9.99-14.99
### Technical Requirements
**Platform:**
- Desktop application (Windows, macOS, Linux)
- No native browser game support
**Packaging Options:**
1. **Electron** (most common for web games)
- Pros: Easy conversion, full Chromium
- Cons: Large file size (~100-200MB), memory intensive
2. **Tauri** (modern alternative)
- Pros: Lightweight (~10MB), uses system WebView
- Cons: Newer, less Steam integration examples
3. **NW.js** (alternative to Electron)
- Pros: Similar to Electron, slightly smaller
- Cons: Less popular, smaller community
**Recommended:** Start with Electron (most proven path for HTML5 → Steam)
**Steamworks Integration:**
- Steam Authentication API
- Steam Achievements API
- Steam Leaderboards API
- Steam Cloud (save sync)
- Steam Friends API (invite friends to games)
- Steam Overlay support
### Implementation Phases
#### Phase 1: Desktop Packaging with Electron (4-5 weeks)
**Tasks:**
1. Set up Electron project
2. Package Next.js app for Electron
3. Configure build pipeline:
- Windows (x64)
- macOS (Apple Silicon + Intel)
- Linux (x64)
4. Handle offline mode and local Socket.io server
5. Implement native window controls
6. Add auto-updater support
7. Test on all platforms
8. Optimize bundle size (<500MB total)
**Deliverables:**
- Desktop app for Windows/macOS/Linux
- Standalone builds (no separate server needed)
- Native app experience
**Effort:** 120-150 hours
**Cost:** $0
#### Phase 2: Steamworks SDK Integration (3-4 weeks)
**Tasks:**
1. Register as Steamworks developer (pay $100 fee)
2. Create Steam app page
3. Integrate Steamworks SDK:
- Install Greenworks (Electron + Steamworks bridge)
- Implement Steam Authentication
- Replace internal auth with Steam accounts
4. Build achievement system:
- Define 20-30 achievements per game
- Integrate Steamworks Achievements API
- Create achievement icons (64x64px)
5. Implement Steam Leaderboards:
- Per-game leaderboards
- Global cross-game leaderboard
6. Add Steam Cloud saves:
- Sync game progress across devices
- Handle conflict resolution
7. Integrate Steam Friends API:
- "Invite friend to game room" button
- Show online friends in lobby
8. Test Steam Overlay functionality
**Deliverables:**
- Full Steamworks integration
- Achievement system (20-30 achievements)
- Steam Leaderboards
- Cloud saves
- Friend invites
**Effort:** 100-120 hours
**Cost:** $100 (Steam Direct fee, one-time, recoupable)
#### Phase 3: Store Page & Marketing Assets (2-3 weeks)
**Tasks:**
1. Create Steam store page:
- Title: "Soroban Academy: Mathematical Strategy Games"
- Capsule images (multiple sizes)
- Hero image / header
- 5-10 screenshots per game
- 60-90 second trailer (show all games)
- Detailed description with educational benefits
- System requirements
- Price: $9.99-14.99
2. Write compelling marketing copy
3. Create game trailer (video editing)
4. Design Steam library assets
5. Set up community hub
6. Configure Steam tags:
- Education, Strategy, Multiplayer, Casual, Math, Board Game
7. Prepare press kit and media
**Deliverables:**
- Complete Steam store page
- Marketing trailer
- Press kit
- Community hub setup
**Effort:** 60-80 hours
**Cost:** $0-300 (if hiring video editor)
#### Phase 4: Steam Review & Launch (2-3 weeks)
**Tasks:**
1. Upload build to Steamworks
2. Set release date (2+ weeks out)
3. Make store page public
4. Submit for Steam review (1-5 days)
5. Address any review feedback
6. Prepare launch marketing:
- Email existing users
- Social media campaign
- Press outreach
- Reddit/forum posts
- Product Hunt launch
7. Launch on Steam
8. Monitor reviews and feedback
9. Respond to community
10. Plan post-launch updates
**Deliverables:**
- Live on Steam
- Launch marketing campaign
- Community management process
**Effort:** 60-80 hours
**Cost:** $0-500 (marketing budget)
#### Phase 5: Post-Launch Support (Ongoing)
**Tasks:**
- Weekly bug fixes and patches
- Monthly content updates
- Seasonal events
- New achievements
- Community engagement
- Steam Sale participation
- User feedback integration
**Deliverables:**
- Regular updates
- Active community
- Growing review score
**Effort:** 20-40 hours/month ongoing
**Cost:** $0
### Total Timeline: 11-15 weeks
### Total Effort: 340-430 hours
### Total Cost: $100-900
### Dependencies
- Functioning multiplayer system (✓ exists)
- All games stable and bug-free
- Marketing materials (screenshots, videos)
- Legal documentation (EULA, privacy policy)
- Support email/forum setup
### Risks & Mitigation
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Electron bundle too large | Medium | Medium | Optimize assets, use lazy loading |
| Steamworks integration bugs | High | Medium | Extensive testing, use Greenworks |
| Low sales | High | High | Strong marketing, community building |
| Review bombing | Medium | Low | Active community management |
| Multiplayer server costs | Medium | Medium | P2P option or Steam networking |
| Steam review rejection | High | Low | Follow guidelines strictly |
| Competition from free web version | High | High | Add Steam-exclusive features |
### Pricing Strategy
**Options:**
1. **Premium ($9.99-14.99):** Full game bundle with Steam features
2. **Freemium:** Free base + DLC for premium games
3. **Early Access ($7.99):** Launch at lower price, increase at 1.0
**Recommendation:** Premium $12.99 with frequent sales
- Perceived value: 6 games = ~$2 each
- Recoup $100 fee after 8 sales
- Sales can drive volume (50% off = $6.49)
### Marketing Angles for Steam
1. **Rithmomachia Focus:** "Play the 1,000-year-old medieval chess"
2. **Educational Value:** "Math learning disguised as strategy gaming"
3. **Multiplayer Fun:** "Challenge friends in mathematical duels"
4. **Historical Gaming:** "Rediscover forgotten board games"
5. **Family Friendly:** "Safe, educational gaming for all ages"
### Success Metrics
**Launch Goals:**
- **Wishlist:** 1,000+ before launch
- **Sales:** 500+ units in first month
- **Reviews:** 50+ reviews, 85%+ positive
- **Revenue:** $5,000+ in first quarter
**Long-term Goals:**
- **Sales:** 5,000+ units in first year
- **Reviews:** 200+ reviews, 90%+ positive
- **Revenue:** $30,000+ annually
- **Community:** Active Discord/forums with 1,000+ members
---
## Prioritization & Recommended Timeline
### Recommended Order of Execution
**Phase A: Quick Wins (First 3 months)**
1. **Google Classroom - Phase 1 (OAuth/SSO)** - Immediate teacher value
2. **CrazyGames submission** - Fast player discovery
3. **Google Classroom - Phase 2 (Class Import)** - Core teacher feature
**Phase B: Institution Access (Months 4-6)**
4. **Clever SSO integration** - Scale to districts
5. **ClassLink SSO integration** - Additional district reach
6. **Google Classroom - Phase 3 (Assignments)** - Deep integration
**Phase C: Casual Discovery (Months 6-9)**
7. **Poki submission** - Largest casual audience
8. **Kongregate pre-approval + submission** - Niche community
**Phase D: Premium Market (Months 9-12)**
9. **Steam development** - Desktop packaging + Steamworks
10. **Steam launch** - Premium educational game market
### Timeline Gantt Overview
```
Month 1-2: [Google OAuth/SSO] [CrazyGames Prep]
Month 2-3: [Google Class Import] [CrazyGames Launch]
Month 4-5: [Clever SSO] [ClassLink SSO]
Month 6-7: [Google Assignments] [Poki Prep]
Month 7-8: [Poki Launch] [Kongregate]
Month 9-10: [Steam Electron Build]
Month 10-11: [Steam Steamworks Integration]
Month 11-12: [Steam Store Page & Launch]
```
### Total Cost Summary
| Platform | One-time Cost | Ongoing Cost | Notes |
|----------|--------------|--------------|-------|
| Google Classroom | $0 | $0 | Free for education |
| Clever | $0 | $0 | Free for developers |
| ClassLink | $0 | $0 | Free for developers |
| CrazyGames | $0 | $0 | Revenue share via ads |
| Poki | $0 | $0 | 50/50 revenue share |
| Kongregate | $0 | $0 | Revenue share via ads |
| Steam | $100 | $0-200/mo | Server costs optional |
| **TOTAL** | **$100** | **$0-200/mo** | Very affordable |
### Resource Requirements
**Developer Time:**
- **Total effort:** 1,140-1,450 hours across all platforms
- **Timeline:** 9-12 months (with 1-2 developers)
- **Ongoing:** 20-60 hours/month maintenance
**External Costs:**
- Steam Direct fee: $100
- Optional video editing: $0-500
- Optional marketing budget: $0-1,000
- Server costs (if needed): $50-200/month
**Skills Needed:**
- OAuth/SAML implementation
- REST API integration
- Electron/desktop app packaging
- Steamworks SDK integration
- Marketing/community management
---
## Risk Management
### Top 5 Risks Across All Platforms
1. **Privacy Compliance (COPPA, FERPA, GDPR)**
- **Impact:** Critical (could block educational adoption)
- **Mitigation:** Legal review, implement consent systems, privacy policy updates
2. **Platform Rejection (Poki, Kongregate)**
- **Impact:** High (wasted development time)
- **Mitigation:** Apply early, start with less selective platforms, gather metrics
3. **Low Adoption/Sales**
- **Impact:** High (ROI concern)
- **Mitigation:** Strong marketing, community building, teacher outreach
4. **Technical Integration Complexity**
- **Impact:** Medium (timeline delays)
- **Mitigation:** Use established libraries, start simple, iterate
5. **Server Costs for Multiplayer**
- **Impact:** Medium (ongoing expenses)
- **Mitigation:** Optimize server efficiency, consider P2P, price accordingly
---
## Success Metrics Dashboard
### Key Performance Indicators (KPIs)
**User Acquisition:**
- New users per month (by source)
- Sign-up conversion rate
- Platform-specific downloads/plays
**Engagement:**
- Daily/Monthly Active Users (DAU/MAU)
- Average session length
- Games per session
- Return rate (D1, D7, D30)
**Education Impact:**
- Teachers registered
- Classes created
- Assignments completed
- Student progress metrics
**Revenue (Steam):**
- Units sold
- Revenue per month
- Refund rate
- Review score
**Platform-Specific:**
- **Google Classroom:** Classes imported, assignments created
- **Clever/ClassLink:** Districts connected, SSO logins
- **Game Portals:** Plays per game, ad revenue, ratings
- **Steam:** Sales, reviews, concurrent players
---
## Next Steps
### Immediate Actions (This Week)
1. **Decide on priority order** - Which platform to tackle first?
2. **Set up project tracking** - Dedicate repository/board for integration work
3. **Legal review** - Review privacy policy and terms for educational compliance
4. **Create developer accounts:**
- Google Cloud Platform
- Clever Developer Portal
- CrazyGames Developer Portal
- Poki submission prep
5. **Audit current codebase** - Check readiness for each integration
### Quick Wins to Start
**Option 1: Google Classroom OAuth (Fastest Impact)**
- 2-3 weeks to launch
- Immediate teacher value
- No approval needed
**Option 2: CrazyGames (Fastest Player Growth)**
- 3-4 weeks to launch
- Immediate player discovery
- Open platform (high acceptance rate)
**Recommendation:** Start both in parallel if resources allow
---
## Appendix
### Useful Links
**Google Classroom:**
- Developer Docs: https://developers.google.com/classroom
- API Reference: https://developers.google.com/classroom/reference
- OAuth Guide: https://developers.google.com/identity/protocols/oauth2
**Clever:**
- Developer Portal: https://dev.clever.com
- SSO Guide: https://dev.clever.com/docs/getting-started-with-clever-sso
- API Docs: https://dev.clever.com/docs/api-overview
**ClassLink:**
- SSO Library: https://www.classlink.com/resources/sso-search
- OneRoster: https://www.imsglobal.org/activity/onerosterlis
**Game Portals:**
- CrazyGames: https://docs.crazygames.com/
- Poki: https://developers.poki.com/
- Kongregate: BD@kongregate.com
**Steam:**
- Steamworks: https://partner.steamgames.com/
- Electron: https://www.electronjs.org/
- Greenworks: https://github.com/greenheartgames/greenworks
### Code Library Recommendations
**OAuth/SSO:**
- `next-auth` (already in use)
- `passport-saml` (for ClassLink SAML)
- `@googleapis/classroom` (official Google Classroom client)
**Electron:**
- `electron-builder` (packaging)
- `electron-updater` (auto-updates)
- `greenworks` (Steamworks bridge)
**Game Portal SDKs:**
- CrazyGames SDK (provided)
- Poki SDK (provided)
- Kongregate API (provided)
---
**Document End**
*For questions or updates to this roadmap, contact the development team.*

View File

@@ -140,7 +140,9 @@
"Bash(src/arcade-games/rithmomachia/components/guide-sections/VictorySection.tsx)",
"Bash(pnpm remove:*)",
"Bash(__NEW_LINE__ sed -n '68,73p' CaptureSection.tsx.bak)",
"WebFetch(domain:hub.docker.com)"
"WebFetch(domain:hub.docker.com)",
"Bash(gcloud auth:*)",
"Bash(gcloud config list:*)"
],
"deny": [],
"ask": []

View File

@@ -0,0 +1,235 @@
#!/bin/bash
# Google Classroom API Setup Script
# This script automates GCP project setup from the command line
#
# Prerequisites:
# - gcloud CLI installed (brew install google-cloud-sdk)
# - Valid Google account
#
# Usage:
# ./scripts/setup-google-classroom.sh
set -e # Exit on any error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE}Google Classroom API Setup${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
# Configuration
PROJECT_ID="soroban-abacus-$(date +%s)" # Unique project ID with timestamp
PROJECT_NAME="Soroban Abacus Flashcards"
BILLING_ACCOUNT="" # Will prompt user if needed
REDIRECT_URIS="http://localhost:3000/api/auth/callback/google,https://abaci.one/api/auth/callback/google"
echo -e "${YELLOW}Project ID:${NC} $PROJECT_ID"
echo ""
# Step 1: Check if gcloud is installed
echo -e "${BLUE}[1/9] Checking gcloud installation...${NC}"
if ! command -v gcloud &> /dev/null; then
echo -e "${RED}Error: gcloud CLI not found${NC}"
echo "Install it with: brew install google-cloud-sdk"
exit 1
fi
echo -e "${GREEN}✓ gcloud CLI found${NC}"
echo ""
# Step 2: Authenticate with Google
echo -e "${BLUE}[2/9] Authenticating with Google...${NC}"
CURRENT_ACCOUNT=$(gcloud auth list --filter=status:ACTIVE --format="value(account)" 2>/dev/null || echo "")
if [ -z "$CURRENT_ACCOUNT" ]; then
echo "No active account found. Opening browser to authenticate..."
gcloud auth login
CURRENT_ACCOUNT=$(gcloud auth list --filter=status:ACTIVE --format="value(account)")
fi
echo -e "${GREEN}✓ Authenticated as: $CURRENT_ACCOUNT${NC}"
echo ""
# Step 3: Create GCP project
echo -e "${BLUE}[3/9] Creating GCP project...${NC}"
echo "Creating project: $PROJECT_ID"
gcloud projects create "$PROJECT_ID" --name="$PROJECT_NAME" 2>/dev/null || {
echo -e "${YELLOW}Project might already exist, continuing...${NC}"
}
gcloud config set project "$PROJECT_ID"
echo -e "${GREEN}✓ Project created/selected: $PROJECT_ID${NC}"
echo ""
# Step 4: Check billing (required for APIs)
echo -e "${BLUE}[4/9] Checking billing account...${NC}"
echo -e "${YELLOW}Note: Google Classroom API requires a billing account, but it's FREE for educational use.${NC}"
echo -e "${YELLOW}You won't be charged unless you explicitly enable paid services.${NC}"
echo ""
# List available billing accounts
BILLING_ACCOUNTS=$(gcloud billing accounts list --format="value(name)" 2>/dev/null || echo "")
if [ -z "$BILLING_ACCOUNTS" ]; then
echo -e "${YELLOW}No billing accounts found.${NC}"
echo -e "${YELLOW}You'll need to create one at: https://console.cloud.google.com/billing${NC}"
echo -e "${YELLOW}Press Enter after creating a billing account, or Ctrl+C to exit${NC}"
read -r
BILLING_ACCOUNTS=$(gcloud billing accounts list --format="value(name)")
fi
# If multiple accounts, let user choose
BILLING_COUNT=$(echo "$BILLING_ACCOUNTS" | wc -l | tr -d ' ')
if [ "$BILLING_COUNT" -eq 1 ]; then
BILLING_ACCOUNT="$BILLING_ACCOUNTS"
else
echo "Available billing accounts:"
gcloud billing accounts list
echo ""
echo -n "Enter billing account ID (e.g., 012345-ABCDEF-678901): "
read -r BILLING_ACCOUNT
fi
# Link billing account to project
echo "Linking billing account to project..."
gcloud billing projects link "$PROJECT_ID" --billing-account="$BILLING_ACCOUNT"
echo -e "${GREEN}✓ Billing account linked${NC}"
echo ""
# Step 5: Enable required APIs
echo -e "${BLUE}[5/9] Enabling required APIs...${NC}"
echo "This may take 1-2 minutes..."
gcloud services enable classroom.googleapis.com --project="$PROJECT_ID"
gcloud services enable people.googleapis.com --project="$PROJECT_ID"
echo -e "${GREEN}✓ APIs enabled:${NC}"
echo " - Google Classroom API"
echo " - Google People API (for profile info)"
echo ""
# Step 6: Create OAuth 2.0 credentials
echo -e "${BLUE}[6/9] Creating OAuth 2.0 credentials...${NC}"
# First check if credentials already exist
EXISTING_CREDS=$(gcloud auth application-default print-access-token &>/dev/null && \
curl -s -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
"https://oauth2.googleapis.com/v1/projects/${PROJECT_ID}/oauthClients" 2>/dev/null | \
grep -c "clientId" || echo "0")
if [ "$EXISTING_CREDS" -gt 0 ]; then
echo -e "${YELLOW}OAuth credentials already exist for this project${NC}"
echo "Skipping credential creation..."
else
# Create OAuth client
# Note: This creates a "Web application" type OAuth client
echo "Creating OAuth 2.0 client..."
# Unfortunately, gcloud doesn't have a direct command for this
# We need to use the REST API
echo -e "${YELLOW}Note: OAuth client creation requires using the web console${NC}"
echo -e "${YELLOW}Opening the OAuth credentials page...${NC}"
echo ""
echo -e "${BLUE}Please follow these steps in the browser:${NC}"
echo "1. Click 'Create Credentials' → 'OAuth client ID'"
echo "2. Application type: 'Web application'"
echo "3. Name: 'Soroban Abacus Web'"
echo "4. Authorized JavaScript origins:"
echo " - http://localhost:3000"
echo " - https://abaci.one"
echo "5. Authorized redirect URIs:"
echo " - http://localhost:3000/api/auth/callback/google"
echo " - https://abaci.one/api/auth/callback/google"
echo "6. Click 'Create'"
echo "7. Copy the Client ID and Client Secret"
echo ""
# Open browser to credentials page
open "https://console.cloud.google.com/apis/credentials?project=$PROJECT_ID" 2>/dev/null || \
echo "Open this URL: https://console.cloud.google.com/apis/credentials?project=$PROJECT_ID"
echo -n "Press Enter after creating the OAuth client..."
read -r
fi
echo -e "${GREEN}✓ OAuth credentials configured${NC}"
echo ""
# Step 7: Configure OAuth Consent Screen
echo -e "${BLUE}[7/9] Configuring OAuth consent screen...${NC}"
echo -e "${YELLOW}The OAuth consent screen requires web console configuration${NC}"
echo ""
echo -e "${BLUE}Please follow these steps:${NC}"
echo "1. User Type: 'External' (unless you have Google Workspace)"
echo "2. App name: 'Soroban Abacus Flashcards'"
echo "3. User support email: Your email"
echo "4. Developer contact: Your email"
echo "5. Scopes: Click 'Add or Remove Scopes' and add:"
echo " - .../auth/userinfo.email"
echo " - .../auth/userinfo.profile"
echo " - .../auth/classroom.courses.readonly"
echo " - .../auth/classroom.rosters.readonly"
echo "6. Test users: Add your email for testing"
echo "7. Save and continue"
echo ""
# Open OAuth consent screen configuration
open "https://console.cloud.google.com/apis/credentials/consent?project=$PROJECT_ID" 2>/dev/null || \
echo "Open this URL: https://console.cloud.google.com/apis/credentials/consent?project=$PROJECT_ID"
echo -n "Press Enter after configuring the consent screen..."
read -r
echo -e "${GREEN}✓ OAuth consent screen configured${NC}"
echo ""
# Step 8: Create .env.local file
echo -e "${BLUE}[8/9] Creating environment configuration...${NC}"
echo ""
echo "Please enter your OAuth credentials from the previous step:"
echo -n "Client ID: "
read -r CLIENT_ID
echo -n "Client Secret: "
read -r -s CLIENT_SECRET
echo ""
# Create or update .env.local
ENV_FILE=".env.local"
if [ -f "$ENV_FILE" ]; then
echo "Backing up existing $ENV_FILE to ${ENV_FILE}.backup"
cp "$ENV_FILE" "${ENV_FILE}.backup"
fi
# Add Google OAuth credentials
echo "" >> "$ENV_FILE"
echo "# Google OAuth (Generated by setup-google-classroom.sh)" >> "$ENV_FILE"
echo "GOOGLE_CLIENT_ID=\"$CLIENT_ID\"" >> "$ENV_FILE"
echo "GOOGLE_CLIENT_SECRET=\"$CLIENT_SECRET\"" >> "$ENV_FILE"
echo "" >> "$ENV_FILE"
echo -e "${GREEN}✓ Environment variables added to $ENV_FILE${NC}"
echo ""
# Step 9: Summary
echo -e "${BLUE}[9/9] Setup Complete!${NC}"
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}Setup Summary${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo -e "Project ID: ${BLUE}$PROJECT_ID${NC}"
echo -e "Project Name: ${BLUE}$PROJECT_NAME${NC}"
echo -e "Billing Account: ${BLUE}$BILLING_ACCOUNT${NC}"
echo -e "APIs Enabled: ${BLUE}Classroom, People${NC}"
echo ""
echo -e "${YELLOW}Next Steps:${NC}"
echo "1. Add Google provider to NextAuth configuration"
echo "2. Test login with 'Sign in with Google'"
echo "3. Verify Classroom API access"
echo ""
echo -e "${BLUE}Useful Commands:${NC}"
echo " View project: gcloud projects describe $PROJECT_ID"
echo " List APIs: gcloud services list --enabled --project=$PROJECT_ID"
echo " View quota: gcloud quotas describe classroom.googleapis.com --project=$PROJECT_ID"
echo ""
echo -e "${GREEN}Setup script complete!${NC}"

View File

@@ -280,21 +280,6 @@ export class RithmomachiaValidator implements GameValidator<RithmomachiaState, R
capture: NonNullable<Extract<RithmomachiaMove, { type: 'MOVE' }>['data']['capture']>,
pyramidFaceUsed?: number | null
): ValidationResult {
// Get mover value
let moverValue: number
if (mover.type === 'P') {
if (!pyramidFaceUsed) {
return { valid: false, error: 'Pyramid must choose a face for capture' }
}
// Validate face is valid
if (!mover.pyramidFaces?.some((f) => f === pyramidFaceUsed)) {
return { valid: false, error: 'Invalid pyramid face' }
}
moverValue = pyramidFaceUsed
} else {
moverValue = mover.value!
}
// Get target value
const targetValue = getEffectiveValue(target)
if (targetValue === null) {
@@ -334,14 +319,49 @@ export class RithmomachiaValidator implements GameValidator<RithmomachiaState, R
helperValue = getEffectiveValue(helperPiece) ?? undefined
}
// Check the relation
const relationCheck = checkRelation(capture.relation, moverValue, targetValue, helperValue)
// Get mover value(s) - for pyramids, try all faces if not specified
if (mover.type === 'P') {
if (pyramidFaceUsed) {
// Specific face provided - validate it
if (!mover.pyramidFaces?.some((f) => f === pyramidFaceUsed)) {
return { valid: false, error: 'Invalid pyramid face' }
}
const relationCheck = checkRelation(
capture.relation,
pyramidFaceUsed,
targetValue,
helperValue
)
if (!relationCheck.valid) {
return { valid: false, error: relationCheck.explanation || 'Relation check failed' }
}
return { valid: true }
} else {
// No face specified - try all faces and accept if ANY works
if (!mover.pyramidFaces || mover.pyramidFaces.length !== 4) {
return { valid: false, error: 'Pyramid must have 4 faces' }
}
if (!relationCheck.valid) {
return { valid: false, error: relationCheck.explanation || 'Relation check failed' }
for (const faceValue of mover.pyramidFaces) {
const relationCheck = checkRelation(capture.relation, faceValue, targetValue, helperValue)
if (relationCheck.valid) {
// At least one face works - capture is valid
return { valid: true }
}
}
// None of the faces worked
return { valid: false, error: 'No pyramid face satisfies the relation' }
}
} else {
// Non-pyramid piece
const moverValue = mover.value!
const relationCheck = checkRelation(capture.relation, moverValue, targetValue, helperValue)
if (!relationCheck.valid) {
return { valid: false, error: relationCheck.explanation || 'Relation check failed' }
}
return { valid: true }
}
return { valid: true }
}
/**

View File

@@ -10,6 +10,7 @@ interface PieceRendererProps {
useNativeAbacusNumbers?: boolean
selected?: boolean
pyramidFaces?: number[]
shouldRotate?: boolean
}
/**
@@ -25,6 +26,7 @@ export function PieceRenderer({
useNativeAbacusNumbers = false,
selected = false,
pyramidFaces = [],
shouldRotate = false,
}: PieceRendererProps) {
const isDark = color === 'B'
const { config } = useAbacusDisplay()
@@ -242,244 +244,249 @@ export function PieceRenderer({
{/* Pyramid face numbers - show when selected */}
{type === 'P' && selected && pyramidFaces.length === 4 && (
<animated.g
style={{
opacity: pyramidNumbersSpring.opacity,
transform: pyramidNumbersSpring.scale.to((s) => `scale(${s})`),
transformOrigin: 'center',
}}
>
{/* Filter for strong drop shadow */}
<defs>
<filter id={`face-shadow-${color}`} x="-100%" y="-100%" width="300%" height="300%">
<feDropShadow
dx="0"
dy="0"
stdDeviation="3"
floodColor="#000000"
floodOpacity="0.9"
/>
</filter>
</defs>
<g transform={shouldRotate ? `rotate(90, ${size / 2}, ${size / 2})` : undefined}>
<animated.g
style={{
opacity: pyramidNumbersSpring.opacity,
transform: pyramidNumbersSpring.scale.to((s) => `scale(${s})`),
transformOrigin: 'center',
}}
>
{/* Filter for strong drop shadow */}
<defs>
<filter id={`face-shadow-${color}`} x="-100%" y="-100%" width="300%" height="300%">
<feDropShadow
dx="0"
dy="0"
stdDeviation="3"
floodColor="#000000"
floodOpacity="0.9"
/>
</filter>
</defs>
{/* Top face */}
{/* Outline/stroke for contrast */}
<text
x={size / 2}
y={size * 0.12}
textAnchor="middle"
dominantBaseline="middle"
fill="none"
stroke={isDark ? '#000000' : '#ffffff'}
strokeWidth={size * 0.05}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
>
{pyramidFaces[0]}
</text>
{/* Main text with shadow and vibrant color */}
<text
x={size / 2}
y={size * 0.12}
textAnchor="middle"
dominantBaseline="middle"
fill={isDark ? '#fbbf24' : '#b45309'}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
filter={`url(#face-shadow-${color})`}
style={{ transition: 'all 0.2s ease' }}
>
{pyramidFaces[0]}
</text>
{/* Top face */}
{/* Outline/stroke for contrast */}
<text
x={size / 2}
y={size * 0.12}
textAnchor="middle"
dominantBaseline="middle"
fill="none"
stroke={isDark ? '#000000' : '#ffffff'}
strokeWidth={size * 0.05}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
>
{pyramidFaces[0]}
</text>
{/* Main text with shadow and vibrant color */}
<text
x={size / 2}
y={size * 0.12}
textAnchor="middle"
dominantBaseline="middle"
fill={isDark ? '#fbbf24' : '#b45309'}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
filter={`url(#face-shadow-${color})`}
style={{ transition: 'all 0.2s ease' }}
>
{pyramidFaces[0]}
</text>
{/* Right face */}
<text
x={size * 0.88}
y={size / 2}
textAnchor="middle"
dominantBaseline="middle"
fill="none"
stroke={isDark ? '#000000' : '#ffffff'}
strokeWidth={size * 0.05}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
>
{pyramidFaces[1]}
</text>
<text
x={size * 0.88}
y={size / 2}
textAnchor="middle"
dominantBaseline="middle"
fill={isDark ? '#fbbf24' : '#b45309'}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
filter={`url(#face-shadow-${color})`}
style={{ transition: 'all 0.2s ease' }}
>
{pyramidFaces[1]}
</text>
{/* Right face */}
<text
x={size * 0.88}
y={size / 2}
textAnchor="middle"
dominantBaseline="middle"
fill="none"
stroke={isDark ? '#000000' : '#ffffff'}
strokeWidth={size * 0.05}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
>
{pyramidFaces[1]}
</text>
<text
x={size * 0.88}
y={size / 2}
textAnchor="middle"
dominantBaseline="middle"
fill={isDark ? '#fbbf24' : '#b45309'}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
filter={`url(#face-shadow-${color})`}
style={{ transition: 'all 0.2s ease' }}
>
{pyramidFaces[1]}
</text>
{/* Bottom face */}
<text
x={size / 2}
y={size * 0.88}
textAnchor="middle"
dominantBaseline="middle"
fill="none"
stroke={isDark ? '#000000' : '#ffffff'}
strokeWidth={size * 0.05}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
>
{pyramidFaces[2]}
</text>
<text
x={size / 2}
y={size * 0.88}
textAnchor="middle"
dominantBaseline="middle"
fill={isDark ? '#fbbf24' : '#b45309'}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
filter={`url(#face-shadow-${color})`}
style={{ transition: 'all 0.2s ease' }}
>
{pyramidFaces[2]}
</text>
{/* Bottom face */}
<text
x={size / 2}
y={size * 0.88}
textAnchor="middle"
dominantBaseline="middle"
fill="none"
stroke={isDark ? '#000000' : '#ffffff'}
strokeWidth={size * 0.05}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
>
{pyramidFaces[2]}
</text>
<text
x={size / 2}
y={size * 0.88}
textAnchor="middle"
dominantBaseline="middle"
fill={isDark ? '#fbbf24' : '#b45309'}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
filter={`url(#face-shadow-${color})`}
style={{ transition: 'all 0.2s ease' }}
>
{pyramidFaces[2]}
</text>
{/* Left face */}
<text
x={size * 0.12}
y={size / 2}
textAnchor="middle"
dominantBaseline="middle"
fill="none"
stroke={isDark ? '#000000' : '#ffffff'}
strokeWidth={size * 0.05}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
>
{pyramidFaces[3]}
</text>
<text
x={size * 0.12}
y={size / 2}
textAnchor="middle"
dominantBaseline="middle"
fill={isDark ? '#fbbf24' : '#b45309'}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
filter={`url(#face-shadow-${color})`}
style={{ transition: 'all 0.2s ease' }}
>
{pyramidFaces[3]}
</text>
</animated.g>
{/* Left face */}
<text
x={size * 0.12}
y={size / 2}
textAnchor="middle"
dominantBaseline="middle"
fill="none"
stroke={isDark ? '#000000' : '#ffffff'}
strokeWidth={size * 0.05}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
>
{pyramidFaces[3]}
</text>
<text
x={size * 0.12}
y={size / 2}
textAnchor="middle"
dominantBaseline="middle"
fill={isDark ? '#fbbf24' : '#b45309'}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
filter={`url(#face-shadow-${color})`}
style={{ transition: 'all 0.2s ease' }}
>
{pyramidFaces[3]}
</text>
</animated.g>
</g>
)}
{/* Other pieces show numbers normally */}
{type !== 'P' &&
(useNativeAbacusNumbers && typeof value === 'number' ? (
// Render mini abacus
<foreignObject
x={size * 0.1}
y={size * 0.1}
width={size * 0.8}
height={size * 0.8}
style={{ overflow: 'visible' }}
>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
{type !== 'P' && (
<g transform={shouldRotate ? `rotate(90, ${size / 2}, ${size / 2})` : undefined}>
{useNativeAbacusNumbers && typeof value === 'number' ? (
// Render mini abacus
<foreignObject
x={size * 0.1}
y={size * 0.1}
width={size * 0.8}
height={size * 0.8}
style={{ overflow: 'visible' }}
>
<AbacusReact
value={value}
columns={Math.max(1, Math.ceil(Math.log10(value + 1)))}
scaleFactor={0.35}
showNumbers={false}
beadShape={config.beadShape}
colorScheme={config.colorScheme}
hideInactiveBeads={config.hideInactiveBeads}
customStyles={{
columnPosts: {
fill: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.15)',
stroke: isDark ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.1)',
strokeWidth: 1,
},
reckoningBar: {
fill: isDark ? 'rgba(255, 255, 255, 0.25)' : 'rgba(0, 0, 0, 0.2)',
stroke: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.15)',
strokeWidth: 1,
},
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
</div>
</foreignObject>
) : (
// Render traditional text number
<g>
{/* Outer glow/shadow for emphasis */}
{isDark ? (
>
<AbacusReact
value={value}
columns={Math.max(1, Math.ceil(Math.log10(value + 1)))}
scaleFactor={0.35}
showNumbers={false}
beadShape={config.beadShape}
colorScheme={config.colorScheme}
hideInactiveBeads={config.hideInactiveBeads}
customStyles={{
columnPosts: {
fill: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.15)',
stroke: isDark ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.1)',
strokeWidth: 1,
},
reckoningBar: {
fill: isDark ? 'rgba(255, 255, 255, 0.25)' : 'rgba(0, 0, 0, 0.2)',
stroke: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.15)',
strokeWidth: 1,
},
}}
/>
</div>
</foreignObject>
) : (
// Render traditional text number
<g>
{/* Outer glow/shadow for emphasis */}
{isDark ? (
<text
x={size / 2}
y={size / 2}
textAnchor="middle"
dominantBaseline="central"
fill="none"
stroke="rgba(255, 255, 255, 0.4)"
strokeWidth={fontSize * 0.2}
fontSize={fontSize}
fontWeight="900"
fontFamily="Georgia, 'Times New Roman', serif"
>
{value}
</text>
) : (
<text
x={size / 2}
y={size / 2}
textAnchor="middle"
dominantBaseline="central"
fill="none"
stroke="rgba(255, 255, 255, 0.95)"
strokeWidth={fontSize * 0.25}
fontSize={fontSize}
fontWeight="900"
fontFamily="Georgia, 'Times New Roman', serif"
>
{value}
</text>
)}
{/* Main text */}
<text
x={size / 2}
y={size / 2}
textAnchor="middle"
dominantBaseline="central"
fill="none"
stroke="rgba(255, 255, 255, 0.4)"
strokeWidth={fontSize * 0.2}
fill={textColor}
fontSize={fontSize}
fontWeight="900"
fontFamily="Georgia, 'Times New Roman', serif"
filter={isDark ? `url(#text-shadow-${color})` : undefined}
>
{value}
</text>
) : (
<text
x={size / 2}
y={size / 2}
textAnchor="middle"
dominantBaseline="central"
fill="none"
stroke="rgba(255, 255, 255, 0.95)"
strokeWidth={fontSize * 0.25}
fontSize={fontSize}
fontWeight="900"
fontFamily="Georgia, 'Times New Roman', serif"
>
{value}
</text>
)}
{/* Main text */}
<text
x={size / 2}
y={size / 2}
textAnchor="middle"
dominantBaseline="central"
fill={textColor}
fontSize={fontSize}
fontWeight="900"
fontFamily="Georgia, 'Times New Roman', serif"
filter={isDark ? `url(#text-shadow-${color})` : undefined}
>
{value}
</text>
</g>
))}
</g>
)}
</g>
)}
</svg>
)
}

View File

@@ -78,6 +78,9 @@ export function PlayingGuideModal({
const [isHovered, setIsHovered] = useState(false)
const [dockPreview, setDockPreview] = useState<'left' | 'right' | null>(null)
const modalRef = useRef<HTMLDivElement>(null)
const hasUndockedRef = useRef(false) // Track if we've undocked during current drag
const undockPositionRef = useRef<{ x: number; y: number } | null>(null) // Position at moment of undocking
const [dragTransform, setDragTransform] = useState<{ x: number; y: number } | null>(null) // Visual transform while dragging from dock
// Save position to localStorage whenever it changes
useEffect(() => {
@@ -114,7 +117,7 @@ export function PlayingGuideModal({
// Handle dragging
const handleMouseDown = (e: React.MouseEvent) => {
console.log(
'[GUIDE_DRAG] handleMouseDown - windowWidth: ' +
'[GUIDE_DRAG] === MOUSE DOWN === windowWidth: ' +
window.innerWidth +
', standalone: ' +
standalone +
@@ -125,8 +128,18 @@ export function PlayingGuideModal({
console.log('[GUIDE_DRAG] Skipping drag - mobile or standalone')
return // No dragging on mobile or standalone
}
console.log('[GUIDE_DRAG] Starting drag - docked: ' + docked)
console.log(
'[GUIDE_DRAG] Starting drag - docked: ' +
docked +
', position: ' +
JSON.stringify(position) +
', size: ' +
JSON.stringify(size)
)
setIsDragging(true)
hasUndockedRef.current = false // Reset undock tracking for new drag
undockPositionRef.current = null // Clear undock position
setDragTransform(null) // Clear any previous transform
// When docked, we need to track the initial mouse position for undocking
if (docked) {
@@ -141,7 +154,12 @@ export function PlayingGuideModal({
y: e.clientY,
})
} else {
console.log('[GUIDE_DRAG] Not docked - setting dragStart offset')
console.log(
'[GUIDE_DRAG] Not docked - setting dragStart offset: ' +
(e.clientX - position.x) +
', ' +
(e.clientY - position.y)
)
setDragStart({
x: e.clientX - position.x,
y: e.clientY - position.y,
@@ -172,34 +190,61 @@ export function PlayingGuideModal({
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
// When docked, check if we've dragged far enough away to undock
if (docked && onUndock) {
console.log(
'[GUIDE_DRAG] Mouse move - clientX: ' +
e.clientX +
', clientY: ' +
e.clientY +
', docked: ' +
docked +
', hasUndocked: ' +
hasUndockedRef.current
)
// When docked and haven't undocked yet, check if we've dragged far enough away to undock
if (docked && onUndock && !hasUndockedRef.current) {
const UNDOCK_THRESHOLD = 50 // pixels to drag before undocking
const dragDistance = Math.sqrt(
(e.clientX - dragStart.x) ** 2 + (e.clientY - dragStart.y) ** 2
)
console.log(
'[GUIDE_DRAG] Dragging while docked - distance: ' +
'[GUIDE_DRAG] Checking threshold - distance: ' +
dragDistance +
', threshold: ' +
UNDOCK_THRESHOLD
UNDOCK_THRESHOLD +
', dragStart: ' +
JSON.stringify(dragStart)
)
if (dragDistance > UNDOCK_THRESHOLD) {
console.log('[GUIDE_DRAG] Threshold exceeded - calling onUndock()')
onUndock()
// After undocking, keep modal at current visual position
// and set dragStart as offset from position to cursor for smooth continued dragging
console.log('[GUIDE_DRAG] === THRESHOLD EXCEEDED === Marking as virtually undocked')
hasUndockedRef.current = true
// Don't call onUndock() yet - wait until mouse up to avoid unmounting during drag
// After undocking, set dragStart as offset from position to cursor for smooth continued dragging
if (modalRef.current) {
const rect = modalRef.current.getBoundingClientRect()
console.log('[GUIDE_DRAG] Modal rect - left: ' + rect.left + ', top: ' + rect.top)
// Keep modal at its current screen position (no jump)
setPosition({
console.log(
'[GUIDE_DRAG] Modal rect - left: ' +
rect.left +
', top: ' +
rect.top +
', width: ' +
rect.width +
', height: ' +
rect.height
)
// Store the undock position in ref for immediate access
undockPositionRef.current = {
x: rect.left,
y: rect.top,
})
console.log('[GUIDE_DRAG] New position set to: ' + rect.left + ', ' + rect.top)
}
console.log(
'[GUIDE_DRAG] Stored undock position in ref: ' +
JSON.stringify(undockPositionRef.current)
)
// Set dragStart as offset from current position to cursor
const newDragStartX = e.clientX - rect.left
const newDragStartY = e.clientY - rect.top
@@ -210,18 +255,74 @@ export function PlayingGuideModal({
x: newDragStartX,
y: newDragStartY,
})
// Also store the position for state (used when actually undocking)
setPosition({
x: rect.left,
y: rect.top,
})
}
} else {
console.log('[GUIDE_DRAG] Below threshold - returning early')
// Still below threshold - don't apply any transform yet
return
}
} else {
// Normal floating modal dragging
setPosition({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y,
})
}
// Check if we're near edges for docking preview
if (onDock && onDockPreview && !docked) {
// Virtually undocked or already floating - update position
if (hasUndockedRef.current || !docked) {
const newX = e.clientX - dragStart.x
const newY = e.clientY - dragStart.y
console.log(
'[GUIDE_DRAG] Calculating position - newX: ' +
newX +
', newY: ' +
newY +
', dragStart: ' +
JSON.stringify(dragStart)
)
if (hasUndockedRef.current && docked) {
// Still docked but virtually undocked - use transform for visual movement
// Use undockPositionRef instead of position state to avoid stale closure
if (undockPositionRef.current) {
const transformX = newX - undockPositionRef.current.x
const transformY = newY - undockPositionRef.current.y
console.log(
'[GUIDE_DRAG] === SETTING TRANSFORM === x: ' +
transformX +
', y: ' +
transformY +
', undockPosition: ' +
JSON.stringify(undockPositionRef.current)
)
setDragTransform({ x: transformX, y: transformY })
}
} else {
// Actually floating - use position
console.log('[GUIDE_DRAG] Floating - setting position: ' + newX + ', ' + newY)
setPosition({
x: newX,
y: newY,
})
}
// Check if we're near edges for docking preview (works for floating or virtually undocked)
console.log(
'[GUIDE_DRAG] Checking docking preview - onDock: ' +
(onDock ? 'defined' : 'undefined') +
', onDockPreview: ' +
(onDockPreview ? 'defined' : 'undefined') +
', docked: ' +
docked +
', hasUndocked: ' +
hasUndockedRef.current
)
if (onDock && onDockPreview && (!docked || hasUndockedRef.current)) {
const DOCK_THRESHOLD = 100
console.log(
'[GUIDE_DRAG] Docking preview condition passed - checking edges, clientX: ' +
e.clientX
)
if (e.clientX < DOCK_THRESHOLD) {
setDockPreview('left')
onDockPreview('left')
@@ -280,21 +381,77 @@ export function PlayingGuideModal({
}
const handleMouseUp = (e: MouseEvent) => {
// Check for docking when releasing drag
if (isDragging && onDock && !docked) {
console.log(
'[GUIDE_DRAG] === MOUSE UP === clientX: ' +
e.clientX +
', docked: ' +
docked +
', hasUndocked: ' +
hasUndockedRef.current +
', isDragging: ' +
isDragging +
', onDock: ' +
(onDock ? 'defined' : 'undefined')
)
// Check for docking when releasing drag (works for floating or virtually undocked)
if (isDragging && onDock && (!docked || hasUndockedRef.current)) {
const DOCK_THRESHOLD = 100 // pixels from edge to trigger docking
console.log(
'[GUIDE_DRAG] Checking for dock - clientX: ' +
e.clientX +
', threshold: ' +
DOCK_THRESHOLD +
', windowWidth: ' +
window.innerWidth
)
if (e.clientX < DOCK_THRESHOLD) {
console.log('[GUIDE_DRAG] Mouse up - near left edge, calling onDock(left)')
onDock('left')
// Don't call onUndock if we're re-docking
setIsDragging(false)
setIsResizing(false)
setResizeDirection('')
setDockPreview(null)
setDragTransform(null)
if (onDockPreview) {
onDockPreview(null)
}
console.log('[GUIDE_DRAG] Cleared state after re-dock to left')
return
} else if (e.clientX > window.innerWidth - DOCK_THRESHOLD) {
console.log('[GUIDE_DRAG] Mouse up - near right edge, calling onDock(right)')
onDock('right')
// Don't call onUndock if we're re-docking
setIsDragging(false)
setIsResizing(false)
setResizeDirection('')
setDockPreview(null)
setDragTransform(null)
if (onDockPreview) {
onDockPreview(null)
}
console.log('[GUIDE_DRAG] Cleared state after re-dock to right')
return
}
}
// If we virtually undocked during this drag and didn't re-dock, now actually undock
if (hasUndockedRef.current && docked && onUndock) {
console.log(
'[GUIDE_DRAG] Mouse up - calling deferred onUndock() with final position: ' +
JSON.stringify(position)
)
onUndock()
}
console.log('[GUIDE_DRAG] Mouse up - clearing all drag state')
setIsDragging(false)
setIsResizing(false)
setResizeDirection('')
setDockPreview(null) // Clear dock preview when drag ends
setDragTransform(null) // Clear drag transform
if (onDockPreview) {
onDockPreview(null) // Clear parent preview state
}
@@ -466,81 +623,138 @@ export function PlayingGuideModal({
)
}
const modalContent = (
<div
ref={modalRef}
data-component="playing-guide-modal"
style={{
position: docked ? 'relative' : 'fixed',
background: 'white',
borderRadius: standalone || docked ? 0 : isVeryNarrow ? '8px' : '12px',
boxShadow: standalone || docked ? 'none' : '0 20px 60px rgba(0, 0, 0, 0.3)',
border: standalone || docked ? 'none' : '1px solid #e5e7eb',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
...(docked
? { width: '100%', height: '100%' }
const modalContent = (() => {
const styleConfig: React.CSSProperties = {
// When virtually undocked (dragTransform present), use fixed positioning to break out of Panel
position: dragTransform || !docked ? 'fixed' : 'relative',
background: 'white',
borderRadius: standalone || (docked && !dragTransform) ? 0 : isVeryNarrow ? '8px' : '12px',
boxShadow:
standalone || (docked && !dragTransform) ? 'none' : '0 20px 60px rgba(0, 0, 0, 0.3)',
border: standalone || (docked && !dragTransform) ? 'none' : '1px solid #e5e7eb',
display: 'flex',
flexDirection: 'column' as const,
overflow: 'hidden',
...(dragTransform && undockPositionRef.current
? // Virtually undocked - show at drag position using ref position
{
left: `${undockPositionRef.current.x + dragTransform.x}px`,
top: `${undockPositionRef.current.y + dragTransform.y}px`,
width: `${size.width}px`,
height: `${size.height}px`,
zIndex: Z_INDEX.MODAL,
}
: docked
? // Still docked
{ width: '100%', height: '100%' }
: standalone
? { top: 0, left: 0, width: '100vw', height: '100vh', zIndex: 1 }
: {
? // Standalone mode
{ top: 0, left: 0, width: '100vw', height: '100vh', zIndex: 1 }
: // Actually floating
{
left: `${position.x}px`,
top: `${position.y}px`,
width: `${size.width}px`,
height: `${size.height}px`,
zIndex: Z_INDEX.MODAL,
}),
// 80% opacity when showing dock preview or when not hovered on desktop
opacity:
dockPreview !== null
// 80% opacity when showing dock preview or when not hovered on desktop
opacity:
dockPreview !== null
? 0.8
: !standalone && !docked && window.innerWidth >= 768 && !isHovered
? 0.8
: !standalone && !docked && window.innerWidth >= 768 && !isHovered
? 0.8
: 1,
transition: 'opacity 0.2s ease',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{!docked && renderResizeHandles()}
: 1,
transition: 'opacity 0.2s ease',
}
{/* Header */}
console.log(
'[GUIDE_DRAG] Rendering with style - position: ' +
styleConfig.position +
', left: ' +
(styleConfig.left ?? 'none') +
', top: ' +
(styleConfig.top ?? 'none') +
', dragTransform: ' +
JSON.stringify(dragTransform) +
', docked: ' +
docked
)
return (
<div
data-element="modal-header"
className={css({
bg: '#f9fafb',
borderBottom: '1px solid #e5e7eb',
userSelect: 'none',
flexShrink: 0,
position: 'relative',
})}
style={{
padding: isVeryNarrow ? '8px' : isNarrow ? '12px' : '24px',
cursor: isDragging
? 'grabbing'
: !standalone && window.innerWidth >= 768
? 'grab'
: 'default',
}}
onMouseDown={handleMouseDown}
ref={modalRef}
data-component="playing-guide-modal"
style={styleConfig}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Close and utility buttons - top right */}
{!docked && renderResizeHandles()}
{/* Header */}
<div
data-element="modal-header"
className={css({
bg: '#f9fafb',
borderBottom: '1px solid #e5e7eb',
userSelect: 'none',
flexShrink: 0,
position: 'relative',
})}
style={{
position: 'absolute',
top: isVeryNarrow ? '4px' : '8px',
right: isVeryNarrow ? '4px' : '8px',
display: 'flex',
alignItems: 'center',
gap: isVeryNarrow ? '4px' : '8px',
padding: isVeryNarrow ? '8px' : isNarrow ? '12px' : '24px',
cursor: isDragging
? 'grabbing'
: !standalone && window.innerWidth >= 768
? 'grab'
: 'default',
}}
onMouseDown={handleMouseDown}
>
{/* Bust-out button (only if not already standalone/docked and not very narrow) */}
{!standalone && !docked && !isVeryNarrow && (
{/* Close and utility buttons - top right */}
<div
style={{
position: 'absolute',
top: isVeryNarrow ? '4px' : '8px',
right: isVeryNarrow ? '4px' : '8px',
display: 'flex',
alignItems: 'center',
gap: isVeryNarrow ? '4px' : '8px',
}}
>
{/* Bust-out button (only if not already standalone/docked and not very narrow) */}
{!standalone && !docked && !isVeryNarrow && (
<button
type="button"
data-action="bust-out-guide"
onClick={handleBustOut}
style={{
background: '#e5e7eb',
color: '#374151',
border: 'none',
borderRadius: isVeryNarrow ? '4px' : '6px',
width: isVeryNarrow ? '24px' : '32px',
height: isVeryNarrow ? '24px' : '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: isVeryNarrow ? '12px' : '16px',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#d1d5db')}
onMouseLeave={(e) => (e.currentTarget.style.background = '#e5e7eb')}
title={t('bustOut')}
>
</button>
)}
{/* Close button */}
<button
type="button"
data-action="bust-out-guide"
onClick={handleBustOut}
data-action="close-guide"
onClick={onClose}
style={{
background: '#e5e7eb',
color: '#374151',
@@ -552,170 +766,144 @@ export function PlayingGuideModal({
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: isVeryNarrow ? '12px' : '16px',
fontSize: isVeryNarrow ? '14px' : '18px',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#d1d5db')}
onMouseLeave={(e) => (e.currentTarget.style.background = '#e5e7eb')}
title={t('bustOut')}
>
</button>
)}
</div>
{/* Close button */}
<button
type="button"
data-action="close-guide"
onClick={onClose}
style={{
background: '#e5e7eb',
color: '#374151',
border: 'none',
borderRadius: isVeryNarrow ? '4px' : '6px',
width: isVeryNarrow ? '24px' : '32px',
height: isVeryNarrow ? '24px' : '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: isVeryNarrow ? '14px' : '18px',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#d1d5db')}
onMouseLeave={(e) => (e.currentTarget.style.background = '#e5e7eb')}
>
</button>
</div>
{/* Centered title and subtitle - hide when very narrow */}
{!isVeryNarrow && (
<div style={{ textAlign: 'center' }}>
<h1
style={{
fontSize: isNarrow ? '16px' : isMedium ? '20px' : '28px',
fontWeight: 'bold',
color: '#111827',
marginBottom: isNarrow ? '4px' : '8px',
lineHeight: 1.2,
}}
>
{t('title')}
</h1>
{!isNarrow && (
<p
{/* Centered title and subtitle - hide when very narrow */}
{!isVeryNarrow && (
<div style={{ textAlign: 'center' }}>
<h1
style={{
fontSize: isMedium ? '12px' : '16px',
color: '#6b7280',
marginBottom: isMedium ? '8px' : '16px',
lineHeight: 1.3,
fontSize: isNarrow ? '16px' : isMedium ? '20px' : '28px',
fontWeight: 'bold',
color: '#111827',
marginBottom: isNarrow ? '4px' : '8px',
lineHeight: 1.2,
}}
>
{t('subtitle')}
</p>
)}
</div>
)}
</div>
{t('title')}
</h1>
{!isNarrow && (
<p
style={{
fontSize: isMedium ? '12px' : '16px',
color: '#6b7280',
marginBottom: isMedium ? '8px' : '16px',
lineHeight: 1.3,
}}
>
{t('subtitle')}
</p>
)}
</div>
)}
</div>
{/* Navigation Tabs - fully responsive, always fit in available width */}
<div
data-element="guide-nav"
style={{
display: 'flex',
flexDirection: 'row',
borderBottom: '2px solid #e5e7eb',
background: '#f9fafb',
flexShrink: 0,
}}
>
{sections.map((section) => (
<button
key={section.id}
type="button"
data-action={`navigate-${section.id}`}
onClick={() => setActiveSection(section.id)}
style={{
flex: '1 1 0', // Equal width tabs
minWidth: 0, // Allow shrinking below content size
padding: isVeryNarrow ? '10px 6px' : isNarrow ? '10px 8px' : '14px 20px',
fontSize: isVeryNarrow ? '16px' : isNarrow ? '12px' : '14px',
fontWeight: activeSection === section.id ? 'bold' : '500',
color: activeSection === section.id ? '#7c2d12' : '#6b7280',
background: activeSection === section.id ? 'white' : 'transparent',
cursor: 'pointer',
transition: 'all 0.2s',
border: 'none',
borderBottom: `3px solid ${activeSection === section.id ? '#7c2d12' : 'transparent'}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: isVeryNarrow ? '0' : isNarrow ? '4px' : '6px',
lineHeight: 1,
overflow: 'hidden',
}}
onMouseEnter={(e) => {
if (activeSection !== section.id) {
e.currentTarget.style.background = '#f3f4f6'
}
}}
onMouseLeave={(e) => {
if (activeSection !== section.id) {
e.currentTarget.style.background = 'transparent'
}
}}
title={section.label}
>
<span style={{ fontSize: isVeryNarrow ? '18px' : 'inherit', flexShrink: 0 }}>
{section.icon}
</span>
{!isVeryNarrow && (
<Textfit
mode="single"
min={8}
max={isNarrow ? 12 : 14}
style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center' }}
>
{section.label}
</Textfit>
)}
</button>
))}
</div>
{/* Navigation Tabs - fully responsive, always fit in available width */}
<div
data-element="guide-nav"
style={{
display: 'flex',
flexDirection: 'row',
borderBottom: '2px solid #e5e7eb',
background: '#f9fafb',
flexShrink: 0,
}}
>
{sections.map((section) => (
<button
key={section.id}
type="button"
data-action={`navigate-${section.id}`}
onClick={() => setActiveSection(section.id)}
style={{
flex: '1 1 0', // Equal width tabs
minWidth: 0, // Allow shrinking below content size
padding: isVeryNarrow ? '10px 6px' : isNarrow ? '10px 8px' : '14px 20px',
fontSize: isVeryNarrow ? '16px' : isNarrow ? '12px' : '14px',
fontWeight: activeSection === section.id ? 'bold' : '500',
color: activeSection === section.id ? '#7c2d12' : '#6b7280',
background: activeSection === section.id ? 'white' : 'transparent',
cursor: 'pointer',
transition: 'all 0.2s',
border: 'none',
borderBottom: `3px solid ${activeSection === section.id ? '#7c2d12' : 'transparent'}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: isVeryNarrow ? '0' : isNarrow ? '4px' : '6px',
lineHeight: 1,
overflow: 'hidden',
}}
onMouseEnter={(e) => {
if (activeSection !== section.id) {
e.currentTarget.style.background = '#f3f4f6'
}
}}
onMouseLeave={(e) => {
if (activeSection !== section.id) {
e.currentTarget.style.background = 'transparent'
}
}}
title={section.label}
>
<span style={{ fontSize: isVeryNarrow ? '18px' : 'inherit', flexShrink: 0 }}>
{section.icon}
</span>
{!isVeryNarrow && (
<Textfit
mode="single"
min={8}
max={isNarrow ? 12 : 14}
style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center' }}
>
{section.label}
</Textfit>
)}
</button>
))}
</div>
{/* Content */}
<div
data-element="guide-content"
style={{
flex: 1,
overflow: 'auto',
padding: isVeryNarrow ? '8px' : isNarrow ? '12px' : '24px',
fontSize: isVeryNarrow ? '12px' : isNarrow ? '13px' : '14px',
lineHeight: 1.5,
}}
>
{activeSection === 'overview' && (
<OverviewSection useNativeAbacusNumbers={useNativeAbacusNumbers} />
)}
{activeSection === 'pieces' && (
<PiecesSection useNativeAbacusNumbers={useNativeAbacusNumbers} />
)}
{activeSection === 'capture' && (
<CaptureSection useNativeAbacusNumbers={useNativeAbacusNumbers} />
)}
{activeSection === 'strategy' && (
<StrategySection useNativeAbacusNumbers={useNativeAbacusNumbers} />
)}
{activeSection === 'harmony' && (
<HarmonySection useNativeAbacusNumbers={useNativeAbacusNumbers} />
)}
{activeSection === 'victory' && (
<VictorySection useNativeAbacusNumbers={useNativeAbacusNumbers} />
)}
{/* Content */}
<div
data-element="guide-content"
style={{
flex: 1,
overflow: 'auto',
padding: isVeryNarrow ? '8px' : isNarrow ? '12px' : '24px',
fontSize: isVeryNarrow ? '12px' : isNarrow ? '13px' : '14px',
lineHeight: 1.5,
}}
>
{activeSection === 'overview' && (
<OverviewSection useNativeAbacusNumbers={useNativeAbacusNumbers} />
)}
{activeSection === 'pieces' && (
<PiecesSection useNativeAbacusNumbers={useNativeAbacusNumbers} />
)}
{activeSection === 'capture' && (
<CaptureSection useNativeAbacusNumbers={useNativeAbacusNumbers} />
)}
{activeSection === 'strategy' && (
<StrategySection useNativeAbacusNumbers={useNativeAbacusNumbers} />
)}
{activeSection === 'harmony' && (
<HarmonySection useNativeAbacusNumbers={useNativeAbacusNumbers} />
)}
{activeSection === 'victory' && (
<VictorySection useNativeAbacusNumbers={useNativeAbacusNumbers} />
)}
</div>
</div>
</div>
)
)
})() // Invoke the IIFE
// If standalone, just render the content without Dialog wrapper
if (standalone) {

View File

@@ -173,9 +173,11 @@ export function RithmomachiaGame() {
padding: guideDocked ? 0 : { base: '12px', sm: '16px', md: '20px' },
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
alignItems: guideDocked ? 'stretch' : 'center',
position: 'relative',
overflow: 'auto',
// When docked, ensure we fill panel height
height: guideDocked ? '100%' : 'auto',
})}
>
<main
@@ -191,7 +193,8 @@ export function RithmomachiaGame() {
flexDirection: 'column',
overflow: 'hidden',
position: 'relative',
height: '100%',
// Ensure main fills parent height
minHeight: 0,
})}
>
{state.gamePhase === 'setup' && (
@@ -245,6 +248,7 @@ export function RithmomachiaGame() {
onClose={() => setIsGuideOpen(false)}
docked={true} // Always render as docked when in panel
onUndock={guideDocked ? handleUndock : undefined} // Only show undock button when truly docked
onDock={handleDock} // Allow re-docking during virtual undock
onDockPreview={handleDockPreview}
/>
</Panel>
@@ -284,6 +288,7 @@ export function RithmomachiaGame() {
onClose={() => setIsGuideOpen(false)}
docked={true} // Always render as docked when in panel
onUndock={guideDocked ? handleUndock : undefined} // Only show undock button when truly docked
onDock={handleDock} // Allow re-docking during virtual undock
onDockPreview={handleDockPreview}
/>
</Panel>

View File

@@ -183,40 +183,87 @@ export function BoardDisplay() {
return
}
const moverValue = getEffectiveValue(moverPiece)
const targetValue = getEffectiveValue(targetPiece)
console.log('[handleCaptureWithRelation] moverValue:', moverValue)
console.log('[handleCaptureWithRelation] targetValue:', targetValue)
if (targetValue === undefined || targetValue === null) {
console.log('[handleCaptureWithRelation] Undefined/null target value, returning')
return
}
// For pyramids, find helpers using all faces
if (
moverValue === undefined ||
moverValue === null ||
targetValue === undefined ||
targetValue === null
moverPiece.type === 'P' &&
moverPiece.pyramidFaces &&
moverPiece.pyramidFaces.length === 4
) {
console.log('[handleCaptureWithRelation] Undefined/null value, returning')
return
console.log(
'[handleCaptureWithRelation] Pyramid - checking all faces for helpers: ' +
moverPiece.pyramidFaces.join(', ')
)
// Collect all valid helpers from all pyramid faces
const allValidHelpers = new Map<string, Piece>()
for (const faceValue of moverPiece.pyramidFaces) {
const helpers = findValidHelpers(faceValue, targetValue, relation)
console.log(
'[handleCaptureWithRelation] Face ' +
faceValue +
' found ' +
helpers.length +
' helpers'
)
for (const helper of helpers) {
allValidHelpers.set(helper.id, helper)
}
}
const validHelpers = Array.from(allValidHelpers.values())
console.log('[handleCaptureWithRelation] Total unique helpers: ' + validHelpers.length)
if (validHelpers.length === 0) {
console.log('[handleCaptureWithRelation] No valid helpers found for any pyramid face')
return
}
console.log('[handleCaptureWithRelation] Auto-selecting first helper:', validHelpers[0])
setSelectedRelation(relation)
setSelectedHelper({
helperPiece: validHelpers[0],
moverPiece,
targetPiece,
})
} else {
// Non-pyramid logic
const moverValue = getEffectiveValue(moverPiece)
console.log('[handleCaptureWithRelation] moverValue:', moverValue)
if (moverValue === undefined || moverValue === null) {
console.log('[handleCaptureWithRelation] Undefined/null mover value, returning')
return
}
// Find valid helpers
const validHelpers = findValidHelpers(moverValue, targetValue, relation)
console.log('[handleCaptureWithRelation] validHelpers:', validHelpers)
if (validHelpers.length === 0) {
// No valid helpers - relation is impossible
console.log('[handleCaptureWithRelation] No valid helpers found')
return
}
// Automatically select the first valid helper (skip helper selection UI)
console.log('[handleCaptureWithRelation] Auto-selecting first helper:', validHelpers[0])
setSelectedRelation(relation)
setSelectedHelper({
helperPiece: validHelpers[0],
moverPiece,
targetPiece,
})
}
// Find valid helpers
const validHelpers = findValidHelpers(moverValue, targetValue, relation)
console.log('[handleCaptureWithRelation] validHelpers:', validHelpers)
if (validHelpers.length === 0) {
// No valid helpers - relation is impossible
console.log('[handleCaptureWithRelation] No valid helpers found')
return
}
// Automatically select the first valid helper (skip helper selection UI)
console.log('[handleCaptureWithRelation] Auto-selecting first helper:', validHelpers[0])
setSelectedRelation(relation)
setSelectedHelper({
helperPiece: validHelpers[0],
moverPiece,
targetPiece,
})
} else {
console.log('[handleCaptureWithRelation] No helper needed, executing capture immediately')
// No helper needed - execute capture immediately
@@ -388,19 +435,36 @@ export function BoardDisplay() {
return []
}
const moverValue = getEffectiveValue(moverPiece)
const targetValue = getEffectiveValue(targetPiece)
if (
moverValue === undefined ||
moverValue === null ||
targetValue === undefined ||
targetValue === null
) {
if (targetValue === undefined || targetValue === null) {
return []
}
const validHelpers = findValidHelpers(moverValue, targetValue, selectedRelation)
// For pyramids, collect helpers from all faces
let validHelpers: Piece[]
if (
moverPiece.type === 'P' &&
moverPiece.pyramidFaces &&
moverPiece.pyramidFaces.length === 4
) {
const allValidHelpers = new Map<string, Piece>()
for (const faceValue of moverPiece.pyramidFaces) {
const helpers = findValidHelpers(faceValue, targetValue, selectedRelation)
for (const helper of helpers) {
allValidHelpers.set(helper.id, helper)
}
}
validHelpers = Array.from(allValidHelpers.values())
} else {
const moverValue = getEffectiveValue(moverPiece)
if (moverValue === undefined || moverValue === null) {
return []
}
validHelpers = findValidHelpers(moverValue, targetValue, selectedRelation)
}
const helpersWithPos = validHelpers.map((piece) => {
const basePos = getSquarePosition(piece.square, layout)
@@ -413,9 +477,9 @@ export function BoardDisplay() {
return helpersWithPos
})()
// Calculate available relations for this capture
const availableRelations = (() => {
if (!captureTarget) return []
// Calculate available relations and pyramid face values for this capture
const { availableRelations, pyramidFaceValues } = (() => {
if (!captureTarget) return { availableRelations: [], pyramidFaceValues: null }
const moverPiece = Object.values(state.pieces).find(
(p) => p.id === captureTarget.pieceId && !p.captured
@@ -424,20 +488,60 @@ export function BoardDisplay() {
(p) => p.square === captureTarget.to && !p.captured
)
if (!moverPiece || !targetPiece) return []
if (!moverPiece || !targetPiece) return { availableRelations: [], pyramidFaceValues: null }
const moverValue = getEffectiveValue(moverPiece)
const targetValue = getEffectiveValue(targetPiece)
if (
moverValue === undefined ||
moverValue === null ||
targetValue === undefined ||
targetValue === null
)
return []
if (targetValue === undefined || targetValue === null)
return { availableRelations: [], pyramidFaceValues: null }
return findAvailableRelations(moverValue, targetValue)
// For pyramids, collect ALL relations from ALL faces and track which face to use
if (
moverPiece.type === 'P' &&
moverPiece.pyramidFaces &&
moverPiece.pyramidFaces.length === 4
) {
console.log(
'[availableRelations] Checking pyramid with faces: ' + moverPiece.pyramidFaces.join(', ')
)
const allRelations = new Set<RelationKind>()
const faceMap = new Map<RelationKind, number>()
// Check each face and collect all available relations, storing first face that works for each relation
for (const faceValue of moverPiece.pyramidFaces) {
const relations = findAvailableRelations(faceValue, targetValue)
console.log(
'[availableRelations] Face ' + faceValue + ' relations: ' + relations.join(', ')
)
for (const rel of relations) {
if (!faceMap.has(rel)) {
faceMap.set(rel, faceValue)
}
allRelations.add(rel)
}
}
const result = Array.from(allRelations)
console.log('[availableRelations] All pyramid relations: ' + result.join(', '))
console.log(
'[availableRelations] Pyramid face map: ' +
Array.from(faceMap.entries())
.map(([r, v]) => r + '=' + v)
.join(', ')
)
return { availableRelations: result, pyramidFaceValues: faceMap }
}
// For non-pyramid pieces, use standard logic
const moverValue = getEffectiveValue(moverPiece)
if (moverValue === undefined || moverValue === null)
return { availableRelations: [], pyramidFaceValues: null }
return {
availableRelations: findAvailableRelations(moverValue, targetValue),
pyramidFaceValues: null,
}
})()
// Calculate if hovered square shows error (for hover preview)
@@ -542,24 +646,126 @@ export function BoardDisplay() {
return { x, y }
})()
// Detect portrait vs landscape and calculate explicit dimensions for rotation
const boardAspectRatio = boardWidth / boardHeight // ~2:1 for 16x8 board
const [containerAspectRatio, setContainerAspectRatio] = useState(1)
const [svgDimensions, setSvgDimensions] = useState<{ width: string; height: string } | null>(null)
const [updateCounter, setUpdateCounter] = useState(0)
useEffect(() => {
const container = document.querySelector('[data-component="board-container"]')
if (!container) {
return
}
// Watch the parent element - that's what gets resized by the panel
const parentContainer = container.parentElement
if (!parentContainer) {
return
}
const updateDimensions = () => {
// Measure the PARENT container, not the wrapper itself
const rect = parentContainer.getBoundingClientRect()
const containerAspect = rect.width / rect.height
const shouldRotateBoard = containerAspect < 1 && boardAspectRatio > 1
if (shouldRotateBoard) {
// When rotating -90°, the board's dimensions swap visually:
// - pre-rotation width → visual height
// - pre-rotation height → visual width
//
// In portrait mode (tall container), prioritize filling HEIGHT.
// Start by filling container height
let visualHeight = rect.height
let visualWidth = visualHeight / boardAspectRatio
// If visual width exceeds container width, scale down PROPORTIONALLY
// (don't switch to filling width - keep height priority)
if (visualWidth > rect.width) {
const scale = rect.width / visualWidth
visualWidth = rect.width
visualHeight = visualHeight * scale
}
// Convert visual dimensions back to pre-rotation SVG dimensions
const preRotationWidth = visualHeight
const preRotationHeight = visualWidth
setSvgDimensions({
width: `${preRotationWidth}px`,
height: `${preRotationHeight}px`,
})
} else {
// Normal landscape: use percentage sizing
setSvgDimensions(null)
}
setContainerAspectRatio(containerAspect)
}
// Initial check - use requestAnimationFrame to ensure layout is painted
const initialUpdate = () => {
requestAnimationFrame(() => {
updateDimensions()
})
}
initialUpdate()
// Use ResizeObserver to detect panel resizing (window resize doesn't catch panel changes!)
const resizeObserver = new ResizeObserver((entries) => {
// Use requestAnimationFrame to ensure we measure after layout
requestAnimationFrame(() => {
updateDimensions()
})
})
// Watch the parent container - that's what gets resized by the panel
resizeObserver.observe(parentContainer)
// Also listen to window resize for when the whole window changes
window.addEventListener('resize', updateDimensions)
return () => {
resizeObserver.disconnect()
window.removeEventListener('resize', updateDimensions)
}
}, [boardAspectRatio])
// Rotate board if container is portrait and board is landscape
const shouldRotate = containerAspectRatio < 1 && boardAspectRatio > 1
return (
<div
data-component="board-container"
className={css({
width: '100%',
maxWidth: '1200px',
margin: '0 auto',
height: '100%',
position: 'relative',
zIndex: Z_INDEX.GAME.OVERLAY,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
})}
>
{/* Unified SVG Board */}
<svg
viewBox={`0 0 ${boardWidth} ${boardHeight}`}
className={css({
width: '100%',
style={{
// Use explicit dimensions when rotated, percentage sizing when normal
width: svgDimensions ? svgDimensions.width : '100%',
height: svgDimensions ? svgDimensions.height : 'auto',
// Don't constrain with max dimensions when rotating - we've already calculated exact size
maxWidth: svgDimensions ? 'none' : '100%',
maxHeight: svgDimensions ? 'none' : '100%',
// Don't use aspect-ratio when rotating - it overrides explicit dimensions
aspectRatio: svgDimensions ? 'auto' : `${boardWidth} / ${boardHeight}`,
cursor: isMyTurn ? 'pointer' : 'default',
overflow: 'visible',
})}
transform: shouldRotate ? 'rotate(-90deg)' : 'none',
transformOrigin: 'center',
transition: 'transform 0.3s ease',
}}
onClick={handleSvgClick}
onMouseMove={handleSvgMouseMove}
onMouseLeave={handleSvgMouseLeave}
@@ -603,19 +809,21 @@ export function BoardDisplay() {
const y = boardHeight - 10
return (
<text
key={`col-${colLabel}`}
x={x}
y={y}
fontSize="20"
fontWeight="bold"
fill="#374151"
fontFamily="sans-serif"
textAnchor="middle"
dominantBaseline="middle"
>
{colLabel}
</text>
<g key={`col-${colLabel}`} transform={`translate(${x}, ${y})`}>
<text
x={0}
y={0}
fontSize="20"
fontWeight="bold"
fill="#374151"
fontFamily="sans-serif"
textAnchor="middle"
dominantBaseline="middle"
transform={shouldRotate ? 'rotate(90)' : undefined}
>
{colLabel}
</text>
</g>
)
})}
@@ -626,19 +834,21 @@ export function BoardDisplay() {
const y = padding + row * (cellSize + gap) + cellSize / 2
return (
<text
key={`row-${actualRank}`}
x={x}
y={y}
fontSize="20"
fontWeight="bold"
fill="#374151"
fontFamily="sans-serif"
textAnchor="middle"
dominantBaseline="middle"
>
{actualRank}
</text>
<g key={`row-${actualRank}`} transform={`translate(${x}, ${y})`}>
<text
x={0}
y={0}
fontSize="20"
fontWeight="bold"
fill="#374151"
fontFamily="sans-serif"
textAnchor="middle"
dominantBaseline="middle"
transform={shouldRotate ? 'rotate(90)' : undefined}
>
{actualRank}
</text>
</g>
)
})}
@@ -659,6 +869,7 @@ export function BoardDisplay() {
opacity={isInNumberBond ? 0.2 : 1}
useNativeAbacusNumbers={useNativeAbacusNumbers}
selected={isSelected}
shouldRotate={shouldRotate}
/>
)
})}
@@ -703,6 +914,7 @@ export function BoardDisplay() {
selectedRelation,
closing: closingDialog,
allPieces: activePieces,
pyramidFaceValues,
findValidHelpers,
selectRelation: handleCaptureWithRelation,
selectHelper: handleHelperSelection,

View File

@@ -12,6 +12,7 @@ export interface SvgPieceProps {
opacity?: number
useNativeAbacusNumbers?: boolean
selected?: boolean
shouldRotate?: boolean
}
export function SvgPiece({
@@ -22,6 +23,7 @@ export function SvgPiece({
opacity = 1,
useNativeAbacusNumbers = false,
selected = false,
shouldRotate = false,
}: SvgPieceProps) {
const file = piece.square.charCodeAt(0) - 65 // A=0
const rank = Number.parseInt(piece.square.slice(1), 10) // 1-8
@@ -59,6 +61,7 @@ export function SvgPiece({
useNativeAbacusNumbers={useNativeAbacusNumbers}
selected={selected}
pyramidFaces={piece.type === 'P' ? piece.pyramidFaces : undefined}
shouldRotate={shouldRotate}
/>
</div>
</foreignObject>

View File

@@ -17,13 +17,28 @@ interface CaptureRelationOptionsProps {
* Animated floating capture relation options with number bond preview on hover
*/
export function CaptureRelationOptions({ availableRelations }: CaptureRelationOptionsProps) {
const { layout, pieces, closing, allPieces, findValidHelpers, selectRelation } =
useCaptureContext()
const {
layout,
pieces,
closing,
allPieces,
pyramidFaceValues,
findValidHelpers,
selectRelation,
} = useCaptureContext()
const { targetPos, cellSize, gap, padding } = layout
const { mover: moverPiece, target: targetPiece } = pieces
const [hoveredRelation, setHoveredRelation] = useState<RelationKind | null>(null)
const [currentHelperIndex, setCurrentHelperIndex] = useState(0)
// Get mover value - either from pyramidFaceValues map (for pyramids) or from piece directly
const getMoverValue = (relation: RelationKind): number | null => {
if (pyramidFaceValues && pyramidFaceValues.has(relation)) {
return pyramidFaceValues.get(relation) || null
}
return getEffectiveValue(moverPiece)
}
// Cycle through valid helpers every 1.5 seconds when hovering
useEffect(() => {
if (!hoveredRelation) {
@@ -31,7 +46,7 @@ export function CaptureRelationOptions({ availableRelations }: CaptureRelationOp
return
}
const moverValue = getEffectiveValue(moverPiece)
const moverValue = getMoverValue(hoveredRelation)
const targetValue = getEffectiveValue(targetPiece)
if (
@@ -56,7 +71,7 @@ export function CaptureRelationOptions({ availableRelations }: CaptureRelationOp
}, 1500)
return () => clearInterval(interval)
}, [hoveredRelation, moverPiece, targetPiece, findValidHelpers])
}, [hoveredRelation, pyramidFaceValues, targetPiece, findValidHelpers])
// Generate tooltip text with actual numbers for the currently displayed helper
const getTooltipText = (relation: RelationKind): string => {
@@ -74,7 +89,7 @@ export function CaptureRelationOptions({ availableRelations }: CaptureRelationOp
return genericMap[relation] || relation
}
const moverValue = getEffectiveValue(moverPiece)
const moverValue = getMoverValue(relation)
const targetValue = getEffectiveValue(targetPiece)
if (
@@ -287,7 +302,7 @@ export function CaptureRelationOptions({ availableRelations }: CaptureRelationOp
{/* Number bond preview when hovering over a relation - cycle through valid helpers */}
{hoveredRelation &&
(() => {
const moverValue = getEffectiveValue(moverPiece)
const moverValue = getMoverValue(hoveredRelation)
const targetValue = getEffectiveValue(targetPiece)
if (

View File

@@ -11,7 +11,7 @@ export interface PlayingPhaseProps {
}
export function PlayingPhase({ onOpenGuide, isGuideOpen }: PlayingPhaseProps) {
const { state, isMyTurn, lastError, clearError, rosterStatus } = useRithmomachia()
const { state, lastError, clearError } = useRithmomachia()
// Get abacus settings for native abacus numbers
const { data: abacusSettings } = useAbacusSettings()
@@ -22,14 +22,106 @@ export function PlayingPhase({ onOpenGuide, isGuideOpen }: PlayingPhaseProps) {
className={css({
display: 'flex',
flexDirection: 'column',
gap: '4',
p: '4',
height: '100%',
overflow: 'hidden',
})}
>
{/* Compact Header Bar */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
px: '4',
py: '3',
bg: 'rgba(255, 255, 255, 0.95)',
borderBottom: '2px solid',
borderColor: 'rgba(251, 191, 36, 0.3)',
flexShrink: 0,
gap: '4',
})}
>
{/* Captured pieces info */}
<div
className={css({
display: 'flex',
gap: { base: '4', md: '6' },
alignItems: 'center',
flex: 1,
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
fontSize: { base: 'sm', md: 'md' },
fontWeight: 'semibold',
color: 'gray.700',
})}
>
<span></span>
<span className={css({ display: { base: 'none', sm: 'inline' } })}>White:</span>
<span className={css({ color: 'gray.900' })}>{state.capturedPieces.W.length}</span>
</div>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
fontSize: { base: 'sm', md: 'md' },
fontWeight: 'semibold',
color: 'gray.700',
})}
>
<span></span>
<span className={css({ display: { base: 'none', sm: 'inline' } })}>Black:</span>
<span className={css({ color: 'gray.900' })}>{state.capturedPieces.B.length}</span>
</div>
</div>
{/* Guide Button */}
{!isGuideOpen && (
<button
type="button"
data-action="open-guide-playing"
onClick={onOpenGuide}
className={css({
px: { base: '3', md: '4' },
py: { base: '1.5', md: '2' },
bg: 'linear-gradient(135deg, #7c2d12, #92400e)',
color: 'white',
border: '2px solid rgba(251, 191, 36, 0.6)',
borderRadius: 'lg',
fontSize: { base: 'sm', md: 'md' },
fontWeight: 'bold',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '2',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.2)',
transition: 'all 0.2s',
flexShrink: 0,
_hover: {
bg: 'linear-gradient(135deg, #92400e, #7c2d12)',
transform: 'translateY(-1px)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
},
})}
>
<span>📖</span>
<span className={css({ display: { base: 'none', sm: 'inline' } })}>Guide</span>
</button>
)}
</div>
{/* Error Banner */}
{lastError && (
<div
className={css({
width: '100%',
mx: '4',
mt: '4',
p: '4',
bg: 'red.100',
borderColor: 'red.400',
@@ -38,6 +130,7 @@ export function PlayingPhase({ onOpenGuide, isGuideOpen }: PlayingPhaseProps) {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0,
})}
>
<span className={css({ color: 'red.800', fontWeight: 'semibold' })}> {lastError}</span>
@@ -60,103 +153,19 @@ export function PlayingPhase({ onOpenGuide, isGuideOpen }: PlayingPhaseProps) {
</div>
)}
{/* Board Area - takes remaining space */}
<div
className={css({
flex: 1,
minHeight: 0,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
justifyContent: 'center',
p: '4',
bg: 'gray.100',
borderRadius: 'md',
gap: '3',
overflow: 'auto',
})}
>
<div>
<span className={css({ fontWeight: 'bold' })}>Turn: </span>
<span className={css({ color: state.turn === 'W' ? 'gray.800' : 'gray.600' })}>
{state.turn === 'W' ? 'White' : 'Black'}
</span>
</div>
<div className={css({ display: 'flex', gap: '2', alignItems: 'center' })}>
{!isGuideOpen && (
<button
type="button"
data-action="open-guide-playing"
onClick={onOpenGuide}
className={css({
px: '3',
py: '1',
bg: 'linear-gradient(135deg, #7c2d12, #92400e)',
color: 'white',
border: '1px solid rgba(251, 191, 36, 0.6)',
borderRadius: 'md',
fontSize: 'sm',
fontWeight: 'semibold',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '1',
transition: 'all 0.2s',
_hover: {
bg: 'linear-gradient(135deg, #92400e, #7c2d12)',
transform: 'translateY(-1px)',
},
})}
>
<span>📖</span>
<span>Guide</span>
</button>
)}
{isMyTurn && (
<div
className={css({
px: '3',
py: '1',
bg: 'green.100',
color: 'green.800',
borderRadius: 'md',
fontSize: 'sm',
fontWeight: 'semibold',
})}
>
Your Turn
</div>
)}
{!isMyTurn && rosterStatus.status === 'ok' && (
<div
className={css({
px: '3',
py: '1',
bg: 'gray.200',
color: 'gray.700',
borderRadius: 'md',
fontSize: 'sm',
fontWeight: 'semibold',
})}
>
Waiting for {state.turn === 'W' ? 'White' : 'Black'}
</div>
)}
</div>
</div>
<BoardDisplay />
<div
className={css({
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '4',
})}
>
<div className={css({ p: '4', bg: 'gray.100', borderRadius: 'md' })}>
<h3 className={css({ fontWeight: 'bold', mb: '2' })}>White Captured</h3>
<div className={css({ fontSize: 'sm' })}>{state.capturedPieces.W.length} pieces</div>
</div>
<div className={css({ p: '4', bg: 'gray.100', borderRadius: 'md' })}>
<h3 className={css({ fontWeight: 'bold', mb: '2' })}>Black Captured</h3>
<div className={css({ fontSize: 'sm' })}>{state.capturedPieces.B.length} pieces</div>
</div>
<BoardDisplay />
</div>
</div>
)

View File

@@ -39,6 +39,9 @@ export interface CaptureContextValue {
// All pieces on the board (for validation)
allPieces: Piece[]
// For pyramid pieces, maps relation to which face value to use
pyramidFaceValues: Map<RelationKind, number> | null
// Helper functions
findValidHelpers: (moverValue: number, targetValue: number, relation: RelationKind) => Piece[]