Replatform OpenWork Share onto Next.js

Move the public share surface to a simple Next.js app while keeping the existing bundle API contracts intact. Extract the landing showcase cards into reusable references and tighten the design language around the grain-and-paper system they define.
This commit is contained in:
Benjamin Shafii
2026-03-08 14:32:19 -07:00
parent f2810054fc
commit 4dc7a6e959
23 changed files with 2062 additions and 488 deletions

View File

@@ -29,11 +29,23 @@ The goal is not generic SaaS polish. OpenWork should feel like calm, premium ope
- Default shell is airy and bright.
- Large surfaces should feel like frosted panels on top of a luminous field.
- Use blur, translucent white fills, and soft shadows.
- Treat grain as a presentational layer, not content.
- Treat paper as the readable layer that sits above the atmospheric field.
- Prefer rounded geometry everywhere:
- Large containers: `rounded-[2rem]` to `rounded-[2.5rem]`
- Medium cards: `rounded-2xl`
- Buttons/chips: `rounded-full`
### Grain and paper
- Grain belongs behind UI or inside isolated showcase canvases. It should never compete with text contrast or become the primary surface.
- Use grain to give the page atmosphere, depth, and a sense of motion; do not use it as a texture wash across every card.
- The canonical fixed-page grain treatment lives in `packages/landing/components/landing-background.tsx` and is powered by `packages/landing/components/responsive-grain.tsx`.
- Grain intensity should stay low on default page backgrounds; stronger color can appear only inside showcase moments like the enterprise canvas.
- Paper means the readable surface layer: white or warm off-white cards with enough opacity to feel stable over the grain field.
- The canonical paper shells live in `packages/landing/app/globals.css` via `.landing-shell`, `.landing-shell-soft`, and `.landing-chip`.
- Use `landing-shell` for primary frosted containers, `landing-shell-soft` for denser interior cards, and `landing-chip` for pills, filters, and lightweight app chrome.
### Shadows and borders
- Shadows should be soft, wide, and low-contrast.
@@ -190,6 +202,7 @@ The app is not a marketing page. It should feel more operational, more focused,
- Right rail: `~280px`
- Center canvas: fluid
- Reading width in the center should stay constrained enough for long-form scanning.
- Canonical landing-derived shell reference: `packages/landing/components/landing-app-demo-panel.tsx`.
### App background and surfaces
@@ -211,6 +224,7 @@ The app is not a marketing page. It should feel more operational, more focused,
- Rows should feel compact but touch-safe.
- Active session state should be obvious through fill/tint, not only text weight.
- Hover actions should appear progressively, not all at once.
- Use worker pills and compact metadata rows like the left column in `packages/landing/components/landing-app-demo-panel.tsx`.
### Center canvas rules
@@ -226,6 +240,7 @@ The app is not a marketing page. It should feel more operational, more focused,
- Workspace mode label above or inside composer.
- Agent/model/tool selectors appear as chips or compact menus.
- Send button should be a strong circular or pill blue affordance.
- The default visual reference for task + context + result composition is the composer block in `packages/landing/components/landing-app-demo-panel.tsx`.
### Right rail rules
@@ -267,6 +282,10 @@ The landing experience should feel like the same company as the app, but more ci
- open text-led sections
- frosted demonstration panels
- dense proof/feature blocks
- Canonical shell references:
- `packages/landing/components/landing-background.tsx`
- `packages/landing/components/responsive-grain.tsx`
- `packages/landing/app/globals.css`
### Landing navigation
@@ -295,6 +314,11 @@ The landing experience should feel like the same company as the app, but more ci
- clear active selection states
- visible task/result flow
- Use glass shells, white internals, and restrained gradients.
- Extract showcase UI into reusable components before duplicating it in new pages.
- Canonical app-interior reference: `packages/landing/components/landing-app-demo-panel.tsx`.
- Canonical share/package card reference: `packages/landing/components/landing-share-package-card.tsx`.
- Canonical cloud worker integration card reference: `packages/landing/components/landing-cloud-workers-card.tsx`.
- Source data for the rotating demo states lives in `packages/landing/components/landing-demo-flows.ts`.
### Landing cards
@@ -302,6 +326,8 @@ The landing experience should feel like the same company as the app, but more ci
- Each card should have a single idea.
- Use a tint or micro-illustration to separate categories.
- Headings are medium weight, not overly bold.
- Reuse the share/package card pattern in `packages/landing/components/landing-share-package-card.tsx` when you need a paper card that explains packaging, bundling, or share-link creation.
- Reuse the worker/integration card pattern in `packages/landing/components/landing-cloud-workers-card.tsx` when you need to show remote readiness, worker state pills, or connector actions like Slack.
### CTA rules
@@ -374,6 +400,8 @@ When building a landing enterprise page in this language, use this structure:
- Is the raster accent used sparingly and intentionally?
- Are large cards rounded, translucent, and softly shadowed?
- Can a user understand the value proposition from the hero + first proof section alone?
- Are grain and paper clearly separated so readable content never sits directly on top of a noisy effect?
- If a section references the app, does it borrow from the extracted canonical components instead of inventing a second shell language?
## Do / Don't
@@ -409,3 +437,8 @@ When creating a new OpenWork UI from scratch, implement in this order:
- Product guidance: `AGENTS.md`
- App session-surface reference: `packages/docs/orbita-layout-style.mdx`
- Product landing implementation target: `packages/landing`
- Landing background + grain reference: `packages/landing/components/landing-background.tsx`
- Landing shell tokens + button behavior: `packages/landing/app/globals.css`
- Canonical app demo interior: `packages/landing/components/landing-app-demo-panel.tsx`
- Canonical share card: `packages/landing/components/landing-share-package-card.tsx`
- Canonical cloud worker card: `packages/landing/components/landing-cloud-workers-card.tsx`

View File

