mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] onboarding: arc visuals (swarm card leaf + tinted logo, brand-lit app tiles, theme device, grain)
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
// The OpenSwarm octopus, our actual brand mark. A raster (its coral already sits right next to the
|
||||
// default accent), so unlike the old asterisk it wears its own color rather than the picked gradient.
|
||||
const OnboardingLogo: React.FC<{ size: number; style?: React.CSSProperties }> = ({ size, style }) => (
|
||||
<img
|
||||
src="./logo.png"
|
||||
width={size}
|
||||
height={size}
|
||||
alt="OpenSwarm"
|
||||
draggable={false}
|
||||
style={{ display: 'block', objectFit: 'contain', ...style }}
|
||||
/>
|
||||
);
|
||||
|
||||
export default OnboardingLogo;
|
||||
@@ -1,21 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
// Claude's signature asterisk mark, drawn as 8 rounded rays around a hollow center; gradient runs hover->pressed so it wears whatever accent the user picked.
|
||||
const Starburst: React.FC<{ size: number; from: string; to: string }> = ({ size, from, to }) => {
|
||||
const id = React.useId();
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="-50 -50 100 100" style={{ display: 'block' }}>
|
||||
<defs>
|
||||
<linearGradient id={id} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor={from} />
|
||||
<stop offset="100%" stopColor={to} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{Array.from({ length: 8 }, (unused, i) => (
|
||||
<rect key={i} x={-7} y={-46} width={14} height={34} rx={7} fill={`url(#${id})`} transform={`rotate(${i * 45})`} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Starburst;
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Check } from 'lucide-react';
|
||||
import { INTEGRATIONS } from '@/app/pages/Tools/integrations';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import BeatShell from './BeatShell';
|
||||
@@ -29,9 +28,11 @@ const BeatApps: React.FC<{
|
||||
c={c}
|
||||
title="Choose the apps you live in."
|
||||
body="I'll shape your starting canvas around them."
|
||||
nextLabel={picks.length > 0 ? 'Next' : 'Skip for now'}
|
||||
nextLabel="Next"
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
secondaryLabel="Skip for now"
|
||||
onSecondary={onNext}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
@@ -48,9 +49,10 @@ const BeatApps: React.FC<{
|
||||
))}
|
||||
<span style={{ marginLeft: 8, fontSize: '0.72rem', fontWeight: 600, color: c.text.tertiary, letterSpacing: '0.04em' }}>OpenSwarm</span>
|
||||
</div>
|
||||
<div style={{ padding: 18, display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(108px, 1fr))', gap: 11 }}>
|
||||
<div style={{ padding: 18, display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(118px, 1fr))', gap: 12 }}>
|
||||
{entries.map((entry, i) => {
|
||||
const picked = picks.includes(entry.id);
|
||||
// Arc's picked tile lights up in the APP's own brand color, not one shared accent.
|
||||
return (
|
||||
<motion.button
|
||||
key={entry.id}
|
||||
@@ -59,21 +61,17 @@ const BeatApps: React.FC<{
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 360, damping: 24, delay: 0.28 + i * 0.035 }}
|
||||
style={{
|
||||
position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 9,
|
||||
padding: '16px 8px 12px', borderRadius: c.radius.md,
|
||||
border: `1.5px solid ${picked ? c.accent.primary : c.border.medium}`,
|
||||
background: c.bg.surface, cursor: 'pointer', fontFamily: 'inherit',
|
||||
boxShadow: picked ? `0 0 0 3px ${c.accent.primary}22` : c.shadow.sm,
|
||||
transition: 'border-color 140ms ease, box-shadow 140ms ease',
|
||||
position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 9,
|
||||
padding: '18px 8px', borderRadius: 16,
|
||||
border: `2px solid ${picked ? entry.color : 'transparent'}`,
|
||||
background: picked ? `${entry.color}14` : c.bg.secondary,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
boxShadow: picked ? `0 0 0 4px ${entry.color}22` : 'none',
|
||||
transition: 'border-color 150ms ease, box-shadow 150ms ease, background 150ms ease',
|
||||
}}
|
||||
>
|
||||
{picked && (
|
||||
<span style={{ position: 'absolute', top: 6, right: 6, width: 17, height: 17, borderRadius: 999, background: c.accent.primary, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Check size={11} color="#fff" />
|
||||
</span>
|
||||
)}
|
||||
<span style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>{entry.icon}</span>
|
||||
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: c.text.secondary, textAlign: 'center' }}>{entry.name}</span>
|
||||
<span style={{ width: 38, height: 38, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>{entry.icon}</span>
|
||||
<span style={{ fontSize: '0.76rem', fontWeight: 500, color: picked ? c.text.primary : c.text.secondary, textAlign: 'center' }}>{entry.name}</span>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useThemeAccent } from '@/shared/styles/ThemeContext';
|
||||
import { Dices } from 'lucide-react';
|
||||
import { Check, Copy, Dices, Download, Share } from 'lucide-react';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import { hexToHsl, hslToHex } from '@/shared/styles/claudeTokens';
|
||||
import { useThemeAccent } from '@/shared/styles/ThemeContext';
|
||||
import type { ProviderIdentity } from '../onboardingV3Api';
|
||||
import Starburst from '../Starburst';
|
||||
import BeatShell from './BeatShell';
|
||||
import BeatShell, { ONBOARDING_SANS } from './BeatShell';
|
||||
|
||||
const MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace';
|
||||
|
||||
const EPITHETS = [
|
||||
'METHODICAL PERFECTIONIST', 'SAVORY ARCHIVIST', 'MIDNIGHT ORCHESTRATOR', 'GENTLE MAXIMALIST',
|
||||
@@ -21,80 +23,255 @@ function nameFromIdentity(identity: ProviderIdentity[]): string {
|
||||
return letters.charAt(0).toUpperCase() + letters.slice(1, 12);
|
||||
}
|
||||
|
||||
// The Arc Card moment: onboarding ends with an identity artifact, not a settings screen. Name is editable in place, the epithet re-rolls, and the leaf wears the accent they just picked.
|
||||
// The card's leaf takes the two ends of the user's picked theme gradient (or a dark->light pair
|
||||
// derived from the single accent), so the artifact literally wears the theme they just chose.
|
||||
function leafStops(gradient: string[] | null, base: string, c: ClaudeTokens): [string, string] {
|
||||
if (gradient && gradient.length >= 2) return [gradient[0], gradient[gradient.length - 1]];
|
||||
const hsl = hexToHsl(base);
|
||||
if (!hsl) return [c.accent.pressed, c.accent.primary];
|
||||
const dark = hslToHex({ h: hsl.h, s: Math.min(1, hsl.s * 1.02), l: Math.max(0.34, hsl.l - 0.1) });
|
||||
const light = hslToHex({ h: (hsl.h + 0.015) % 1, s: Math.max(0.55, hsl.s * 0.92), l: Math.min(0.74, hsl.l + 0.18) });
|
||||
return [dark, light];
|
||||
}
|
||||
|
||||
// One accent-hued ink dark enough to read on the cream card, for every bit of card type.
|
||||
function readableInk(base: string, c: ClaudeTokens): string {
|
||||
const hsl = hexToHsl(base);
|
||||
if (!hsl) return c.accent.pressed;
|
||||
return hslToHex({ h: hsl.h, s: Math.max(0.5, hsl.s), l: Math.min(0.42, hsl.l) });
|
||||
}
|
||||
|
||||
// The Arc Card moment: onboarding ends with an identity artifact, not a settings screen. A gradient
|
||||
// leaf wearing the picked theme, the name + a re-rollable epithet, and a real PNG you can save,
|
||||
// copy, or share, an artifact you show off has to be takeable.
|
||||
const BeatCard: React.FC<{
|
||||
c: ClaudeTokens;
|
||||
identity: ProviderIdentity[];
|
||||
onFinish: (name: string | null) => void;
|
||||
onBack: () => void;
|
||||
}> = ({ c, identity, onFinish, onBack }) => {
|
||||
const { gradient } = useThemeAccent();
|
||||
const { accent, gradient } = useThemeAccent();
|
||||
const [name, setName] = useState(() => nameFromIdentity(identity));
|
||||
const seed = useMemo(() => Math.floor(Math.random() * EPITHETS.length), []);
|
||||
const [roll, setRoll] = useState(0);
|
||||
const epithet = EPITHETS[(seed + roll) % EPITHETS.length];
|
||||
const today = useMemo(() => new Date().toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }), []);
|
||||
const [tilt, setTilt] = useState<{ rx: number; ry: number; mx: number; my: number } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const baseHex = (gradient && gradient[0]) || accent || c.accent.primary;
|
||||
const [dark, light] = leafStops(gradient, baseHex, c);
|
||||
const ink = readableInk(baseHex, c);
|
||||
|
||||
const drawCard = useCallback(async (): Promise<HTMLCanvasElement> => {
|
||||
const W = 580;
|
||||
const H = 800;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = W;
|
||||
cv.height = H;
|
||||
const ctx = cv.getContext('2d');
|
||||
if (!ctx) return cv;
|
||||
ctx.fillStyle = '#FCFBF5';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, 0, W, H, 36);
|
||||
ctx.fill();
|
||||
// The leaf: three round corners + one soft point at bottom-right, wearing the theme gradient.
|
||||
const grad = ctx.createLinearGradient(48, 44, 532, 400);
|
||||
grad.addColorStop(0, dark);
|
||||
grad.addColorStop(1, light);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(48, 44, W - 96, 340, [150, 150, 12, 150]);
|
||||
ctx.fill();
|
||||
// Small brand mark in the corner, tinted with the same gradient (source-in keeps the octopus alpha).
|
||||
const logo = new Image();
|
||||
logo.src = './logo.png';
|
||||
await new Promise<void>((res) => { logo.onload = () => res(); logo.onerror = () => res(); });
|
||||
if (logo.naturalWidth > 0) {
|
||||
const lc = document.createElement('canvas');
|
||||
lc.width = 72;
|
||||
lc.height = 72;
|
||||
const lctx = lc.getContext('2d');
|
||||
if (lctx) {
|
||||
lctx.drawImage(logo, 0, 0, 72, 72);
|
||||
lctx.globalCompositeOperation = 'source-in';
|
||||
const lg = lctx.createLinearGradient(0, 0, 72, 72);
|
||||
lg.addColorStop(0, dark);
|
||||
lg.addColorStop(1, light);
|
||||
lctx.fillStyle = lg;
|
||||
lctx.fillRect(0, 0, 72, 72);
|
||||
ctx.drawImage(lc, 34, 30, 42, 42);
|
||||
}
|
||||
}
|
||||
ctx.fillStyle = ink;
|
||||
ctx.font = `800 58px ${ONBOARDING_SANS}`;
|
||||
ctx.fillText(name.trim() || 'Your name', 52, 476);
|
||||
ctx.font = `600 21px ${MONO}`;
|
||||
ctx.fillText(epithet.split('').join(' '), 54, 520);
|
||||
// Bottom-left stamp: OPENSWARM | hatch | date, outlined; bottom-right OPEN / SWARM lockup.
|
||||
const stampText = `OPENSWARM ${today.toUpperCase()}`;
|
||||
ctx.font = `600 18px ${MONO}`;
|
||||
const stampW = ctx.measureText(stampText).width + 30;
|
||||
ctx.strokeStyle = `${ink}88`;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(48, H - 96, stampW, 42, 8);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = ink;
|
||||
ctx.fillText(stampText, 63, H - 68);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.font = `800 19px ${ONBOARDING_SANS}`;
|
||||
ctx.fillText('OPEN', W - 48, H - 84);
|
||||
ctx.fillText('SWARM', W - 48, H - 62);
|
||||
ctx.textAlign = 'left';
|
||||
return cv;
|
||||
}, [name, epithet, today, dark, light, ink]);
|
||||
|
||||
const saveCard = useCallback(() => {
|
||||
void drawCard().then((cv) => {
|
||||
const a = document.createElement('a');
|
||||
a.download = 'swarm-card.png';
|
||||
a.href = cv.toDataURL('image/png');
|
||||
a.click();
|
||||
});
|
||||
}, [drawCard]);
|
||||
|
||||
const copyCard = useCallback(() => {
|
||||
void drawCard().then((cv) => {
|
||||
cv.toBlob((blob) => {
|
||||
if (!blob || !navigator.clipboard || typeof ClipboardItem === 'undefined') return;
|
||||
void navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then(() => {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
});
|
||||
});
|
||||
});
|
||||
}, [drawCard]);
|
||||
|
||||
// Native share sheet when the platform offers one (Arc's first card action); quietly absent otherwise.
|
||||
const canShare = typeof navigator.canShare === 'function' && typeof File !== 'undefined'
|
||||
&& navigator.canShare({ files: [new File([''], 'swarm-card.png', { type: 'image/png' })] });
|
||||
const shareCard = useCallback(() => {
|
||||
void drawCard().then((cv) => {
|
||||
cv.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
const file = new File([blob], 'swarm-card.png', { type: 'image/png' });
|
||||
void navigator.share({ files: [file], title: 'My Swarm Card' }).catch(() => {});
|
||||
});
|
||||
});
|
||||
}, [drawCard]);
|
||||
|
||||
// Arc's card actions: quiet icon-only buttons under the card on the dark stage.
|
||||
const chip = (label: string, Icon: typeof Dices, onClick: () => void): React.ReactElement => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', width: 34, height: 34,
|
||||
border: 'none', background: 'transparent', color: 'rgba(255,255,255,0.62)',
|
||||
cursor: 'pointer', borderRadius: 8, transition: 'color 140ms ease',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'rgba(255,255,255,0.95)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255,255,255,0.62)'; }}
|
||||
>
|
||||
<Icon size={17} />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<BeatShell
|
||||
c={c}
|
||||
title={name ? `Welcome to OpenSwarm, ${name}.` : 'Welcome to OpenSwarm.'}
|
||||
body="Here's your Swarm Card. Show it off or keep it to yourself."
|
||||
title={name ? `Welcome to OpenSwarm, ${name}` : 'Welcome to OpenSwarm'}
|
||||
body={"Here's your Swarm Card. Show it off to the world or keep it to yourself.\n\nAnd with that, you're ready to run your new OS."}
|
||||
nextLabel="Get started"
|
||||
onNext={() => onFinish(name.trim() || null)}
|
||||
onBack={onBack}
|
||||
stageDark
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 18, perspective: 900 }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 22, rotate: -3, scale: 0.94 }}
|
||||
animate={{ opacity: 1, y: 0, rotate: 0, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 200, damping: 22, delay: 0.25 }}
|
||||
style={{
|
||||
width: 290, height: 400, borderRadius: 18, background: '#FCFBF5',
|
||||
boxShadow: '0 24px 60px rgba(0,0,0,0.28)', padding: '26px 24px', boxSizing: 'border-box',
|
||||
display: 'flex', flexDirection: 'column', position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div style={{ alignSelf: 'flex-start', marginLeft: -8, marginTop: -4 }}>
|
||||
<Starburst size={158} from={gradient?.[0] ?? c.accent.hover} to={gradient?.[gradient.length - 1] ?? c.accent.pressed} />
|
||||
</div>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value.slice(0, 18))}
|
||||
placeholder="Your name"
|
||||
style={{
|
||||
marginTop: 26, border: 'none', outline: 'none', background: 'transparent',
|
||||
fontSize: '1.7rem', fontWeight: 800, color: c.accent.pressed, fontFamily: 'inherit',
|
||||
width: '100%', padding: 0,
|
||||
<div
|
||||
onMouseMove={(e) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
const px = (e.clientX - r.left) / r.width;
|
||||
const py = (e.clientY - r.top) / r.height;
|
||||
setTilt({ rx: -(py - 0.5) * 13, ry: (px - 0.5) * 13, mx: px * 100, my: py * 100 });
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 6, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: '0.68rem', letterSpacing: '0.14em', color: c.accent.primary }}>
|
||||
{epithet}
|
||||
</div>
|
||||
<div style={{ marginTop: 'auto', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: '0.6rem', letterSpacing: '0.1em',
|
||||
color: c.accent.primary, border: `1px solid ${c.accent.primary}55`, borderRadius: 4, padding: '2px 7px',
|
||||
}}>
|
||||
OPENSWARM · {today.toUpperCase()}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.6rem', letterSpacing: '0.06em', color: c.accent.pressed, fontWeight: 700, textAlign: 'right', lineHeight: 1.35 }}>
|
||||
OPEN<br />SWARM
|
||||
</span>
|
||||
onMouseLeave={() => setTilt(null)}
|
||||
style={{
|
||||
width: 300, height: 414, borderRadius: 18, background: '#FCFBF5',
|
||||
border: '1px solid rgba(0,0,0,0.05)',
|
||||
boxShadow: tilt ? '0 30px 70px rgba(0,0,0,0.34)' : '0 24px 60px rgba(0,0,0,0.28)',
|
||||
padding: '22px 22px 20px', boxSizing: 'border-box',
|
||||
display: 'flex', flexDirection: 'column', position: 'relative', overflow: 'hidden',
|
||||
transform: tilt ? `rotateX(${tilt.rx}deg) rotateY(${tilt.ry}deg)` : 'rotateX(0deg) rotateY(0deg)',
|
||||
transition: tilt ? 'box-shadow 200ms ease' : 'transform 320ms ease, box-shadow 200ms ease',
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
{/* Cursor-following shine, the ProfileCard glare. */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, pointerEvents: 'none', opacity: tilt ? 1 : 0, transition: 'opacity 250ms ease',
|
||||
background: tilt ? `radial-gradient(280px circle at ${tilt.mx}% ${tilt.my}%, rgba(255,255,255,0.45), transparent 62%)` : undefined,
|
||||
}} />
|
||||
{/* Small brand mark in the corner, wearing the same theme gradient (masked octopus). */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 14, left: 16, width: 22, height: 22, zIndex: 3,
|
||||
WebkitMaskImage: 'url(./logo.png)', maskImage: 'url(./logo.png)',
|
||||
WebkitMaskSize: 'contain', maskSize: 'contain',
|
||||
WebkitMaskRepeat: 'no-repeat', maskRepeat: 'no-repeat',
|
||||
WebkitMaskPosition: 'center', maskPosition: 'center',
|
||||
background: `linear-gradient(138deg, ${dark}, ${light})`,
|
||||
}} />
|
||||
{/* The leaf: three round corners + one soft point, bottom-right, wearing the theme gradient. */}
|
||||
<div style={{
|
||||
width: '100%', height: 152, flexShrink: 0,
|
||||
borderRadius: '47% 47% 8px 47%',
|
||||
background: `linear-gradient(138deg, ${dark} 0%, ${light} 100%)`,
|
||||
}} />
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value.slice(0, 18))}
|
||||
placeholder="Your name"
|
||||
style={{
|
||||
marginTop: 20, border: 'none', outline: 'none', background: 'transparent',
|
||||
fontSize: '1.7rem', fontWeight: 800, color: ink, fontFamily: 'inherit',
|
||||
width: '100%', padding: 0,
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 5, fontFamily: MONO, fontSize: '0.66rem', letterSpacing: '0.14em', color: ink }}>
|
||||
{epithet}
|
||||
</div>
|
||||
<div style={{ marginTop: 'auto', display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between' }}>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 7,
|
||||
fontFamily: MONO, fontSize: '0.56rem', letterSpacing: '0.08em',
|
||||
color: ink, border: `1px solid ${ink}55`, borderRadius: 5, padding: '3px 7px',
|
||||
}}>
|
||||
OPENSWARM
|
||||
<span style={{
|
||||
width: 11, height: 13, borderRadius: 1,
|
||||
background: `repeating-linear-gradient(45deg, ${ink} 0 1.6px, transparent 1.6px 3.6px)`,
|
||||
}} />
|
||||
{today.toUpperCase()}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.6rem', letterSpacing: '0.06em', color: ink, fontWeight: 800, textAlign: 'right', lineHeight: 1.35 }}>
|
||||
OPEN<br />SWARM
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.button
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
onClick={() => setRoll((r) => r + 1)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 7, border: 'none', background: 'transparent',
|
||||
color: c.text.tertiary, fontSize: '0.82rem', cursor: 'pointer', fontFamily: 'inherit', padding: 4,
|
||||
}}
|
||||
>
|
||||
<Dices size={15} /> Re-roll the title
|
||||
</motion.button>
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.7 }} style={{ display: 'flex', gap: 6 }}>
|
||||
{chip('Re-roll the title', Dices, () => setRoll((r) => r + 1))}
|
||||
{canShare && chip('Share', Share, shareCard)}
|
||||
{chip('Save as image', Download, saveCard)}
|
||||
{chip(copied ? 'Copied' : 'Copy to clipboard', copied ? Check : Copy, copyCard)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</BeatShell>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,40 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useThemeAccent } from '@/shared/styles/ThemeContext';
|
||||
import { useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import { GRAIN_URL } from '@/shared/styles/grainTexture';
|
||||
|
||||
// Film grain as a data URI so the CSP never phones out; opacity keeps it a texture, not noise.
|
||||
export const GRAIN_URL = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E\")";
|
||||
// Arc's torn seam as a SMOOTH wave: a sine sampled densely enough that the polygon reads as a
|
||||
// soft ripple (no sharp points), ~18px wavelength, 6px swell.
|
||||
const WAVE_PERIODS = 52;
|
||||
const WAVE_SAMPLES = WAVE_PERIODS * 8;
|
||||
const ZIGZAG_CLIP = `polygon(0 0, ${Array.from({ length: WAVE_SAMPLES + 1 }, (unused, i) => {
|
||||
const inset = 3 + 3 * Math.sin((i / WAVE_SAMPLES) * WAVE_PERIODS * 2 * Math.PI);
|
||||
return `calc(100% - ${inset.toFixed(2)}px) ${((i / WAVE_SAMPLES) * 100).toFixed(3)}%`;
|
||||
}).join(', ')}, 0 100%)`;
|
||||
|
||||
// Arc's torn seam: the dark copy panel ends in a zigzag bite instead of a straight border.
|
||||
const ZIGZAG_CLIP = `polygon(0 0, 100% 0, ${Array.from({ length: 30 }, (unused, i) => `calc(100% - 12px) ${i * 3.33 + 1.66}%, 100% ${i * 3.33 + 3.33}%`).join(', ')}, 0 100%)`;
|
||||
// Arc's electric indigo CTA: onboarding buttons are brand-colored, not user-accent (the accent doesn't exist until the theme beat).
|
||||
export const CTA_BLUE = '#4b48f8';
|
||||
// Arc sets its onboarding in a heavy grotesque; the app's token "sans" actually falls back to a
|
||||
// serif (Anthropic Sans isn't bundled), so the flow pins a real sans stack.
|
||||
export const ONBOARDING_SANS = '-apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Helvetica, Arial, sans-serif';
|
||||
// Arc's onboarding-window backdrop: cold azure light from the top-left, deep indigo mid, a WARM
|
||||
// violet-magenta glow rising from the bottom-right corner, always grained.
|
||||
export const ARC_BLUE_BG = [
|
||||
'radial-gradient(110% 95% at 92% 100%, rgba(186, 70, 235, 0.55) 0%, rgba(146, 60, 244, 0.28) 34%, transparent 62%)',
|
||||
'radial-gradient(120% 100% at 6% 4%, rgba(148, 178, 255, 0.85) 0%, rgba(110, 135, 255, 0.35) 38%, transparent 66%)',
|
||||
'linear-gradient(152deg, #5f7bff 0%, #4a4ff6 44%, #4338ef 68%, #6f3af3 100%)',
|
||||
].join(', ');
|
||||
// Arc's neutral stage: warm mauve-gray under heavy grain (their import beat), until the user picks a color.
|
||||
const STAGE_MAUVE = '#a8a5b3';
|
||||
|
||||
const SPRING = { type: 'spring' as const, stiffness: 260, damping: 26 };
|
||||
|
||||
// One idea per room: dark copy panel left (torn edge, grain, staggered spring+blur copy), live artifact on a grained stage right. Copy never moves after it lands; only the artifact is alive.
|
||||
// One idea per room, staged EXACTLY like Arc: the whole window is grained electric blue, a rounded
|
||||
// split card floats centered in it, dark copy panel (torn right edge, grain, staggered spring+blur
|
||||
// copy, bottom-pinned CTA) beside a heavily grained stage. `wide` = Arc's account layout instead:
|
||||
// edge-to-edge 50/50 split, centered copy, no floating card.
|
||||
const BeatShell: React.FC<{
|
||||
c: ClaudeTokens;
|
||||
title: string;
|
||||
@@ -22,11 +44,31 @@ const BeatShell: React.FC<{
|
||||
onNext: () => void;
|
||||
onBack?: () => void;
|
||||
children: React.ReactNode;
|
||||
}> = ({ c, title, body, nextLabel, nextDisabled, onNext, onBack, children }) => {
|
||||
// Arc's post-theme screens wear the user's color everywhere; the stage drinks the picked stops (or accent) instead of staying flat paper.
|
||||
wide?: boolean;
|
||||
logo?: React.ReactNode;
|
||||
stageDark?: boolean;
|
||||
secondaryLabel?: string;
|
||||
onSecondary?: () => void;
|
||||
}> = ({ c, title, body, nextLabel, nextDisabled, onNext, onBack, children, wide, logo, stageDark, secondaryLabel, onSecondary }) => {
|
||||
// Once the user has picked stops the stage wears them (our theme beat repaints live); before that it stays Arc-mauve.
|
||||
const { accent, gradient } = useThemeAccent();
|
||||
const stops = gradient ?? (accent ? [accent] : [c.accent.primary]);
|
||||
const stageWash = `linear-gradient(115deg, ${stops.map((hex, i) => `${hex}2e ${stops.length > 1 ? (i / (stops.length - 1)) * 100 : 100}%`).join(', ')}), ${c.bg.secondary}`;
|
||||
const { washOpacity, grain } = useThemeWash();
|
||||
const stops = gradient ?? (accent ? [accent] : null);
|
||||
// Picked color reads VIVID on the stage (alpha floor over near-white), not muddied into the mauve.
|
||||
const washAlpha = Math.round(Math.max(0.5, washOpacity) * 255).toString(16).padStart(2, '0');
|
||||
const stageBg = stageDark
|
||||
? '#262320'
|
||||
: stops
|
||||
? `linear-gradient(115deg, ${stops.map((hex, i) => `${hex}${washAlpha} ${stops.length > 1 ? (i / (stops.length - 1)) * 100 : 100}%`).join(', ')}), #edebe7`
|
||||
: STAGE_MAUVE;
|
||||
// Arc post-theme: the CTA flips to cream (dark label) and the window backdrop wears the user's
|
||||
// stops under a soft white veil; before any pick both stay brand blue.
|
||||
const themed = !!stops;
|
||||
const ctaBg = themed ? '#F5EFDF' : CTA_BLUE;
|
||||
const ctaFg = themed ? '#232320' : '#fff';
|
||||
const backdrop = stops
|
||||
? `linear-gradient(rgba(255,255,255,0.16), rgba(255,255,255,0.16)), linear-gradient(160deg, ${stops.map((hex, i) => `${hex} ${stops.length > 1 ? (i / (stops.length - 1)) * 100 : 100}%`).join(', ')})`
|
||||
: ARC_BLUE_BG;
|
||||
// Zen steal: controls stay inert until the entrance lands so a double-click from the prior beat can't fire them.
|
||||
const [armed, setArmed] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -40,66 +82,118 @@ const BeatShell: React.FC<{
|
||||
transition: { ...SPRING, delay },
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', background: c.bg.secondary }}>
|
||||
<div
|
||||
style={{
|
||||
// vw (not %, which resolves oddly in the flex child) keeps the copy panel ~36% instead of a thin strip on ultra-wide screens, so the stage never balloons into a beige void.
|
||||
position: 'relative', width: 'clamp(360px, 36vw, 600px)', flexShrink: 0, display: 'flex', flexDirection: 'column',
|
||||
justifyContent: 'center', padding: '48px 46px 48px 44px', boxSizing: 'border-box',
|
||||
background: c.bg.inverse, color: c.text.inverse, clipPath: ZIGZAG_CLIP,
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: GRAIN_URL, opacity: 0.1, pointerEvents: 'none', mixBlendMode: 'overlay' }} />
|
||||
{onBack && (
|
||||
<motion.button
|
||||
{...enter(0.05)}
|
||||
onClick={() => armed && onBack()}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, alignSelf: 'flex-start',
|
||||
marginBottom: 18, padding: 0, border: 'none', background: 'transparent',
|
||||
color: c.text.inverse + '77', fontSize: '0.85rem', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={14} /> Back
|
||||
</motion.button>
|
||||
)}
|
||||
<motion.h1 {...enter(0.12)} style={{ margin: 0, fontSize: 'clamp(1.9rem, 3.2vw, 2.6rem)', lineHeight: 1.12, fontWeight: 700, letterSpacing: '-0.01em' }}>
|
||||
{title}
|
||||
</motion.h1>
|
||||
<motion.p {...enter(0.26)} style={{ margin: '16px 0 0', fontSize: '0.98rem', lineHeight: 1.55, color: c.text.inverse + '99', maxWidth: '34ch' }}>
|
||||
{body}
|
||||
</motion.p>
|
||||
<motion.div {...enter(0.42)} style={{ marginTop: 40 }}>
|
||||
const panel = (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative', flexShrink: 0, display: 'flex', flexDirection: 'column',
|
||||
width: wide ? '50%' : 'clamp(300px, 36%, 460px)',
|
||||
justifyContent: wide ? 'center' : 'flex-start',
|
||||
alignItems: wide ? 'center' : 'stretch',
|
||||
textAlign: wide ? 'center' : 'left',
|
||||
padding: wide ? '48px 64px' : '56px 40px 36px 44px', boxSizing: 'border-box',
|
||||
// Fixed white-on-near-black: the panel is ALWAYS dark, so it never reads theme tokens (dark mode used to turn the copy invisible).
|
||||
background: '#1e1e1d', color: '#ffffff', clipPath: ZIGZAG_CLIP,
|
||||
fontFamily: ONBOARDING_SANS,
|
||||
// Overlap the stage by one tooth-depth (+ sit above it) so the torn edge bites INTO the stage color instead of leaving a dead paper gap.
|
||||
marginRight: -6, zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: GRAIN_URL, opacity: 0.07, pointerEvents: 'none', mixBlendMode: 'overlay' }} />
|
||||
{onBack && (
|
||||
<motion.button
|
||||
{...enter(0.05)}
|
||||
onClick={() => armed && onBack()}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, alignSelf: 'flex-start',
|
||||
marginBottom: 18, padding: 0, border: 'none', background: 'transparent',
|
||||
color: 'rgba(255,255,255,0.47)', fontSize: '0.85rem', cursor: 'pointer', fontFamily: 'inherit',
|
||||
position: wide ? 'absolute' : 'relative', top: wide ? 24 : undefined, left: wide ? 28 : undefined,
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={14} /> Back
|
||||
</motion.button>
|
||||
)}
|
||||
{logo && <motion.div {...enter(0.08)} style={{ marginBottom: 18 }}>{logo}</motion.div>}
|
||||
<motion.h1 {...enter(0.12)} style={{ margin: 0, fontSize: wide ? 'clamp(2.1rem, 2.8vw, 2.7rem)' : 'clamp(2.1rem, 3.2vw, 3rem)', lineHeight: 1.06, fontWeight: 800, letterSpacing: '-0.02em', fontFamily: 'inherit' }}>
|
||||
{title}
|
||||
</motion.h1>
|
||||
<motion.p {...enter(0.26)} style={{ margin: '18px 0 0', fontSize: '1.04rem', lineHeight: 1.6, color: 'rgba(255,255,255,0.72)', maxWidth: '36ch', whiteSpace: 'pre-line' }}>
|
||||
{body}
|
||||
</motion.p>
|
||||
{/* Arc pins the CTA to the panel's bottom on card beats; on the wide account beat it follows the content. */}
|
||||
<motion.div {...enter(0.42)} style={{ marginTop: wide ? 34 : 'auto', paddingTop: wide ? 0 : 28, width: wide ? 'min(340px, 100%)' : '100%' }}>
|
||||
<button
|
||||
onClick={() => armed && !nextDisabled && onNext()}
|
||||
disabled={!!nextDisabled}
|
||||
style={{
|
||||
width: '100%', padding: '15px 18px', borderRadius: 10,
|
||||
border: 'none', background: ctaBg, color: ctaFg,
|
||||
fontSize: '1rem', fontWeight: 700, cursor: nextDisabled ? 'default' : 'pointer',
|
||||
opacity: nextDisabled ? 0.45 : 1, fontFamily: 'inherit',
|
||||
transition: 'background 150ms ease, opacity 150ms ease',
|
||||
}}
|
||||
>
|
||||
{nextLabel}
|
||||
</button>
|
||||
{/* Arc's quiet escape hatch under the primary CTA. */}
|
||||
{secondaryLabel && onSecondary && (
|
||||
<button
|
||||
onClick={() => armed && !nextDisabled && onNext()}
|
||||
disabled={!!nextDisabled}
|
||||
onClick={() => armed && onSecondary()}
|
||||
style={{
|
||||
width: '100%', padding: '13px 18px', borderRadius: c.radius.md,
|
||||
border: 'none', background: c.accent.primary, color: '#fff',
|
||||
fontSize: '0.98rem', fontWeight: 600, cursor: nextDisabled ? 'default' : 'pointer',
|
||||
opacity: nextDisabled ? 0.45 : 1, fontFamily: 'inherit',
|
||||
transition: 'background 150ms ease, opacity 150ms ease',
|
||||
marginTop: 12, width: '100%', border: 'none', background: 'transparent',
|
||||
color: 'rgba(255,255,255,0.55)', fontSize: '0.88rem', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit', padding: 4,
|
||||
}}
|
||||
>
|
||||
{nextLabel}
|
||||
{secondaryLabel}
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const stage = (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.55, delay: 0.15 }}
|
||||
style={{
|
||||
position: 'relative', flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 32, boxSizing: 'border-box', overflow: 'auto',
|
||||
background: wide ? ARC_BLUE_BG : stageBg,
|
||||
}}
|
||||
>
|
||||
{/* Arc's stage always wears texture; the slider can add more but never strips it during onboarding. */}
|
||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: GRAIN_URL, opacity: Math.max(0.22, grain), pointerEvents: 'none' }} />
|
||||
<div style={{ position: 'relative', width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
if (wide) {
|
||||
return (
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', fontFamily: ONBOARDING_SANS }}>
|
||||
{panel}
|
||||
{stage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: backdrop, fontFamily: ONBOARDING_SANS }}>
|
||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: GRAIN_URL, opacity: 0.3, pointerEvents: 'none' }} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.55, delay: 0.15 }}
|
||||
initial={{ opacity: 0, y: 18, scale: 0.985 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ ...SPRING, delay: 0.05 }}
|
||||
style={{
|
||||
position: 'relative', flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 36, boxSizing: 'border-box', overflow: 'auto',
|
||||
background: stageWash,
|
||||
position: 'relative', display: 'flex', width: 'min(1240px, 82%)', height: 'min(880px, 82%)',
|
||||
borderRadius: 24, overflow: 'hidden', boxShadow: '0 30px 80px rgba(20, 16, 80, 0.35)',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: GRAIN_URL, opacity: 0.08, pointerEvents: 'none' }} />
|
||||
<div style={{ position: 'relative', width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{children}
|
||||
</div>
|
||||
{panel}
|
||||
{stage}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -51,13 +51,15 @@ const BeatTheme: React.FC<{
|
||||
nextLabel="Next"
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
stageDark
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.94, y: 14 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 220, damping: 24, delay: 0.2 }}
|
||||
style={{
|
||||
width: 'min(430px, 100%)', borderRadius: 20, background: c.bg.inverse,
|
||||
// Fixed dark: the picker is a physical device, it doesn't repaint with the theme it edits.
|
||||
width: 'min(430px, 100%)', borderRadius: 20, background: '#141413',
|
||||
boxShadow: '0 18px 50px rgba(0,0,0,0.3)', padding: '14px 16px 18px', boxSizing: 'border-box',
|
||||
display: 'flex', flexDirection: 'column', gap: 12,
|
||||
}}
|
||||
@@ -72,7 +74,7 @@ const BeatTheme: React.FC<{
|
||||
width: 34, height: 28, borderRadius: 8, border: 'none', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: choice === key ? c.accent.primary : 'transparent',
|
||||
color: choice === key ? '#fff' : c.text.inverse + '88',
|
||||
color: choice === key ? '#fff' : 'rgba(255,255,255,0.55)',
|
||||
transition: 'background 140ms ease, color 140ms ease',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
// Real brand marks (simple-icons paths) for the connect rows; the dots read as placeholders.
|
||||
const CLAUDE_PATH = 'M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.583.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312z';
|
||||
|
||||
const GEMINI_PATH = 'M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81z';
|
||||
|
||||
const OPENAI_PATH = 'M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z';
|
||||
|
||||
export function providerLogo(providerId: string, size = 24): React.ReactElement | null {
|
||||
if (providerId === 'claude') {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size}><path fill="#D97757" d={CLAUDE_PATH} /></svg>
|
||||
);
|
||||
}
|
||||
if (providerId === 'antigravity') {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size}>
|
||||
<defs>
|
||||
<linearGradient id="osw-gem" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#4285F4" />
|
||||
<stop offset="60%" stopColor="#9B72CB" />
|
||||
<stop offset="100%" stopColor="#D96570" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#osw-gem)" d={GEMINI_PATH} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (providerId === 'codex') {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size}><path fill="#10A37F" d={OPENAI_PATH} /></svg>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { Minus, Plus } from 'lucide-react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Minus, Plus } from 'lucide-react';
|
||||
import { hexToHsl, hslToHex } from '@/shared/styles/claudeTokens';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import { Knob, SquiggleSlider } from './WashDials';
|
||||
|
||||
export const ACCENT_PRESETS = ['#ae5630', '#b0453c', '#8e5cb8', '#3a6fc4', '#2e8f6f', '#b08b2e', '#c2588f', '#5c6470'];
|
||||
export const ACCENT_PRESETS = [
|
||||
'#ae5630', '#b0453c', '#8e5cb8', '#3a6fc4', '#2e8f6f', '#b08b2e', '#c2588f', '#5c6470',
|
||||
'#e8b4b8', '#f2d0a4', '#a8d8b9', '#9ec5e8', '#c3aed6', '#f7e8a4', '#87d1c6', '#d98cb3',
|
||||
];
|
||||
const PRESETS_PER_PAGE = 8;
|
||||
const MAX_STOPS = 3;
|
||||
|
||||
function stopToXY(hex: string): { x: number; y: number } | null {
|
||||
@@ -30,6 +35,8 @@ const AccentColorPad: React.FC<{
|
||||
const padRef = useRef<HTMLDivElement | null>(null);
|
||||
const grabbedRef = useRef<number | null>(null);
|
||||
const lastApplyRef = useRef(0);
|
||||
const [presetPage, setPresetPage] = useState(0);
|
||||
const presetPages = Math.ceil(ACCENT_PRESETS.length / PRESETS_PER_PAGE);
|
||||
|
||||
const pointToHex = useCallback((clientX: number, clientY: number): string | null => {
|
||||
const pad = padRef.current;
|
||||
@@ -104,9 +111,16 @@ const AccentColorPad: React.FC<{
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
style={{
|
||||
// Arc's pad: dark dot-grid field with the spectrum only ghosting through, the picked dots carry the color.
|
||||
position: 'relative', height, borderRadius: c.radius.lg, cursor: 'crosshair',
|
||||
border: `1px solid ${c.border.medium}`, touchAction: 'none',
|
||||
background: 'linear-gradient(to bottom, rgba(255,255,255,0.55), rgba(0,0,0,0.45)), linear-gradient(to right, hsl(0,72%,55%), hsl(60,72%,55%), hsl(120,72%,55%), hsl(180,72%,55%), hsl(240,72%,55%), hsl(300,72%,55%), hsl(360,72%,55%))',
|
||||
background: [
|
||||
'radial-gradient(rgba(255,255,255,0.13) 1px, transparent 1.4px)',
|
||||
'linear-gradient(rgba(30,29,27,0.84), rgba(30,29,27,0.84))',
|
||||
'linear-gradient(to bottom, rgba(255,255,255,0.55), rgba(0,0,0,0.45))',
|
||||
'linear-gradient(to right, hsl(0,72%,55%), hsl(60,72%,55%), hsl(120,72%,55%), hsl(180,72%,55%), hsl(240,72%,55%), hsl(300,72%,55%), hsl(360,72%,55%))',
|
||||
].join(', '),
|
||||
backgroundSize: '14px 14px, auto, auto, auto',
|
||||
}}
|
||||
>
|
||||
{stops.map((hex, i) => {
|
||||
@@ -150,8 +164,16 @@ const AccentColorPad: React.FC<{
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
|
||||
{ACCENT_PRESETS.map((hex) => (
|
||||
{/* Arc's preset carousel: a page of dots between chevrons. */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<button
|
||||
onClick={() => setPresetPage((p) => Math.max(0, p - 1))}
|
||||
disabled={presetPage === 0}
|
||||
style={{ border: 'none', background: 'transparent', padding: 0, cursor: presetPage === 0 ? 'default' : 'pointer', color: c.text.tertiary, opacity: presetPage === 0 ? 0.35 : 1, display: 'flex' }}
|
||||
>
|
||||
<ChevronLeft size={15} />
|
||||
</button>
|
||||
{ACCENT_PRESETS.slice(presetPage * PRESETS_PER_PAGE, (presetPage + 1) * PRESETS_PER_PAGE).map((hex) => (
|
||||
<button
|
||||
key={hex}
|
||||
onClick={() => onChange([hex])}
|
||||
@@ -162,6 +184,13 @@ const AccentColorPad: React.FC<{
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setPresetPage((p) => Math.min(presetPages - 1, p + 1))}
|
||||
disabled={presetPage >= presetPages - 1}
|
||||
style={{ border: 'none', background: 'transparent', padding: 0, cursor: presetPage >= presetPages - 1 ? 'default' : 'pointer', color: c.text.tertiary, opacity: presetPage >= presetPages - 1 ? 0.35 : 1, display: 'flex' }}
|
||||
>
|
||||
<ChevronRight size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onChange(null)}
|
||||
style={{
|
||||
@@ -172,16 +201,11 @@ const AccentColorPad: React.FC<{
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
{/* Arc's dials row: wavy line = grain, round knob = intensity. */}
|
||||
{wash && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.78rem', color: c.text.tertiary }}>
|
||||
<span style={{ width: 52, flexShrink: 0 }}>Intensity</span>
|
||||
<input type="range" min={0} max={1} step={0.01} value={wash.opacity} onChange={(e) => wash.onOpacity(parseFloat(e.target.value))} style={{ flex: 1, accentColor: c.accent.primary }} />
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.78rem', color: c.text.tertiary }}>
|
||||
<span style={{ width: 52, flexShrink: 0 }}>Grain</span>
|
||||
<input type="range" min={0} max={1} step={0.01} value={wash.grain} onChange={(e) => wash.onGrain(parseFloat(e.target.value))} style={{ flex: 1, accentColor: c.accent.primary }} />
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 14, color: c.text.tertiary }}>
|
||||
<SquiggleSlider value={wash.grain} onChange={wash.onGrain} width={190} />
|
||||
<Knob value={wash.opacity} onChange={wash.onOpacity} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
|
||||
// Arc's theme-device dials: grain is a wavy line with a pill thumb, intensity is a round dotted
|
||||
// knob. Strokes ride currentColor so the same dials read on the dark device and light Settings.
|
||||
|
||||
export const SquiggleSlider: React.FC<{ value: number; onChange: (v: number) => void; width?: number }> = ({ value, onChange, width = 170 }) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const dragging = useRef(false);
|
||||
const apply = useCallback((clientX: number) => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
onChange(Math.min(1, Math.max(0, (clientX - r.left) / r.width)));
|
||||
}, [onChange]);
|
||||
const H = 30;
|
||||
const wavePath = Array.from({ length: 61 }, (unused, i) => {
|
||||
const x = (i / 60) * width;
|
||||
const y = H / 2 + Math.sin((i / 60) * Math.PI * 2 * 5) * 6;
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(' ');
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
title="Grain"
|
||||
onPointerDown={(e) => { dragging.current = true; (e.target as HTMLElement).setPointerCapture?.(e.pointerId); apply(e.clientX); }}
|
||||
onPointerMove={(e) => { if (dragging.current) apply(e.clientX); }}
|
||||
onPointerUp={() => { dragging.current = false; }}
|
||||
style={{ position: 'relative', width, height: H, cursor: 'pointer', touchAction: 'none', flexShrink: 0 }}
|
||||
>
|
||||
<svg width={width} height={H} style={{ display: 'block' }}>
|
||||
<path d={wavePath} stroke="currentColor" strokeOpacity={0.5} strokeWidth={2} fill="none" strokeLinecap="round" />
|
||||
</svg>
|
||||
<div style={{
|
||||
position: 'absolute', top: '50%', left: value * width, transform: 'translate(-50%, -50%)',
|
||||
width: 14, height: 26, borderRadius: 999, background: '#fff',
|
||||
boxShadow: '0 1px 5px rgba(0,0,0,0.4)', pointerEvents: 'none',
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Knob: React.FC<{ value: number; onChange: (v: number) => void; size?: number }> = ({ value, onChange, size = 34 }) => {
|
||||
const dragging = useRef<{ startY: number; startV: number } | null>(null);
|
||||
const angle = -135 + value * 270;
|
||||
return (
|
||||
<div
|
||||
title="Intensity"
|
||||
onPointerDown={(e) => { dragging.current = { startY: e.clientY, startV: value }; (e.target as HTMLElement).setPointerCapture?.(e.pointerId); }}
|
||||
onPointerMove={(e) => { const d = dragging.current; if (!d) return; onChange(Math.min(1, Math.max(0, d.startV + (d.startY - e.clientY) / 120))); }}
|
||||
onPointerUp={() => { dragging.current = null; }}
|
||||
style={{
|
||||
position: 'relative', width: size + 10, height: size + 10, display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', cursor: 'ns-resize', touchAction: 'none', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: 0, borderRadius: 999, border: '2px dotted currentColor', opacity: 0.4 }} />
|
||||
<div style={{ width: size - 8, height: size - 8, borderRadius: 999, background: '#3a3835', transform: `rotate(${angle}deg)`, display: 'flex', justifyContent: 'center' }}>
|
||||
<div style={{ width: 3, height: 9, borderRadius: 2, background: '#fff', marginTop: 2 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
// Zen-style film grain: a static 220px PNG tile of sparse BIPOLAR speckle (some pixels lighten,
|
||||
// some darken, most near-transparent), so at full slider it reads as texture in the paint rather
|
||||
// than gray haze on top. Opacity IS the grain slider, exactly like Zen's --zen-grainy-background-opacity.
|
||||
export const GRAIN_URL = 'url("./grain-texture.png")';
|
||||
Reference in New Issue
Block a user