diff --git a/shell/src-tauri/src/lib.rs b/shell/src-tauri/src/lib.rs index d01667a..52e23e9 100644 --- a/shell/src-tauri/src/lib.rs +++ b/shell/src-tauri/src/lib.rs @@ -49,6 +49,7 @@ use tauri::{ ipc::Channel, menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + webview::PageLoadEvent, AppHandle, Emitter, Manager, RunEvent, State, WebviewUrl, WebviewWindowBuilder, WindowEvent, }; @@ -660,21 +661,26 @@ pub fn run() { kill_sidecar(app_handle); } // macOS delivers Reopen when the app is re-activated — clicking its - // notification banner, its Dock icon, etc. Desktop notifications - // can't carry a routable button (the plugin's show() is - // fire-and-forget), so this is how the sign-in nudge's "click here" - // lands somewhere: if we're up but not signed in and nothing's on - // screen, bring up Settings → account. + // notification banner ("Maximal is running"), its Dock icon, etc. + // Desktop notifications can't carry a routable button (the plugin's + // show() is fire-and-forget), so Reopen is how a banner click lands + // somewhere: if nothing's on screen, open Settings. Route to the + // account section when we're up but not signed in (the sign-in + // nudge), otherwise plain Settings. #[cfg(target_os = "macos")] RunEvent::Reopen { has_visible_windows, .. } => { - if !has_visible_windows - && app_handle.state::().get() + if !has_visible_windows { + let section = if app_handle.state::().get() == SidecarState::RunningUnauthenticated - { - open_settings_window(app_handle, Some("account")); + { + Some("account") + } else { + None + }; + open_settings_window(app_handle, section); } } _ => {} @@ -1406,6 +1412,18 @@ fn create_splash(app: &AppHandle) { .transparent(true) .always_on_top(true) .skip_taskbar(true) + // Build hidden and only show once the webview reports the DOM has + // loaded. On Windows/WebView2 the native surface is presented before + // the compositor draws its first frame, so a visible-from-launch + // transparent window shows an empty outline for hundreds of ms–seconds + // before the brand-red `.splash` div pops in. macOS/WKWebView paints in + // lockstep with show, so this fires immediately there — no regression. + .visible(false) + .on_page_load(|window, payload| { + if payload.event() == PageLoadEvent::Finished { + let _ = window.show(); + } + }) .center() .build(); match result { @@ -1483,11 +1501,19 @@ fn dismiss_splash(app: &AppHandle) { /// denial or a dev (`cargo run`) no-op must not matter. fn fire_startup_notification(app: &AppHandle) { use tauri_plugin_notification::NotificationExt; + // Where the icon lives — and which way to point — is platform-specific: + // macOS puts it in the top menu bar (↑); Windows puts it in the + // bottom-right system tray (↓). Leave the macOS copy exactly as-is. + let body = if cfg!(target_os = "macos") { + "Look for the Maximal icon in your menu bar ↑" + } else { + "Maximal is running in your system tray ↓" + }; if let Err(err) = app .notification() .builder() .title("Maximal is running") - .body("Look for the Maximal icon in your menu bar ↑") + .body(body) .show() { eprintln!("[shell] startup notification failed: {err}"); diff --git a/shell/src/ui/features/apps/AppCard.tsx b/shell/src/ui/features/apps/AppCard.tsx index 069aa80..88677d8 100644 --- a/shell/src/ui/features/apps/AppCard.tsx +++ b/shell/src/ui/features/apps/AppCard.tsx @@ -2,8 +2,10 @@ import { useState } from "react"; import type { AppEntry } from "../../../proxy/client"; import { Button } from "../../components/Button"; +import { ConfirmDialog } from "../../components/ConfirmDialog"; import { Switch } from "../../components/Switch"; import { cx } from "../../components/cx"; +import { isWindows } from "../../platform"; import type { MutationResult } from "./useApps"; @@ -36,6 +38,13 @@ function conflictCopy(app: AppEntry): { title: string; detail: string } | null { export function AppCard({ app, onToggle, onRescan }: AppCardProps): JSX.Element { const [copied, setCopied] = useState(false); const [rescanning, setRescanning] = useState(false); + // Windows-only: disabling Claude Code routing doesn't take effect in an + // already-running session (Claude Code reads its base URL at launch on + // Windows; macOS picks it up live). Warn before disabling so the user + // knows to /exit and relaunch. See issue #178. + const [restartWarnOpen, setRestartWarnOpen] = useState(false); + const [disabling, setDisabling] = useState(false); + const needsWindowsRestartWarning = app.id === "claude-code" && isWindows(); const comingSoon = app.kind === "coming-soon"; const notInstalled = app.status === "not-installed"; @@ -64,6 +73,23 @@ export function AppCard({ app, onToggle, onRescan }: AppCardProps): JSX.Element setRescanning(false); }; + // Intercept only the Claude-Code-disable-on-Windows case; everything else + // (enabling, other apps, macOS) toggles straight through. + const handleToggle = (next: boolean): void => { + if (!next && needsWindowsRestartWarning) { + setRestartWarnOpen(true); + return; + } + void onToggle(next); + }; + + const confirmDisable = async (): Promise => { + setDisabling(true); + await onToggle(false); + setDisabling(false); + setRestartWarnOpen(false); + }; + return (
void onToggle(next)} + onCheckedChange={handleToggle} label={app.enabled ? "On" : "Off"} /> )} @@ -158,6 +184,32 @@ export function AppCard({ app, onToggle, onRescan }: AppCardProps): JSX.Element )} + + {/* Windows-only heads-up before disabling Claude Code routing: a + running session keeps using the proxy until it's restarted. */} + {needsWindowsRestartWarning && ( + +

+ On Windows, a Claude Code session that's already running keeps + routing through maximal until you restart it. +

+

+ After you disable this, run /exit{" "} + in Claude Code and start it again for the change to take effect. +

+ + } + confirmLabel="Disable routing" + cancelLabel="Keep on" + busy={disabling} + onConfirm={confirmDisable} + onCancel={() => setRestartWarnOpen(false)} + /> + )}
); } diff --git a/shell/src/ui/platform.ts b/shell/src/ui/platform.ts new file mode 100644 index 0000000..27667dc --- /dev/null +++ b/shell/src/ui/platform.ts @@ -0,0 +1,18 @@ +/** + * Best-effort OS detection for the settings webview. + * + * The embedded UI is the SAME bundle on every platform, so anything that + * must differ by OS (e.g. the Windows-only "restart Claude Code" caveat) + * has to branch at runtime. We have no `tauri-plugin-os` registered, and + * the UI can also be opened in a plain browser, so the dependency-free + * signal is the user-agent: WebView2 on Windows reports "Windows NT", + * WKWebView on macOS reports "Macintosh". Prefer the structured + * `userAgentData.platform` when present, fall back to the UA string. + */ +export function isWindows(): boolean { + if (typeof navigator === "undefined") return false; + const uaData = (navigator as { userAgentData?: { platform?: string } }) + .userAgentData; + if (uaData?.platform) return /windows/i.test(uaData.platform); + return /windows/i.test(navigator.userAgent); +} diff --git a/site/src/components/GodRays.astro b/site/src/components/GodRays.astro index bdcf3b1..d151856 100644 --- a/site/src/components/GodRays.astro +++ b/site/src/components/GodRays.astro @@ -42,6 +42,7 @@ uniform vec2 uRes; uniform float uTime; uniform float uIntensity; + uniform float uLightY; uniform vec3 uColorA; uniform vec3 uColorB; uniform vec3 uColorC; @@ -51,12 +52,12 @@ float aspect = uRes.x / uRes.y; vec2 p = vec2((uv.x - 0.5) * aspect + 0.5, uv.y); - vec2 light = vec2(0.5, 0.8); + vec2 light = vec2(0.5, uLightY); vec2 d = p - light; float dist = length(d); float ang = atan(d.y, d.x); - float t = uTime * 0.16; + float t = uTime * 0.55; float r = 0.45 + 0.30 * sin(ang * 7.0 + t) + 0.22 * sin(ang * 13.0 - t * 1.4 + 2.1) @@ -67,12 +68,15 @@ float falloff = smoothstep(1.5, 0.05, dist); float beams = r * falloff; + // Fade the beams out around the center so the convergence reads as a + // soft glow, not a hard point. + beams *= smoothstep(0.0, 0.26, dist); float glow = smoothstep(0.85, 0.0, dist) * 0.16; float v = (beams * 0.85 + glow) * uIntensity; // Cyclic blend across the brand jewel tones by ray angle, so adjacent // shafts differ in hue; drifts slowly over time. - float g = fract(ang / 6.28318 * 1.2 + uTime * 0.02); + float g = fract(ang / 6.28318 * 1.2 + uTime * 0.06); vec3 col; if (g < 0.33333) col = mix(uColorA, uColorB, g * 3.0); else if (g < 0.66667) col = mix(uColorB, uColorC, (g - 0.33333) * 3.0); @@ -126,6 +130,7 @@ const uColorA = gl.getUniformLocation(prog, "uColorA") const uColorB = gl.getUniformLocation(prog, "uColorB") const uColorC = gl.getUniformLocation(prog, "uColorC") + const uLightY = gl.getUniformLocation(prog, "uLightY") gl.uniform1f(uIntensity, 0.62) // Brand jewel tones (dark-mode tints): turquoise → indigo → magenta. No // gold — these coordinate with the teal cards and complement the red hero. @@ -148,11 +153,29 @@ gl!.uniform2f(uRes, w, h) } + // The light source is anchored to the hero card's center: it tracks the + // card as the page scrolls, and pins at the top of the viewport once the + // card's center scrolls off the top — so the bright convergence never + // floats in the gaps between cards. + const hero = document.querySelector(".hero") + function lightY(): number { + if (!hero) return 0.8 + const r = hero.getBoundingClientRect() + const vh = window.innerHeight || 1 + const centerY = r.top + r.height / 2 + // Track the hero card's center, but never let the source rise above 48px + // off the top — so the glowing convergence is fully off-screen by the + // time the card has scrolled past, never floating between the lower cards. + const pinned = Math.max(centerY, -48) + return 1 - pinned / vh + } + const start = performance.now() let running = false let rafId = 0 function frame(now: number): void { gl!.uniform1f(uTime, (now - start) / 1000) + gl!.uniform1f(uLightY, lightY()) gl!.drawArrays(gl!.TRIANGLES, 0, 3) if (running) rafId = requestAnimationFrame(frame) } @@ -199,6 +222,23 @@ else sync() }) + // When animating, the rAF loop re-anchors the light every frame. Under + // reduced motion we draw only a single frame, so redraw it on scroll so the + // anchored light still tracks the hero card. + let scrollPending = false + window.addEventListener( + "scroll", + () => { + if (running || !reduced || !darkMM.matches || scrollPending) return + scrollPending = true + requestAnimationFrame((now) => { + scrollPending = false + frame(now) + }) + }, + { passive: true }, + ) + sync() } diff --git a/site/src/components/Paint.astro b/site/src/components/Paint.astro new file mode 100644 index 0000000..ee13054 --- /dev/null +++ b/site/src/components/Paint.astro @@ -0,0 +1,471 @@ +--- +// Glossy candy-paint background, shared across the page. Raw WebGL + inlined +// GLSL (no dependency). A single hoisted script initializes EVERY `.paint` +// canvas on the page from its data-* config, so the hero card, the painted +// content cards, the primary button and the footer all share one shader. +// +// Modes: +// candy — hero: vertical candy gradient + metal flake (animated twinkle) +// + a broad top-down sheen. The full muscle-car paint job. +// matte — content cards: same paint, but the flake is toned down and +// static (no twinkle), rendered as a single frame. Cheap. +// clearcoat — button / footer: a flat base + sheen only. No flake, no +// gradient — just a glossy clear coat. +// +// Sized to its own box via ResizeObserver. Reduced motion / hidden tab / +// off-screen → paused. pointer-events:none, behind the content. No WebGL → it +// simply doesn't draw and the element's own background shows. +interface Props { + base?: string; + mode?: "candy" | "matte" | "clearcoat"; + class?: string; +} +const { base = "#c8334a", mode = "candy", class: cls = "" } = Astro.props; + +// flake · flake-animates · vertical gradient · sheen · coat-depth · loop +const CFG = { + candy: { flake: 1.0, anim: 1, gradient: 1, sheen: 0.32, coat: 0, animate: 1 }, + matte: { flake: 0.32, anim: 0, gradient: 1, sheen: 0.22, coat: 0, animate: 0 }, + clearcoat: { flake: 0.0, anim: 0, gradient: 0, sheen: 0.34, coat: 0, animate: 0 }, + // Polished metal: a strong top→bottom value ramp + bright sheen, no flake. + // Meant to be masked to a shape (see Spark.astro) for a chromed icon. + chrome: { flake: 0.0, anim: 0, gradient: 1, sheen: 0.6, coat: 0, animate: 0 }, + // Secondary islands: a clear coat with DEPTH — a smooth height field, shaded + // against the top light, reads as a thick translucent coating with broad + // soft reflections. No flake; subtle enough not to fight the copy. + coat: { flake: 0.0, anim: 0, gradient: 1, sheen: 0.24, coat: 1, animate: 0 }, +}[mode]; +--- + + + + + + diff --git a/site/src/components/Spark.astro b/site/src/components/Spark.astro new file mode 100644 index 0000000..d413681 --- /dev/null +++ b/site/src/components/Spark.astro @@ -0,0 +1,34 @@ +--- +// A chromed spark icon: a Paint canvas in "chrome" mode (polished-metal GLSL) +// masked to the four-point spark shape, so the star itself is a glossy metal +// reflection rather than a flat fill. Used on primary buttons. +import Paint from "./Paint.astro"; +interface Props { + class?: string; +} +const { class: cls = "" } = Astro.props; +--- + + + + diff --git a/site/src/components/markdoc/GetStarted.astro b/site/src/components/markdoc/GetStarted.astro index 9ce2eee..7b194eb 100644 --- a/site/src/components/markdoc/GetStarted.astro +++ b/site/src/components/markdoc/GetStarted.astro @@ -2,15 +2,18 @@ // Get started in 3 steps. Step 1 carries the actual download (macOS live, // Windows "coming soon"); the standalone download section folded in here. // Homebrew + other install options live in the repo README. +import Paint from "../Paint.astro"; +import Spark from "../Spark.astro"; import { getDownloadInfo } from "../../lib/downloads"; const { macDmg, versionLabel, releasesUrl, repoUrl } = await getDownloadInfo(); const readmeUrl = `${repoUrl}#readme`; +const M = `maximal`; const steps = [ { title: "Install maximal", - body: "Grab the macOS app — it lives quietly in your menu bar. Windows is coming soon.", + body: "Grab the macOS app. It lives quietly in your menu bar; Windows is coming soon.", }, { title: "Sign in with GitHub", @@ -18,16 +21,14 @@ const steps = [ }, { title: "Pick your tools", - body: "Claude Code, Codex, opencode, Cursor — point them at maximal and they run on your Copilot models. Switch anytime; no re-signing-in.", + body: `Point Claude Code, Codex, opencode, or GitHub Copilot at ${M} and they run on your Copilot models. Switch anytime; no re-signing-in.`, }, ]; --- -
+
+
-