@@ -0,0 +1,133 @@
import { ChevronRight } from "lucide-react";
import type { DemoFlow } from "./landing-demo-flows";
import { landingDemoFlowTimes } from "./landing-demo-flows";
type Props = {
flows: DemoFlow[];
activeFlowId: string;
onSelectFlow: (id: string) => void;
timesById?: Record<string, string>;
className?: string;
};
export function LandingAppDemoPanel(props: Props) {
const activeFlow = props.flows.find((flow) => flow.id === props.activeFlowId) ?? props.flows[0];
const timesById = props.timesById ?? landingDemoFlowTimes;
return (
<div className={["relative z-10 flex flex-col gap-4 md:flex-row", props.className].filter(Boolean).join(" ")}>
<div className="flex w-full flex-col gap-1 rounded-2xl border border-gray-100 bg-gray-50 p-2 md:w-1/3">
<div className="flex items-center justify-between rounded-xl bg-gray-100/90 p-3">
<div className="flex items-center gap-3">
<div className={`h-6 w-6 rounded-full ${activeFlow.activeAgent.color}`}></div>
<span className="text-sm font-medium">{activeFlow.activeAgent.name}</span>
</div>
<span className="text-xs text-gray-400">Active</span>
</div>
{activeFlow.agents.map((agent) => (
<div
key={agent.name}
className="flex cursor-pointer items-center justify-between rounded-xl p-3 transition-colors hover:bg-gray-50/80"
>
<div className="flex items-center gap-3">
<div className={`h-6 w-6 rounded-full ${agent.color}`}></div>
<span className="text-sm font-medium">{agent.name}</span>
</div>
<span className="text-xs text-gray-400">{agent.desc}</span>
</div>
))}
<div className="mt-4 px-1 pb-1">
<div className="relative flex flex-col gap-1 pl-3 before:absolute before:bottom-2 before:left-0 before:top-2 before:w-[2px] before:bg-gray-100 before:content-['']">
{props.flows.map((flow) => {
const isActive = flow.id === activeFlow.id;
return (
<button
key={flow.id}
type="button"
onClick={() => props.onSelectFlow(flow.id)}
className={`flex items-center justify-between rounded-xl px-3 py-2.5 text-left text-[13px] transition-colors ${
isActive ? "bg-gray-100/80" : "hover:bg-gray-50/80"
}`}
>
<span
className={`mr-2 truncate ${
isActive ? "font-medium text-gray-700" : "text-gray-600"
}`}
>
{flow.tabLabel}
</span>
<span className="whitespace-nowrap text-gray-400">
{timesById[flow.id] ?? "Now"}
</span>
</button>
);
})}
</div>
</div>
</div>
<div className="flex min-h-[400px] w-full flex-col overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm md:w-2/3">
<div className="flex flex-1 flex-col gap-6 overflow-y-auto p-6 text-[13px]">
{activeFlow.chatHistory.map((message, index) => {
if (message.role === "user") {
return (
<div
key={`${message.role}-${index}`}
className="mt-2 max-w-[85%] self-center rounded-3xl bg-gray-100/80 px-5 py-3 text-center text-gray-800"
>
{message.content}
</div>
);
}
if (message.role === "timeline") {
return (
<div
key={`${message.role}-${index}`}
className="ml-2 flex flex-col gap-3 text-xs text-gray-400"
>
{message.items.map((item) => (
<div key={item} className="flex items-center gap-2">
<ChevronRight size={10} className="text-gray-300" />
<span>{item}</span>
</div>
))}
</div>
);
}
return (
<div
key={`${message.role}-${index}`}
className="mb-2 ml-2 max-w-[95%] text-[13px] leading-relaxed text-gray-800"
>
{message.content}
</div>
);
})}
</div>
<div className="border-t border-white/50 bg-white/50 p-4">
<div className="mb-2 px-1 text-xs text-gray-400">Describe your task</div>
<div className="rounded-xl border border-gray-100 bg-white p-3.5 text-sm leading-relaxed text-[#011627] shadow-sm">
{activeFlow.task} <span className="text-gray-400">[task]</span> {activeFlow.context}{" "}
<span className="text-gray-400">[context]</span> {activeFlow.output}{" "}
<span className="text-gray-400">[result]</span>
</div>
<div className="mt-3 flex items-center justify-end px-1">
<button
type="button"
className="rounded-full bg-[#011627] px-6 py-2 text-xs font-medium text-white shadow-sm transition-colors hover:bg-black"
>
Run Task
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,78 @@
type Props = {
className?: string;
};
function SlackGlyph() {
return (
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden="true">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.528 2.528 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.523-2.523 2.526 2.526 0 0 1 2.52-2.52V21.48A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z" />
</svg>
);
}
export function LandingCloudWorkersCard(props: Props) {
return (
<div
className={[
"landing-shell flex w-full max-w-lg flex-col gap-6 rounded-[2rem] p-4 md:p-8",
props.className
]
.filter(Boolean)
.join(" ")}
>
<div className="landing-chip mb-2 flex w-fit items-center justify-between rounded-full p-1">
<button
type="button"
className="flex items-center gap-2 rounded-full border border-gray-100 bg-white px-4 py-2 text-sm font-medium shadow-sm"
>
<div className="h-3 w-3 rounded-full bg-[#f97316]"></div>
Cloud Workers
</button>
</div>
<div className="landing-shell-soft flex w-full flex-col gap-3 rounded-2xl p-2">
<div className="group relative cursor-pointer rounded-xl bg-gray-50/80 p-4">
<div className="mb-1 flex items-center justify-between">
<div className="text-[15px] font-medium text-[#011627] transition-colors group-hover:text-blue-600">
Founder Ops Pilot
</div>
<div className="flex items-center gap-1.5 rounded border border-green-100/50 bg-green-50 px-2 py-1">
<div className="h-1.5 w-1.5 rounded-full bg-green-500"></div>
<span className="text-[10px] font-bold tracking-wider text-green-700">READY</span>
</div>
</div>
<div className="mb-4 text-[13px] text-gray-500">Assists with operations and onboarding.</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="rounded-full bg-[#011627] px-4 py-2 text-center text-xs font-medium text-white shadow-sm transition-colors hover:bg-black"
>
Open in OpenWork
</button>
<button
type="button"
className="flex items-center gap-2 rounded-full border border-gray-200 bg-white px-4 py-2 text-center text-xs font-medium text-[#011627] shadow-sm transition-colors hover:bg-gray-50"
>
<SlackGlyph />
Connect to Slack
</button>
</div>
</div>
<div className="group relative cursor-pointer rounded-xl border border-transparent p-4 transition-colors hover:border-gray-100 hover:bg-gray-50/80">
<div className="mb-1 flex items-center justify-between">
<div className="text-[15px] font-medium text-[#011627] transition-colors group-hover:text-blue-600">
Marketing Copilot
</div>
<div className="flex items-center gap-1.5 rounded border border-gray-200 bg-gray-50 px-2 py-1">
<div className="h-1.5 w-1.5 rounded-full bg-gray-400"></div>
<span className="text-[10px] font-bold tracking-wider text-gray-500">OFFLINE</span>
</div>
</div>
<div className="text-[13px] text-gray-500">Creates draft campaigns from Notion docs.</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,197 @@
export type ChatMessage =
| {
role: "user" | "agent";
content: string;
}
| {
role: "timeline";
items: string[];
};
export type DemoFlow = {
id: string;
categoryLabel: string;
tabLabel: string;
title: string;
description: string;
activeAgent: { name: string; color: string };
agents: { name: string; desc: string; color: string }[];
task: string;
context: string;
output: string;
chatHistory: ChatMessage[];
};
export const landingDemoFlows: DemoFlow[] = [
{
id: "browser-automation",
categoryLabel: "Browser Automation",
tabLabel: "Like Twitter replies and export users",
title: "Like Twitter replies and export users",
description:
"Turn plain-language requests into browser actions across the tools your team already uses.",
activeAgent: {
name: "Browser Operator",
color: "bg-[#2463eb]"
},
agents: [
{
name: "Digital Twin",
desc: "Extended digital you",
color: "bg-[#4f6ee8]"
},
{
name: "Sales Inbound",
desc: "Qualifies leads",
color: "bg-[#f97316]"
},
{
name: "Personal",
desc: "Daily tasks",
color: "bg-[#0f9f7f]"
}
],
task:
'Open this Twitter thread, like all the replies, and extract the user details into a CSV file.',
context:
'Ensure you scroll through the entire thread to load all replies before interacting and extracting data.',
output: 'Save the extracted data as "tweet_replies.csv" on my desktop.',
chatHistory: [
{
role: "user",
content:
"Here is a link to a tweet: https://x.com/user/status/12345. Like all the replies and save the user details to a CSV on your computer."
},
{
role: "timeline",
items: [
"Execution timeline 1 step - Navigates to tweet URL in browser",
'Execution timeline 4 steps - Scrolls to load and clicks "like" on all 42 replies',
"Execution timeline 2 steps - Extracts usernames and bio data",
'Execution timeline 1 step - Writes tweet_replies.csv to local filesystem'
]
},
{
role: "agent",
content:
'I have successfully liked 42 replies on that thread and saved the user details to "tweet_replies.csv" on your computer. Is there anything else you need?'
}
]
},
{
id: "data-analysis",
categoryLabel: "Data Analysis",
tabLabel: "Summarize Q3 revenue outliers",
title: "Summarize Q3 revenue outliers",
description:
"Work from Excel files or pasted spreadsheets without changing how your team already shares data.",
activeAgent: {
name: "Excel Analyst",
color: "bg-[#0f9f7f]"
},
agents: [
{
name: "Digital Twin",
desc: "Extended digital you",
color: "bg-[#4f6ee8]"
},
{
name: "Sales Inbound",
desc: "Qualifies leads",
color: "bg-[#f97316]"
},
{
name: "Personal",
desc: "Daily tasks",
color: "bg-[#0f9f7f]"
}
],
task:
"Analyze this Excel model even if the user uploads the file or pastes the spreadsheet directly into chat.",
context:
"Flag the biggest changes in revenue, explain the outliers, and compare each segment against plan.",
output:
"Return the findings as a clean table plus a short executive summary.",
chatHistory: [
{
role: "user",
content: "Here is the Q3_Financials.xlsx file. Find the biggest outliers."
},
{
role: "timeline",
items: [
"Execution timeline 1 step - Reads Q3_Financials.xlsx",
"Execution timeline 2 steps - Parses sheets and normalizes data",
"Execution timeline 1 step - Runs statistical anomaly detection"
]
},
{
role: "agent",
content:
"I analyzed the spreadsheet and found 3 major outliers. The most significant is a 42% spike in marketing spend during August, which correlates with the new campaign launch."
}
]
},
{
id: "outreach-creation",
categoryLabel: "Outreach Creation",
tabLabel: "Draft follow-up for Acme Corp",
title: "Draft follow-up for Acme Corp",
description:
"Turn Notion MCP context into personalized outreach, then push the final result into your CRM.",
activeAgent: {
name: "Outreach Writer",
color: "bg-[#d97706]"
},
agents: [
{
name: "Digital Twin",
desc: "Extended digital you",
color: "bg-[#4f6ee8]"
},
{
name: "Sales Inbound",
desc: "Qualifies leads",
color: "bg-[#f97316]"
},
{
name: "Personal",
desc: "Daily tasks",
color: "bg-[#0f9f7f]"
}
],
task:
"Draft founder outreach from our Notion workspace, then save the final message and next step into HubSpot.",
context:
"Use the Notion MCP notes for tone, product context, and the last touchpoint before writing.",
output: "Create the email, update the CRM record, and queue the follow-up.",
chatHistory: [
{
role: "user",
content:
"Draft a follow-up to Acme Corp based on our last meeting notes in Notion."
},
{
role: "timeline",
items: [
'Execution timeline 1 step - Queries Notion MCP for "Acme Corp meeting notes"',
"Execution timeline 2 steps - Extracts action items and tone preferences",
"Execution timeline 1 step - Generates personalized email draft"
]
},
{
role: "agent",
content:
"I've drafted the follow-up email based on the action items from the Notion notes. It highlights the custom integration timeline we discussed. Would you like me to push this to HubSpot?"
}
]
}
];
export const landingDemoFlowTimes: Record<string, string> = {
"browser-automation": "1s ago",
"data-analysis": "15m ago",
"outreach-creation": "22h ago"
};
export const defaultLandingDemoFlowId = landingDemoFlows[0]?.id ?? "";

View File

@@ -2,204 +2,22 @@
import Link from "next/link";
import { AnimatePresence, motion, useInView } from "framer-motion";
import { Check, ChevronRight, Shield } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { LandingAppDemoPanel } from "./landing-app-demo-panel";
import { LandingBackground } from "./landing-background";
import { LandingCloudWorkersCard } from "./landing-cloud-workers-card";
import {
defaultLandingDemoFlowId,
landingDemoFlows,
landingDemoFlowTimes
} from "./landing-demo-flows";
import { LandingSharePackageCard } from "./landing-share-package-card";
import { SiteFooter } from "./site-footer";
import { SiteNav } from "./site-nav";
import { ResponsiveGrain } from "./responsive-grain";
import { WaitlistForm } from "./waitlist-form";
type ChatMessage =
| {
role: "user" | "agent";
content: string;
}
| {
role: "timeline";
items: string[];
};
type DemoFlow = {
id: string;
categoryLabel: string;
tabLabel: string;
title: string;
description: string;
activeAgent: { name: string; color: string };
agents: { name: string; desc: string; color: string }[];
task: string;
context: string;
output: string;
chatHistory: ChatMessage[];
};
const demoFlows: DemoFlow[] = [
{
id: "browser-automation",
categoryLabel: "Browser Automation",
tabLabel: "Like Twitter replies and export users",
title: "Like Twitter replies and export users",
description:
"Turn plain-language requests into browser actions across the tools your team already uses.",
activeAgent: {
name: "Browser Operator",
color: "bg-[#2463eb]"
},
agents: [
{
name: "Digital Twin",
desc: "Extended digital you",
color: "bg-[#4f6ee8]"
},
{
name: "Sales Inbound",
desc: "Qualifies leads",
color: "bg-[#f97316]"
},
{
name: "Personal",
desc: "Daily tasks",
color: "bg-[#0f9f7f]"
}
],
task:
'Open this Twitter thread, like all the replies, and extract the user details into a CSV file.',
context:
'Ensure you scroll through the entire thread to load all replies before interacting and extracting data.',
output: 'Save the extracted data as "tweet_replies.csv" on my desktop.',
chatHistory: [
{
role: "user",
content:
"Here is a link to a tweet: https://x.com/user/status/12345. Like all the replies and save the user details to a CSV on your computer."
},
{
role: "timeline",
items: [
"Execution timeline 1 step - Navigates to tweet URL in browser",
'Execution timeline 4 steps - Scrolls to load and clicks "like" on all 42 replies',
"Execution timeline 2 steps - Extracts usernames and bio data",
'Execution timeline 1 step - Writes tweet_replies.csv to local filesystem'
]
},
{
role: "agent",
content:
'I have successfully liked 42 replies on that thread and saved the user details to "tweet_replies.csv" on your computer. Is there anything else you need?'
}
]
},
{
id: "data-analysis",
categoryLabel: "Data Analysis",
tabLabel: "Summarize Q3 revenue outliers",
title: "Summarize Q3 revenue outliers",
description:
"Work from Excel files or pasted spreadsheets without changing how your team already shares data.",
activeAgent: {
name: "Excel Analyst",
color: "bg-[#0f9f7f]"
},
agents: [
{
name: "Digital Twin",
desc: "Extended digital you",
color: "bg-[#4f6ee8]"
},
{
name: "Sales Inbound",
desc: "Qualifies leads",
color: "bg-[#f97316]"
},
{
name: "Personal",
desc: "Daily tasks",
color: "bg-[#0f9f7f]"
}
],
task:
"Analyze this Excel model even if the user uploads the file or pastes the spreadsheet directly into chat.",
context:
"Flag the biggest changes in revenue, explain the outliers, and compare each segment against plan.",
output:
"Return the findings as a clean table plus a short executive summary.",
chatHistory: [
{
role: "user",
content: "Here is the Q3_Financials.xlsx file. Find the biggest outliers."
},
{
role: "timeline",
items: [
"Execution timeline 1 step - Reads Q3_Financials.xlsx",
"Execution timeline 2 steps - Parses sheets and normalizes data",
"Execution timeline 1 step - Runs statistical anomaly detection"
]
},
{
role: "agent",
content:
"I analyzed the spreadsheet and found 3 major outliers. The most significant is a 42% spike in marketing spend during August, which correlates with the new campaign launch."
}
]
},
{
id: "outreach-creation",
categoryLabel: "Outreach Creation",
tabLabel: "Draft follow-up for Acme Corp",
title: "Draft follow-up for Acme Corp",
description:
"Turn Notion MCP context into personalized outreach, then push the final result into your CRM.",
activeAgent: {
name: "Outreach Writer",
color: "bg-[#d97706]"
},
agents: [
{
name: "Digital Twin",
desc: "Extended digital you",
color: "bg-[#4f6ee8]"
},
{
name: "Sales Inbound",
desc: "Qualifies leads",
color: "bg-[#f97316]"
},
{
name: "Personal",
desc: "Daily tasks",
color: "bg-[#0f9f7f]"
}
],
task:
"Draft founder outreach from our Notion workspace, then save the final message and next step into HubSpot.",
context:
"Use the Notion MCP notes for tone, product context, and the last touchpoint before writing.",
output: "Create the email, update the CRM record, and queue the follow-up.",
chatHistory: [
{
role: "user",
content:
"Draft a follow-up to Acme Corp based on our last meeting notes in Notion."
},
{
role: "timeline",
items: [
'Execution timeline 1 step - Queries Notion MCP for "Acme Corp meeting notes"',
"Execution timeline 2 steps - Extracts action items and tone preferences",
"Execution timeline 1 step - Generates personalized email draft"
]
},
{
role: "agent",
content:
"I've drafted the follow-up email based on the action items from the Notion notes. It highlights the custom integration timeline we discussed. Would you like me to push this to HubSpot?"
}
]
}
];
type Props = {
stars: string;
downloadHref: string;
@@ -213,7 +31,7 @@ const externalLinkProps = (href: string) =>
: {};
export function LandingHome(props: Props) {
const [activeDemoId, setActiveDemoId] = useState(demoFlows[0].id);
const [activeDemoId, setActiveDemoId] = useState(defaultLandingDemoFlowId);
const [activeUseCase, setActiveUseCase] = useState(0);
const enterpriseShowcaseRef = useRef<HTMLElement>(null);
const showEnterpriseShowcase = useInView(enterpriseShowcaseRef, {
@@ -222,7 +40,7 @@ export function LandingHome(props: Props) {
});
const activeDemo = useMemo(
() => demoFlows.find((flow) => flow.id === activeDemoId) ?? demoFlows[0],
() => landingDemoFlows.find((flow) => flow.id === activeDemoId) ?? landingDemoFlows[0],
[activeDemoId]
);
@@ -336,140 +154,18 @@ export function LandingHome(props: Props) {
</div>
<div className="bg-white p-4 md:p-6">
<div className="relative z-10 flex flex-col gap-4 md:flex-row">
<div className="flex w-full flex-col gap-1 rounded-2xl border border-gray-100 bg-gray-50 p-2 md:w-1/3">
<div className="flex items-center justify-between rounded-xl bg-gray-100/90 p-3">
<div className="flex items-center gap-3">
<div
className={`h-6 w-6 rounded-full ${activeDemo.activeAgent.color}`}
></div>
<span className="text-sm font-medium">
{activeDemo.activeAgent.name}
</span>
</div>
<span className="text-xs text-gray-400">Active</span>
</div>
{activeDemo.agents.map((agent) => (
<div
key={agent.name}
className="flex cursor-pointer items-center justify-between rounded-xl p-3 transition-colors hover:bg-gray-50/80"
>
<div className="flex items-center gap-3">
<div
className={`h-6 w-6 rounded-full ${agent.color}`}
></div>
<span className="text-sm font-medium">{agent.name}</span>
</div>
<span className="text-xs text-gray-400">{agent.desc}</span>
</div>
))}
<div className="mt-4 px-1 pb-1">
<div className="relative flex flex-col gap-1 pl-3 before:absolute before:bottom-2 before:left-0 before:top-2 before:w-[2px] before:bg-gray-100 before:content-['']">
{demoFlows.map((flow, idx) => {
const isActive = flow.id === activeDemo.id;
const timeAgo =
idx === 0 ? "1s ago" : idx === 1 ? "15m ago" : "22h ago";
return (
<button
key={flow.id}
type="button"
onClick={() => setActiveDemoId(flow.id)}
className={`flex items-center justify-between rounded-xl px-3 py-2.5 text-left text-[13px] transition-colors ${
isActive ? "bg-gray-100/80" : "hover:bg-gray-50/80"
}`}
>
<span
className={`mr-2 truncate ${
isActive
? "font-medium text-gray-700"
: "text-gray-600"
}`}
>
{flow.tabLabel}
</span>
<span className="whitespace-nowrap text-gray-400">
{timeAgo}
</span>
</button>
);
})}
</div>
</div>
</div>
<div className="flex min-h-[400px] w-full flex-col overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm md:w-2/3">
<div className="flex flex-1 flex-col gap-6 overflow-y-auto p-6 text-[13px]">
{activeDemo.chatHistory.map((message, idx) => {
if (message.role === "user") {
return (
<div
key={idx}
className="mt-2 max-w-[85%] self-center rounded-3xl bg-gray-100/80 px-5 py-3 text-center text-gray-800"
>
{message.content}
</div>
);
}
if (message.role === "timeline") {
return (
<div
key={idx}
className="ml-2 flex flex-col gap-3 text-xs text-gray-400"
>
{message.items.map((item) => (
<div key={item} className="flex items-center gap-2">
<ChevronRight size={10} className="text-gray-300" />
<span>{item}</span>
</div>
))}
</div>
);
}
return (
<div
key={idx}
className="mb-2 ml-2 max-w-[95%] text-[13px] leading-relaxed text-gray-800"
>
{message.content}
</div>
);
})}
</div>
<div className="border-t border-white/50 bg-white/50 p-4">
<div className="mb-2 px-1 text-xs text-gray-400">
Describe your task
</div>
<div className="rounded-xl border border-gray-100 bg-white p-3.5 text-sm leading-relaxed text-[#011627] shadow-sm">
{activeDemo.task}{" "}
<span className="text-gray-400">[task]</span>{" "}
{activeDemo.context}{" "}
<span className="text-gray-400">[context]</span>{" "}
{activeDemo.output}{" "}
<span className="text-gray-400">[result]</span>
</div>
<div className="mt-3 flex items-center justify-end px-1">
<button
type="button"
className="rounded-full bg-[#011627] px-6 py-2 text-xs font-medium text-white shadow-sm transition-colors hover:bg-black"
>
Run Task
</button>
</div>
</div>
</div>
</div>
<LandingAppDemoPanel
flows={landingDemoFlows}
activeFlowId={activeDemo.id}
onSelectFlow={setActiveDemoId}
timesById={landingDemoFlowTimes}
/>
</div>
</div>
<div className="relative z-10 mb-4 flex w-full flex-col items-start justify-between gap-4 px-2 md:flex-row md:items-center">
<div className="landing-chip flex w-full flex-wrap gap-2 overflow-x-auto rounded-full p-1.5 md:w-[600px]">
{demoFlows.map((flow) => {
{landingDemoFlows.map((flow) => {
const isActive = flow.id === activeDemo.id;
return (
@@ -681,157 +377,9 @@ export function LandingHome(props: Props) {
transition={{ duration: 0.2 }}
className="z-10 flex w-full justify-center"
>
{activeUseCase === 0 ? (
<div className="landing-shell-soft flex w-full max-w-md flex-col gap-6 rounded-[2rem] p-6 text-center md:p-8">
<div>
<h3 className="text-xl font-semibold tracking-tight text-[#011627]">
Package Your Worker
</h3>
<p className="mt-1 text-sm text-gray-500">
Drag and drop skills, agents, or MCPs here to bundle
them.
</p>
</div>
{activeUseCase === 0 ? <LandingSharePackageCard /> : null}
<div className="group flex cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-gray-200 bg-gray-50/50 p-8 transition-colors hover:bg-gray-50">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 transition-transform group-hover:scale-105">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#1a44f2"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
</div>
<div className="text-[15px] font-medium text-[#011627]">
Drop OpenWork files here
</div>
<div className="mt-1 text-[13px] text-gray-400">
or click to browse local files
</div>
</div>
<div className="flex flex-col gap-2 text-left">
<div className="mb-1 px-1 text-xs font-bold uppercase tracking-wider text-gray-400">
Included
</div>
<div className="flex items-center gap-3 rounded-xl border border-gray-100 bg-white p-3 shadow-sm">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[#f97316] text-white">
<Shield size={16} />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-[#011627]">
Sales Inbound
</div>
<div className="text-[12px] text-gray-500">
Agent · v1.2.0
</div>
</div>
<Check size={16} className="shrink-0 text-green-500" />
</div>
</div>
<button
type="button"
className="mt-2 flex w-full items-center justify-center gap-2 rounded-xl bg-[#011627] py-3.5 text-[15px] font-medium text-white shadow-md transition-colors hover:bg-black"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
Generate Share Link
</button>
</div>
) : null}
{activeUseCase === 1 ? (
<div className="landing-shell flex w-full max-w-lg flex-col gap-6 rounded-[2rem] p-4 md:p-8">
<div className="landing-chip mb-2 flex w-fit items-center justify-between rounded-full p-1">
<button
type="button"
className="flex items-center gap-2 rounded-full border border-gray-100 bg-white px-4 py-2 text-sm font-medium shadow-sm"
>
<div className="h-3 w-3 rounded-full bg-[#f97316]"></div>
Cloud Workers
</button>
</div>
<div className="landing-shell-soft flex w-full flex-col gap-3 rounded-2xl p-2">
<div className="group relative cursor-pointer rounded-xl bg-gray-50/80 p-4">
<div className="mb-1 flex items-center justify-between">
<div className="text-[15px] font-medium text-[#011627] transition-colors group-hover:text-blue-600">
Founder Ops Pilot
</div>
<div className="flex items-center gap-1.5 rounded border border-green-100/50 bg-green-50 px-2 py-1">
<div className="h-1.5 w-1.5 rounded-full bg-green-500"></div>
<span className="text-[10px] font-bold tracking-wider text-green-700">
READY
</span>
</div>
</div>
<div className="mb-4 text-[13px] text-gray-500">
Assists with operations and onboarding.
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="rounded-full bg-[#011627] px-4 py-2 text-center text-xs font-medium text-white shadow-sm transition-colors hover:bg-black"
>
Open in OpenWork
</button>
<button
type="button"
className="flex items-center gap-2 rounded-full border border-gray-200 bg-white px-4 py-2 text-center text-xs font-medium text-[#011627] shadow-sm transition-colors hover:bg-gray-50"
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="currentColor"
>
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.528 2.528 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.523-2.523 2.526 2.526 0 0 1 2.52-2.52V21.48A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z" />
</svg>
Connect to Slack
</button>
</div>
</div>
<div className="group relative cursor-pointer rounded-xl border border-transparent p-4 transition-colors hover:border-gray-100 hover:bg-gray-50/80">
<div className="mb-1 flex items-center justify-between">
<div className="text-[15px] font-medium text-[#011627] transition-colors group-hover:text-blue-600">
Marketing Copilot
</div>
<div className="flex items-center gap-1.5 rounded border border-gray-200 bg-gray-50 px-2 py-1">
<div className="h-1.5 w-1.5 rounded-full bg-gray-400"></div>
<span className="text-[10px] font-bold tracking-wider text-gray-500">
OFFLINE
</span>
</div>
</div>
<div className="text-[13px] text-gray-500">
Creates draft campaigns from Notion docs.
</div>
</div>
</div>
</div>
) : null}
{activeUseCase === 1 ? <LandingCloudWorkersCard /> : null}
{activeUseCase === 2 ? (
<div className="landing-shell-soft flex h-[380px] w-full max-w-lg flex-col overflow-hidden rounded-2xl p-0">

View File

@@ -0,0 +1,88 @@
import { Check, Shield } from "lucide-react";
type Props = {
className?: string;
};
function LinkIcon() {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
);
}
export function LandingSharePackageCard(props: Props) {
return (
<div
className={[
"landing-shell-soft flex w-full max-w-md flex-col gap-6 rounded-[2rem] p-6 text-center md:p-8",
props.className
]
.filter(Boolean)
.join(" ")}
>
<div>
<h3 className="text-xl font-semibold tracking-tight text-[#011627]">Package Your Worker</h3>
<p className="mt-1 text-sm text-gray-500">
Drag and drop skills, agents, or MCPs here to bundle them.
</p>
</div>
<div className="group flex cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-gray-200 bg-gray-50/50 p-8 transition-colors hover:bg-gray-50">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-[#1a44f2] transition-transform group-hover:scale-105">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
</div>
<div className="text-[15px] font-medium text-[#011627]">Drop OpenWork files here</div>
<div className="mt-1 text-[13px] text-gray-400">or click to browse local files</div>
</div>
<div className="flex flex-col gap-2 text-left">
<div className="mb-1 px-1 text-xs font-bold uppercase tracking-wider text-gray-400">Included</div>
<div className="flex items-center gap-3 rounded-xl border border-gray-100 bg-white p-3 shadow-sm">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[#f97316] text-white">
<Shield size={16} />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-[#011627]">Sales Inbound</div>
<div className="text-[12px] text-gray-500">Agent · v1.2.0</div>
</div>
<Check size={16} className="shrink-0 text-green-500" />
</div>
</div>
<button
type="button"
className="mt-2 flex w-full items-center justify-center gap-2 rounded-xl bg-[#011627] py-3.5 text-[15px] font-medium text-white shadow-md transition-colors hover:bg-black"
>
<LinkIcon />
Generate Share Link
</button>
</div>
);
}

9
pnpm-lock.yaml generated
View File

@@ -340,6 +340,15 @@ importers:
jsonc-parser:
specifier: ^3.3.1
version: 3.3.1
next:
specifier: 14.2.5
version: 14.2.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react:
specifier: 18.2.0
version: 18.2.0
react-dom:
specifier: 18.2.0
version: 18.2.0(react@18.2.0)
ulid:
specifier: ^2.3.0
version: 2.4.0

View File

@@ -1,3 +1,4 @@
node_modules/
.next/
.vercel/
.vercel

View File

@@ -1,8 +1,8 @@
# OpenWork Share Service (Publisher)
This is a tiny publisher service for OpenWork "share link" bundles.
This is a Next.js publisher app for OpenWork "share link" bundles.
It is designed to be deployed on Vercel and backed by Vercel Blob.
It keeps the existing bundle APIs, but the public share surface now runs as a simple Next.js site backed by Vercel Blob.
## Endpoints
@@ -74,15 +74,24 @@ The packager rejects files that appear to contain secrets in shareable config.
## Local development
This repo is intended for Vercel deployment.
For local testing you can use:
```bash
cd services/openwork-share
pnpm install
vercel dev
pnpm --dir services/openwork-share dev
```
Open `http://localhost:3000`.
## Deploy
Recommended project settings:
- Root directory: `services/openwork-share`
- Framework preset: Next.js
- Build command: `pnpm --dir services/openwork-share build`
- Output directory: `.next`
## Tests
```bash

View File

@@ -0,0 +1,179 @@
import Head from "next/head";
import { useState } from "react";
import ShareNav from "./share-nav";
function toneClass(item) {
if (item?.tone === "agent") return "dot-agent";
if (item?.tone === "mcp") return "dot-mcp";
if (item?.tone === "command") return "dot-command";
return "dot-skill";
}
export default function ShareBundlePage(props) {
const [copyState, setCopyState] = useState("Copy share link");
const copyShareUrl = async () => {
if (!props.shareUrl) return;
try {
await navigator.clipboard.writeText(props.shareUrl);
setCopyState("Copied!");
window.setTimeout(() => setCopyState("Copy share link"), 2000);
} catch {
setCopyState("Copy failed");
window.setTimeout(() => setCopyState("Copy share link"), 2000);
}
};
const pageTitle = props.missing ? "Bundle not found - OpenWork Share" : `${props.title} - OpenWork Share`;
const pageDescription = props.missing
? "This share link does not exist anymore, or the bundle id is invalid."
: props.description;
return (
<>
<Head>
<title>{pageTitle}</title>
<meta name="description" content={pageDescription} />
<link rel="canonical" href={props.canonicalUrl} />
<meta property="og:type" content="website" />
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={pageDescription} />
<meta property="og:url" content={props.canonicalUrl} />
<meta property="og:image" content={props.ogImageUrl} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={pageDescription} />
<meta name="twitter:image" content={props.ogImageUrl} />
{props.missing ? null : (
<>
<meta name="openwork:bundle-id" content={props.id} />
<meta name="openwork:bundle-type" content={props.bundleType} />
<meta name="openwork:schema-version" content={props.schemaVersion} />
<meta name="openwork:open-in-app-url" content={props.openInAppDeepLink} />
<link rel="alternate" type="application/json" href={props.jsonUrl} />
</>
)}
</Head>
<main className="shell">
<ShareNav />
{props.missing ? (
<section className="status-card">
<span className="eyebrow">OpenWork Share</span>
<h1>Bundle not found</h1>
<p>
This share link does not exist anymore, or the bundle id is invalid.
</p>
<div className="hero-actions">
<a className="button-primary" href="/">
Package another worker
</a>
</div>
</section>
) : (
<>
<section className="hero-layout">
<div className="hero-copy">
<span className="eyebrow">{props.typeLabel}</span>
<h1>
{props.title} <em>ready</em>
</h1>
<p className="hero-body">{props.description}</p>
<div className="hero-actions">
<a className="button-primary" href={props.openInAppDeepLink}>
Open in app
</a>
<a className="button-secondary" href={props.openInWebAppUrl} target="_blank" rel="noreferrer">
Open in web app
</a>
</div>
<p className="hero-note">{props.installHint}</p>
</div>
<div className="hero-artifact">
<div className="app-window">
<div className="app-window-header">
<div className="mac-dots" aria-hidden="true">
<div className="mac-dot red"></div>
<div className="mac-dot yellow"></div>
<div className="mac-dot green"></div>
</div>
<div className="app-window-title">OpenWork</div>
</div>
<div className="app-window-body">
<div className="included-section">
<h4>Package contents</h4>
<div className="included-list">
{props.items.length ? (
props.items.map((item) => (
<div className="included-item" key={`${item.kind}-${item.name}`}>
<div className="item-left">
<div className={`item-dot ${toneClass(item)}`}></div>
<div>
<div className="item-title">{item.name}</div>
<div className="item-meta">
{item.kind} · {item.meta}
</div>
</div>
</div>
</div>
))
) : (
<div className="included-item">
<div className="item-left">
<div className="item-dot dot-skill"></div>
<div>
<div className="item-title">OpenWork bundle</div>
<div className="item-meta">Shared config</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</section>
<section className="results-grid">
<div className="result-card">
<h3>Bundle details</h3>
<p>Stable metadata for parsing and direct OpenWork import.</p>
<dl className="metadata-list">
{props.metadataRows.map((row) => (
<div className="metadata-row" key={row.label}>
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</div>
))}
</dl>
</div>
<div className="result-card">
<h3>Raw endpoints</h3>
<p>Keep the human page and machine payload side by side.</p>
<div className="url-stack">
<div className="url-box">
<a href={props.jsonUrl}>JSON payload</a>
</div>
<div className="url-box mono">{props.shareUrl}</div>
</div>
<div className="button-row">
<a className="button-secondary" href={props.downloadUrl}>
Download JSON
</a>
<button className="button-secondary" type="button" onClick={copyShareUrl}>
{copyState}
</button>
</div>
</div>
</section>
</>
)}
</main>
</>
);
}

View File

@@ -0,0 +1,395 @@
import { useEffect, useMemo, useRef, useState } from "react";
function toneClass(item) {
if (item?.tone === "agent") return "dot-agent";
if (item?.tone === "mcp") return "dot-mcp";
if (item?.tone === "command") return "dot-command";
return "dot-skill";
}
function buildVirtualEntry(content) {
return {
name: "SKILL.md",
path: ".opencode/skills/pasted-skill/SKILL.md",
async text() {
return String(content || "");
}
};
}
async function fileToPayload(file) {
return {
name: file.name,
path: file.relativePath || file.webkitRelativePath || file.path || file.name,
content: await file.text()
};
}
function flattenEntries(entry, prefix = "") {
return new Promise((resolve, reject) => {
if (entry?.isFile) {
entry.file(
(file) => {
file.relativePath = `${prefix}${file.name}`;
resolve([file]);
},
reject
);
return;
}
if (!entry?.isDirectory) {
resolve([]);
return;
}
const reader = entry.createReader();
const files = [];
const readBatch = () => {
reader.readEntries(
async (entries) => {
if (!entries.length) {
resolve(files);
return;
}
for (const child of entries) {
files.push(...(await flattenEntries(child, `${prefix}${entry.name}/`)));
}
readBatch();
},
reject
);
};
readBatch();
});
}
async function collectDroppedFiles(dataTransfer) {
const items = Array.from(dataTransfer?.items || []);
if (!items.length) return Array.from(dataTransfer?.files || []);
const collected = [];
for (const item of items) {
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
if (!entry) {
const file = item.getAsFile ? item.getAsFile() : null;
if (file) collected.push(file);
continue;
}
collected.push(...(await flattenEntries(entry)));
}
return collected;
}
export default function ShareHomeClient() {
const [selectedEntries, setSelectedEntries] = useState([]);
const [pasteValue, setPasteValue] = useState("");
const [preview, setPreview] = useState(null);
const [generatedUrl, setGeneratedUrl] = useState("");
const [warnings, setWarnings] = useState([]);
const [statusText, setStatusText] = useState("Nothing selected yet.");
const [busyMode, setBusyMode] = useState(null);
const [dropActive, setDropActive] = useState(false);
const [copyState, setCopyState] = useState("Copy link");
const [pasteState, setPasteState] = useState("Paste one skill and we will package it like a dropped file.");
const requestIdRef = useRef(0);
const hasPastedSkill = pasteValue.trim().length > 0;
const busy = busyMode !== null;
const effectiveEntries = useMemo(
() => (selectedEntries.length ? selectedEntries : hasPastedSkill ? [buildVirtualEntry(pasteValue.trim())] : []),
[selectedEntries, hasPastedSkill, pasteValue]
);
const pasteCountLabel = `${pasteValue.trim().length} ${pasteValue.trim().length === 1 ? "character" : "characters"}`;
const requestPackage = async (previewOnly) => {
const files = await Promise.all(effectiveEntries.map(fileToPayload));
const response = await fetch("/v1/package", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json"
},
body: JSON.stringify({ files, preview: previewOnly })
});
let json = null;
try {
json = await response.json();
} catch {
json = null;
}
if (!response.ok) {
throw new Error(json?.message || "Packaging failed.");
}
return json;
};
useEffect(() => {
if (!effectiveEntries.length) {
requestIdRef.current += 1;
setPreview(null);
setGeneratedUrl("");
setWarnings([]);
setBusyMode(null);
setStatusText("Nothing selected yet.");
return;
}
const currentRequestId = requestIdRef.current + 1;
requestIdRef.current = currentRequestId;
let cancelled = false;
setBusyMode("preview");
setStatusText("Reading files...");
void (async () => {
try {
const nextPreview = await requestPackage(true);
if (cancelled || requestIdRef.current !== currentRequestId) return;
setPreview(nextPreview);
setStatusText("Preview ready. Click Generate to publish.");
} catch (error) {
if (cancelled || requestIdRef.current !== currentRequestId) return;
setPreview(null);
setStatusText(error instanceof Error ? error.message : "Preview failed.");
} finally {
if (!cancelled && requestIdRef.current === currentRequestId) {
setBusyMode(null);
}
}
})();
return () => {
cancelled = true;
};
}, [effectiveEntries]);
const assignEntries = (files) => {
setSelectedEntries(Array.from(files || []).filter(Boolean));
setGeneratedUrl("");
setWarnings([]);
setCopyState("Copy link");
};
const handlePasteChange = (event) => {
setPasteValue(event.target.value);
setSelectedEntries([]);
setGeneratedUrl("");
setWarnings([]);
setCopyState("Copy link");
setPasteState(
event.target.value.trim()
? "Generate a link to preview and publish the pasted skill."
: "Paste one skill and we will package it like a dropped file."
);
};
const pasteFromClipboard = async () => {
if (!navigator.clipboard?.readText) {
setPasteState("Clipboard access is not available in this browser.");
return;
}
try {
const text = await navigator.clipboard.readText();
if (!text.trim()) {
setPasteState("Clipboard is empty.");
return;
}
setPasteValue(text);
setSelectedEntries([]);
setGeneratedUrl("");
setWarnings([]);
setCopyState("Copy link");
setPasteState("Clipboard pasted. Preview is ready.");
} catch {
setPasteState("Clipboard access was blocked. Paste manually into the field.");
}
};
const publishBundle = async () => {
if (!effectiveEntries.length || busy) return;
setBusyMode("publish");
setStatusText("Publishing...");
try {
const result = await requestPackage(false);
setPreview(result);
setWarnings(Array.isArray(result?.warnings) ? result.warnings : []);
setGeneratedUrl(typeof result?.url === "string" ? result.url : "");
setStatusText("Package published successfully!");
} catch (error) {
setStatusText(error instanceof Error ? error.message : "Publishing failed.");
} finally {
setBusyMode(null);
}
};
const copyGeneratedUrl = async () => {
if (!generatedUrl) return;
try {
await navigator.clipboard.writeText(generatedUrl);
setCopyState("Copied!");
window.setTimeout(() => setCopyState("Copy link"), 2000);
} catch {
setCopyState("Copy failed");
window.setTimeout(() => setCopyState("Copy link"), 2000);
}
};
return (
<>
<section className="hero-layout">
<div className="hero-copy">
<span className="eyebrow">OpenWork Share</span>
<h1>
Package your <em>worker</em>
</h1>
<p className="hero-body">
Drop skills, agents, commands, or MCP configs into a grain-and-paper share flow that feels like the OpenWork landing page, then publish one clean import link.
</p>
<p className="hero-note">Secrets stay out. The packager rejects configs that look unsafe to publish.</p>
</div>
<div className="hero-artifact">
<div className="simple-app">
<div className="simple-app-header">
<h2 className="simple-app-title">Create a share link</h2>
<p className="simple-app-copy">
Drop OpenWork files, preview the inferred bundle, then generate a public link.
</p>
</div>
<label
className={`drop-zone${dropActive ? " is-dragover" : ""}`}
aria-busy={busy ? "true" : "false"}
onDragEnter={(event) => {
event.preventDefault();
setDropActive(true);
}}
onDragOver={(event) => {
event.preventDefault();
setDropActive(true);
}}
onDragLeave={(event) => {
event.preventDefault();
setDropActive(false);
}}
onDrop={async (event) => {
event.preventDefault();
setDropActive(false);
if (busy) return;
const files = await collectDroppedFiles(event.dataTransfer);
assignEntries(files);
}}
>
<input
className="visually-hidden"
type="file"
multiple
onChange={(event) => assignEntries(event.target.files)}
/>
<div className="drop-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
</div>
<div className="drop-text">
<h3>Drop OpenWork files here</h3>
<p>or click to browse local files</p>
</div>
{preview?.items?.length ? (
<div className="included-section">
<h4>Included</h4>
<div className="included-list">
{preview.items.map((item) => (
<div className="included-item" key={`${item.kind}-${item.name}`}>
<div className="item-left">
<div className={`item-dot ${toneClass(item)}`}></div>
<div>
<div className="item-title">{item.name || "Unnamed item"}</div>
<div className="item-meta">{item.kind || "Item"}</div>
</div>
</div>
</div>
))}
</div>
</div>
) : null}
</label>
<div className="paste-panel">
<textarea
value={pasteValue}
onChange={handlePasteChange}
placeholder="Paste a full SKILL.md file here, including frontmatter and markdown instructions."
/>
<div className="paste-meta">
<span>{pasteState}</span>
<span>{pasteCountLabel}</span>
</div>
<div className="paste-actions">
<button className="button-secondary" type="button" onClick={pasteFromClipboard}>
Paste from clipboard
</button>
</div>
</div>
<button
className="button-primary"
type="button"
onClick={() => void publishBundle()}
disabled={busy || !effectiveEntries.length || !preview}
>
{busyMode === "publish" ? "Publishing..." : "Generate share link"}
</button>
<div className="status-area" data-busy={busy ? "true" : "false"}>
<span>{statusText}</span>
</div>
</div>
</div>
</section>
{generatedUrl ? (
<section className="results-grid">
<div className="result-card">
<h3>Share link ready</h3>
<p>Your worker package is published. Anyone with this link can import it directly into OpenWork.</p>
<div className="url-box">{generatedUrl}</div>
<div className="button-row">
<a className="button-primary" href={generatedUrl} target="_blank" rel="noreferrer">
Open share page
</a>
<button className="button-secondary" type="button" onClick={copyGeneratedUrl}>
{copyState}
</button>
</div>
</div>
<div className="result-card">
<h3>Warnings</h3>
<p>Review any files that were skipped.</p>
<ul className="warnings-list">
{warnings.length ? (
warnings.map((warning) => <li key={warning}>{warning}</li>)
) : (
<li className="warnings-empty">No warnings. Package is clean.</li>
)}
</ul>
</div>
</section>
) : null}
</>
);
}

View File

@@ -0,0 +1,42 @@
const OPENWORK_DOWNLOAD_URL = "https://openwork.software/download";
function GitHubMark() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg>
);
}
export default function ShareNav() {
return (
<nav className="nav">
<a className="brand" href="/" aria-label="OpenWork Share home">
<span className="brand-mark" aria-hidden="true"></span>
<span>openwork</span>
</a>
<div className="nav-links">
<a href="https://openwork.software/docs" target="_blank" rel="noreferrer">
Docs
</a>
<a href={OPENWORK_DOWNLOAD_URL} target="_blank" rel="noreferrer">
Download
</a>
<a href="https://openwork.software/enterprise" target="_blank" rel="noreferrer">
Enterprise
</a>
</div>
<div className="nav-actions">
<a
className="button-secondary"
href="https://github.com/different-ai/openwork"
target="_blank"
rel="noreferrer"
>
<GitHubMark />
GitHub
</a>
</div>
</nav>
);
}

View File

@@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
return [
{ source: "/health", destination: "/api/health" },
{ source: "/og/:path*", destination: "/api/og/:path*" },
{ source: "/v1/:path*", destination: "/api/v1/:path*" }
];
}
};
export default nextConfig;

View File

@@ -4,12 +4,17 @@
"version": "0.0.1",
"type": "module",
"scripts": {
"build": "node -e \"console.log('openwork-share: no build step')\"",
"dev": "next dev --hostname 0.0.0.0",
"build": "next build",
"start": "next start --hostname 0.0.0.0",
"test": "node --test api/b/render-bundle-page.test.js api/_lib/package-openwork-files.test.js"
},
"dependencies": {
"@vercel/blob": "^0.27.0",
"jsonc-parser": "^3.3.1",
"next": "14.2.5",
"react": "18.2.0",
"react-dom": "18.2.0",
"yaml": "^2.8.1",
"ulid": "^2.3.0"
}

View File

@@ -0,0 +1,5 @@
import "../styles/globals.css";
export default function ShareApp({ Component, pageProps }) {
return <Component {...pageProps} />;
}

View File

@@ -0,0 +1 @@
export { default } from "../../api/health.js";

View File

@@ -0,0 +1 @@
export { default } from "../../../api/og/[id].js";

View File

@@ -0,0 +1,7 @@
export const config = {
api: {
bodyParser: false
}
};
export { default } from "../../../api/v1/bundles.js";

View File

@@ -0,0 +1,7 @@
export const config = {
api: {
bodyParser: false
}
};
export { default } from "../../../api/v1/package.js";

View File

@@ -0,0 +1,119 @@
import { buildBundleNarrative, buildBundleUrls, buildOgImageUrl, buildOpenInAppUrls, collectBundleItems, getBundleCounts, humanizeType, parseBundle, wantsDownload, wantsJsonResponse } from "../../api/_lib/share-utils.js";
import { fetchBundleJsonById } from "../../api/_lib/blob-store.js";
import ShareBundlePage from "../../components/share-bundle-page";
function buildMetadataRows(id, bundle, counts, schemaVersion) {
return [
{ label: "ID", value: id },
{ label: "Type", value: bundle.type || "unknown" },
{ label: "Schema", value: schemaVersion },
...(counts.skillCount ? [{ label: "Skills", value: String(counts.skillCount) }] : []),
...(counts.agentCount ? [{ label: "Agents", value: String(counts.agentCount) }] : []),
...(counts.mcpCount ? [{ label: "MCPs", value: String(counts.mcpCount) }] : []),
...(counts.commandCount ? [{ label: "Commands", value: String(counts.commandCount) }] : []),
...(counts.hasConfig ? [{ label: "Config", value: "yes" }] : [])
];
}
export async function getServerSideProps(context) {
const { params, query, req, res } = context;
const id = String(params?.id ?? "").trim();
const requestLike = { query, headers: req.headers };
const serveJson = wantsJsonResponse(requestLike);
if (!id) {
if (serveJson) {
res.statusCode = 400;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ message: "id is required" }));
return { props: { responded: true } };
}
res.statusCode = 404;
return {
props: {
missing: true,
canonicalUrl: buildBundleUrls(req, "missing").shareUrl.replace(/\/b\/missing$/, "/"),
ogImageUrl: buildOgImageUrl(req, "root")
}
};
}
try {
const { blob, rawBuffer, rawJson } = await fetchBundleJsonById(id);
if (serveJson) {
res.setHeader("Vary", "Accept");
res.setHeader("Cache-Control", "public, max-age=3600");
res.setHeader("Content-Type", blob.contentType || "application/json");
if (wantsDownload(requestLike)) {
res.setHeader("Content-Disposition", `attachment; filename="openwork-bundle-${id}.json"`);
}
res.end(rawBuffer);
return { props: { responded: true } };
}
const bundle = parseBundle(rawJson);
const urls = buildBundleUrls(req, id);
const ogImageUrl = buildOgImageUrl(req, id);
const { openInAppDeepLink, openInWebAppUrl } = buildOpenInAppUrls(urls.shareUrl, {
label: bundle.name || "Shared worker package"
});
const counts = getBundleCounts(bundle);
const schemaVersion = bundle.schemaVersion == null ? "unknown" : String(bundle.schemaVersion);
const typeLabel = humanizeType(bundle.type);
const title = bundle.name || `OpenWork ${typeLabel}`;
const description = bundle.description || buildBundleNarrative(bundle);
const installHint =
bundle.type === "skill"
? "Open in app to create a new worker and install this skill in one step."
: bundle.type === "skills-set"
? "Open in app to create a new worker with this entire skills set already attached."
: "Open in app to create a new worker with these skills, agents, MCPs, and config already bundled.";
return {
props: {
responded: false,
missing: false,
id,
title,
description,
canonicalUrl: urls.shareUrl,
shareUrl: urls.shareUrl,
jsonUrl: urls.jsonUrl,
downloadUrl: urls.downloadUrl,
ogImageUrl,
openInAppDeepLink,
openInWebAppUrl,
installHint,
bundleType: bundle.type || "unknown",
typeLabel,
schemaVersion,
items: collectBundleItems(bundle, 8),
metadataRows: buildMetadataRows(id, bundle, counts, schemaVersion)
}
};
} catch {
if (serveJson) {
res.statusCode = 404;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ message: "Not found" }));
return { props: { responded: true } };
}
res.statusCode = 404;
return {
props: {
responded: false,
missing: true,
canonicalUrl: buildBundleUrls(req, id).shareUrl,
ogImageUrl: buildOgImageUrl(req, "root")
}
};
}
}
export default function BundlePage(props) {
if (props.responded) return null;
return <ShareBundlePage {...props} />;
}

View File

@@ -0,0 +1,42 @@
import Head from "next/head";
import ShareHomeClient from "../components/share-home-client";
import ShareNav from "../components/share-nav";
import { DEFAULT_PUBLIC_BASE_URL } from "../api/_lib/share-utils.js";
const rootOgImageUrl = `${DEFAULT_PUBLIC_BASE_URL}/og/root`;
export default function ShareHomePage() {
return (
<>
<Head>
<title>Package Your Worker - OpenWork Share</title>
<meta
name="description"
content="Drag and drop OpenWork skills, agents, commands, or MCP config to publish a shareable worker package."
/>
<link rel="canonical" href={DEFAULT_PUBLIC_BASE_URL} />
<meta property="og:type" content="website" />
<meta property="og:title" content="Package Your Worker" />
<meta
property="og:description"
content="Drop skills, agents, or MCPs into OpenWork Share and publish a worker package in one move."
/>
<meta property="og:url" content={DEFAULT_PUBLIC_BASE_URL} />
<meta property="og:image" content={rootOgImageUrl} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Package Your Worker" />
<meta
name="twitter:description"
content="Drop skills, agents, or MCPs into OpenWork Share and publish a worker package in one move."
/>
<meta name="twitter:image" content={rootOgImageUrl} />
</Head>
<main className="shell">
<ShareNav />
<ShareHomeClient />
</main>
</>
);
}

View File

@@ -0,0 +1,673 @@
@font-face {
font-family: "FK Raster Roman Compact Smooth";
src: url("https://openwork.software/fonts/FKRasterRomanCompact-Smooth.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
:root {
color-scheme: light;
--ow-bg: #f6f9fc;
--ow-ink: #011627;
--ow-muted: #5f6b7a;
--ow-card: rgba(255, 255, 255, 0.78);
--ow-card-strong: rgba(255, 255, 255, 0.94);
--ow-border: rgba(255, 255, 255, 0.76);
--ow-border-soft: rgba(148, 163, 184, 0.18);
--ow-shadow: 0 24px 70px -28px rgba(15, 23, 42, 0.18);
--ow-shadow-soft: 0 20px 50px -24px rgba(15, 23, 42, 0.12);
--ow-shadow-strong: 0 28px 80px -28px rgba(15, 23, 42, 0.26);
--ow-primary: #011627;
--ow-primary-hover: rgb(110, 110, 110);
--ow-ease: cubic-bezier(0.31, 0.325, 0, 0.92);
--ow-skill: linear-gradient(135deg, #f97316, #facc15);
--ow-agent: linear-gradient(135deg, #1d4ed8, #60a5fa);
--ow-mcp: linear-gradient(135deg, #0f766e, #2dd4bf);
--ow-command: linear-gradient(135deg, #7c3aed, #c084fc);
}
* {
box-sizing: border-box;
}
html,
body,
#__next {
min-height: 100%;
}
body {
margin: 0;
font-family: Inter, "Segoe UI", "Helvetica Neue", sans-serif;
color: var(--ow-ink);
background:
radial-gradient(circle at top left, rgba(251, 191, 36, 0.34), transparent 34%),
radial-gradient(circle at top right, rgba(96, 165, 250, 0.28), transparent 30%),
radial-gradient(circle at bottom right, rgba(244, 114, 182, 0.14), transparent 28%),
linear-gradient(180deg, #fbfdff 0%, var(--ow-bg) 100%);
overflow-x: hidden;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
radial-gradient(rgba(1, 22, 39, 0.055) 0.75px, transparent 0.75px),
radial-gradient(rgba(1, 22, 39, 0.03) 0.6px, transparent 0.6px);
background-position: 0 0, 18px 18px;
background-size: 36px 36px;
opacity: 0.34;
mix-blend-mode: multiply;
}
a,
button,
input,
textarea {
font: inherit;
}
a {
color: inherit;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.shell {
position: relative;
z-index: 1;
width: min(100%, 1180px);
margin: 0 auto;
padding: 28px 18px 54px;
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 28px;
}
.brand,
.nav-links a,
.button-primary,
.button-secondary,
.hero-artifact,
.app-window,
.simple-app,
.drop-zone,
.result-card,
.status-card {
transition:
background-color 300ms var(--ow-ease),
border-color 300ms var(--ow-ease),
color 300ms var(--ow-ease),
box-shadow 300ms var(--ow-ease),
transform 300ms var(--ow-ease);
}
.brand {
display: inline-flex;
align-items: center;
gap: 12px;
min-height: 44px;
padding: 0 18px;
border-radius: 999px;
text-decoration: none;
background: rgba(255, 255, 255, 0.62);
border: 1px solid rgba(255, 255, 255, 0.78);
box-shadow: 0 18px 40px -30px rgba(15, 23, 42, 0.3);
backdrop-filter: blur(12px);
font-weight: 600;
}
.brand:hover {
transform: translateY(-1px);
}
.brand-mark {
width: 14px;
height: 14px;
border-radius: 999px;
background: linear-gradient(135deg, #011627, #1d4ed8 60%, #60a5fa);
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.85);
}
.nav-links,
.nav-actions,
.button-row,
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.nav-links a,
.button-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 44px;
padding: 0 18px;
border-radius: 999px;
text-decoration: none;
background: rgba(255, 255, 255, 0.66);
border: 1px solid rgba(255, 255, 255, 0.82);
box-shadow: 0 18px 44px -34px rgba(15, 23, 42, 0.28);
backdrop-filter: blur(12px);
color: var(--ow-ink);
}
.nav-links a:hover,
.button-secondary:hover {
background: rgb(242, 242, 242);
box-shadow:
rgba(0, 0, 0, 0.06) 0 0 0 1px,
rgba(0, 0, 0, 0.04) 0 1px 2px 0,
rgba(0, 0, 0, 0.04) 0 2px 4px 0;
}
.button-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 48px;
padding: 0 22px;
border-radius: 999px;
border: none;
cursor: pointer;
text-decoration: none;
color: #fff;
background: var(--ow-primary);
box-shadow: 0 22px 46px -28px rgba(1, 22, 39, 0.7);
font-weight: 600;
}
.button-primary:hover {
background: var(--ow-primary-hover);
transform: translateY(-1px);
}
.button-primary:disabled,
.button-secondary:disabled {
cursor: not-allowed;
opacity: 0.58;
transform: none;
}
.hero-layout {
display: grid;
gap: 24px;
grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr);
align-items: stretch;
}
.hero-copy,
.simple-app,
.paste-panel,
.result-card,
.status-card {
display: grid;
gap: 18px;
}
.hero-copy h1,
.status-card h1 {
margin: 0;
font-size: clamp(3rem, 8vw, 5rem);
line-height: 0.94;
letter-spacing: -0.06em;
}
.hero-copy h1 em,
.status-card h1 em {
font-family: "FK Raster Roman Compact Smooth", Georgia, serif;
font-style: normal;
font-weight: 400;
}
.hero-body,
.hero-note,
.simple-app-copy,
.result-card p,
.status-card p {
margin: 0;
color: var(--ow-muted);
line-height: 1.6;
}
.hero-body {
max-width: 40rem;
font-size: 1.05rem;
}
.hero-note {
font-size: 13px;
}
.eyebrow {
display: inline-flex;
width: fit-content;
min-height: 34px;
align-items: center;
padding: 0 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(255, 255, 255, 0.95);
box-shadow: 0 14px 34px -28px rgba(15, 23, 42, 0.28);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ow-muted);
}
.hero-artifact,
.status-card {
position: relative;
overflow: hidden;
border-radius: 40px;
border: 1px solid var(--ow-border);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.72));
box-shadow: var(--ow-shadow-strong);
backdrop-filter: blur(22px);
padding: clamp(24px, 4vw, 40px);
}
.hero-artifact::before,
.hero-artifact::after,
.status-card::before,
.status-card::after {
content: "";
position: absolute;
border-radius: 999px;
pointer-events: none;
}
.hero-artifact::before,
.status-card::before {
top: -72px;
right: -42px;
width: 240px;
height: 240px;
background: radial-gradient(circle, rgba(251, 191, 36, 0.3), rgba(251, 191, 36, 0));
}
.hero-artifact::after,
.status-card::after {
left: -28px;
bottom: -96px;
width: 280px;
height: 280px;
background: radial-gradient(circle, rgba(96, 165, 250, 0.24), rgba(96, 165, 250, 0));
}
.simple-app,
.app-window {
position: relative;
z-index: 1;
border-radius: 32px;
background: var(--ow-card-strong);
border: 1px solid rgba(255, 255, 255, 0.72);
box-shadow: var(--ow-shadow-soft);
}
.simple-app {
padding: 24px;
}
.simple-app-title,
.result-card h3 {
margin: 0;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.03em;
}
.drop-zone {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
border: 1px dashed rgba(148, 163, 184, 0.42);
border-radius: 24px;
padding: 32px 24px;
text-align: center;
background: rgba(255, 255, 255, 0.72);
cursor: pointer;
}
.drop-zone:hover,
.drop-zone.is-dragover {
border-color: rgba(37, 99, 235, 0.4);
background: rgba(240, 247, 255, 0.95);
}
.drop-zone[aria-busy="true"] {
cursor: progress;
opacity: 0.7;
}
.drop-icon {
display: flex;
width: 52px;
height: 52px;
align-items: center;
justify-content: center;
border-radius: 16px;
background: #ffffff;
color: #2563eb;
box-shadow: 0 8px 20px -14px rgba(15, 23, 42, 0.22);
}
.drop-icon svg {
width: 24px;
height: 24px;
}
.drop-text h3,
.included-section h4 {
margin: 0;
}
.drop-text h3 {
font-size: 18px;
font-weight: 600;
}
.drop-text p,
.paste-meta,
.status-area {
margin: 0;
font-size: 13px;
color: var(--ow-muted);
}
.included-section {
width: 100%;
text-align: left;
}
.included-section h4 {
margin-bottom: 12px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ow-muted);
}
.included-list,
.metadata-list,
.warnings-list,
.url-stack {
display: grid;
gap: 10px;
}
.included-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-radius: 18px;
background: rgba(248, 250, 252, 0.92);
border: 1px solid rgba(148, 163, 184, 0.14);
}
.item-left {
display: flex;
align-items: center;
gap: 12px;
}
.item-dot {
width: 24px;
height: 24px;
border-radius: 999px;
}
.dot-agent {
background: var(--ow-agent);
}
.dot-skill {
background: var(--ow-skill);
}
.dot-mcp {
background: var(--ow-mcp);
}
.dot-command {
background: var(--ow-command);
}
.item-title {
font-size: 14px;
font-weight: 600;
}
.item-meta {
font-size: 12px;
color: var(--ow-muted);
}
.paste-panel textarea,
.url-box {
width: 100%;
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.28);
background: rgba(255, 255, 255, 0.96);
color: var(--ow-ink);
}
.paste-panel textarea {
min-height: 180px;
resize: vertical;
padding: 14px 16px;
}
.paste-panel textarea:focus {
outline: 2px solid rgba(36, 99, 235, 0.18);
outline-offset: 2px;
border-color: rgba(36, 99, 235, 0.34);
}
.paste-meta {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
}
.status-area {
text-align: center;
}
.app-window {
overflow: hidden;
}
.app-window-header {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 12px 16px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.62));
border-bottom: 1px solid rgba(255, 255, 255, 0.5);
}
.mac-dots {
position: absolute;
left: 16px;
display: flex;
gap: 6px;
}
.mac-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.mac-dot.red {
background: #ff5f56;
border: 1px solid rgba(224, 68, 62, 0.2);
}
.mac-dot.yellow {
background: #ffbd2e;
border: 1px solid rgba(222, 161, 35, 0.2);
}
.mac-dot.green {
background: #27c93f;
border: 1px solid rgba(26, 171, 41, 0.2);
}
.app-window-title {
font-size: 12px;
font-weight: 500;
color: var(--ow-muted);
}
.app-window-body {
padding: 24px;
background: #ffffff;
}
.results-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
margin-top: 64px;
}
.result-card {
padding: 32px;
border-radius: 28px;
background: #ffffff;
border: 1px solid var(--ow-border);
box-shadow: var(--ow-shadow);
}
.url-box {
padding: 16px;
font-size: 13px;
word-break: break-word;
}
.metadata-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
font-size: 13px;
}
.metadata-row dt,
.warnings-empty {
color: var(--ow-muted);
}
.metadata-row dd {
margin: 0;
font-weight: 600;
}
.warnings-list {
margin: 0;
padding-left: 20px;
color: #b91c1c;
line-height: 1.6;
}
.warnings-empty {
list-style: none;
margin-left: -20px;
}
.status-card {
max-width: 720px;
margin: 48px auto 0;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 960px) {
.nav,
.hero-layout,
.results-grid {
grid-template-columns: 1fr;
}
.nav {
flex-direction: column;
align-items: stretch;
}
.nav-links,
.nav-actions {
justify-content: center;
}
.hero-layout,
.results-grid {
display: grid;
}
}
@media (max-width: 720px) {
.shell {
padding: 20px 14px 40px;
}
.hero-copy h1,
.status-card h1 {
font-size: clamp(2.4rem, 14vw, 3.5rem);
}
.simple-app,
.result-card,
.status-card,
.hero-artifact {
padding: 20px;
border-radius: 24px;
}
.button-row,
.hero-actions,
.nav-links,
.nav-actions {
flex-direction: column;
align-items: stretch;
}
.button-primary,
.button-secondary,
.nav-links a {
width: 100%;
}
}

View File

@@ -1,10 +0,0 @@
{
"outputDirectory": ".",
"rewrites": [
{ "source": "/", "destination": "/api" },
{ "source": "/health", "destination": "/api/health" },
{ "source": "/og/(.*)", "destination": "/api/og/$1" },
{ "source": "/b/(.*)", "destination": "/api/b/$1" },
{ "source": "/v1/(.*)", "destination": "/api/v1/$1" }
]
}