diff --git a/frontend/public/grain-texture.png b/frontend/public/grain-texture.png new file mode 100644 index 00000000..e4777a5a Binary files /dev/null and b/frontend/public/grain-texture.png differ diff --git a/frontend/src/app/components/OnboardingV3/OnboardingLogo.tsx b/frontend/src/app/components/OnboardingV3/OnboardingLogo.tsx new file mode 100644 index 00000000..f9c1d034 --- /dev/null +++ b/frontend/src/app/components/OnboardingV3/OnboardingLogo.tsx @@ -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 }) => ( + OpenSwarm +); + +export default OnboardingLogo; diff --git a/frontend/src/app/components/OnboardingV3/Starburst.tsx b/frontend/src/app/components/OnboardingV3/Starburst.tsx deleted file mode 100644 index 9acd5187..00000000 --- a/frontend/src/app/components/OnboardingV3/Starburst.tsx +++ /dev/null @@ -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 ( - - - - - - - - {Array.from({ length: 8 }, (unused, i) => ( - - ))} - - ); -}; - -export default Starburst; diff --git a/frontend/src/app/components/OnboardingV3/beats/BeatApps.tsx b/frontend/src/app/components/OnboardingV3/beats/BeatApps.tsx index 408f085f..8ee41e18 100644 --- a/frontend/src/app/components/OnboardingV3/beats/BeatApps.tsx +++ b/frontend/src/app/components/OnboardingV3/beats/BeatApps.tsx @@ -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} > OpenSwarm -
+
{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 ( - {picked && ( - - - - )} - {entry.icon} - {entry.name} + {entry.icon} + {entry.name} ); })} diff --git a/frontend/src/app/components/OnboardingV3/beats/BeatCard.tsx b/frontend/src/app/components/OnboardingV3/beats/BeatCard.tsx index df405342..c8ce1aa2 100644 --- a/frontend/src/app/components/OnboardingV3/beats/BeatCard.tsx +++ b/frontend/src/app/components/OnboardingV3/beats/BeatCard.tsx @@ -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 => { + 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((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 => ( + + ); return ( onFinish(name.trim() || null)} onBack={onBack} + stageDark > -
+
-
- -
- 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, +
{ + 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 }); }} - /> -
- {epithet} -
-
- - OPENSWARM ยท {today.toUpperCase()} - - - OPEN
SWARM -
+ 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. */} +
+ {/* Small brand mark in the corner, wearing the same theme gradient (masked octopus). */} +
+ {/* The leaf: three round corners + one soft point, bottom-right, wearing the theme gradient. */} +
+ 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, + }} + /> +
+ {epithet} +
+
+ + OPENSWARM + + {today.toUpperCase()} + + + OPEN
SWARM +
+
- 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, - }} - > - Re-roll the title - + + {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)} +
); diff --git a/frontend/src/app/components/OnboardingV3/beats/BeatShell.tsx b/frontend/src/app/components/OnboardingV3/beats/BeatShell.tsx index 84e047f9..b6263170 100644 --- a/frontend/src/app/components/OnboardingV3/beats/BeatShell.tsx +++ b/frontend/src/app/components/OnboardingV3/beats/BeatShell.tsx @@ -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 ( -
-
-
- {onBack && ( - 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', - }} - > - Back - - )} - - {title} - - - {body} - - + const panel = ( +
+
+ {onBack && ( + 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, + }} + > + Back + + )} + {logo && {logo}} + + {title} + + + {body} + + {/* Arc pins the CTA to the panel's bottom on card beats; on the wide account beat it follows the content. */} + + + {/* Arc's quiet escape hatch under the primary CTA. */} + {secondaryLabel && onSecondary && ( - + )} + +
+ ); + + const stage = ( + + {/* Arc's stage always wears texture; the slider can add more but never strips it during onboarding. */} +
+
+ {children}
+ + ); + + if (wide) { + return ( +
+ {panel} + {stage} +
+ ); + } + + return ( +
+
-
-
- {children} -
+ {panel} + {stage}
); diff --git a/frontend/src/app/components/OnboardingV3/beats/BeatTheme.tsx b/frontend/src/app/components/OnboardingV3/beats/BeatTheme.tsx index e8599311..62ad72b0 100644 --- a/frontend/src/app/components/OnboardingV3/beats/BeatTheme.tsx +++ b/frontend/src/app/components/OnboardingV3/beats/BeatTheme.tsx @@ -51,13 +51,15 @@ const BeatTheme: React.FC<{ nextLabel="Next" onNext={onNext} onBack={onBack} + stageDark > diff --git a/frontend/src/app/components/OnboardingV3/providerLogos.tsx b/frontend/src/app/components/OnboardingV3/providerLogos.tsx new file mode 100644 index 00000000..caf0e692 --- /dev/null +++ b/frontend/src/app/components/OnboardingV3/providerLogos.tsx @@ -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 ( + + ); + } + if (providerId === 'antigravity') { + return ( + + + + + + + + + + + ); + } + if (providerId === 'codex') { + return ( + + ); + } + return null; +} diff --git a/frontend/src/app/components/theme/AccentColorPad.tsx b/frontend/src/app/components/theme/AccentColorPad.tsx index afc76983..7a51de91 100644 --- a/frontend/src/app/components/theme/AccentColorPad.tsx +++ b/frontend/src/app/components/theme/AccentColorPad.tsx @@ -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(null); const grabbedRef = useRef(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<{
-
- {ACCENT_PRESETS.map((hex) => ( + {/* Arc's preset carousel: a page of dots between chevrons. */} +
+ + {ACCENT_PRESETS.slice(presetPage * PRESETS_PER_PAGE, (presetPage + 1) * PRESETS_PER_PAGE).map((hex) => (
+ {/* Arc's dials row: wavy line = grain, round knob = intensity. */} {wash && ( -
- - +
+ +
)}
diff --git a/frontend/src/app/components/theme/WashDials.tsx b/frontend/src/app/components/theme/WashDials.tsx new file mode 100644 index 00000000..88543815 --- /dev/null +++ b/frontend/src/app/components/theme/WashDials.tsx @@ -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(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 ( +
{ 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 }} + > + + + +
+
+ ); +}; + +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 ( +
{ 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, + }} + > +
+
+
+
+
+ ); +}; diff --git a/frontend/src/shared/styles/grainTexture.ts b/frontend/src/shared/styles/grainTexture.ts new file mode 100644 index 00000000..30382438 --- /dev/null +++ b/frontend/src/shared/styles/grainTexture.ts @@ -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")';