源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)

This commit is contained in:
agent-radar
2026-09-02 01:05:41 +08:00
commit 04bac1f6e2
2200 changed files with 489415 additions and 0 deletions
+499
View File
@@ -0,0 +1,499 @@
import type {
GrainGradientParams,
GrainGradientShape,
MeshGradientParams,
} from "@paper-design/shaders";
export type PaperMeshGradientConfig = Required<
Pick<
MeshGradientParams,
| "colors"
| "distortion"
| "swirl"
| "grainMixer"
| "grainOverlay"
| "speed"
| "frame"
>
>;
export type PaperGrainGradientConfig = Required<
Pick<
GrainGradientParams,
| "colorBack"
| "colors"
| "softness"
| "intensity"
| "noise"
| "shape"
| "speed"
| "frame"
>
>;
export type SeededPaperOption = {
seed?: string;
};
export const paperMeshGradientDefaults: PaperMeshGradientConfig = {
colors: ["#e0eaff", "#241d9a", "#f75092", "#9f50d3"],
distortion: 0.8,
swirl: 0.1,
grainMixer: 0,
grainOverlay: 0,
speed: 0.1,
frame: 0,
};
export const paperGrainGradientDefaults: PaperGrainGradientConfig = {
colors: ["#7300ff", "#eba8ff", "#00bfff", "#2b00ff"],
colorBack: "#000000",
softness: 0.5,
intensity: 0.5,
noise: 0.25,
shape: "ripple",
speed: 0.4,
frame: 0,
};
const grainShapes: GrainGradientShape[] = [
"corners",
"wave",
"dots",
"truchet",
"ripple",
"blob",
"sphere",
];
const meshPaletteFamilies = [
["#e0eaff", "#241d9a", "#f75092", "#9f50d3"],
["#ddfff5", "#006c67", "#35d8c0", "#8cff7a"],
["#ffe5c2", "#8a2500", "#ff7b39", "#ffd166"],
["#f5f7ff", "#0d1b52", "#3f8cff", "#00c2ff"],
["#fff2f2", "#6f1237", "#ff4d6d", "#ffb703"],
["#f0ffe1", "#254d00", "#8cc63f", "#00a76f"],
["#f5edff", "#44206b", "#b5179e", "#7209b7"],
["#f4f1ea", "#3a2f1f", "#927c55", "#d0c2a8"],
];
const grainPaletteFamilies = [
["#7300ff", "#eba8ff", "#00bfff", "#2b00ff"],
["#0df2c1", "#0b7cff", "#74efff", "#1a2cff"],
["#ff7a18", "#ffd166", "#ff4d6d", "#5f0f40"],
["#8dff6a", "#1f7a1f", "#d7ff70", "#00c48c"],
["#f6a6ff", "#7027c9", "#ff66c4", "#20115b"],
["#b9ecff", "#006494", "#00a6a6", "#072ac8"],
["#f7f0d6", "#8c5e34", "#d68c45", "#4e342e"],
["#ffd9f5", "#ff006e", "#8338ec", "#3a0ca3"],
];
const paletteModes = [
{
hueOffsets: [0, 22, 182, 238],
saturations: [0.92, 0.7, 0.84, 0.74],
lightnesses: [0.82, 0.28, 0.6, 0.5],
},
{
hueOffsets: [0, 118, 242, 304],
saturations: [0.88, 0.76, 0.82, 0.7],
lightnesses: [0.8, 0.42, 0.58, 0.48],
},
{
hueOffsets: [0, 44, 156, 214],
saturations: [0.94, 0.78, 0.86, 0.72],
lightnesses: [0.78, 0.4, 0.6, 0.46],
},
{
hueOffsets: [0, 76, 184, 326],
saturations: [0.86, 0.8, 0.78, 0.76],
lightnesses: [0.82, 0.52, 0.42, 0.58],
},
{
hueOffsets: [0, 140, 196, 224],
saturations: [0.84, 0.7, 0.76, 0.88],
lightnesses: [0.86, 0.46, 0.36, 0.54],
},
{
hueOffsets: [0, 162, 212, 342],
saturations: [0.9, 0.72, 0.8, 0.82],
lightnesses: [0.8, 0.38, 0.52, 0.56],
},
];
type MeshGradientOverrides = SeededPaperOption &
Partial<PaperMeshGradientConfig>;
type GrainGradientOverrides = SeededPaperOption &
Partial<PaperGrainGradientConfig>;
export function getSeededPaperMeshGradientConfig(
seed: string,
): PaperMeshGradientConfig {
const random = createRandom(seed, "mesh");
return {
colors: createSeededPalette(
paperMeshGradientDefaults.colors,
seed,
"mesh-colors",
{
families: meshPaletteFamilies,
hueShift: 42,
saturationShift: 0.18,
lightnessShift: 0.14,
baseBlend: [0.08, 0.2],
},
),
distortion: roundTo(clamp(0.58 + random() * 0.32, 0, 1), 3),
swirl: roundTo(clamp(0.03 + random() * 0.28, 0, 1), 3),
grainMixer: roundTo(clamp(random() * 0.18, 0, 1), 3),
grainOverlay: roundTo(clamp(random() * 0.12, 0, 1), 3),
speed: 0.5,
frame: Math.round(random() * 240000),
};
}
export function getSeededPaperGrainGradientConfig(
seed: string,
): PaperGrainGradientConfig {
const random = createRandom(seed, "grain");
const colors = createSeededPalette(
paperGrainGradientDefaults.colors,
seed,
"grain-colors",
{
families: grainPaletteFamilies,
hueShift: 58,
saturationShift: 0.22,
lightnessShift: 0.18,
baseBlend: [0.04, 0.14],
},
);
const anchorColor = colors[Math.floor(random() * colors.length)] ?? colors[0];
return {
colors,
colorBack: createSeededBackground(anchorColor, seed, "grain-background"),
softness: roundTo(clamp(0.22 + random() * 0.56, 0, 1), 3),
intensity: roundTo(clamp(0.2 + random() * 0.6, 0, 1), 3),
noise: roundTo(clamp(0.12 + random() * 0.34, 0, 1), 3),
shape:
grainShapes[Math.floor(random() * grainShapes.length)] ??
paperGrainGradientDefaults.shape,
speed: roundTo(0.2 + random() * 0.6, 3),
frame: Math.round(random() * 320000),
};
}
export function resolvePaperMeshGradientConfig(
options: MeshGradientOverrides = {},
): PaperMeshGradientConfig {
const seeded = options.seed
? getSeededPaperMeshGradientConfig(options.seed)
: paperMeshGradientDefaults;
return {
colors: options.colors ?? seeded.colors,
distortion: options.distortion ?? seeded.distortion,
swirl: options.swirl ?? seeded.swirl,
grainMixer: options.grainMixer ?? seeded.grainMixer,
grainOverlay: options.grainOverlay ?? seeded.grainOverlay,
speed: options.speed ?? seeded.speed,
frame: options.frame ?? seeded.frame,
};
}
export function resolvePaperGrainGradientConfig(
options: GrainGradientOverrides = {},
): PaperGrainGradientConfig {
const seeded = options.seed
? getSeededPaperGrainGradientConfig(options.seed)
: paperGrainGradientDefaults;
return {
colors: options.colors ?? seeded.colors,
colorBack: options.colorBack ?? seeded.colorBack,
softness: options.softness ?? seeded.softness,
intensity: options.intensity ?? seeded.intensity,
noise: options.noise ?? seeded.noise,
shape: options.shape ?? seeded.shape,
speed: options.speed ?? seeded.speed,
frame: options.frame ?? seeded.frame,
};
}
function buildSeedSource(seed: string) {
const trimmedSeed = seed.trim();
const separatorIndex = trimmedSeed.indexOf("_");
if (separatorIndex === -1) {
return trimmedSeed;
}
const prefix = trimmedSeed.slice(0, separatorIndex);
const suffix = trimmedSeed.slice(separatorIndex + 1);
const suffixTail = suffix.slice(5) || suffix;
return `${trimmedSeed}|${prefix}|${suffix}|${suffixTail}`;
}
function createSeededPalette(
baseColors: string[],
seed: string,
namespace: string,
options: {
families: string[][];
hueShift: number;
saturationShift: number;
lightnessShift: number;
baseBlend: [number, number];
},
) {
const familyRandom = createRandom(seed, `${namespace}:family`);
const primaryIndex = Math.floor(familyRandom() * options.families.length);
const secondaryOffset =
1 + Math.floor(familyRandom() * (options.families.length - 1));
const secondaryIndex =
(primaryIndex + secondaryOffset) % options.families.length;
const primary = options.families[primaryIndex] ?? baseColors;
const secondary =
options.families[secondaryIndex] ?? [...baseColors].reverse();
const primaryShift = Math.floor(familyRandom() * primary.length);
const secondaryShift = Math.floor(familyRandom() * secondary.length);
const paletteMode =
paletteModes[Math.floor(familyRandom() * paletteModes.length)] ??
paletteModes[0];
const baseHue = familyRandom() * 360;
return baseColors.map((color, index) => {
const random = createRandom(seed, `${namespace}:${index}`);
const primaryColor =
primary[(index + primaryShift) % primary.length] ?? color;
const secondaryColor =
secondary[(index + secondaryShift) % secondary.length] ?? primaryColor;
const proceduralColor = hslToHex(
(baseHue +
paletteMode.hueOffsets[index % paletteMode.hueOffsets.length] +
(random() * 2 - 1) * 18 +
360) %
360,
clamp(
paletteMode.saturations[index % paletteMode.saturations.length] +
(random() * 2 - 1) * 0.08,
0,
1,
),
clamp(
paletteMode.lightnesses[index % paletteMode.lightnesses.length] +
(random() * 2 - 1) * 0.08,
0,
1,
),
);
const mixedFamilyColor = mixHexColors(
primaryColor,
secondaryColor,
0.18 + random() * 0.64,
);
const remixedFamilyColor = mixHexColors(
mixedFamilyColor,
primary[(index + secondaryShift + 1) % primary.length] ??
mixedFamilyColor,
random() * 0.32,
);
const proceduralFamilyColor = mixHexColors(
proceduralColor,
remixedFamilyColor,
0.22 + random() * 0.34,
);
const [minBaseBlend, maxBaseBlend] = options.baseBlend;
const blendedBaseColor = mixHexColors(
proceduralFamilyColor,
color,
minBaseBlend + random() * (maxBaseBlend - minBaseBlend),
);
return adjustHexColor(blendedBaseColor, {
hueShift: (random() * 2 - 1) * options.hueShift + (random() * 2 - 1) * 14,
saturationShift: (random() * 2 - 1) * options.saturationShift + 0.06,
lightnessShift: (random() * 2 - 1) * options.lightnessShift,
});
});
}
function createSeededBackground(
baseColor: string,
seed: string,
namespace: string,
) {
const [red, green, blue] = hexToRgb(baseColor);
const [hue] = rgbToHsl(red, green, blue);
const random = createRandom(seed, namespace);
return hslToHex(
hue,
clamp(0.18 + random() * 0.18, 0, 1),
clamp(0.03 + random() * 0.09, 0, 1),
);
}
function adjustHexColor(
hex: string,
adjustments: {
hueShift: number;
saturationShift: number;
lightnessShift: number;
},
) {
const [red, green, blue] = hexToRgb(hex);
const [hue, saturation, lightness] = rgbToHsl(red, green, blue);
return hslToHex(
(hue + adjustments.hueShift + 360) % 360,
clamp(saturation + adjustments.saturationShift, 0, 1),
clamp(lightness + adjustments.lightnessShift, 0, 1),
);
}
function mixHexColors(colorA: string, colorB: string, amount: number) {
const [redA, greenA, blueA] = hexToRgb(colorA);
const [redB, greenB, blueB] = hexToRgb(colorB);
const mixAmount = clamp(amount, 0, 1);
return rgbToHex(
Math.round(redA + (redB - redA) * mixAmount),
Math.round(greenA + (greenB - greenA) * mixAmount),
Math.round(blueA + (blueB - blueA) * mixAmount),
);
}
function createRandom(seed: string, namespace: string) {
return mulberry32(hashString(`${buildSeedSource(seed)}::${namespace}`));
}
function hashString(input: string) {
let hash = 2166136261;
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function mulberry32(seed: number) {
return function nextRandom() {
let value = (seed += 0x6d2b79f5);
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function roundTo(value: number, precision: number) {
const power = 10 ** precision;
return Math.round(value * power) / power;
}
function hexToRgb(hex: string): [number, number, number] {
const normalized = hex.replace(/^#/, "");
const expanded =
normalized.length === 3
? normalized
.split("")
.map((part) => `${part}${part}`)
.join("")
: normalized;
if (expanded.length !== 6) {
throw new Error(`Unsupported hex color: ${hex}`);
}
const value = Number.parseInt(expanded, 16);
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
}
function rgbToHsl(
red: number,
green: number,
blue: number,
): [number, number, number] {
const normalizedRed = red / 255;
const normalizedGreen = green / 255;
const normalizedBlue = blue / 255;
const max = Math.max(normalizedRed, normalizedGreen, normalizedBlue);
const min = Math.min(normalizedRed, normalizedGreen, normalizedBlue);
const lightness = (max + min) / 2;
if (max === min) {
return [0, 0, lightness];
}
const delta = max - min;
const saturation =
lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
let hue = 0;
switch (max) {
case normalizedRed:
hue =
(normalizedGreen - normalizedBlue) / delta +
(normalizedGreen < normalizedBlue ? 6 : 0);
break;
case normalizedGreen:
hue = (normalizedBlue - normalizedRed) / delta + 2;
break;
default:
hue = (normalizedRed - normalizedGreen) / delta + 4;
break;
}
return [hue * 60, saturation, lightness];
}
function hslToHex(hue: number, saturation: number, lightness: number) {
if (saturation === 0) {
const value = Math.round(lightness * 255);
return rgbToHex(value, value, value);
}
const hueToRgb = (p: number, q: number, t: number) => {
let normalizedT = t;
if (normalizedT < 0) normalizedT += 1;
if (normalizedT > 1) normalizedT -= 1;
if (normalizedT < 1 / 6) return p + (q - p) * 6 * normalizedT;
if (normalizedT < 1 / 2) return q;
if (normalizedT < 2 / 3) return p + (q - p) * (2 / 3 - normalizedT) * 6;
return p;
};
const normalizedHue = hue / 360;
const q =
lightness < 0.5
? lightness * (1 + saturation)
: lightness + saturation - lightness * saturation;
const p = 2 * lightness - q;
const red = hueToRgb(p, q, normalizedHue + 1 / 3);
const green = hueToRgb(p, q, normalizedHue);
const blue = hueToRgb(p, q, normalizedHue - 1 / 3);
return rgbToHex(
Math.round(red * 255),
Math.round(green * 255),
Math.round(blue * 255),
);
}
function rgbToHex(red: number, green: number, blue: number) {
return `#${[red, green, blue]
.map((value) => value.toString(16).padStart(2, "0"))
.join("")}`;
}
@@ -0,0 +1,90 @@
"use client";
import { Dithering } from "@paper-design/shaders-react";
import { useSyncExternalStore, type ReactNode } from "react";
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
const widthClasses = {
compact: "max-w-md",
wide: "max-w-3xl",
full: "max-w-5xl",
} as const;
function subscribeToReducedMotion(onStoreChange: () => void) {
if (typeof window === "undefined") return () => {};
const mediaQuery = window.matchMedia(REDUCED_MOTION_QUERY);
mediaQuery.addEventListener("change", onStoreChange);
return () => mediaQuery.removeEventListener("change", onStoreChange);
}
function getReducedMotionSnapshot() {
return typeof window === "undefined"
? true
: window.matchMedia(REDUCED_MOTION_QUERY).matches;
}
export type DitheredOnboardingShellProps = {
children: ReactNode;
state: string;
width?: keyof typeof widthClasses;
rootTestId?: string;
backgroundTestId?: string;
foregroundTestId?: string;
};
/**
* Shared Den/desktop onboarding surface. Keep the fixed seed and palette in
* one place so the enterprise activation gate is visually identical to the
* pixel-dither onboarding experience that hands control to it.
*/
export function DitheredOnboardingShell({
children,
state,
width = "compact",
rootTestId = "join-org-root",
backgroundTestId = "join-org-background",
foregroundTestId = "join-org-foreground",
}: DitheredOnboardingShellProps) {
const reducedMotion = useSyncExternalStore(
subscribeToReducedMotion,
getReducedMotionSnapshot,
() => true,
);
const shaderSpeed = reducedMotion ? 0 : 0.012;
return (
<div
className="relative isolate min-h-dvh overflow-y-auto bg-[#f8fbff] px-4 py-8 text-slate-950 sm:py-12"
data-testid={rootTestId}
data-state={state}
>
<div
aria-hidden="true"
className="pointer-events-none fixed inset-0 z-0 overflow-hidden bg-[#f8fbff] opacity-[0.09]"
data-motion={shaderSpeed === 0 ? "reduced" : "ambient"}
data-shader-speed={shaderSpeed}
data-testid={backgroundTestId}
>
<Dithering
speed={shaderSpeed}
shape="warp"
type="4x4"
size={2.4}
scale={0.9}
frame={24017.6}
colorBack="#F8FBFF"
colorFront="#8FB7E8"
style={{ backgroundColor: "#F8FBFF", width: "100%", height: "100%" }}
/>
</div>
<main
className={`relative z-10 mx-auto flex min-h-[calc(100dvh-4rem)] w-full ${widthClasses[width]} flex-col justify-center sm:min-h-[calc(100dvh-6rem)]`}
data-testid={foregroundTestId}
>
{children}
</main>
</div>
);
}
+357
View File
@@ -0,0 +1,357 @@
"use client"
import { useEffect, useState, type ReactNode } from "react"
import { detectPlatform, type DetectedArch, type DetectedOS, type DetectedPlatform } from "./platform-detect"
export type DownloadCardInstallers = {
macos: { appleSilicon: string; intel: string }
windows: { x64: string; arm64: string }
linux: { appImageX64: string; appImageArm64: string; tarX64: string; tarArm64: string }
}
export type DownloadPlatformOption = {
href: string
label: string
arch?: DetectedArch
}
export type DownloadPlatformGroup = {
os: DetectedOS
title: string
options: DownloadPlatformOption[]
}
const FALLBACK_RELEASE = "https://github.com/different-ai/openwork/releases"
const FALLBACK_INSTALLERS: DownloadCardInstallers = {
macos: { appleSilicon: FALLBACK_RELEASE, intel: FALLBACK_RELEASE },
windows: { x64: FALLBACK_RELEASE, arm64: FALLBACK_RELEASE },
linux: {
appImageX64: FALLBACK_RELEASE,
appImageArm64: FALLBACK_RELEASE,
tarX64: FALLBACK_RELEASE,
tarArm64: FALLBACK_RELEASE,
},
}
function DownloadIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={className}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1={12} y1={15} x2={12} y2={3} />
</svg>
)
}
function MonitorIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={className}
>
<rect x={2} y={3} width={20} height={14} rx={2} />
<line x1={8} y1={21} x2={16} y2={21} />
<line x1={12} y1={17} x2={12} y2={21} />
</svg>
)
}
function PlatformIcon({ os, className }: { os: DetectedOS; className?: string }) {
if (os === "macos") {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
<path d="M16.7 12.9c0-2 1.6-3 1.7-3.1a3.7 3.7 0 0 0-2.9-1.6c-1.2-.1-2.4.7-3 .7-.6 0-1.5-.7-2.5-.7-1.3 0-2.6.8-3.3 2-1.4 2.5-.4 6.1 1 8.1.7 1 1.5 2.1 2.6 2 .9 0 1.4-.6 2.6-.6 1.2 0 1.6.6 2.6.6 1.1 0 1.8-1 2.5-2a8.8 8.8 0 0 0 1.1-2.3 3.5 3.5 0 0 1-2.4-3.1ZM14.7 7c.6-.8 1-1.9.9-3-.9 0-2 .6-2.7 1.4-.6.7-1.1 1.8-1 2.9 1 .1 2.1-.5 2.8-1.3Z" />
</svg>
)
}
if (os === "windows") {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
<path d="m3 5.2 7.4-1v7.1H3V5.2Zm8.4-1.1L21 2.8v8.5h-9.6V4.1ZM3 12.3h7.4v7.1l-7.4-1v-6.1Zm8.4 0H21v8.5l-9.6-1.3v-7.2Z" />
</svg>
)
}
// Tux from Font Awesome Free 6 brands (CC BY 4.0, fontawesome.com/license/free).
return (
<svg viewBox="0 0 448 512" fill="currentColor" aria-hidden="true" className={className}>
<path d="M220.8 123.3c1 .5 1.8 1.7 3 1.7 1.1 0 2.8-.4 2.9-1.5.2-1.4-1.9-2.3-3.2-2.9-1.7-.7-3.9-1-5.5-.1-.4.2-.8.7-.6 1.1.3 1.3 2.3 1.1 3.4 1.7zm-21.9 1.7c1.2 0 2-1.2 3-1.7 1.1-.6 3.1-.4 3.5-1.6.2-.4-.2-.9-.6-1.1-1.6-.9-3.8-.6-5.5.1-1.3.6-3.4 1.5-3.2 2.9.1 1 1.8 1.5 2.8 1.4zM420 403.8c-3.6-4-5.3-11.6-7.2-19.7-1.8-8.1-3.9-16.8-10.5-22.4-1.3-1.1-2.6-2.1-4-2.9-1.3-.8-2.7-1.5-4.1-2 9.2-27.3 5.6-54.5-3.7-79.1-11.4-30.1-31.3-56.4-46.5-74.4-17.1-21.5-33.7-41.9-33.4-72C311.1 85.4 315.7.1 234.8 0 132.4-.2 158 103.4 156.9 135.2c-1.7 23.4-6.4 41.8-22.5 64.7-18.9 22.5-45.5 58.8-58.1 96.7-6 17.9-8.8 36.1-6.2 53.3-6.5 5.8-11.4 14.7-16.6 20.2-4.2 4.3-10.3 5.9-17 8.3s-14 6-18.5 14.5c-2.1 3.9-2.8 8.1-2.8 12.4 0 3.9.6 7.9 1.2 11.8 1.2 8.1 2.5 15.7.8 20.8-5.2 14.4-5.9 24.4-2.2 31.7 3.8 7.3 11.4 10.5 20.1 12.3 17.3 3.6 40.8 2.7 59.3 12.5 19.8 10.4 39.9 14.1 55.9 10.4 11.6-2.6 21.1-9.6 25.9-20.2 12.5-.1 26.3-5.4 48.3-6.6 14.9-1.2 33.6 5.3 55.1 4.1.6 2.3 1.4 4.6 2.5 6.7v.1c8.3 16.7 23.8 24.3 40.3 23 16.6-1.3 34.1-11 48.3-27.9 13.6-16.4 36-23.2 50.9-32.2 7.4-4.5 13.4-10.1 13.9-18.3.4-8.2-4.4-17.3-15.5-29.7zM223.7 87.3c9.8-22.2 34.2-21.8 44-.4 6.5 14.2 3.6 30.9-4.3 40.4-1.6-.8-5.9-2.6-12.6-4.9 1.1-1.2 3.1-2.7 3.9-4.6 4.8-11.8-.2-27-9.1-27.3-7.3-.5-13.9 10.8-11.8 23-4.1-2-9.4-3.5-13-4.4-1-6.9-.3-14.6 2.9-21.8zM183 75.8c10.1 0 20.8 14.2 19.1 33.5-3.5 1-7.1 2.5-10.2 4.6 1.2-8.9-3.3-20.1-9.6-19.6-8.4.7-9.8 21.2-1.8 28.1 1 .8 1.9-.2-5.9 5.5-15.6-14.6-10.5-52.1 8.4-52.1zm-13.6 60.7c6.2-4.6 13.6-10 14.1-10.5 4.7-4.4 13.5-14.2 27.9-14.2 7.1 0 15.6 2.3 25.9 8.9 6.3 4.1 11.3 4.4 22.6 9.3 8.4 3.5 13.7 9.7 10.5 18.2-2.6 7.1-11 14.4-22.7 18.1-11.1 3.6-19.8 16-38.2 14.9-3.9-.2-7-1-9.6-2.1-8-3.5-12.2-10.4-20-15-8.6-4.8-13.2-10.4-14.7-15.3-1.4-4.9 0-9 4.2-12.3zm3.3 334c-2.7 35.1-43.9 34.4-75.3 18-29.9-15.8-68.6-6.5-76.5-21.9-2.4-4.7-2.4-12.7 2.6-26.4v-.2c2.4-7.6.6-16-.6-23.9-1.2-7.8-1.8-15 .9-20 3.5-6.7 8.5-9.1 14.8-11.3 10.3-3.7 11.8-3.4 19.6-9.9 5.5-5.7 9.5-12.9 14.3-18 5.1-5.5 10-8.1 17.7-6.9 8.1 1.2 15.1 6.8 21.9 16l19.6 35.6c9.5 19.9 43.1 48.4 41 68.9zm-1.4-25.9c-4.1-6.6-9.6-13.6-14.4-19.6 7.1 0 14.2-2.2 16.7-8.9 2.3-6.2 0-14.9-7.4-24.9-13.5-18.2-38.3-32.5-38.3-32.5-13.5-8.4-21.1-18.7-24.6-29.9s-3-23.3-.3-35.2c5.2-22.9 18.6-45.2 27.2-59.2 2.3-1.7.8 3.2-8.7 20.8-8.5 16.1-24.4 53.3-2.6 82.4.6-20.7 5.5-41.8 13.8-61.5 12-27.4 37.3-74.9 39.3-112.7 1.1.8 4.6 3.2 6.2 4.1 4.6 2.7 8.1 6.7 12.6 10.3 12.4 10 28.5 9.2 42.4 1.2 6.2-3.5 11.2-7.5 15.9-9 9.9-3.1 17.8-8.6 22.3-15 7.7 30.4 25.7 74.3 37.2 95.7 6.1 11.4 18.3 35.5 23.6 64.6 3.3-.1 7 .4 10.9 1.4 13.8-35.7-11.7-74.2-23.3-84.9-4.7-4.6-4.9-6.6-2.6-6.5 12.6 11.2 29.2 33.7 35.2 59 2.8 11.6 3.3 23.7.4 35.7 16.4 6.8 35.9 17.9 30.7 34.8-2.2-.1-3.2 0-4.2 0 3.2-10.1-3.9-17.6-22.8-26.1-19.6-8.6-36-8.6-38.3 12.5-12.1 4.2-18.3 14.7-21.4 27.3-2.8 11.2-3.6 24.7-4.4 39.9-.5 7.7-3.6 18-6.8 29-32.1 22.9-76.7 32.9-114.3 7.2zm257.4-11.5c-.9 16.8-41.2 19.9-63.2 46.5-13.2 15.7-29.4 24.4-43.6 25.5s-26.5-4.8-33.7-19.3c-4.7-11.1-2.4-23.1 1.1-36.3 3.7-14.2 9.2-28.8 9.9-40.6.8-15.2 1.7-28.5 4.2-38.7 2.6-10.3 6.6-17.2 13.7-21.1.3-.2.7-.3 1-.5.8 13.2 7.3 26.6 18.8 29.5 12.6 3.3 30.7-7.5 38.4-16.3 9-.3 15.7-.9 22.6 5.1 9.9 8.5 7.1 30.3 17.1 41.6 10.6 11.6 14 19.5 13.7 24.6zM173.3 148.7c2 1.9 4.7 4.5 8 7.1 6.6 5.2 15.8 10.6 27.3 10.6 11.6 0 22.5-5.9 31.8-10.8 4.9-2.6 10.9-7 14.8-10.4s5.9-6.3 3.1-6.6-2.6 2.6-6 5.1c-4.4 3.2-9.7 7.4-13.9 9.8-7.4 4.2-19.5 10.2-29.9 10.2s-18.7-4.8-24.9-9.7c-3.1-2.5-5.7-5-7.7-6.9-1.5-1.4-1.9-4.6-4.3-4.9-1.4-.1-1.8 3.7 1.7 6.5z" />
</svg>
)
}
function DownloadColumn({
os,
title,
detectedLabel,
showPlatformIcon,
flat,
children,
}: {
os: DetectedOS
title: string
detectedLabel: string | null
showPlatformIcon: boolean
flat: boolean
children: ReactNode
}) {
return (
<div className={flat ? "bg-transparent py-1" : "bg-white px-6 py-4"}>
<div className="flex items-center gap-2">
{showPlatformIcon ? (
<PlatformIcon os={os} className="h-4 w-4 text-[#07192C]" />
) : (
<MonitorIcon className="h-4 w-4 text-[#8A96AC]" />
)}
<span className="text-[13px] font-semibold text-[#07192C]">{title}</span>
{detectedLabel ? (
<span className="rounded-full bg-[#E5F5EA] px-1.5 py-px text-[10px] font-medium text-[#15803D]">{detectedLabel}</span>
) : null}
</div>
<div className="mt-3 flex flex-col gap-2">{children}</div>
</div>
)
}
function DownloadLink({
href,
children,
recommended,
testId,
openInNewTab,
keepLabelOnOneLine,
flat,
onDownload,
}: {
href: string
children: ReactNode
recommended?: boolean
testId?: string
openInNewTab: boolean
keepLabelOnOneLine: boolean
flat: boolean
onDownload?: () => void
}) {
return (
<a
href={href}
target={openInNewTab ? "_blank" : undefined}
rel={openInNewTab ? "noreferrer" : undefined}
data-testid={testId ?? "download-openwork-link"}
data-download-openwork-link="true"
data-recommended={recommended ? "true" : undefined}
onClick={onDownload}
className={
recommended
? `inline-flex items-center gap-2 ${flat ? "rounded-xl" : "rounded-lg"} border border-[#07192C] bg-[#07192C] px-3 py-2 text-[12px] font-medium text-white transition-colors hover:border-[#12283F] hover:bg-[#12283F]${keepLabelOnOneLine ? " whitespace-nowrap" : ""}`
: `inline-flex items-center gap-2 ${flat ? "rounded-xl border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50" : "rounded-lg border-[#DFE5EE] bg-[#F8FAFC] hover:border-[#C9D5E7] hover:bg-[#EEF4FC]"} border px-3 py-2 text-[12px] font-medium text-[#1C2B44] transition-colors${keepLabelOnOneLine ? " whitespace-nowrap" : ""}`
}
>
<DownloadIcon className={`h-3 w-3 shrink-0 ${recommended ? "text-white/70" : "text-[#5A6886]"}`} />
{children}
{recommended ? (
<span className="ml-auto shrink-0 whitespace-nowrap rounded-full bg-white/15 px-1.5 py-px text-[10px] font-medium text-white/90">
For your device
</span>
) : null}
</a>
)
}
function getArchLabel(os: DetectedOS, arch: DetectedArch): string {
if (os === "macos") return arch === "arm64" ? "Apple Silicon" : "Intel"
return arch === "arm64" ? "ARM64" : "x64"
}
function getDetectedLabel(detected: DetectedPlatform | null, os: DetectedOS): string | null {
if (!detected || detected.os !== os) return null
if (detected.arch === null) return "Detected"
return `Detected · ${getArchLabel(os, detected.arch)}`
}
function isRecommended(detected: DetectedPlatform | null, os: DetectedOS, arch?: DetectedArch): boolean {
return Boolean(arch && detected && detected.os === os && detected.arch === arch)
}
function useDetectedPlatform() {
const [detected, setDetected] = useState<DetectedPlatform | null>(null)
useEffect(() => {
let cancelled = false
void detectPlatform().then((platform) => {
if (!cancelled) setDetected(platform)
})
return () => {
cancelled = true
}
}, [])
return detected
}
function DownloadPlatformGridContent({
detected,
groups,
openInNewTab,
customCompanyPresentation,
flat,
recommendedTestId,
onDownload,
}: {
detected: DetectedPlatform | null
groups: DownloadPlatformGroup[]
openInNewTab: boolean
customCompanyPresentation: boolean
flat: boolean
recommendedTestId?: string
onDownload?: (option: DownloadPlatformOption) => void
}) {
return (
<div className={`grid ${flat ? "gap-x-10 gap-y-6 bg-transparent" : "gap-px border-t border-[#E9EDF3] bg-[#E9EDF3]"} ${customCompanyPresentation ? "lg:grid-cols-3" : "sm:grid-cols-3"}`}>
{groups.map((group) => (
<DownloadColumn
key={group.os}
os={group.os}
title={group.title}
detectedLabel={getDetectedLabel(detected, group.os)}
showPlatformIcon={customCompanyPresentation}
flat={flat}
>
{group.options.map((option) => (
<DownloadLink
key={`${option.label}-${option.href}`}
href={option.href}
recommended={isRecommended(detected, group.os, option.arch)}
testId={isRecommended(detected, group.os, option.arch) ? recommendedTestId : undefined}
openInNewTab={openInNewTab}
keepLabelOnOneLine={customCompanyPresentation}
flat={flat}
onDownload={onDownload ? () => onDownload(option) : undefined}
>
{option.label}
</DownloadLink>
))}
</DownloadColumn>
))}
</div>
)
}
export function DownloadPlatformGrid({
groups,
openInNewTab = false,
recommendedTestId,
onDownload,
variant = "card",
}: {
groups: DownloadPlatformGroup[]
openInNewTab?: boolean
recommendedTestId?: string
onDownload?: (option: DownloadPlatformOption) => void
variant?: "card" | "flat"
}) {
const detected = useDetectedPlatform()
return (
<div
data-testid="download-platform-grid"
data-detected-os={detected?.os}
data-detected-arch={detected ? detected.arch ?? "unknown" : undefined}
data-detection-source={detected?.source}
className={variant === "flat" ? "overflow-visible" : "overflow-hidden rounded-[14px] border border-[#E3E7EE]"}
>
<DownloadPlatformGridContent
detected={detected}
groups={groups}
openInNewTab={openInNewTab}
customCompanyPresentation
flat={variant === "flat"}
recommendedTestId={recommendedTestId}
onDownload={onDownload}
/>
</div>
)
}
function groupsForInstallers(installers: DownloadCardInstallers): DownloadPlatformGroup[] {
return [
{
os: "macos",
title: "macOS",
options: [
{ href: installers.macos.appleSilicon, label: "Apple Silicon (M1+)", arch: "arm64" },
{ href: installers.macos.intel, label: "Intel", arch: "x64" },
],
},
{
os: "windows",
title: "Windows",
options: [
{ href: installers.windows.x64, label: "x64 Installer", arch: "x64" },
{ href: installers.windows.arm64, label: "ARM64 Installer", arch: "arm64" },
],
},
{
os: "linux",
title: "Linux",
options: [
{ href: installers.linux.appImageX64, label: "AppImage (x64)", arch: "x64" },
{ href: installers.linux.appImageArm64, label: "AppImage (ARM64)", arch: "arm64" },
{ href: installers.linux.tarX64, label: "tar.gz (x64)" },
{ href: installers.linux.tarArm64, label: "tar.gz (ARM64)" },
],
},
]
}
export function DownloadOpenWorkCard({
installers,
releaseTag,
}: {
installers?: DownloadCardInstallers | null
releaseTag?: string
}) {
const detected = useDetectedPlatform()
const resolvedInstallers = installers ?? FALLBACK_INSTALLERS
const tag = releaseTag?.trim()
return (
<section
data-testid="download-openwork-card"
data-detected-os={detected?.os}
data-detected-arch={detected ? detected.arch ?? "unknown" : undefined}
data-detected-os-version={detected ? detected.osVersion ?? "unknown" : undefined}
data-detection-source={detected?.source}
className="overflow-hidden rounded-[18px] border border-[#E3E7EE] bg-white shadow-[0_24px_60px_-32px_rgba(7,25,44,0.22)]"
>
<div className="bg-gradient-to-b from-[#FAFBFE] to-white px-6 py-5">
<div className="flex items-center gap-2.5">
<DownloadIcon className="h-5 w-5 text-[#07192C]/70" />
<span className="text-[16px] font-semibold text-[#07192C]">Download OpenWork</span>
{tag ? (
<span className="rounded-full bg-[#F1F4F9] px-2 py-0.5 text-[11px] font-medium text-[#5A6886]">{tag}</span>
) : null}
</div>
<p className="mt-2 max-w-[520px] text-[13px] leading-[1.6] text-[#5A6886]">
Install the desktop app on macOS, Windows, or Linux. Your workspace connects automatically after sign-in.
</p>
</div>
<DownloadPlatformGridContent
detected={detected}
groups={groupsForInstallers(resolvedInstallers)}
openInNewTab
customCompanyPresentation={false}
flat={false}
/>
</section>
)
}
+27
View File
@@ -0,0 +1,27 @@
export {
getSeededPaperGrainGradientConfig,
getSeededPaperMeshGradientConfig,
paperGrainGradientDefaults,
paperMeshGradientDefaults,
resolvePaperGrainGradientConfig,
resolvePaperMeshGradientConfig,
} from "../common/paper"
export type {
PaperGrainGradientConfig,
PaperMeshGradientConfig,
SeededPaperOption,
} from "../common/paper"
export { PaperGrainGradient } from "./paper/grain-gradient"
export type { PaperGrainGradientProps } from "./paper/grain-gradient"
export { PaperMeshGradient } from "./paper/mesh-gradient"
export type { PaperMeshGradientProps } from "./paper/mesh-gradient"
export { StaticSeededGradient } from "./paper/static-seeded-gradient"
export type { StaticSeededGradientProps } from "./paper/static-seeded-gradient"
export { DownloadOpenWorkCard, DownloadPlatformGrid } from "./download-card"
export type { DownloadCardInstallers, DownloadPlatformGroup, DownloadPlatformOption } from "./download-card"
export { OpenWorkRoadmap, roadmapSections } from "./roadmap"
export type { RoadmapItem, RoadmapSection, RoadmapStatus } from "./roadmap"
export { detectPlatform } from "./platform-detect"
export type { DetectedArch, DetectedOS, DetectedPlatform } from "./platform-detect"
export { DitheredOnboardingShell } from "./dithered-onboarding-shell"
export type { DitheredOnboardingShellProps } from "./dithered-onboarding-shell"
@@ -0,0 +1,101 @@
"use client"
import {
defaultObjectSizing,
defaultPatternSizing,
type GrainGradientShape,
} from "@paper-design/shaders"
import { GrainGradient, type GrainGradientProps } from "@paper-design/shaders-react"
import { resolvePaperGrainGradientConfig } from "../../common/paper"
export interface PaperGrainGradientProps
extends Omit<
GrainGradientProps,
"colorBack" | "colors" | "softness" | "intensity" | "noise" | "shape" | "speed" | "frame"
> {
seed?: string
fill?: boolean
colorBack?: string
colors?: string[]
softness?: number
intensity?: number
noise?: number
shape?: GrainGradientProps["shape"]
speed?: number
frame?: number
}
export function PaperGrainGradient({
seed,
fill = true,
colorBack,
colors,
softness,
intensity,
noise,
shape,
speed,
frame,
fit,
rotation,
scale,
originX,
originY,
offsetX,
offsetY,
worldWidth,
worldHeight,
width,
height,
...props
}: PaperGrainGradientProps) {
const resolved = resolvePaperGrainGradientConfig({
seed,
colorBack,
colors,
softness,
intensity,
noise,
shape,
speed,
frame,
})
const sizingDefaults = getSizingDefaults(resolved.shape)
return (
<GrainGradient
{...props}
width={width ?? (fill ? "100%" : undefined)}
height={height ?? (fill ? "100%" : undefined)}
fit={fit ?? sizingDefaults.fit}
rotation={rotation ?? sizingDefaults.rotation}
scale={scale ?? sizingDefaults.scale}
originX={originX ?? sizingDefaults.originX}
originY={originY ?? sizingDefaults.originY}
offsetX={offsetX ?? sizingDefaults.offsetX}
offsetY={offsetY ?? sizingDefaults.offsetY}
worldWidth={worldWidth ?? sizingDefaults.worldWidth}
worldHeight={worldHeight ?? sizingDefaults.worldHeight}
colorBack={resolved.colorBack}
colors={resolved.colors}
softness={resolved.softness}
intensity={resolved.intensity}
noise={resolved.noise}
shape={resolved.shape}
speed={resolved.speed}
frame={resolved.frame}
/>
)
}
function getSizingDefaults(shape: GrainGradientShape) {
switch (shape) {
case "wave":
case "dots":
case "truchet":
return defaultPatternSizing
default:
return defaultObjectSizing
}
}
@@ -0,0 +1,61 @@
"use client"
import { MeshGradient, type MeshGradientProps } from "@paper-design/shaders-react"
import { resolvePaperMeshGradientConfig } from "../../common/paper"
export interface PaperMeshGradientProps
extends Omit<
MeshGradientProps,
"colors" | "distortion" | "swirl" | "grainMixer" | "grainOverlay" | "speed" | "frame"
> {
seed?: string
fill?: boolean
colors?: string[]
distortion?: number
swirl?: number
grainMixer?: number
grainOverlay?: number
speed?: number
frame?: number
}
export function PaperMeshGradient({
seed,
fill = true,
colors,
distortion,
swirl,
grainMixer,
grainOverlay,
speed,
frame,
width,
height,
...props
}: PaperMeshGradientProps) {
const resolved = resolvePaperMeshGradientConfig({
seed,
colors,
distortion,
swirl,
grainMixer,
grainOverlay,
speed,
frame,
})
return (
<MeshGradient
{...props}
width={width ?? (fill ? "100%" : undefined)}
height={height ?? (fill ? "100%" : undefined)}
colors={resolved.colors}
distortion={resolved.distortion}
swirl={resolved.swirl}
grainMixer={resolved.grainMixer}
grainOverlay={resolved.grainOverlay}
speed={resolved.speed}
frame={resolved.frame}
/>
)
}
@@ -0,0 +1,46 @@
import type { CSSProperties, HTMLAttributes } from "react"
import { getSeededPaperMeshGradientConfig } from "../../common/paper"
export interface StaticSeededGradientProps
extends HTMLAttributes<HTMLDivElement> {
seed: string
}
/**
* A CSS-only approximation of the seeded Paper mesh palette.
*
* Use this for repeated surfaces such as catalog rows. Unlike PaperMeshGradient,
* it creates no canvas or WebGL context, so large lists stay within browser GPU
* limits while retaining the same deterministic color identity.
*/
export function StaticSeededGradient({
seed,
className,
style,
...props
}: StaticSeededGradientProps) {
const { colors, frame } = getSeededPaperMeshGradientConfig(seed)
const [light, dark, accent, highlight] = colors
const angle = 115 + (frame % 55)
const backgroundStyle: CSSProperties = {
backgroundColor: light,
backgroundImage: [
`radial-gradient(circle at 18% 16%, ${light} 0%, transparent 52%)`,
`radial-gradient(circle at 82% 22%, ${accent} 0%, transparent 50%)`,
`radial-gradient(circle at 72% 88%, ${highlight} 0%, transparent 54%)`,
`linear-gradient(${angle}deg, ${dark} 0%, ${accent} 48%, ${highlight} 100%)`,
].join(", "),
...style,
}
return (
<div
{...props}
aria-hidden={props["aria-hidden"] ?? true}
className={className}
data-seed={seed}
data-static-paper-gradient=""
style={backgroundStyle}
/>
)
}
+105
View File
@@ -0,0 +1,105 @@
export type DetectedArch = "arm64" | "x64"
export type DetectedOS = "macos" | "windows" | "linux"
export type DetectedPlatform = {
os: DetectedOS
arch: DetectedArch | null
osVersion: string | null
source: "ua-ch" | "webgl" | "ua"
}
type UADataValues = { platform?: string; platformVersion?: string; architecture?: string; bitness?: string }
type NavigatorUAData = { getHighEntropyValues(hints: string[]): Promise<UADataValues> }
declare global {
interface Navigator {
userAgentData?: NavigatorUAData
}
}
function osFromUserAgent(userAgent: string): DetectedOS {
if (userAgent.includes("win")) return "windows"
if (userAgent.includes("linux") && !userAgent.includes("android")) return "linux"
return "macos"
}
function osFromPlatform(platform: string, fallback: DetectedOS): DetectedOS {
if (/win/i.test(platform)) return "windows"
if (/linux/i.test(platform)) return "linux"
if (/mac/i.test(platform)) return "macos"
return fallback
}
function archFromUAData(values: UADataValues): DetectedArch | null {
const architecture = values.architecture?.toLowerCase()
if (architecture === "arm") return "arm64"
if (architecture === "x86" && values.bitness === "64") return "x64"
return null
}
function majorVersion(platformVersion?: string): number | null {
const match = platformVersion?.match(/^\d+/)
if (!match) return null
return Number(match[0])
}
function osVersionFromUAData(os: DetectedOS, platformVersion?: string): string | null {
const major = majorVersion(platformVersion)
if (major === null) return null
if (os === "macos") return `macOS ${major}`
if (os === "windows") {
if (major >= 13) return "Windows 11"
if (major >= 1) return "Windows 10"
}
return null
}
function archFromRenderer(renderer: string): DetectedArch | null {
if (/apple/i.test(renderer)) return "arm64"
if (/intel|amd|radeon/i.test(renderer)) return "x64"
return null
}
function archFromUserAgent(userAgent: string): DetectedArch | null {
if (/aarch64|arm64/.test(userAgent)) return "arm64"
if (/x86_64|win64|x64/.test(userAgent)) return "x64"
return null
}
export async function detectPlatform(): Promise<DetectedPlatform | null> {
if (typeof navigator === "undefined") return null
let userAgent = ""
try {
userAgent = navigator.userAgent.toLowerCase()
} catch {}
const baselineOS = osFromUserAgent(userAgent)
try {
const userAgentData = navigator.userAgentData
if (userAgentData) {
const values = await userAgentData.getHighEntropyValues(["platform", "platformVersion", "architecture", "bitness"])
const os = values.platform ? osFromPlatform(values.platform, baselineOS) : baselineOS
return {
os,
arch: archFromUAData(values),
osVersion: osVersionFromUAData(os, values.platformVersion),
source: "ua-ch",
}
}
} catch {}
if (baselineOS === "macos") {
try {
if (typeof document !== "undefined") {
const canvas = document.createElement("canvas")
const gl = canvas.getContext("webgl")
const debugInfo = gl?.getExtension("WEBGL_debug_renderer_info")
const value: unknown = gl && debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : null
const renderer = typeof value === "string" ? value : ""
return { os: "macos", arch: archFromRenderer(renderer), osVersion: null, source: "webgl" }
}
} catch {}
}
return { os: baselineOS, arch: archFromUserAgent(userAgent), osVersion: null, source: "ua" }
}
+448
View File
@@ -0,0 +1,448 @@
export type RoadmapStatus = "live" | "partial" | "building" | "next" | "exploring"
export type RoadmapItem = {
title: string
description: string
status: RoadmapStatus
}
export type RoadmapSection = {
id: string
eyebrow: string
title: string
description: string
callout?: string
items: RoadmapItem[]
}
type StatusDetails = {
label: string
description: string
className: string
dotClassName: string
}
const statusDetails: Record<RoadmapStatus, StatusDetails> = {
live: {
label: "Live",
description: "Available today",
className: "border-emerald-200 bg-emerald-50 text-emerald-800",
dotClassName: "bg-emerald-500",
},
partial: {
label: "Partial",
description: "Supported with limitations",
className: "border-violet-200 bg-violet-50 text-violet-800",
dotClassName: "bg-violet-500",
},
building: {
label: "Building",
description: "Active work",
className: "border-blue-200 bg-blue-50 text-blue-800",
dotClassName: "bg-blue-500",
},
next: {
label: "Next",
description: "The next product horizon",
className: "border-amber-200 bg-amber-50 text-amber-800",
dotClassName: "bg-amber-500",
},
exploring: {
label: "Exploring",
description: "A direction, not a commitment",
className: "border-slate-200 bg-slate-50 text-slate-700",
dotClassName: "bg-slate-400",
},
}
export const roadmapSections: RoadmapSection[] = [
{
id: "desktop-home",
eyebrow: "01 · the home base",
title: "the desktop app is home",
description:
"The desktop app is the main OpenWork experience. It is where people work with files, run agents, manage sessions, create skills, connect services, and customize a workspace.",
callout:
"The desktop app is where most people configure OpenWork today. Connect is how that configuration travels.",
items: [
{
title: "Desktop app for macOS, Windows, and Linux",
description: "A complete local-first workspace for agentic work.",
status: "live",
},
{
title: "Local files and workspaces",
description: "Work directly with the files and repositories already on your computer.",
status: "live",
},
{
title: "Skills, plugins, MCPs, and connected services",
description: "Customize what your agent knows and which systems it can use.",
status: "live",
},
{
title: "Organization-managed capabilities",
description: "Share approved skills and connections without rebuilding every setup by hand.",
status: "live",
},
{
title: "Artifacts",
description: "Preview, edit, download, and reopen generated files without leaving the desktop workspace.",
status: "live",
},
{
title: "Live artifacts",
description: "Persistent, interactive dashboards that refresh with current data from your connected apps and files every time you open them.",
status: "next",
},
{
title: "Scheduled tasks",
description: "Run any prompt on a schedule or trigger — set it once and let it handle itself.",
status: "partial",
},
{
title: "Built-in browser control",
description: "Let agents navigate, click, type, and capture pages in a browser that stays visible inside the app.",
status: "live",
},
{
title: "Isolated sandbox workspaces",
description: "Run work in a separate Docker or microsandbox environment, with some platform and setup limitations today.",
status: "partial",
},
{
title: "Better organization for long-running work",
description: "Make active, waiting, and completed work easier to understand and return to.",
status: "building",
},
],
},
{
id: "setup-follows",
eyebrow: "02 · portable by design",
title: "your setup follows you",
description:
"What you configure in OpenWork should not stay trapped in one interface. OpenWork Connect brings the same capabilities into the agents you already use.",
items: [
{
title: "OpenWork Connect MCP",
description: "Search and run your assigned capabilities through one remote MCP connection.",
status: "live",
},
{
title: "Codex, Claude Code, Cursor, and OpenCode",
description: "Use OpenWork from compatible agents without rewriting skills or changing MCP servers.",
status: "live",
},
{
title: "Organization marketplaces and access controls",
description: "Publish capabilities once and decide which people and teams receive them.",
status: "live",
},
{
title: "Shared and per-user authentication",
description: "Admins can provide a shared connection or ask each member to sign in with their own account.",
status: "live",
},
{
title: "Git-based publishing and automatic sync",
description: "Keep shared capabilities aligned with the repositories where teams build them.",
status: "building",
},
],
},
{
id: "central-management",
eyebrow: "03 · central management",
title: "central management",
description:
"OpenWork Cloud is the control plane for distributing capabilities, applying desktop policies, managing identity and access, and understanding adoption across the organization.",
callout:
"Configure policies and access once in OpenWork Cloud. The desktop app and OpenWork Connect apply them for each member and team.",
items: [
{
title: "Desktop policies",
description: "Control custom providers, OpenCode Zen, workspaces, settings, extensions, built-in tools, and onboarding by organization, team, or member.",
status: "live",
},
{
title: "Members, teams, and roles",
description: "Invite people, organize teams, and decide who can manage capabilities and security settings.",
status: "live",
},
{
title: "Skills and plugin marketplaces",
description: "Publish skills, commands, MCP dependencies, and extensions once, then assign them to the right people and teams.",
status: "live",
},
{
title: "Anthropic-compatible plugins",
description: "Import Claude-compatible plugin and marketplace manifests and normalize their skills, MCPs, commands, and tools into OpenWork extensions.",
status: "live",
},
{
title: "SAML SSO",
description: "Connect an identity provider and provision organization members when they first sign in.",
status: "live",
},
{
title: "Usage and adoption telemetry",
description: "See active members, sessions, and task outcomes over time using event metadata, never prompts, code, or file contents.",
status: "live",
},
{
title: "OpenTelemetry coverage",
description: "Extend OTLP traces, metrics, and logs across OpenWork services and deployment paths.",
status: "building",
},
],
},
{
id: "hosted-workspaces",
eyebrow: "04 · work that stays on",
title: "a workspace that stays on",
description:
"Hosted workspaces give a person or team a persistent filesystem and a predictable environment in the cloud. Files, dependencies, repository state, and running work remain available after the laptop closes.",
callout:
"Start with the desktop app. Leave work running in a hosted workspace. Return from the desktop, Slack, mobile, or another surface.",
items: [
{
title: "Remote workspace connections",
description: "Open a compatible remote runtime through the normal desktop workspace flow.",
status: "live",
},
{
title: "Persistent hosted workspaces",
description: "Give every user or team a durable filesystem and agent environment in the cloud.",
status: "building",
},
{
title: "Reproducible environments",
description: "Provision known tools, dependencies, permissions, and workspace configuration every time.",
status: "building",
},
{
title: "Long-running and background tasks",
description: "Keep work moving without requiring the desktop app or laptop to remain open.",
status: "building",
},
{
title: "Scheduled workflows",
description: "Run recurring work against the same workspace, files, and authenticated services.",
status: "next",
},
{
title: "Continue from another surface",
description: "Move between interfaces without losing the workspace or the work already in progress.",
status: "next",
},
],
},
{
id: "every-surface",
eyebrow: "05 · meet people where they work",
title: "OpenWork on every surface",
description:
"The desktop app remains the richest OpenWork experience. Other surfaces provide focused ways to reach the same capabilities, permissions, workspaces, and history.",
items: [
{
title: "OpenWork desktop",
description: "The complete interface for creating, configuring, and doing work.",
status: "live",
},
{
title: "Existing AI agents through MCP",
description: "Bring OpenWork into the coding and agent tools you already use.",
status: "live",
},
{
title: "Slack",
description: "Start work, receive results, and return to the same workspace from a team channel or private chat.",
status: "next",
},
{
title: "Mobile",
description: "Check work, approve actions, and keep tasks moving away from your computer.",
status: "next",
},
{
title: "Dispatch from your phone",
description: "Assign a task from your phone and let it run on your desktop — with your local files, connections, and skills.",
status: "next",
},
{
title: "Email and messaging",
description: "Reach OpenWork from more of the places where requests and decisions arrive.",
status: "exploring",
},
{
title: "Custom organization agents",
description: "Build specialized surfaces on top of the same OpenWork workspace and capability system.",
status: "exploring",
},
],
},
{
id: "systems",
eyebrow: "06 · beyond a single conversation",
title: "systems, not just conversations",
description:
"Persistent environments and portable authentication make it possible to turn successful agent work into reliable systems that can run again.",
items: [
{
title: "Search and execute",
description: "Keep agent context small while making the full OpenWork capability catalog available on demand.",
status: "live",
},
{
title: "Authenticated multi-step execution",
description: "Combine multiple services and actions into one controlled server-side run.",
status: "building",
},
{
title: "Schedules and event triggers",
description: "Start work at a specific time or when something changes in a connected system.",
status: "next",
},
{
title: "Human approvals and resumable runs",
description: "Pause at sensitive steps, ask for a decision, and continue from the same state.",
status: "next",
},
{
title: "Retries, logs, and run history",
description: "Make repeated workflows observable and easier to operate when something fails.",
status: "next",
},
],
},
]
function StatusBadge({ status }: { status: RoadmapStatus }) {
const details = statusDetails[status]
return (
<span
className={`inline-flex shrink-0 items-center gap-2 rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] ${details.className}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${details.dotClassName}`} />
{details.label}
</span>
)
}
function RoadmapSectionBlock({ section }: { section: RoadmapSection }) {
return (
<section id={section.id} className="scroll-mt-24 border-t border-slate-200 py-14 md:py-20">
<div className="grid gap-8 lg:grid-cols-[0.8fr_1.2fr] lg:gap-16">
<div>
<div className="mb-4 text-[11px] font-semibold uppercase tracking-[0.22em] text-blue-600">{section.eyebrow}</div>
<h2 className="max-w-md text-3xl font-medium leading-[1.05] tracking-[-0.035em] text-[#011627] sm:text-4xl md:text-5xl">
{section.title}
</h2>
<p className="mt-5 max-w-lg text-base leading-7 text-slate-600">{section.description}</p>
{section.callout ? (
<p className="mt-6 max-w-lg border-l-2 border-blue-400 pl-4 text-sm font-medium leading-6 text-slate-700">
{section.callout}
</p>
) : null}
</div>
<div className="divide-y divide-slate-200 border-y border-slate-200">
{section.items.map((item) => (
<article key={item.title} className="py-5 first:pt-0 last:pb-0 md:py-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="pr-4">
<h3 className="text-base font-semibold tracking-tight text-[#011627] md:text-lg">{item.title}</h3>
<p className="mt-1.5 max-w-xl text-sm leading-6 text-slate-500">{item.description}</p>
</div>
<StatusBadge status={item.status} />
</div>
</article>
))}
</div>
</div>
</section>
)
}
export function OpenWorkRoadmap({
feedbackHref = "https://openworklabs.com/feedback?source=roadmap",
docsHref = "https://openworklabs.com/docs",
}: {
feedbackHref?: string
docsHref?: string
}) {
const statuses: RoadmapStatus[] = ["live", "partial", "building", "next", "exploring"]
return (
<div data-testid="openwork-roadmap" className="text-[#011627]">
<section className="pb-14 pt-10 md:pb-20 md:pt-16">
<div className="mb-7 flex flex-wrap items-center gap-3">
<span className="rounded-full border border-slate-200 bg-white px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-600 shadow-sm">
OpenWork roadmap
</span>
<span className="text-xs text-slate-500">Updated August 2026</span>
</div>
<h1 className="max-w-4xl text-5xl font-medium leading-[0.98] tracking-[-0.05em] text-[#011627] sm:text-6xl md:text-7xl lg:text-[5.5rem]">
your workspace,
<br />
<span className="text-blue-600">on every surface.</span>
</h1>
<div className="mt-8 grid gap-6 text-base leading-7 text-slate-600 md:grid-cols-2 md:text-lg md:leading-8">
<p>
Most people use OpenWork through the desktop app today. It is where you create a workspace, work with files, connect services, add skills, and customize how your agent works.
</p>
<p>
What you create there should not stay trapped there. OpenWork Connect already brings the same capabilities into compatible agents. Next come persistent hosted workspaces, Slack, mobile, and more.
</p>
</div>
<div className="mt-8 flex flex-wrap gap-x-5 gap-y-3 border-y border-slate-200 py-4">
{statuses.map((status) => {
const details = statusDetails[status]
return (
<div key={status} className="flex items-center gap-2 text-xs text-slate-500" title={details.description}>
<span className={`h-2 w-2 rounded-full ${details.dotClassName}`} />
<span className="font-semibold text-slate-700">{details.label}</span>
<span className="hidden sm:inline">{details.description}</span>
</div>
)
})}
</div>
</section>
<div className="mt-10 md:mt-16">
{roadmapSections.map((section) => (
<RoadmapSectionBlock key={section.id} section={section} />
))}
</div>
<section className="py-16 text-center md:py-24">
<div className="mx-auto max-w-2xl">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-blue-600">Help shape what comes next</div>
<h2 className="mt-4 text-3xl font-medium tracking-[-0.035em] sm:text-4xl md:text-5xl">what should OpenWork build next?</h2>
<p className="mx-auto mt-4 max-w-xl text-base leading-7 text-slate-600">
Tell us which workflow, workspace, or surface would make the biggest difference to how you work.
</p>
<div className="mt-7 flex flex-col justify-center gap-3 sm:flex-row">
<a
href={feedbackHref}
className="inline-flex min-h-12 items-center justify-center rounded-full bg-[#011627] px-6 text-sm font-medium text-white shadow-[0_14px_32px_-16px_rgba(1,22,39,0.55)] transition hover:-translate-y-0.5 hover:bg-[#102638]"
>
Share feedback
</a>
<a
href={docsHref}
className="inline-flex min-h-12 items-center justify-center rounded-full border border-slate-200 bg-white px-6 text-sm font-medium text-slate-700 shadow-sm transition hover:-translate-y-0.5 hover:border-slate-300"
>
Read the docs
</a>
</div>
</div>
</section>
</div>
)
}