源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user