源码快照 @ 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
+44
View File
@@ -0,0 +1,44 @@
# @openwork/ui
Shared UI primitives for OpenWork apps.
This package ships one entrypoint: `@openwork/ui/react`, used by `apps/app`
and `ee/apps/den-web`. (A Solid flavor existed during the Solid-to-React
migration and was removed once the last Solid consumer disappeared.)
Exports resolve to `src/` directly, so consumers need no build step.
## Paper components
The first shared components live under the `paper` namespace and wrap Paper Design shaders with OpenWork-specific defaults and deterministic seed support.
Current components:
- `PaperMeshGradient`
- `PaperGrainGradient`
- `OpenWorkRoadmap`
- `DownloadOpenWorkCard`
Both accept a `seed` prop. Pass a TypeID-like string such as `om_01kmhbscaze02vp04ykqa4tcsb` and the component will deterministically derive colors and shader params from it. The same seed always produces the same result.
Explicit props still work and override the seeded values, so the merge order is:
1. OpenWork defaults
2. Seed-derived values from `seed`
3. Explicit props passed by the caller
## Roadmap component
`OpenWorkRoadmap` is the shared visual roadmap used by the landing and docs
routes. Its typed sections are exported as `roadmapSections` so other OpenWork
surfaces can reuse the same source of truth.
## Layout convention
These components default to `fill={true}`, which means they render at `width: 100%` and `height: 100%`. Put them inside a sized container and they will fill it without needing manual width or height props.
## Agent notes
- Shared seed logic lives in `src/common/paper.ts`
- React wrappers live in `src/react/paper/*`
- Prefer extending the existing seed helpers instead of inventing per-app one-off shader configs
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@openwork/ui",
"private": true,
"type": "module",
"exports": {
"./react": {
"types": "./src/react/index.ts",
"development": "./src/react/index.ts",
"default": "./src/react/index.ts"
}
},
"files": [
"dist",
"src",
"README.md"
],
"scripts": {
"build": "tsup --config tsup.config.react.ts"
},
"dependencies": {
"@paper-design/shaders": "0.0.72",
"@paper-design/shaders-react": "0.0.72"
},
"peerDependencies": {
"react": "^18 || ^19"
},
"devDependencies": {
"@types/react": "^19.2.14",
"react": "catalog:",
"tsup": "^8.5.0",
"typescript": "^5.6.3"
}
}
+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>
)
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"jsx": "react-jsx"
},
"include": [
"src/common/**/*",
"src/react/**/*"
]
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from "tsup"
export default defineConfig({
entry: {
"react/index": "src/react/index.ts",
},
tsconfig: "./tsconfig.react.json",
format: ["esm"],
dts: {
tsconfig: "./tsconfig.react.json",
},
clean: true,
target: "es2022",
platform: "browser",
sourcemap: false,
splitting: false,
treeshake: true,
external: ["react", "react/jsx-runtime"],
esbuildOptions(options) {
options.jsx = "automatic"
options.jsxImportSource = "react"
},
})