Compare commits
8 Commits
abacus-rea
...
abacus-rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee53bb9a9d | ||
|
|
28a2d40996 | ||
|
|
37e330f26e | ||
|
|
cc96802df8 | ||
|
|
5d97673406 | ||
|
|
26bdb11237 | ||
|
|
5ac55cc149 | ||
|
|
096104b094 |
@@ -161,11 +161,14 @@
|
||||
"Bash(printenv:*)",
|
||||
"Bash(typst:*)",
|
||||
"Bash(npx tsx:*)",
|
||||
"Bash(sort:*)"
|
||||
"Bash(sort:*)",
|
||||
"Bash(scp:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledMcpjsonServers": ["sqlite"]
|
||||
"enabledMcpjsonServers": [
|
||||
"sqlite"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { writeFileSync, readFileSync, mkdirSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
@@ -6,7 +6,6 @@ import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { useMyAbacus } from '@/contexts/MyAbacusContext'
|
||||
import { HomeHeroContext } from '@/contexts/HomeHeroContext'
|
||||
import { Z_INDEX } from '@/constants/zIndex'
|
||||
|
||||
export function MyAbacus() {
|
||||
const { isOpen, close, toggle } = useMyAbacus()
|
||||
@@ -31,21 +30,6 @@ export function MyAbacus() {
|
||||
const isHeroVisible = homeHeroContext?.isHeroVisible ?? false
|
||||
const isHeroMode = isOnHomePage && isHeroVisible && !isOpen
|
||||
|
||||
// Track scroll position for hero mode
|
||||
const [scrollY, setScrollY] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHeroMode) return
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrollY(window.scrollY)
|
||||
}
|
||||
|
||||
handleScroll() // Initial position
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [isHeroMode])
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
@@ -114,7 +98,7 @@ export function MyAbacus() {
|
||||
inset: 0,
|
||||
bg: 'rgba(0, 0, 0, 0.8)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
zIndex: Z_INDEX.MY_ABACUS_BACKDROP,
|
||||
zIndex: 101,
|
||||
animation: 'backdropFadeIn 0.4s ease-out',
|
||||
})}
|
||||
onClick={close}
|
||||
@@ -144,7 +128,7 @@ export function MyAbacus() {
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
zIndex: Z_INDEX.MY_ABACUS + 1,
|
||||
zIndex: 103,
|
||||
animation: 'fadeIn 0.3s ease-out 0.2s both',
|
||||
_hover: {
|
||||
bg: 'rgba(255, 255, 255, 0.2)',
|
||||
@@ -162,36 +146,28 @@ export function MyAbacus() {
|
||||
data-component="my-abacus"
|
||||
data-mode={isOpen ? 'open' : isHeroMode ? 'hero' : 'button'}
|
||||
onClick={isOpen || isHeroMode ? undefined : toggle}
|
||||
style={
|
||||
isHeroMode
|
||||
? {
|
||||
// Hero mode: position accounts for scroll to flow with page (subtract scroll to move up with content)
|
||||
// Positioned lower (60vh instead of 50vh) to avoid covering subtitle
|
||||
top: `calc(60vh - ${scrollY}px)`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
zIndex: Z_INDEX.MY_ABACUS,
|
||||
position: isHeroMode ? 'absolute' : 'fixed',
|
||||
zIndex: 102,
|
||||
cursor: isOpen || isHeroMode ? 'default' : 'pointer',
|
||||
transition: isHeroMode ? 'none' : 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
// Three modes: hero (inline with content), button (bottom-right), open (center)
|
||||
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
// Three modes: hero (absolute - scrolls with document), button (fixed), open (fixed)
|
||||
...(isOpen
|
||||
? {
|
||||
// Open mode: center of screen
|
||||
// Open mode: fixed to center of viewport
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}
|
||||
: isHeroMode
|
||||
? {
|
||||
// Hero mode: centered horizontally, top handled by inline style
|
||||
// Hero mode: absolute positioning - scrolls naturally with document
|
||||
top: '60vh',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}
|
||||
: {
|
||||
// Button mode: bottom-right corner
|
||||
// Button mode: fixed to bottom-right corner
|
||||
bottom: { base: '4', md: '6' },
|
||||
right: { base: '4', md: '6' },
|
||||
transform: 'translate(0, 0)',
|
||||
@@ -259,58 +235,22 @@ export function MyAbacus() {
|
||||
animated={isOpen || isHeroMode}
|
||||
customStyles={isHeroMode ? structuralStyles : trophyStyles}
|
||||
onValueChange={setAbacusValue}
|
||||
// 3D Enhancement - realistic mode for hero and open states
|
||||
enhanced3d={isOpen || isHeroMode ? 'realistic' : undefined}
|
||||
material3d={
|
||||
isOpen || isHeroMode
|
||||
? {
|
||||
heavenBeads: 'glossy',
|
||||
earthBeads: 'satin',
|
||||
lighting: 'dramatic',
|
||||
woodGrain: true,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title and achievement info - only visible when open */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
mt: { base: '16', md: '20', lg: '24' },
|
||||
textAlign: 'center',
|
||||
animation: 'fadeIn 0.5s ease-out 0.3s both',
|
||||
maxW: '600px',
|
||||
px: '8',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl', lg: '4xl' },
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #fbbf24 100%)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
mb: '3',
|
||||
})}
|
||||
>
|
||||
My Abacus
|
||||
</h2>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: { base: 'md', md: 'lg' },
|
||||
color: 'gray.300',
|
||||
mb: '4',
|
||||
fontWeight: 'medium',
|
||||
})}
|
||||
>
|
||||
Your personal abacus grows with you
|
||||
</p>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: { base: 'sm', md: 'md' },
|
||||
color: 'gray.400',
|
||||
lineHeight: '1.6',
|
||||
})}
|
||||
>
|
||||
Complete tutorials, play games, and earn achievements to unlock higher place values
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyframes for animations */}
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
"Bash(git add:*)",
|
||||
"Bash(git rm:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(npm run build:*)"
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(git reset:*)",
|
||||
"Bash(cat:*)"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
# [2.4.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.3.0...abacus-react-v2.4.0) (2025-11-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove distracting parallax and wobble 3D effects ([28a2d40](https://github.com/antialias/soroban-abacus-flashcards/commit/28a2d40996256700bf19cd80130b26e24441949f))
|
||||
* remove wobble physics and enhance wood grain visibility ([5d97673](https://github.com/antialias/soroban-abacus-flashcards/commit/5d976734062eb3d943bfdfdd125473c56b533759))
|
||||
* rewrite 3D stories to use props instead of CSS wrappers ([26bdb11](https://github.com/antialias/soroban-abacus-flashcards/commit/26bdb112370cece08634e3d693d15336111fc70f))
|
||||
* use absolute positioning for hero abacus to eliminate scroll lag ([096104b](https://github.com/antialias/soroban-abacus-flashcards/commit/096104b094b45aa584f2b9d47a440a8c14d82fc0))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* complete 3D enhancement integration for all three proposals ([5ac55cc](https://github.com/antialias/soroban-abacus-flashcards/commit/5ac55cc14980b778f9be32f0833f8760aa16b631))
|
||||
* enable 3D enhancement on hero/open MyAbacus modes ([37e330f](https://github.com/antialias/soroban-abacus-flashcards/commit/37e330f26e5398c2358599361cd417b4aeefac7d))
|
||||
|
||||
# [2.3.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.2.0...abacus-react-v2.3.0) (2025-11-03)
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ A comprehensive React component for rendering interactive Soroban (Japanese abac
|
||||
- 🔧 **Developer-friendly** - Comprehensive hooks and callback system
|
||||
- 🎓 **Tutorial system** - Built-in overlay and guidance capabilities
|
||||
- 🧩 **Framework-free SVG** - Complete control over rendering
|
||||
- ✨ **3D Enhancement** - Three levels of progressive 3D effects for immersive visuals
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -113,6 +114,82 @@ Educational guidance with tooltips
|
||||
/>
|
||||
```
|
||||
|
||||
## 3D Enhancement
|
||||
|
||||
Make the abacus feel tangible and satisfying with three progressive levels of 3D effects.
|
||||
|
||||
### Subtle Mode
|
||||
|
||||
Light depth shadows and perspective for subtle dimensionality.
|
||||
|
||||
```tsx
|
||||
<AbacusReact
|
||||
value={12345}
|
||||
columns={5}
|
||||
enhanced3d="subtle"
|
||||
interactive
|
||||
animated
|
||||
/>
|
||||
```
|
||||
|
||||
### Realistic Mode
|
||||
|
||||
Material-based rendering with lighting effects and textures.
|
||||
|
||||
```tsx
|
||||
<AbacusReact
|
||||
value={7890}
|
||||
columns={4}
|
||||
enhanced3d="realistic"
|
||||
material3d={{
|
||||
heavenBeads: 'glossy', // 'glossy' | 'satin' | 'matte'
|
||||
earthBeads: 'satin',
|
||||
lighting: 'top-down', // 'top-down' | 'ambient' | 'dramatic'
|
||||
woodGrain: true // Add wood texture to frame
|
||||
}}
|
||||
interactive
|
||||
animated
|
||||
/>
|
||||
```
|
||||
|
||||
**Materials:**
|
||||
- `glossy` - High shine with strong highlights
|
||||
- `satin` - Balanced shine (default)
|
||||
- `matte` - Subtle shading, no shine
|
||||
|
||||
**Lighting:**
|
||||
- `top-down` - Balanced directional light from above
|
||||
- `ambient` - Soft light from all directions
|
||||
- `dramatic` - Strong directional light for high contrast
|
||||
|
||||
### Delightful Mode
|
||||
|
||||
Maximum satisfaction with enhanced physics and interactive effects.
|
||||
|
||||
```tsx
|
||||
<AbacusReact
|
||||
value={8642}
|
||||
columns={4}
|
||||
enhanced3d="delightful"
|
||||
material3d={{
|
||||
heavenBeads: 'glossy',
|
||||
earthBeads: 'satin',
|
||||
lighting: 'dramatic',
|
||||
woodGrain: true
|
||||
}}
|
||||
physics3d={{
|
||||
hoverParallax: true // Beads lift on hover with Z-depth
|
||||
}}
|
||||
interactive
|
||||
animated
|
||||
soundEnabled
|
||||
/>
|
||||
```
|
||||
|
||||
**Physics Options:**
|
||||
- `hoverParallax` - Beads near mouse cursor lift up with depth perception
|
||||
|
||||
All 3D modes work with existing configurations and preserve exact geometry.
|
||||
|
||||
## Core API
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
|
||||
/* Wood grain texture overlay */
|
||||
.abacus-3d-container.enhanced-realistic .frame-wood {
|
||||
opacity: 0.15;
|
||||
opacity: 0.4;
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -293,11 +293,6 @@
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Wobble physics - applied via inline styles from React Spring */
|
||||
.bead-wobble {
|
||||
/* transform-origin set dynamically */
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
/* Frame depth enhancement */
|
||||
.abacus-3d-container.enhanced-delightful rect[class*="column-post"],
|
||||
@@ -307,25 +302,11 @@
|
||||
drop-shadow(0 0 2px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
/* Wood grain texture - enhanced */
|
||||
.frame-wood-enhanced {
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(139, 90, 43, 0.03) 2px,
|
||||
rgba(139, 90, 43, 0.03) 4px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 1px,
|
||||
rgba(101, 67, 33, 0.02) 1px,
|
||||
rgba(101, 67, 33, 0.02) 2px
|
||||
);
|
||||
opacity: 0.2;
|
||||
/* Wood grain texture - enhanced for delightful mode */
|
||||
.abacus-3d-container.enhanced-delightful .frame-wood {
|
||||
opacity: 0.45;
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Accessibility - Reduced motion */
|
||||
|
||||
@@ -124,7 +124,7 @@ export function getLightingFilter(lighting: LightingStyle = "top-down"): string
|
||||
* Calculate Z-depth for a bead based on enhancement level and state
|
||||
*/
|
||||
export function getBeadZDepth(
|
||||
enhanced3d: boolean | "subtle" | "realistic" | "delightful",
|
||||
enhanced3d: boolean | "subtle" | "realistic",
|
||||
active: boolean
|
||||
): number {
|
||||
if (!enhanced3d || enhanced3d === true) return 0;
|
||||
@@ -136,77 +136,28 @@ export function getBeadZDepth(
|
||||
return 6;
|
||||
case "realistic":
|
||||
return 10;
|
||||
case "delightful":
|
||||
return 12;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate wobble rotation based on velocity (for delightful mode)
|
||||
*/
|
||||
export function getWobbleRotation(velocity: number, axis: "x" | "y" = "x"): string {
|
||||
const maxRotation = 3; // degrees
|
||||
const rotation = Math.max(-maxRotation, Math.min(maxRotation, velocity * -2));
|
||||
|
||||
if (axis === "x") {
|
||||
return `rotateX(${rotation}deg)`;
|
||||
}
|
||||
return `rotateY(${rotation}deg)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate parallax offset based on mouse position
|
||||
*/
|
||||
export function calculateParallaxOffset(
|
||||
beadX: number,
|
||||
beadY: number,
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
containerX: number,
|
||||
containerY: number,
|
||||
intensity: number = 0.5
|
||||
): { x: number; y: number; z: number } {
|
||||
// Calculate distance from bead center to mouse
|
||||
const dx = (mouseX - containerX) - beadX;
|
||||
const dy = (mouseY - containerY) - beadY;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// Max influence radius (pixels)
|
||||
const maxRadius = 150;
|
||||
|
||||
if (distance > maxRadius) {
|
||||
return { x: 0, y: 0, z: 0 };
|
||||
}
|
||||
|
||||
// Calculate lift amount (inverse square falloff)
|
||||
const influence = Math.max(0, 1 - (distance / maxRadius));
|
||||
const lift = influence * influence * intensity;
|
||||
|
||||
return {
|
||||
x: dx * lift * 0.1,
|
||||
y: dy * lift * 0.1,
|
||||
z: lift * 8
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate wood grain texture SVG pattern
|
||||
*/
|
||||
export function getWoodGrainPattern(id: string): string {
|
||||
return `
|
||||
<pattern id="${id}" x="0" y="0" width="100" height="100" patternUnits="userSpaceOnUse">
|
||||
<rect width="100" height="100" fill="#8B5A2B" opacity="0.3"/>
|
||||
<!-- Grain lines -->
|
||||
<path d="M 0 10 Q 25 8 50 10 T 100 10" stroke="#654321" stroke-width="0.5" fill="none" opacity="0.4"/>
|
||||
<path d="M 0 30 Q 25 28 50 30 T 100 30" stroke="#654321" stroke-width="0.5" fill="none" opacity="0.3"/>
|
||||
<path d="M 0 50 Q 25 48 50 50 T 100 50" stroke="#654321" stroke-width="0.5" fill="none" opacity="0.4"/>
|
||||
<path d="M 0 70 Q 25 68 50 70 T 100 70" stroke="#654321" stroke-width="0.5" fill="none" opacity="0.3"/>
|
||||
<path d="M 0 90 Q 25 88 50 90 T 100 90" stroke="#654321" stroke-width="0.5" fill="none" opacity="0.4"/>
|
||||
<!-- Knots -->
|
||||
<ellipse cx="20" cy="25" rx="8" ry="6" fill="#654321" opacity="0.2"/>
|
||||
<ellipse cx="75" cy="65" rx="6" ry="8" fill="#654321" opacity="0.2"/>
|
||||
<rect width="100" height="100" fill="#8B5A2B" opacity="0.5"/>
|
||||
<!-- Grain lines - more visible -->
|
||||
<path d="M 0 10 Q 25 8 50 10 T 100 10" stroke="#654321" stroke-width="1" fill="none" opacity="0.6"/>
|
||||
<path d="M 0 30 Q 25 28 50 30 T 100 30" stroke="#654321" stroke-width="1" fill="none" opacity="0.5"/>
|
||||
<path d="M 0 50 Q 25 48 50 50 T 100 50" stroke="#654321" stroke-width="1" fill="none" opacity="0.6"/>
|
||||
<path d="M 0 70 Q 25 68 50 70 T 100 70" stroke="#654321" stroke-width="1" fill="none" opacity="0.5"/>
|
||||
<path d="M 0 90 Q 25 88 50 90 T 100 90" stroke="#654321" stroke-width="1" fill="none" opacity="0.6"/>
|
||||
<!-- Knots - more prominent -->
|
||||
<ellipse cx="20" cy="25" rx="8" ry="6" fill="#654321" opacity="0.35"/>
|
||||
<ellipse cx="75" cy="65" rx="6" ry="8" fill="#654321" opacity="0.35"/>
|
||||
<ellipse cx="45" cy="82" rx="5" ry="7" fill="#654321" opacity="0.3"/>
|
||||
</pattern>
|
||||
`;
|
||||
}
|
||||
@@ -215,9 +166,8 @@ export function getWoodGrainPattern(id: string): string {
|
||||
* Get container class names for 3D enhancement level
|
||||
*/
|
||||
export function get3DContainerClasses(
|
||||
enhanced3d: boolean | "subtle" | "realistic" | "delightful" | undefined,
|
||||
lighting?: LightingStyle,
|
||||
parallaxEnabled?: boolean
|
||||
enhanced3d: boolean | "subtle" | "realistic" | undefined,
|
||||
lighting?: LightingStyle
|
||||
): string {
|
||||
const classes: string[] = ["abacus-3d-container"];
|
||||
|
||||
@@ -228,8 +178,6 @@ export function get3DContainerClasses(
|
||||
classes.push("enhanced-subtle");
|
||||
} else if (enhanced3d === "realistic") {
|
||||
classes.push("enhanced-realistic");
|
||||
} else if (enhanced3d === "delightful") {
|
||||
classes.push("enhanced-delightful");
|
||||
}
|
||||
|
||||
// Add lighting class
|
||||
@@ -237,11 +185,6 @@ export function get3DContainerClasses(
|
||||
classes.push(`lighting-${lighting}`);
|
||||
}
|
||||
|
||||
// Add parallax class
|
||||
if (parallaxEnabled && enhanced3d === "delightful") {
|
||||
classes.push("parallax-enabled");
|
||||
}
|
||||
|
||||
return classes.join(" ");
|
||||
}
|
||||
|
||||
@@ -260,7 +203,7 @@ export function getBeadGradientId(
|
||||
/**
|
||||
* Physics config for different enhancement levels
|
||||
*/
|
||||
export function getPhysicsConfig(enhanced3d: boolean | "subtle" | "realistic" | "delightful") {
|
||||
export function getPhysicsConfig(enhanced3d: boolean | "subtle" | "realistic") {
|
||||
const base = {
|
||||
tension: 300,
|
||||
friction: 22,
|
||||
@@ -272,20 +215,11 @@ export function getPhysicsConfig(enhanced3d: boolean | "subtle" | "realistic" |
|
||||
return { ...base, clamp: true };
|
||||
}
|
||||
|
||||
if (enhanced3d === "realistic") {
|
||||
return {
|
||||
tension: 320,
|
||||
friction: 24,
|
||||
mass: 0.6,
|
||||
clamp: false
|
||||
};
|
||||
}
|
||||
|
||||
// delightful
|
||||
// realistic
|
||||
return {
|
||||
tension: 280,
|
||||
friction: 20,
|
||||
mass: 0.7,
|
||||
clamp: false, // Allow overshoot for satisfying settle
|
||||
tension: 320,
|
||||
friction: 24,
|
||||
mass: 0.6,
|
||||
clamp: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { AbacusReact } from './AbacusReact';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import './Abacus3D.css';
|
||||
import React from 'react';
|
||||
|
||||
const meta: Meta<typeof AbacusReact> = {
|
||||
title: 'Soroban/3D Effects Showcase',
|
||||
@@ -13,29 +12,20 @@ const meta: Meta<typeof AbacusReact> = {
|
||||
component: `
|
||||
# 3D Enhancement Showcase
|
||||
|
||||
Three levels of progressive 3D enhancement for the abacus to make interactions feel satisfying and real.
|
||||
Two levels of progressive 3D enhancement for the abacus to make interactions feel satisfying and real.
|
||||
|
||||
## Proposal 1: Subtle (CSS Perspective + Shadows)
|
||||
## Subtle (CSS Perspective + Shadows)
|
||||
- Light perspective tilt
|
||||
- Depth shadows on active beads
|
||||
- Smooth transitions
|
||||
- **Zero performance cost**
|
||||
|
||||
## Proposal 2: Realistic (Lighting + Materials)
|
||||
- Everything from Proposal 1 +
|
||||
- Realistic lighting effects
|
||||
- Material-based bead rendering (glossy/satin/matte)
|
||||
- Ambient occlusion
|
||||
- Frame depth
|
||||
|
||||
## Proposal 3: Delightful (Physics + Micro-interactions)
|
||||
- Everything from Proposal 2 +
|
||||
- Enhanced physics with satisfying bounce
|
||||
- Clack ripple effects when beads snap
|
||||
- Hover parallax
|
||||
- Maximum satisfaction
|
||||
|
||||
**Note:** Currently these are CSS-only demos. Full integration with React Spring physics coming next!
|
||||
## Realistic (Lighting + Materials)
|
||||
- Everything from Subtle +
|
||||
- Realistic lighting effects with material gradients
|
||||
- Glossy/Satin/Matte bead materials
|
||||
- Wood grain textures on frame
|
||||
- Enhanced physics for realistic motion
|
||||
`
|
||||
}
|
||||
}
|
||||
@@ -46,596 +36,387 @@ Three levels of progressive 3D enhancement for the abacus to make interactions f
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// Wrapper component to apply 3D CSS classes
|
||||
const Wrapper3D: React.FC<{
|
||||
children: React.ReactNode;
|
||||
level: 'subtle' | 'realistic' | 'delightful';
|
||||
lighting?: 'top-down' | 'ambient' | 'dramatic';
|
||||
}> = ({ children, level, lighting }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const svg = containerRef.current.querySelector('.abacus-svg');
|
||||
const beads = containerRef.current.querySelectorAll('.abacus-bead');
|
||||
|
||||
// Add classes to container
|
||||
containerRef.current.classList.add('abacus-3d-container');
|
||||
containerRef.current.classList.add(`enhanced-${level}`);
|
||||
if (lighting) {
|
||||
containerRef.current.classList.add(`lighting-${lighting}`);
|
||||
}
|
||||
|
||||
// Apply will-change for performance
|
||||
if (level === 'delightful') {
|
||||
beads.forEach(bead => {
|
||||
(bead as HTMLElement).style.willChange = 'transform, filter';
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [level, lighting]);
|
||||
|
||||
return <div ref={containerRef}>{children}</div>;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// PROPOSAL 1: SUBTLE
|
||||
// SIDE-BY-SIDE COMPARISON
|
||||
// ============================================
|
||||
|
||||
export const Subtle_Static: Story = {
|
||||
name: '1. Subtle - Static Display',
|
||||
export const CompareAllLevels: Story = {
|
||||
name: '🎯 Compare All Levels',
|
||||
render: () => (
|
||||
<Wrapper3D level="subtle">
|
||||
<AbacusReact
|
||||
value={12345}
|
||||
columns={5}
|
||||
showNumbers
|
||||
colorScheme="place-value"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Subtle 3D with light perspective tilt and depth shadows. Notice the slight elevation of active beads.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Subtle_Interactive: Story = {
|
||||
name: '1. Subtle - Interactive',
|
||||
render: () => (
|
||||
<Wrapper3D level="subtle">
|
||||
<AbacusReact
|
||||
value={678}
|
||||
columns={3}
|
||||
showNumbers
|
||||
interactive
|
||||
animated
|
||||
soundEnabled
|
||||
colorScheme="heaven-earth"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Subtle 3D + interaction. Click beads to see depth shadows change. Notice how the perspective gives a sense of physicality.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Subtle_Tutorial: Story = {
|
||||
name: '1. Subtle - Tutorial Mode',
|
||||
render: () => {
|
||||
const [step, setStep] = React.useState(0);
|
||||
const highlights = [
|
||||
{ placeValue: 0, beadType: 'earth' as const, position: 2 },
|
||||
{ placeValue: 1, beadType: 'heaven' as const },
|
||||
{ placeValue: 2, beadType: 'earth' as const, position: 0 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Wrapper3D level="subtle">
|
||||
<AbacusReact
|
||||
value={123}
|
||||
columns={3}
|
||||
showNumbers
|
||||
interactive
|
||||
animated
|
||||
highlightBeads={[highlights[step]]}
|
||||
colorScheme="place-value"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<button onClick={() => setStep((step + 1) % 3)} style={{ padding: '8px 16px' }}>
|
||||
Next Step ({step + 1}/3)
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '60px', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h3 style={{ marginBottom: '10px', textAlign: 'center' }}>No Enhancement</h3>
|
||||
<AbacusReact
|
||||
value={4242}
|
||||
columns={4}
|
||||
showNumbers
|
||||
interactive
|
||||
animated
|
||||
colorScheme="place-value"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Tutorial mode with subtle 3D effects. The depth helps highlight which bead to focus on.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// PROPOSAL 2: REALISTIC
|
||||
// ============================================
|
||||
|
||||
export const Realistic_TopDown: Story = {
|
||||
name: '2. Realistic - Top-Down Lighting',
|
||||
render: () => (
|
||||
<Wrapper3D level="realistic" lighting="top-down">
|
||||
<AbacusReact
|
||||
value={24680}
|
||||
columns={5}
|
||||
showNumbers
|
||||
colorScheme="place-value"
|
||||
beadShape="circle"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Realistic 3D with top-down lighting. Notice the enhanced shadows and sense of illumination from above.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Realistic_Ambient: Story = {
|
||||
name: '2. Realistic - Ambient Lighting',
|
||||
render: () => (
|
||||
<Wrapper3D level="realistic" lighting="ambient">
|
||||
<AbacusReact
|
||||
value={13579}
|
||||
columns={5}
|
||||
showNumbers
|
||||
colorScheme="place-value"
|
||||
beadShape="diamond"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Realistic 3D with ambient lighting. Softer, more even illumination creates a cozy feel.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Realistic_Dramatic: Story = {
|
||||
name: '2. Realistic - Dramatic Lighting',
|
||||
render: () => (
|
||||
<Wrapper3D level="realistic" lighting="dramatic">
|
||||
<AbacusReact
|
||||
value={99999}
|
||||
columns={5}
|
||||
showNumbers
|
||||
colorScheme="heaven-earth"
|
||||
beadShape="square"
|
||||
colorPalette="colorblind"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Realistic 3D with dramatic lighting. Strong directional light creates bold shadows and depth.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Realistic_Interactive: Story = {
|
||||
name: '2. Realistic - Interactive',
|
||||
render: () => (
|
||||
<Wrapper3D level="realistic" lighting="top-down">
|
||||
<AbacusReact
|
||||
value={555}
|
||||
columns={3}
|
||||
showNumbers
|
||||
interactive
|
||||
animated
|
||||
soundEnabled
|
||||
colorScheme="place-value"
|
||||
colorPalette="nature"
|
||||
scaleFactor={1.3}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Realistic 3D + interaction. Click beads and watch the enhanced shadows and lighting respond. Feel that satisfaction!'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Realistic_AllShapes: Story = {
|
||||
name: '2. Realistic - All Bead Shapes',
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: '40px', flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Wrapper3D level="realistic" lighting="top-down">
|
||||
<AbacusReact
|
||||
value={777}
|
||||
columns={3}
|
||||
showNumbers
|
||||
beadShape="diamond"
|
||||
colorScheme="place-value"
|
||||
/>
|
||||
</Wrapper3D>
|
||||
<p style={{ marginTop: '12px', fontSize: '14px' }}>Diamond</p>
|
||||
<div>
|
||||
<h3 style={{ marginBottom: '10px', textAlign: 'center' }}>Subtle</h3>
|
||||
<AbacusReact
|
||||
value={4242}
|
||||
columns={4}
|
||||
showNumbers
|
||||
interactive
|
||||
animated
|
||||
colorScheme="place-value"
|
||||
scaleFactor={1.2}
|
||||
enhanced3d="subtle"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Wrapper3D level="realistic" lighting="top-down">
|
||||
<AbacusReact
|
||||
value={777}
|
||||
columns={3}
|
||||
showNumbers
|
||||
beadShape="circle"
|
||||
colorScheme="place-value"
|
||||
/>
|
||||
</Wrapper3D>
|
||||
<p style={{ marginTop: '12px', fontSize: '14px' }}>Circle</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Wrapper3D level="realistic" lighting="top-down">
|
||||
<AbacusReact
|
||||
value={777}
|
||||
columns={3}
|
||||
showNumbers
|
||||
beadShape="square"
|
||||
colorScheme="place-value"
|
||||
/>
|
||||
</Wrapper3D>
|
||||
<p style={{ marginTop: '12px', fontSize: '14px' }}>Square</p>
|
||||
|
||||
<div>
|
||||
<h3 style={{ marginBottom: '10px', textAlign: 'center' }}>Realistic (Satin Beads + Wood Frame)</h3>
|
||||
<AbacusReact
|
||||
value={4242}
|
||||
columns={4}
|
||||
showNumbers
|
||||
interactive
|
||||
animated
|
||||
colorScheme="place-value"
|
||||
scaleFactor={1.2}
|
||||
enhanced3d="realistic"
|
||||
material3d={{
|
||||
heavenBeads: 'satin',
|
||||
earthBeads: 'satin',
|
||||
lighting: 'top-down',
|
||||
woodGrain: true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Realistic 3D works beautifully with all three bead shapes.'
|
||||
story: 'Side-by-side comparison of both enhancement levels. **Click beads** to see how they move!'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// PROPOSAL 3: DELIGHTFUL
|
||||
// PROPOSAL 1: SUBTLE
|
||||
// ============================================
|
||||
|
||||
export const Delightful_Static: Story = {
|
||||
name: '3. Delightful - Maximum Depth',
|
||||
render: () => (
|
||||
<Wrapper3D level="delightful">
|
||||
<AbacusReact
|
||||
value={11111}
|
||||
columns={5}
|
||||
showNumbers
|
||||
colorScheme="alternating"
|
||||
beadShape="circle"
|
||||
colorPalette="mnemonic"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Delightful 3D with maximum depth and richness. The beads really pop off the page!'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Delightful_Interactive: Story = {
|
||||
name: '3. Delightful - Interactive (Physics Ready)',
|
||||
render: () => (
|
||||
<Wrapper3D level="delightful">
|
||||
<AbacusReact
|
||||
value={987}
|
||||
columns={3}
|
||||
showNumbers
|
||||
interactive
|
||||
animated
|
||||
soundEnabled
|
||||
colorScheme="place-value"
|
||||
scaleFactor={1.3}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Delightful 3D + interaction. This is the CSS foundation - physics effects (wobble, clack ripple) will be added in the next iteration. Already feels great!'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Delightful_LargeScale: Story = {
|
||||
name: '3. Delightful - Large Scale',
|
||||
render: () => (
|
||||
<Wrapper3D level="delightful">
|
||||
<AbacusReact
|
||||
value={9876543210}
|
||||
columns={10}
|
||||
showNumbers
|
||||
colorScheme="place-value"
|
||||
scaleFactor={1}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Delightful 3D scales beautifully even with many columns. The depth hierarchy helps organize the visual.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// COMPARISON VIEWS
|
||||
// ============================================
|
||||
|
||||
export const CompareAllLevels: Story = {
|
||||
name: 'Compare All Three Levels',
|
||||
render: () => {
|
||||
const value = 4242;
|
||||
const columns = 4;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '60px', padding: '20px' }}>
|
||||
{/* No 3D */}
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h3 style={{ fontSize: '16px', marginBottom: '20px', color: '#666' }}>
|
||||
No Enhancement (Current)
|
||||
</h3>
|
||||
<AbacusReact
|
||||
value={value}
|
||||
columns={columns}
|
||||
showNumbers
|
||||
colorScheme="place-value"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subtle */}
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h3 style={{ fontSize: '16px', marginBottom: '20px', color: '#666' }}>
|
||||
Proposal 1: Subtle 😊
|
||||
</h3>
|
||||
<Wrapper3D level="subtle">
|
||||
<AbacusReact
|
||||
value={value}
|
||||
columns={columns}
|
||||
showNumbers
|
||||
colorScheme="place-value"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
<p style={{ fontSize: '12px', color: '#888', marginTop: '12px' }}>
|
||||
Light tilt + depth shadows
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Realistic */}
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h3 style={{ fontSize: '16px', marginBottom: '20px', color: '#666' }}>
|
||||
Proposal 2: Realistic 😍
|
||||
</h3>
|
||||
<Wrapper3D level="realistic" lighting="top-down">
|
||||
<AbacusReact
|
||||
value={value}
|
||||
columns={columns}
|
||||
showNumbers
|
||||
colorScheme="place-value"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
<p style={{ fontSize: '12px', color: '#888', marginTop: '12px' }}>
|
||||
Lighting + materials + ambient occlusion
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Delightful */}
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h3 style={{ fontSize: '16px', marginBottom: '20px', color: '#666' }}>
|
||||
Proposal 3: Delightful 🤩
|
||||
</h3>
|
||||
<Wrapper3D level="delightful">
|
||||
<AbacusReact
|
||||
value={value}
|
||||
columns={columns}
|
||||
showNumbers
|
||||
colorScheme="place-value"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
<p style={{ fontSize: '12px', color: '#888', marginTop: '12px' }}>
|
||||
Maximum depth + enhanced lighting (physics effects coming next!)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export const Subtle_Basic: Story = {
|
||||
name: '1️⃣ Subtle - Basic',
|
||||
args: {
|
||||
value: 12345,
|
||||
columns: 5,
|
||||
showNumbers: true,
|
||||
interactive: true,
|
||||
animated: true,
|
||||
colorScheme: 'place-value',
|
||||
scaleFactor: 1.2,
|
||||
enhanced3d: 'subtle'
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Side-by-side comparison of all three enhancement levels. Which feels best to you?'
|
||||
story: 'Subtle 3D with light perspective tilt and depth shadows. Click beads to interact!'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const CompareInteractive: Story = {
|
||||
name: 'Compare Interactive (Side-by-Side)',
|
||||
render: () => {
|
||||
const [value1, setValue1] = React.useState(123);
|
||||
const [value2, setValue2] = React.useState(456);
|
||||
const [value3, setValue3] = React.useState(789);
|
||||
// ============================================
|
||||
// PROPOSAL 2: REALISTIC (Materials)
|
||||
// ============================================
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '40px', flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h4 style={{ fontSize: '14px', marginBottom: '12px' }}>Subtle</h4>
|
||||
<Wrapper3D level="subtle">
|
||||
<AbacusReact
|
||||
value={value1}
|
||||
onValueChange={(v) => setValue1(Number(v))}
|
||||
columns={3}
|
||||
showNumbers
|
||||
interactive
|
||||
animated
|
||||
colorScheme="place-value"
|
||||
/>
|
||||
</Wrapper3D>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h4 style={{ fontSize: '14px', marginBottom: '12px' }}>Realistic</h4>
|
||||
<Wrapper3D level="realistic" lighting="top-down">
|
||||
<AbacusReact
|
||||
value={value2}
|
||||
onValueChange={(v) => setValue2(Number(v))}
|
||||
columns={3}
|
||||
showNumbers
|
||||
interactive
|
||||
animated
|
||||
colorScheme="place-value"
|
||||
/>
|
||||
</Wrapper3D>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h4 style={{ fontSize: '14px', marginBottom: '12px' }}>Delightful</h4>
|
||||
<Wrapper3D level="delightful">
|
||||
<AbacusReact
|
||||
value={value3}
|
||||
onValueChange={(v) => setValue3(Number(v))}
|
||||
columns={3}
|
||||
showNumbers
|
||||
interactive
|
||||
animated
|
||||
colorScheme="place-value"
|
||||
/>
|
||||
</Wrapper3D>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export const Realistic_GlossyBeads: Story = {
|
||||
name: '2️⃣ Realistic - Glossy Beads',
|
||||
args: {
|
||||
value: 7890,
|
||||
columns: 4,
|
||||
showNumbers: true,
|
||||
interactive: true,
|
||||
animated: true,
|
||||
colorScheme: 'heaven-earth',
|
||||
scaleFactor: 1.3,
|
||||
enhanced3d: 'realistic',
|
||||
material3d: {
|
||||
heavenBeads: 'glossy',
|
||||
earthBeads: 'glossy',
|
||||
lighting: 'top-down'
|
||||
}
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Try all three side-by-side! Click beads and feel the difference in satisfaction.'
|
||||
story: '**Glossy material** with high shine and strong highlights. Notice the radial gradients on the beads!'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Realistic_SatinBeads: Story = {
|
||||
name: '2️⃣ Realistic - Satin Beads',
|
||||
args: {
|
||||
value: 7890,
|
||||
columns: 4,
|
||||
showNumbers: true,
|
||||
interactive: true,
|
||||
animated: true,
|
||||
colorScheme: 'heaven-earth',
|
||||
scaleFactor: 1.3,
|
||||
enhanced3d: 'realistic',
|
||||
material3d: {
|
||||
heavenBeads: 'satin',
|
||||
earthBeads: 'satin',
|
||||
lighting: 'top-down'
|
||||
}
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: '**Satin material** (default) with balanced shine. Medium highlights, smooth appearance.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Realistic_MatteBeads: Story = {
|
||||
name: '2️⃣ Realistic - Matte Beads',
|
||||
args: {
|
||||
value: 7890,
|
||||
columns: 4,
|
||||
showNumbers: true,
|
||||
interactive: true,
|
||||
animated: true,
|
||||
colorScheme: 'heaven-earth',
|
||||
scaleFactor: 1.3,
|
||||
enhanced3d: 'realistic',
|
||||
material3d: {
|
||||
heavenBeads: 'matte',
|
||||
earthBeads: 'matte',
|
||||
lighting: 'ambient'
|
||||
}
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: '**Matte material** with subtle shading, no shine. Flat, understated appearance.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Realistic_MixedMaterials: Story = {
|
||||
name: '2️⃣ Realistic - Mixed Materials',
|
||||
args: {
|
||||
value: 5678,
|
||||
columns: 4,
|
||||
showNumbers: true,
|
||||
interactive: true,
|
||||
animated: true,
|
||||
colorScheme: 'heaven-earth',
|
||||
scaleFactor: 1.3,
|
||||
enhanced3d: 'realistic',
|
||||
material3d: {
|
||||
heavenBeads: 'glossy', // Heaven beads are shiny
|
||||
earthBeads: 'matte', // Earth beads are flat
|
||||
lighting: 'dramatic'
|
||||
}
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: '**Mixed materials**: Glossy heaven beads (5-value) + Matte earth beads (1-value). Different visual weight!'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Realistic_WoodGrain: Story = {
|
||||
name: '2️⃣ Realistic - Wood Grain Frame',
|
||||
args: {
|
||||
value: 3456,
|
||||
columns: 4,
|
||||
showNumbers: true,
|
||||
interactive: true,
|
||||
animated: true,
|
||||
colorScheme: 'monochrome',
|
||||
scaleFactor: 1.3,
|
||||
enhanced3d: 'realistic',
|
||||
material3d: {
|
||||
heavenBeads: 'satin',
|
||||
earthBeads: 'satin',
|
||||
lighting: 'top-down',
|
||||
woodGrain: true // Enable wood texture on frame
|
||||
}
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: '**Wood grain texture** overlaid on the frame (rods and reckoning bar). Traditional soroban aesthetic!'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Realistic_LightingComparison: Story = {
|
||||
name: '2️⃣ Realistic - Lighting Comparison',
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '40px', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h4 style={{ marginBottom: '10px', textAlign: 'center' }}>Top-Down Lighting</h4>
|
||||
<AbacusReact
|
||||
value={999}
|
||||
columns={3}
|
||||
showNumbers
|
||||
interactive
|
||||
animated
|
||||
colorScheme="place-value"
|
||||
scaleFactor={1.2}
|
||||
enhanced3d="realistic"
|
||||
material3d={{
|
||||
heavenBeads: 'glossy',
|
||||
earthBeads: 'glossy',
|
||||
lighting: 'top-down'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 style={{ marginBottom: '10px', textAlign: 'center' }}>Ambient Lighting</h4>
|
||||
<AbacusReact
|
||||
value={999}
|
||||
columns={3}
|
||||
showNumbers
|
||||
interactive
|
||||
animated
|
||||
colorScheme="place-value"
|
||||
scaleFactor={1.2}
|
||||
enhanced3d="realistic"
|
||||
material3d={{
|
||||
heavenBeads: 'glossy',
|
||||
earthBeads: 'glossy',
|
||||
lighting: 'ambient'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 style={{ marginBottom: '10px', textAlign: 'center' }}>Dramatic Lighting</h4>
|
||||
<AbacusReact
|
||||
value={999}
|
||||
columns={3}
|
||||
showNumbers
|
||||
interactive
|
||||
animated
|
||||
colorScheme="place-value"
|
||||
scaleFactor={1.2}
|
||||
enhanced3d="realistic"
|
||||
material3d={{
|
||||
heavenBeads: 'glossy',
|
||||
earthBeads: 'glossy',
|
||||
lighting: 'dramatic'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Compare different **lighting styles**: top-down (balanced), ambient (soft all around), dramatic (strong directional).'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// FEATURE TESTS
|
||||
// INTERACTIVE PLAYGROUND
|
||||
// ============================================
|
||||
|
||||
export const ColorSchemes_With3D: Story = {
|
||||
name: '3D Works With All Color Schemes',
|
||||
export const Playground: Story = {
|
||||
name: '🎮 Interactive Playground',
|
||||
render: () => {
|
||||
const value = 333;
|
||||
const schemes: Array<'monochrome' | 'place-value' | 'alternating' | 'heaven-earth'> = [
|
||||
'monochrome',
|
||||
'place-value',
|
||||
'alternating',
|
||||
'heaven-earth'
|
||||
];
|
||||
const [level, setLevel] = React.useState<'subtle' | 'realistic'>('realistic');
|
||||
const [material, setMaterial] = React.useState<'glossy' | 'satin' | 'matte'>('glossy');
|
||||
const [lighting, setLighting] = React.useState<'top-down' | 'ambient' | 'dramatic'>('dramatic');
|
||||
const [woodGrain, setWoodGrain] = React.useState(true);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '30px', flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
{schemes.map(scheme => (
|
||||
<div key={scheme} style={{ textAlign: 'center' }}>
|
||||
<Wrapper3D level="realistic" lighting="top-down">
|
||||
<AbacusReact
|
||||
value={value}
|
||||
columns={3}
|
||||
showNumbers
|
||||
colorScheme={scheme}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
<p style={{ fontSize: '12px', marginTop: '8px', textTransform: 'capitalize' }}>
|
||||
{scheme}
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '30px', alignItems: 'center' }}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '20px',
|
||||
padding: '20px',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '500px'
|
||||
}}>
|
||||
<div>
|
||||
<label style={{ fontWeight: 'bold', display: 'block', marginBottom: '5px' }}>Enhancement Level</label>
|
||||
<select value={level} onChange={e => setLevel(e.target.value as any)} style={{ width: '100%', padding: '5px' }}>
|
||||
<option value="subtle">Subtle</option>
|
||||
<option value="realistic">Realistic</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'The 3D effects work seamlessly with all existing color schemes.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const ColorPalettes_With3D: Story = {
|
||||
name: '3D Works With All Palettes',
|
||||
render: () => {
|
||||
const value = 555;
|
||||
const palettes: Array<'default' | 'colorblind' | 'mnemonic' | 'grayscale' | 'nature'> = [
|
||||
'default',
|
||||
'colorblind',
|
||||
'mnemonic',
|
||||
'grayscale',
|
||||
'nature'
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '30px', flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
{palettes.map(palette => (
|
||||
<div key={palette} style={{ textAlign: 'center' }}>
|
||||
<Wrapper3D level="realistic" lighting="top-down">
|
||||
<AbacusReact
|
||||
value={value}
|
||||
columns={3}
|
||||
showNumbers
|
||||
colorScheme="place-value"
|
||||
colorPalette={palette}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
<p style={{ fontSize: '12px', marginTop: '8px', textTransform: 'capitalize' }}>
|
||||
{palette}
|
||||
</p>
|
||||
<div>
|
||||
<label style={{ fontWeight: 'bold', display: 'block', marginBottom: '5px' }}>Bead Material</label>
|
||||
<select value={material} onChange={e => setMaterial(e.target.value as any)} style={{ width: '100%', padding: '5px' }}>
|
||||
<option value="glossy">Glossy</option>
|
||||
<option value="satin">Satin</option>
|
||||
<option value="matte">Matte</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<label style={{ fontWeight: 'bold', display: 'block', marginBottom: '5px' }}>Lighting</label>
|
||||
<select value={lighting} onChange={e => setLighting(e.target.value as any)} style={{ width: '100%', padding: '5px' }}>
|
||||
<option value="top-down">Top-Down</option>
|
||||
<option value="ambient">Ambient</option>
|
||||
<option value="dramatic">Dramatic</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<input type="checkbox" checked={woodGrain} onChange={e => setWoodGrain(e.target.checked)} />
|
||||
<span>Wood Grain</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AbacusReact
|
||||
value={6789}
|
||||
columns={4}
|
||||
showNumbers
|
||||
interactive
|
||||
animated
|
||||
soundEnabled
|
||||
colorScheme="rainbow"
|
||||
scaleFactor={1.4}
|
||||
enhanced3d={level}
|
||||
material3d={{
|
||||
heavenBeads: material,
|
||||
earthBeads: material,
|
||||
lighting: lighting,
|
||||
woodGrain: woodGrain
|
||||
}}
|
||||
/>
|
||||
|
||||
<p style={{ maxWidth: '500px', textAlign: 'center', color: '#666' }}>
|
||||
Click beads to interact! Try different combinations above to find your favorite look and feel.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'The 3D effects enhance all color palettes beautifully.'
|
||||
story: 'Experiment with all the 3D options! Mix and match materials, lighting, and physics to find your perfect configuration.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,14 +253,6 @@ export interface Abacus3DMaterial {
|
||||
woodGrain?: boolean; // Add wood texture to frame
|
||||
}
|
||||
|
||||
export interface Abacus3DPhysics {
|
||||
wobble?: boolean; // Beads rotate slightly during movement
|
||||
clackEffect?: boolean; // Visual ripple when beads snap
|
||||
hoverParallax?: boolean; // Beads lift on hover
|
||||
particleSnap?: "off" | "subtle" | "sparkle"; // Particle effects on snap
|
||||
hapticFeedback?: boolean; // Trigger haptic feedback on mobile
|
||||
}
|
||||
|
||||
export interface AbacusConfig {
|
||||
// Basic configuration
|
||||
value?: number | bigint;
|
||||
@@ -279,9 +271,8 @@ export interface AbacusConfig {
|
||||
soundVolume?: number;
|
||||
|
||||
// 3D Enhancement
|
||||
enhanced3d?: boolean | "subtle" | "realistic" | "delightful";
|
||||
enhanced3d?: boolean | "subtle" | "realistic";
|
||||
material3d?: Abacus3DMaterial;
|
||||
physics3d?: Abacus3DPhysics;
|
||||
|
||||
// Advanced customization
|
||||
customStyles?: AbacusCustomStyles;
|
||||
@@ -1247,6 +1238,10 @@ interface BeadProps {
|
||||
colorScheme?: string;
|
||||
colorPalette?: string;
|
||||
totalColumns?: number;
|
||||
// 3D Enhancement
|
||||
enhanced3d?: boolean | "subtle" | "realistic";
|
||||
material3d?: Abacus3DMaterial;
|
||||
columnIndex?: number;
|
||||
}
|
||||
|
||||
const Bead: React.FC<BeadProps> = ({
|
||||
@@ -1275,16 +1270,25 @@ const Bead: React.FC<BeadProps> = ({
|
||||
colorScheme = "monochrome",
|
||||
colorPalette = "default",
|
||||
totalColumns = 1,
|
||||
enhanced3d,
|
||||
material3d,
|
||||
columnIndex,
|
||||
}) => {
|
||||
// Detect server-side rendering
|
||||
const isServer = typeof window === 'undefined';
|
||||
|
||||
// Use springs only if not on server and animations are enabled
|
||||
// Even on server, we must call hooks unconditionally, so we provide static values
|
||||
// Enhanced physics config for 3D modes
|
||||
const physicsConfig = React.useMemo(() => {
|
||||
if (!enableAnimation || isServer) return { duration: 0 };
|
||||
if (!enhanced3d || enhanced3d === true || enhanced3d === 'subtle') return config.default;
|
||||
return Abacus3DUtils.getPhysicsConfig(enhanced3d);
|
||||
}, [enableAnimation, isServer, enhanced3d]);
|
||||
|
||||
const [{ x: springX, y: springY }, api] = useSpring(() => ({
|
||||
x,
|
||||
y,
|
||||
config: enableAnimation && !isServer ? config.default : { duration: 0 }
|
||||
config: physicsConfig
|
||||
}));
|
||||
|
||||
// Arrow pulse animation for urgency indication
|
||||
@@ -1363,11 +1367,11 @@ const Bead: React.FC<BeadProps> = ({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (enableAnimation) {
|
||||
api.start({ x, y, config: { tension: 400, friction: 30, mass: 0.8 } });
|
||||
api.start({ x, y, config: physicsConfig });
|
||||
} else {
|
||||
api.set({ x, y });
|
||||
}
|
||||
}, [x, y, enableAnimation, api]);
|
||||
}, [x, y, enableAnimation, api, physicsConfig]);
|
||||
|
||||
// Pulse animation for direction arrows to indicate urgency
|
||||
React.useEffect(() => {
|
||||
@@ -1396,12 +1400,22 @@ const Bead: React.FC<BeadProps> = ({
|
||||
const renderShape = () => {
|
||||
const halfSize = size / 2;
|
||||
|
||||
// Determine fill - use gradient for realistic mode, otherwise use color
|
||||
let fillValue = color;
|
||||
if (enhanced3d === 'realistic' && columnIndex !== undefined) {
|
||||
if (bead.type === 'heaven') {
|
||||
fillValue = `url(#bead-gradient-${columnIndex}-heaven)`;
|
||||
} else {
|
||||
fillValue = `url(#bead-gradient-${columnIndex}-earth-${bead.position})`;
|
||||
}
|
||||
}
|
||||
|
||||
switch (shape) {
|
||||
case "diamond":
|
||||
return (
|
||||
<polygon
|
||||
points={`${size * 0.7},0 ${size * 1.4},${halfSize} ${size * 0.7},${size} 0,${halfSize}`}
|
||||
fill={color}
|
||||
fill={fillValue}
|
||||
stroke="#000"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
@@ -1411,7 +1425,7 @@ const Bead: React.FC<BeadProps> = ({
|
||||
<rect
|
||||
width={size}
|
||||
height={size}
|
||||
fill={color}
|
||||
fill={fillValue}
|
||||
stroke="#000"
|
||||
strokeWidth="0.5"
|
||||
rx="1"
|
||||
@@ -1424,7 +1438,7 @@ const Bead: React.FC<BeadProps> = ({
|
||||
cx={halfSize}
|
||||
cy={halfSize}
|
||||
r={halfSize}
|
||||
fill={color}
|
||||
fill={fillValue}
|
||||
stroke="#000"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
@@ -1458,8 +1472,7 @@ const Bead: React.FC<BeadProps> = ({
|
||||
? {
|
||||
transform: to(
|
||||
[springX, springY],
|
||||
(sx, sy) =>
|
||||
`translate(${sx - getXOffset()}px, ${sy - getYOffset()}px)`,
|
||||
(sx, sy) => `translate(${sx - getXOffset()}px, ${sy - getYOffset()}px)`,
|
||||
),
|
||||
cursor: enableGestures ? "grab" : onClick ? "pointer" : "default",
|
||||
touchAction: "none" as const,
|
||||
@@ -1571,7 +1584,6 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
||||
// 3D enhancement props
|
||||
enhanced3d,
|
||||
material3d,
|
||||
physics3d,
|
||||
// Advanced customization props
|
||||
customStyles,
|
||||
callbacks,
|
||||
@@ -1992,9 +2004,15 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
||||
// console.log(`🎯 activeColumn changed to: ${activeColumn}`);
|
||||
}, [activeColumn]);
|
||||
|
||||
// 3D Enhancement: Calculate container classes
|
||||
const containerClasses = Abacus3DUtils.get3DContainerClasses(
|
||||
enhanced3d,
|
||||
material3d?.lighting
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="abacus-container"
|
||||
className={containerClasses}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
textAlign: "center",
|
||||
@@ -2053,6 +2071,68 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
||||
opacity: 0 !important;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 3D Enhancement: Material gradients for beads */}
|
||||
{enhanced3d === 'realistic' && material3d && (
|
||||
<>
|
||||
{/* Generate gradients for all beads based on material type */}
|
||||
{Array.from({ length: effectiveColumns }, (_, colIndex) => {
|
||||
const placeValue = (effectiveColumns - 1 - colIndex) as ValidPlaceValues;
|
||||
|
||||
// Create dummy beads to get their colors
|
||||
const heavenBead: BeadConfig = {
|
||||
type: 'heaven',
|
||||
value: 5,
|
||||
active: true,
|
||||
position: 0,
|
||||
placeValue
|
||||
};
|
||||
const earthBead: BeadConfig = {
|
||||
type: 'earth',
|
||||
value: 1,
|
||||
active: true,
|
||||
position: 0,
|
||||
placeValue
|
||||
};
|
||||
|
||||
const heavenColor = getBeadColor(heavenBead, effectiveColumns, finalConfig.colorScheme, finalConfig.colorPalette, false);
|
||||
const earthColor = getBeadColor(earthBead, effectiveColumns, finalConfig.colorScheme, finalConfig.colorPalette, false);
|
||||
|
||||
return (
|
||||
<React.Fragment key={`gradients-col-${colIndex}`}>
|
||||
{/* Heaven bead gradient */}
|
||||
<defs dangerouslySetInnerHTML={{
|
||||
__html: Abacus3DUtils.getBeadGradient(
|
||||
`bead-gradient-${colIndex}-heaven`,
|
||||
heavenColor,
|
||||
material3d.heavenBeads || 'satin',
|
||||
true
|
||||
)
|
||||
}} />
|
||||
|
||||
{/* Earth bead gradients */}
|
||||
{[0, 1, 2, 3].map(pos => (
|
||||
<defs key={`earth-${pos}`} dangerouslySetInnerHTML={{
|
||||
__html: Abacus3DUtils.getBeadGradient(
|
||||
`bead-gradient-${colIndex}-earth-${pos}`,
|
||||
earthColor,
|
||||
material3d.earthBeads || 'satin',
|
||||
true
|
||||
)
|
||||
}} />
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}).filter(Boolean)}
|
||||
|
||||
{/* Wood grain texture pattern */}
|
||||
{material3d.woodGrain && (
|
||||
<defs dangerouslySetInnerHTML={{
|
||||
__html: Abacus3DUtils.getWoodGrainPattern('wood-grain-pattern')
|
||||
}} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</defs>
|
||||
|
||||
{/* Background glow effects - rendered behind everything */}
|
||||
@@ -2120,17 +2200,31 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<rect
|
||||
key={`rod-pv${placeValue}`}
|
||||
x={x - dimensions.rodWidth / 2}
|
||||
y={rodStartY}
|
||||
width={dimensions.rodWidth}
|
||||
height={rodEndY - rodStartY}
|
||||
fill={rodStyle.fill}
|
||||
stroke={rodStyle.stroke}
|
||||
strokeWidth={rodStyle.strokeWidth}
|
||||
opacity={rodStyle.opacity}
|
||||
/>
|
||||
<React.Fragment key={`rod-pv${placeValue}`}>
|
||||
<rect
|
||||
x={x - dimensions.rodWidth / 2}
|
||||
y={rodStartY}
|
||||
width={dimensions.rodWidth}
|
||||
height={rodEndY - rodStartY}
|
||||
fill={rodStyle.fill}
|
||||
stroke={rodStyle.stroke}
|
||||
strokeWidth={rodStyle.strokeWidth}
|
||||
opacity={rodStyle.opacity}
|
||||
className="column-post"
|
||||
/>
|
||||
{/* Wood grain texture overlay for column posts */}
|
||||
{enhanced3d === 'realistic' && material3d?.woodGrain && (
|
||||
<rect
|
||||
x={x - dimensions.rodWidth / 2}
|
||||
y={rodStartY}
|
||||
width={dimensions.rodWidth}
|
||||
height={rodEndY - rodStartY}
|
||||
fill="url(#wood-grain-pattern)"
|
||||
className="frame-wood"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2146,7 +2240,22 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
||||
stroke={customStyles?.reckoningBar?.stroke || "none"}
|
||||
strokeWidth={customStyles?.reckoningBar?.strokeWidth ?? 0}
|
||||
opacity={customStyles?.reckoningBar?.opacity ?? 1}
|
||||
className="reckoning-bar"
|
||||
/>
|
||||
{/* Wood grain texture overlay for reckoning bar */}
|
||||
{enhanced3d === 'realistic' && material3d?.woodGrain && (
|
||||
<rect
|
||||
x={dimensions.rodSpacing / 2 - dimensions.beadSize / 2}
|
||||
y={barY}
|
||||
width={
|
||||
(effectiveColumns - 1) * dimensions.rodSpacing + dimensions.beadSize
|
||||
}
|
||||
height={dimensions.barThickness}
|
||||
fill="url(#wood-grain-pattern)"
|
||||
className="frame-wood"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Beads */}
|
||||
{beadStates.map((columnBeads, colIndex) =>
|
||||
@@ -2329,6 +2438,9 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
||||
colorScheme={finalConfig.colorScheme}
|
||||
colorPalette={finalConfig.colorPalette}
|
||||
totalColumns={effectiveColumns}
|
||||
enhanced3d={enhanced3d}
|
||||
material3d={material3d}
|
||||
columnIndex={colIndex}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user