Get started

Up and running in three steps.

@@ -55,15 +56,9 @@ const steps = [ href={macDmg} rel="noopener" > + - + macOS {versionLabel && {versionLabel}} @@ -112,16 +107,9 @@ const steps = [ margin-bottom: 1.75rem; } - .getstarted__spark { - width: 1.25rem; - height: 1.25rem; - fill: var(--accent-gold); - align-self: center; - } - .getstarted__sub { font-family: var(--sans); - color: var(--text-muted); + color: var(--text); margin: 0; flex-basis: 100%; } @@ -167,7 +155,7 @@ const steps = [ .step__text { font-family: var(--sans); - color: var(--text-muted); + color: var(--text); line-height: 1.55; margin: 0; } @@ -179,7 +167,7 @@ const steps = [ .step__readme { margin: 0.7rem 0 0; font-size: 0.85rem; - color: var(--text-muted); + color: var(--text); } @media (max-width: 640px) { diff --git a/site/src/components/markdoc/Hero.astro b/site/src/components/markdoc/Hero.astro index 4a9bff0..f437485 100644 --- a/site/src/components/markdoc/Hero.astro +++ b/site/src/components/markdoc/Hero.astro @@ -3,6 +3,8 @@ // typed in on load. The full tagline is server-rendered (SEO + no-JS + // prefers-reduced-motion all get the complete sentence); JS only replays it as // a typing effect when motion is allowed. +import Paint from "../Paint.astro"; +import Spark from "../Spark.astro"; import { getDownloadInfo } from "../../lib/downloads"; interface Props { tagline: string; @@ -11,10 +13,11 @@ interface Props { const { tagline, downloadLabel = "Download for macOS" } = Astro.props; // Markdoc doesn't forward $variables into tag attributes, so pull the resolved // release straight from the shared (memoized) build-time module. -const { macDmg: downloadUrl, releasesUrl } = await getDownloadInfo(); +const { macDmg: downloadUrl, versionLabel } = await getDownloadInfo(); ---
+

maximal

{tagline} + - + {downloadLabel} - - ) - } - { - releasesUrl && ( - - Other ways to install + {versionLabel && {versionLabel}} ) } @@ -56,9 +52,28 @@ const { macDmg: downloadUrl, releasesUrl } = await getDownloadInfo(); .hero::before { display: none; } + /* Lift the hero content above the paint canvas (which is positioned, so it + would otherwise paint over the non-positioned content). A soft shadow keeps + the cream type crisp over the moving gloss highlights. */ .wordmark, + .tagline, + .hero-cta { + position: relative; + z-index: 1; + } + /* Cream type, shaded as if lit from above the card: a top-bright gradient + fill on the wordmark + a soft shadow cast down onto the paint. */ + .wordmark { + background: linear-gradient(180deg, #fcf5e6 0%, #f1e5cb 55%, #d6c4a0 100%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; + filter: drop-shadow(0 5px 9px rgba(26, 0, 4, 0.5)); + } .tagline { color: #f4ead4; + text-shadow: 0 2px 5px rgba(26, 0, 4, 0.45); } .hero-typed { @@ -83,20 +98,10 @@ const { macDmg: downloadUrl, releasesUrl } = await getDownloadInfo(); margin: 1.5rem 0 1.25rem; } .hero-cta__primary { - /* Let the primary CTA size to its content rather than stretch. */ + /* Size to content rather than stretch. The glossy primary-button effect + (clear-coat Paint + lit label) is shared from global.css .btn--primary. */ flex: 0 0 auto; } - .hero-cta__secondary { - font-family: var(--mono); - font-size: 0.9rem; - color: #f4ead4; - text-decoration: underline; - text-underline-offset: 3px; - text-decoration-color: var(--ink-gold); - } - .hero-cta__secondary:hover { - text-decoration-thickness: 2px; - } @keyframes hero-caret-blink { 50% { @@ -148,7 +153,11 @@ const { macDmg: downloadUrl, releasesUrl } = await getDownloadInfo(); btn.classList.add("btn--soon"); btn.removeAttribute("href"); btn.setAttribute("aria-disabled", "true"); + var paint = btn.querySelector(".btn-paint"); + if (paint) paint.style.display = "none"; var label = btn.querySelector(".btn-label span:last-child"); - if (label) label.textContent = "Windows — coming soon"; + if (label) label.textContent = "Windows"; + var meta = btn.querySelector(".btn-meta"); + if (meta) meta.textContent = "coming soon"; })(); diff --git a/site/src/components/markdoc/ShowTell.astro b/site/src/components/markdoc/ShowTell.astro index 0141cfd..bf83e40 100644 --- a/site/src/components/markdoc/ShowTell.astro +++ b/site/src/components/markdoc/ShowTell.astro @@ -2,6 +2,7 @@ // "Show, don't tell": the tools you use } maximal { your Copilot models. // Labels sit in a row ABOVE the braces; the tall braces wrap only the lists // (gather → route → fan out). Brand marks aid scanning. Presentational. +import Paint from "../Paint.astro"; // Brand marks from Simple Icons (CC0). `fill: true` = solid logo whose path // carries its own fill="currentColor"; `fill: false` = a monoline stroke glyph. @@ -61,15 +62,15 @@ const BRACE = `M5 1C10 1 10 6 10 16L10 42C10 47 12 50 15 50C12 50 10 53 10 58L10 const shot = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/screenshots/app-dashboard.png`; --- -

+
+

Why try maximal

- maximal runs on your own machine. The tools you already use point at - maximal, and it does the work of connecting them to the models you want — - all on the GitHub Copilot subscription you already pay for, with Copilot's - privacy and security guarantees. No hosted gateway to wire up, no per-token - bill to track — none of what a service like OpenRouter asks of you. + maximal swaps out the engine your AI tools use + with the models that power GitHub Copilot. You get all of the agility and + creativity without sacrificing the power, privacy, and security of GitHub + Copilot.

@@ -161,7 +162,7 @@ const shot = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/screenshots/app-das
maximal's usage dashboard — token usage broken down by model across your tools over the last 30 days
- Every tool, every model, every token — see exactly where your Copilot + Every tool, every model, every token. See exactly where your Copilot subscription goes.
@@ -193,14 +194,17 @@ const shot = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/screenshots/app-das } /* Two rows: labels on top (row 1), then the braced lists + node (row 2), - so the braces wrap only the lists — not the labels. */ + so the braces wrap only the lists — not the labels. Columns are content- + sized and the whole group is centered, so each list hugs its brace and the + left/right blocks stay symmetric around the node (no stranded whitespace). */ .flow { display: grid; - grid-template-columns: minmax(0, 1fr) 22px auto 22px minmax(0, 1fr); + grid-template-columns: auto 24px auto 24px auto; grid-template-rows: auto 1fr; + justify-content: center; align-items: stretch; - column-gap: 1.25rem; - row-gap: 1rem; + column-gap: 1.5rem; + row-gap: 1.1rem; margin-top: 1.75rem; } @@ -213,6 +217,12 @@ const shot = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/screenshots/app-das color: var(--text); margin: 0; align-self: end; + justify-self: start; + } + /* The models label hugs the right block's left edge so it sits above its + first list item, mirroring the tools label on the left. */ + .flow__label--models { + justify-self: start; } .flow__label--tools { grid-column: 1; @@ -311,9 +321,14 @@ const shot = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/screenshots/app-das } .flow__mark { + /* Match the hero wordmark's cut exactly (weight + variable-font axes + + tracking), just smaller — so the island "maximal" reads as the same + wordmark, not a different font. */ font-family: var(--serif); - font-size: 2.2rem; - font-weight: 600; + font-size: 2.4rem; + font-weight: 900; + font-variation-settings: "SOFT" 30, "WONK" 1, "opsz" 144; + letter-spacing: -0.04em; color: var(--text); line-height: 1; margin: 0; diff --git a/site/src/content/landing/index.mdoc b/site/src/content/landing/index.mdoc index 50e2b55..26ea4d3 100644 --- a/site/src/content/landing/index.mdoc +++ b/site/src/content/landing/index.mdoc @@ -1,5 +1,5 @@ {% hero - tagline="Connect the AI tools you use to the models in your GitHub Copilot subscription." + tagline="Connect the AI tools you use to the models in GitHub Copilot." downloadLabel="Download for macOS" /%} {% showtell /%} diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index fd6daa4..5755615 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -28,10 +28,10 @@ const { Content } = await render(entry); href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght,SOFT,WONK@9..144,400..900,0..100,0..1&display=swap" /> - maximal — your AI tools, your Copilot models, one connection + maximal · your AI tools, your Copilot models, one connection - - @@ -116,5 +100,27 @@ const { Content } = await render(entry); }); })(); + + diff --git a/site/src/styles/global.css b/site/src/styles/global.css index fdb4070..56be1f0 100644 --- a/site/src/styles/global.css +++ b/site/src/styles/global.css @@ -203,14 +203,41 @@ main > article { --card-accent: var(--brand-ochre); } -/* Footer reads as a quiet base, not a section. */ -.card--console { - background: var(--surface); - color: var(--text-muted); +/* ===== Painted cards ===== + Cards that carry the candy-paint shader (a ) read as + saturated brand surfaces, like the hero. Drop the top accent rule, lift the + content above the canvas, and locally re-point the neutral text/border tokens + to a cream palette so copy stays legible on the deep brand base in both + themes. (--accent is left alone so the live download button keeps its fill.) */ +.card--painted { + --text: #f4ead4; + --text-muted: #d9ccad; + --border: rgb(244 234 212 / 0.18); + --border-strong: rgb(244 234 212 / 0.32); + --link: #f3d886; + --accent-gold: #f0d27a; + color: var(--text); + /* Subtle "lit from above" elevation so the cream copy lifts off the deep + brand base. Inherited by all descendant text. */ + text-shadow: 0 1px 2px rgb(8 16 22 / 0.38); } -.card--console::before { +.card--painted::before { display: none; } +.card--painted > .paint { + z-index: 0; +} +.card--painted > :not(.paint) { + position: relative; + z-index: 1; +} +/* Brand-tone fallback (and the silhouette behind the paint) if WebGL is off. */ +.card--turquoise.card--painted { + background: #1b6f7a; +} +.card--indigo.card--painted { + background: #43357f; +} /* ===== Hero ===== */ @@ -248,6 +275,16 @@ main > article { font-size: 1.19em; } +/* Inline brand wordmark for body copy: Fraunces bold at the running text size, + so "maximal" reads as the product name wherever it appears in a sentence. */ +.brandword { + font-family: var(--serif); + font-weight: 700; + font-style: normal; + font-variation-settings: "opsz" 40, "SOFT" 0, "WONK" 0; + letter-spacing: -0.01em; +} + .tagline { font-family: var(--serif); font-weight: 500; @@ -396,21 +433,103 @@ pre code { font-weight: 400; } +/* Primary button: one shared glossy clear-coat treatment used everywhere a + primary CTA appears (hero download, get-started download). A clear-coat Paint + canvas () sits behind a label that + is lit from above like the wordmark — top-bright gradient fill + soft cast + shadow. Fixed deep-teal base (matching the Paint) so the cream label keeps + contrast in both themes; the bg is also the no-WebGL fallback. */ .btn--primary { - background: var(--accent); - color: var(--accent-ink); - border-color: var(--accent); + position: relative; + overflow: hidden; + background: #15656f; + color: #fcf5e6; + border-color: #15656f; font-weight: 600; } .btn--primary:hover { - filter: brightness(1.06); - border-color: var(--accent); + filter: brightness(1.05); + border-color: #15656f; } +.btn--primary .btn-label, .btn--primary .btn-meta { - color: var(--accent-ink); - opacity: 0.8; + position: relative; + z-index: 1; +} + +/* Light the label text from above. The gradient can't tint the SVG (it uses + currentColor), so only the text span gets the clip; the spark stays solid. + A tight dark shadow plus a soft drop separates the cream label from the + glossy teal coat so it pops. */ +.btn--primary .btn-label > span:last-child { + background: linear-gradient(180deg, #ffffff 0%, #f2e6cb 58%, #d4c099 100%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; + filter: drop-shadow(0 1px 1px rgba(0, 14, 18, 0.7)) + drop-shadow(0 2px 5px rgba(0, 14, 18, 0.45)); +} + +.btn--primary .btn-spark { + color: #ffffff; + filter: drop-shadow(0 1px 1px rgba(0, 14, 18, 0.7)) + drop-shadow(0 2px 4px rgba(0, 14, 18, 0.4)); +} + +.btn--primary .btn-meta { + color: #f6efda; + opacity: 0.95; + filter: drop-shadow(0 1px 2px rgba(0, 14, 18, 0.5)); +} + +/* Click celebration: a soft light bloom from the click point + a tiny press. + Subtle and quick. JS sets --rx/--ry to the click coords and toggles + .btn--celebrate; skipped under prefers-reduced-motion (the JS bails). */ +.btn--primary::after { + content: ""; + position: absolute; + left: var(--rx, 50%); + top: var(--ry, 50%); + width: 10px; + height: 10px; + margin: -5px; + border-radius: 50%; + background: radial-gradient( + circle, + rgba(255, 255, 255, 0.6), + rgba(255, 255, 255, 0) + ); + transform: scale(0); + opacity: 0; + pointer-events: none; + z-index: 2; +} + +.btn--celebrate { + animation: btn-press 260ms ease; +} +.btn--celebrate::after { + animation: btn-bloom 560ms ease-out; +} + +@keyframes btn-press { + 40% { + transform: scale(0.975); + } +} + +@keyframes btn-bloom { + 0% { + transform: scale(0); + opacity: 0.7; + } + 100% { + transform: scale(20); + opacity: 0; + } } .btn-spark { @@ -452,23 +571,6 @@ pre code { pointer-events: none; } -/* ===== Footer ===== */ - -.site-footer { - display: flex; - flex-wrap: wrap; - gap: 0.5rem 1.5rem; - justify-content: space-between; - align-items: center; - font-size: 0.88rem; -} - -.site-footer a { - color: var(--link); - text-decoration: underline; - text-underline-offset: 3px; -} - /* ===== Bottom-pinned glass dock ===== */ .dock { diff --git a/src/configure-claude-desktop.ts b/src/configure-claude-desktop.ts index dba0e2e..1258ea6 100644 --- a/src/configure-claude-desktop.ts +++ b/src/configure-claude-desktop.ts @@ -46,12 +46,15 @@ const MANAGED_PROFILE_OUT = "maximal-claude-3p.mobileconfig" * Candidate Claude Desktop install locations to probe, per platform. * * - macOS: the app bundle in `/Applications`. - * - Windows: the per-user Squirrel install dir (`%LOCALAPPDATA%\\AnthropicClaude`) - * and the launcher alias the installer drops on PATH - * (`%LOCALAPPDATA%\\Microsoft\\WindowsApps\\Claude.exe`). Either present - * means Claude Desktop is installed. (The MSIX / Microsoft-Store build - * lives under `%LOCALAPPDATA%\\Packages\\Claude_*` — we don't enumerate - * that package-family dir here; `--force` covers it.) + * - Windows: the per-user Squirrel install dir (`%LOCALAPPDATA%\\AnthropicClaude`), + * the launcher alias the installer drops on PATH + * (`%LOCALAPPDATA%\\Microsoft\\WindowsApps\\Claude.exe`), and the MSIX / + * Microsoft-Store build's known package dir + * (`%LOCALAPPDATA%\\Packages\\Claude_pzs8sxrjxfjjc`). Any present means + * Claude Desktop is installed. New Windows installs are MSIX, which does + * NOT create the Squirrel dir or always drop the alias, so the Packages + * signal — plus the prefix scan in `claudeAppInstalled` — is what catches + * a modern install. * * Returns an empty list on unsupported platforms (caller treats that as * "can't tell" and only blocks on darwin/win32). @@ -62,16 +65,41 @@ function claudeAppCandidates( ): Array { if (platform === "darwin") return [CLAUDE_APP_PATH] if (platform === "win32") { - const localAppData = - process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local") + const localAppData = windowsLocalAppData(home) return [ path.join(localAppData, "AnthropicClaude"), path.join(localAppData, "Microsoft", "WindowsApps", "Claude.exe"), + path.join(localAppData, "Packages", "Claude_pzs8sxrjxfjjc"), ] } return [] } +/** `%LOCALAPPDATA%`, or its default location under `home` when unset. */ +function windowsLocalAppData(home: string): string { + return process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local") +} + +/** + * MSIX package family names mutate (`Claude_`, + * `AnthropicPBC.Claude_`), so an exact path can't catch every install. + * Scan `%LOCALAPPDATA%\\Packages` for any entry in the Claude family. No-ops + * (returns false) when the Packages dir is absent or unreadable. + */ +function windowsMsixClaudeInstalled(home: string): boolean { + const packages = path.join(windowsLocalAppData(home), "Packages") + try { + return fs + .readdirSync(packages) + .some( + (name) => + name.startsWith("Claude_") || name.startsWith("AnthropicPBC.Claude"), + ) + } catch { + return false + } +} + interface ConfigureOptions { force: boolean revert: boolean @@ -177,7 +205,7 @@ export function claudeAppInstalled( const candidates = claudeAppCandidates(platform, home) // Unsupported platform: nothing to probe → can't tell, don't block. if (candidates.length === 0) return true - return candidates.some((p) => { + const hasCandidate = candidates.some((p) => { try { // Accept either a directory (macOS .app bundle, Windows Squirrel dir) // or a file (Windows launcher alias) — any existing candidate counts. @@ -187,6 +215,11 @@ export function claudeAppInstalled( return false } }) + if (hasCandidate) return true + // Windows MSIX installs use a hashed package-family dir that no fixed path + // can pin down — scan the Packages dir for the Claude family as a fallback. + if (platform === "win32") return windowsMsixClaudeInstalled(home) + return false } export const configureClaudeDesktop = defineCommand({ diff --git a/src/lib/claude-desktop-3p-config.ts b/src/lib/claude-desktop-3p-config.ts index 3c0e7c2..3370de1 100644 --- a/src/lib/claude-desktop-3p-config.ts +++ b/src/lib/claude-desktop-3p-config.ts @@ -99,22 +99,32 @@ export function gatewayProfile( * where the Cowork-3P build actually reads it: * * - macOS: `~/Library/Application Support/Claude-3p` - * - Windows: `%APPDATA%\Claude-3p` (Electron's Windows userData base is - * Roaming AppData — NOT Local — so this is `…\Roaming\Claude-3p`). + * - Windows: `%LOCALAPPDATA%\Claude-3p` (Anthropic's docs locate the 3P + * `configLibrary` under Local AppData, NOT Roaming. Note this + * diverges from the consumer `claude_desktop_config.json`, which + * IS under Roaming `%APPDATA%\Claude` — on Windows the two halves + * live on different drives, unlike macOS where both share + * `~/Library/Application Support`.) * * The `configLibrary/` subdir (added by callers) is where the applied * inference profile lives; the historically-wrong target was the standard- * mode `…\Claude\claude_desktop_config.json` (no `-3p`), which the 3P build * ignores. `platform` is injectable so the Windows branch is unit-testable * on a POSIX host. + * + * NOTE: an MSIX/Store install virtualizes this under + * `%LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalState\Claude-3p`; this + * returns the plain `.exe`-install path (the common case). See + * docs/spec / the Claude-Desktop-3P memory for the MSIX wrinkle. */ export function getClaude3pDir( home: string = os.homedir(), platform: NodeJS.Platform = process.platform, ): string { if (platform === "win32") { - const appData = process.env.APPDATA ?? path.join(home, "AppData", "Roaming") - return path.join(appData, `Claude${USERDATA_3P_SUFFIX}`) + const localAppData = + process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local") + return path.join(localAppData, `Claude${USERDATA_3P_SUFFIX}`) } return path.join( home, diff --git a/src/routes/settings/apps.ts b/src/routes/settings/apps.ts index 4c05d44..7f2206b 100644 --- a/src/routes/settings/apps.ts +++ b/src/routes/settings/apps.ts @@ -16,8 +16,8 @@ import type { Context } from "hono" import { Hono } from "hono" -import fs from "node:fs" +import { claudeAppInstalled } from "~/configure-claude-desktop" import { type ClaudeInstall, detectClaudeInstalls, @@ -29,7 +29,6 @@ import { } from "~/lib/claude-code-settings" import { applyConfigLibraryProfile, - getClaude3pDir, isConfigLibraryApplied, revertConfigLibraryProfile, } from "~/lib/claude-desktop-3p-config" @@ -54,13 +53,6 @@ function httpError(message: string, status: number): HTTPError { return new HTTPError(message, new Response(message, { status })) } -/** Is Claude Desktop present? True when the macOS app bundle is installed - * or its third-party (Claude-3p) userData dir exists. */ -function claudeDesktopInstalled(): boolean { - if (fs.existsSync("/Applications/Claude.app")) return true - return fs.existsSync(getClaude3pDir()) -} - function buildClaudeCodeApp( precomputedInstalls?: ReadonlyArray, conflict: AppEntryT["conflict"] = null, @@ -92,7 +84,7 @@ function buildClaudeCodeApp( } function buildClaudeDesktopApp(): AppEntryT { - const installed = claudeDesktopInstalled() + const installed = claudeAppInstalled() const configured = isConfigLibraryApplied() return { id: "claude-desktop", diff --git a/tests/claude-desktop-3p-config.test.ts b/tests/claude-desktop-3p-config.test.ts index 94fb35f..91edcf4 100644 --- a/tests/claude-desktop-3p-config.test.ts +++ b/tests/claude-desktop-3p-config.test.ts @@ -50,35 +50,37 @@ function topConfig(): Record { } describe("getClaude3pDir — Windows (platform injected)", () => { - let savedAppData: string | undefined + let savedLocalAppData: string | undefined beforeEach(() => { - savedAppData = process.env.APPDATA + savedLocalAppData = process.env.LOCALAPPDATA }) afterEach(() => { - if (savedAppData === undefined) delete process.env.APPDATA - else process.env.APPDATA = savedAppData + if (savedLocalAppData === undefined) delete process.env.LOCALAPPDATA + else process.env.LOCALAPPDATA = savedLocalAppData }) - it("targets %APPDATA%/Claude-3p (the -3p suffix, in Roaming)", () => { - process.env.APPDATA = path.join(home, "AppData", "Roaming") + it("targets %LOCALAPPDATA%/Claude-3p (the -3p suffix, in Local — not Roaming)", () => { + process.env.LOCALAPPDATA = path.join(home, "AppData", "Local") const dir = getClaude3pDir(home, "win32") - // The -3p suffix is what the Cowork-3P build reads; the historically- - // wrong target was the standard-mode `…\Claude\` (no -3p suffix). - expect(dir).toBe(path.join(home, "AppData", "Roaming", "Claude-3p")) + // Anthropic's docs locate the 3P configLibrary under Local AppData, not + // Roaming. This diverges from the consumer `claude_desktop_config.json` + // (which IS under Roaming %APPDATA%\Claude) — on Windows the two halves + // live on different drives, unlike macOS. + expect(dir).toBe(path.join(home, "AppData", "Local", "Claude-3p")) expect(path.basename(dir)).toBe("Claude-3p") // configLibrary is the subdir the applied inference profile lands in. const lib = path.join(dir, "configLibrary") expect(lib).toBe( - path.join(home, "AppData", "Roaming", "Claude-3p", "configLibrary"), + path.join(home, "AppData", "Local", "Claude-3p", "configLibrary"), ) }) - it("falls back to ~/AppData/Roaming/Claude-3p when %APPDATA% is unset", () => { - delete process.env.APPDATA + it("falls back to ~/AppData/Local/Claude-3p when %LOCALAPPDATA% is unset", () => { + delete process.env.LOCALAPPDATA const dir = getClaude3pDir(home, "win32") - expect(dir).toBe(path.join(home, "AppData", "Roaming", "Claude-3p")) + expect(dir).toBe(path.join(home, "AppData", "Local", "Claude-3p")) }) it("still resolves the macOS path when platform is darwin", () => { diff --git a/tests/configure-claude-desktop.test.ts b/tests/configure-claude-desktop.test.ts index 4c37a50..8f30810 100644 --- a/tests/configure-claude-desktop.test.ts +++ b/tests/configure-claude-desktop.test.ts @@ -58,6 +58,44 @@ describe("claudeAppInstalled — Windows (platform injected)", () => { expect(claudeAppInstalled("win32", home)).toBe(true) }) + it("detects the MSIX package dir (Packages/Claude_pzs8sxrjxfjjc)", () => { + fs.mkdirSync( + path.join(home, "AppData", "Local", "Packages", "Claude_pzs8sxrjxfjjc"), + { recursive: true }, + ) + expect(claudeAppInstalled("win32", home)).toBe(true) + }) + + it("detects a Claude-family MSIX package by prefix (hashed family name)", () => { + // The publisher-hash suffix mutates between installs, so detection scans + // the Packages dir for the family prefix rather than an exact name. + fs.mkdirSync( + path.join( + home, + "AppData", + "Local", + "Packages", + "AnthropicPBC.Claude_1a2b3c4d5e6f7", + ), + { recursive: true }, + ) + expect(claudeAppInstalled("win32", home)).toBe(true) + }) + + it("does not false-positive on an unrelated Packages entry", () => { + fs.mkdirSync( + path.join( + home, + "AppData", + "Local", + "Packages", + "Microsoft.SomethingElse", + ), + { recursive: true }, + ) + expect(claudeAppInstalled("win32", home)).toBe(false) + }) + it("returns true on unsupported platforms (can't tell → don't block)", () => { expect(claudeAppInstalled("linux", home)).toBe(true) })