// components.jsx — shared UI for BeanScriptor
// CoffeeBean glyph, BeanSlider, FlavorWheel, glass surfaces.
const PALETTE = {
espresso: '#241712',
coffee: '#4A2D22',
cocoa: '#7A4A36',
caramel: '#B8794A',
crema: '#F4E8D5',
cream: '#FBF7EF',
paper: '#FFFCF6',
ink: '#1B120D',
inkMuted: 'rgba(36, 23, 18, 0.55)',
inkSoft: 'rgba(36, 23, 18, 0.32)',
hairline: 'rgba(36, 23, 18, 0.08)',
sage: 'oklch(62% 0.06 145)',
sageDeep: 'oklch(48% 0.07 148)',
tg: '#2B7CD9',
tgSoft: 'rgba(43, 124, 217, 0.10)',
};
// ─────────────────────────────────────────────────────────────
// CoffeeBean — the signature glyph
// ─────────────────────────────────────────────────────────────
function CoffeeBean({ size = 20, color = PALETTE.coffee, fill = true, tilt = -18, style = {} }) {
const c = color;
return (
);
}
// ─────────────────────────────────────────────────────────────
// Glass surface — frosted card
// ─────────────────────────────────────────────────────────────
function GlassCard({ children, style = {}, padding = 16, radius = 22 }) {
return (
{children}
);
}
// ─────────────────────────────────────────────────────────────
// Section heading (label + optional kicker)
// ─────────────────────────────────────────────────────────────
function SectionLabel({ children, kicker, style = {} }) {
return (
{children}
{kicker &&
{kicker}
}
);
}
// ─────────────────────────────────────────────────────────────
// BeanSlider — horizontal slider with bean thumb + tick marks
// ─────────────────────────────────────────────────────────────
function BeanSlider({ value, onChange, min = 0, max = 10, step = 1, ticks, leftLabel, rightLabel }) {
const trackRef = React.useRef(null);
const [drag, setDrag] = React.useState(false);
const pct = (value - min) / (max - min);
const handleMove = (clientX) => {
if (!trackRef.current) return;
const rect = trackRef.current.getBoundingClientRect();
let p = (clientX - rect.left) / rect.width;
p = Math.max(0, Math.min(1, p));
const raw = min + p * (max - min);
const snapped = Math.round(raw / step) * step;
onChange(Math.max(min, Math.min(max, snapped)));
};
// The drag effect only re-runs when drag flips — route through a ref so
// mid-drag moves always see the current onChange/min/max/step.
const handleMoveRef = React.useRef(handleMove);
handleMoveRef.current = handleMove;
React.useEffect(() => {
if (!drag) return;
const onMove = (e) => {
if (e.touches) e.preventDefault(); // keep the page from scrolling under the drag
handleMoveRef.current(e.touches ? e.touches[0].clientX : e.clientX);
};
const onUp = () => setDrag(false);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
window.addEventListener('touchmove', onMove, { passive: false });
window.addEventListener('touchend', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
window.removeEventListener('touchmove', onMove);
window.removeEventListener('touchend', onUp);
};
}, [drag]);
const tickMarks = ticks || Array.from({ length: max - min + 1 }, (_, i) => min + i);
return (
{ setDrag(true); handleMove(e.clientX); }}
onTouchStart={(e) => { setDrag(true); handleMove(e.touches[0].clientX); }}
style={{
position: 'relative', height: 36, cursor: 'pointer',
display: 'flex', alignItems: 'center',
}}
>
{/* track */}
{/* fill */}
{/* ticks */}
{tickMarks.map((t) => {
const tp = (t - min) / (max - min);
const active = t <= value;
return (
);
})}
{/* thumb */}
{(leftLabel || rightLabel) && (
{leftLabel}
{rightLabel}
)}
);
}
// ─────────────────────────────────────────────────────────────
// FlavorWheel — 8 clickable sectors with optional sub-petals
// ─────────────────────────────────────────────────────────────
const FLAVORS = [
{ key: 'fruity', label: 'Fruity', hue: 8, notes: ['Berry', 'Citrus', 'Stone Fruit', 'Tropical'] },
{ key: 'sweet', label: 'Sweet', hue: 35, notes: ['Honey', 'Caramel', 'Brown Sugar', 'Maple'] },
{ key: 'nutty', label: 'Nutty', hue: 28, notes: ['Almond', 'Hazelnut', 'Cocoa', 'Chocolate'] },
{ key: 'roasty', label: 'Roasty', hue: 18, notes: ['Smoke', 'Tobacco', 'Burnt', 'Ash'] },
{ key: 'spicy', label: 'Spicy', hue: 348, notes: ['Pepper', 'Clove', 'Anise', 'Cardamom'] },
{ key: 'veggie', label: 'Veggie', hue: 130, notes: ['Herb', 'Grass', 'Pea', 'Olive Oil'] },
{ key: 'sour', label: 'Sour', hue: 60, notes: ['Citric', 'Malic', 'Lactic', 'Phosphoric'] },
{ key: 'floral', label: 'Floral', hue: 320, notes: ['Jasmine', 'Rose', 'Chamomile', 'Bergamot'] },
];
function flavorColor(hue, sat = 0.08, l = 70, a = 1) {
return `oklch(${l}% ${sat} ${hue} / ${a})`;
}
function FlavorWheel({ active = [], onToggle, size = 280 }) {
const cx = size / 2, cy = size / 2;
const rOuter = size / 2 - 4;
const rInner = size / 2 - 70;
const rCore = 28;
const sectors = FLAVORS.length;
const sweep = (2 * Math.PI) / sectors;
// build sector path
const sectorPath = (i, r1, r2) => {
const a0 = -Math.PI / 2 + i * sweep - sweep / 2;
const a1 = a0 + sweep;
const x0 = cx + r2 * Math.cos(a0), y0 = cy + r2 * Math.sin(a0);
const x1 = cx + r2 * Math.cos(a1), y1 = cy + r2 * Math.sin(a1);
const x2 = cx + r1 * Math.cos(a1), y2 = cy + r1 * Math.sin(a1);
const x3 = cx + r1 * Math.cos(a0), y3 = cy + r1 * Math.sin(a0);
return `M ${x0} ${y0} A ${r2} ${r2} 0 0 1 ${x1} ${y1} L ${x2} ${y2} A ${r1} ${r1} 0 0 0 ${x3} ${y3} Z`;
};
const labelPos = (i, r) => {
const a = -Math.PI / 2 + i * sweep;
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
};
return (
);
}
// ─────────────────────────────────────────────────────────────
// Tag chip
// ─────────────────────────────────────────────────────────────
function Chip({ children, active, icon, onClick, style = {} }) {
return (
);
}
// ─────────────────────────────────────────────────────────────
// Field helpers
// ─────────────────────────────────────────────────────────────
function FieldShell({ label, suffix, children, span = 1 }) {
return (
{label}
{children}
{suffix && {suffix}}
);
}
// Character filter for numeric fields. mode 'int' keeps digits only;
// 'decimal' keeps digits and at most one dot. Commas become dots — the iOS
// decimal keypad shows only a comma in many locales (ru, de, fr, …), so
// blocking it would make decimal input impossible on those devices.
// Applied to the whole value on change, so pasted text is filtered too.
function sanitizeNumeric(raw, mode = 'decimal') {
let v = String(raw).replace(/,/g, '.');
v = mode === 'int' ? v.replace(/[^\d]/g, '') : v.replace(/[^\d.]/g, '');
if (mode !== 'int') {
const i = v.indexOf('.');
if (i !== -1) v = v.slice(0, i + 1) + v.slice(i + 1).replace(/\./g, '');
}
return v;
}
function NumField({ label, suffix, value, onChange, span = 1, mode = 'decimal', maxLength }) {
return (
onChange(sanitizeNumeric(e.target.value, mode))}
style={{
background: 'transparent', border: 'none', outline: 'none',
fontSize: 18, fontWeight: 600, color: PALETTE.espresso,
fontFeatureSettings: '"tnum"', width: '100%',
fontFamily: '-apple-system, system-ui',
padding: 0,
}}
/>
);
}
function TextField({ label, value, onChange, placeholder, span = 1 }) {
return (
onChange(e.target.value)}
placeholder={placeholder}
style={{
background: 'transparent', border: 'none', outline: 'none',
fontSize: 16, fontWeight: 500, color: PALETTE.espresso,
width: '100%', padding: 0,
fontFamily: '-apple-system, system-ui',
}}
/>
);
}
// ─────────────────────────────────────────────────────────────
// Tiny inline icons (not lucide; matched for simplicity)
// ─────────────────────────────────────────────────────────────
const Icon = {
back: (s = 22) => (
),
plus: (s = 18) => (
),
flame: (s = 14) => (
),
snow: (s = 14) => (
),
clock: (s = 14) => (
),
drop: (s = 14) => (
),
chevron: (s = 16) => (
),
};
Object.assign(window, {
PALETTE, CoffeeBean, GlassCard, SectionLabel, BeanSlider,
FlavorWheel, FLAVORS, Chip, FieldShell, NumField, TextField, Icon,
sanitizeNumeric,
});