{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 => (
+ { e.currentTarget.style.color = 'rgba(255,255,255,0.95)'; }}
+ onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255,255,255,0.62)'; }}
+ >
+
+
+ );
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. */}
+
+ 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}
+
+ {/* Arc's quiet escape hatch under the primary CTA. */}
+ {secondaryLabel && onSecondary && (
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}
-
+ )}
+
+
+ );
+
+ 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. */}
+
+ 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' }}
+ >
+
+
+ {ACCENT_PRESETS.slice(presetPage * PRESETS_PER_PAGE, (presetPage + 1) * PRESETS_PER_PAGE).map((hex) => (
onChange([hex])}
@@ -162,6 +184,13 @@ const AccentColorPad: React.FC<{
}}
/>
))}
+ 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' }}
+ >
+
+
onChange(null)}
style={{
@@ -172,16 +201,11 @@ const AccentColorPad: React.FC<{
Reset
+ {/* 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")';