mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
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:
@@ -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`
|
||||
|
||||
133
packages/landing/components/landing-app-demo-panel.tsx
Normal file
133
packages/landing/components/landing-app-demo-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
packages/landing/components/landing-cloud-workers-card.tsx
Normal file
78
packages/landing/components/landing-cloud-workers-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
packages/landing/components/landing-demo-flows.ts
Normal file
197
packages/landing/components/landing-demo-flows.ts
Normal 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 ?? "";
|
||||
@@ -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">
|
||||
|
||||
88
packages/landing/components/landing-share-package-card.tsx
Normal file
88
packages/landing/components/landing-share-package-card.tsx
Normal 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
9
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
1
services/openwork-share/.gitignore
vendored
1
services/openwork-share/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.vercel/
|
||||
.vercel
|
||||
|
||||
@@ -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
|
||||
|
||||
179
services/openwork-share/components/share-bundle-page.js
Normal file
179
services/openwork-share/components/share-bundle-page.js
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
395
services/openwork-share/components/share-home-client.js
Normal file
395
services/openwork-share/components/share-home-client.js
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
42
services/openwork-share/components/share-nav.js
Normal file
42
services/openwork-share/components/share-nav.js
Normal 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>
|
||||
);
|
||||
}
|
||||
12
services/openwork-share/next.config.mjs
Normal file
12
services/openwork-share/next.config.mjs
Normal 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;
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
5
services/openwork-share/pages/_app.js
Normal file
5
services/openwork-share/pages/_app.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import "../styles/globals.css";
|
||||
|
||||
export default function ShareApp({ Component, pageProps }) {
|
||||
return <Component {...pageProps} />;
|
||||
}
|
||||
1
services/openwork-share/pages/api/health.js
Normal file
1
services/openwork-share/pages/api/health.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "../../api/health.js";
|
||||
1
services/openwork-share/pages/api/og/[id].js
Normal file
1
services/openwork-share/pages/api/og/[id].js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "../../../api/og/[id].js";
|
||||
7
services/openwork-share/pages/api/v1/bundles.js
Normal file
7
services/openwork-share/pages/api/v1/bundles.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false
|
||||
}
|
||||
};
|
||||
|
||||
export { default } from "../../../api/v1/bundles.js";
|
||||
7
services/openwork-share/pages/api/v1/package.js
Normal file
7
services/openwork-share/pages/api/v1/package.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false
|
||||
}
|
||||
};
|
||||
|
||||
export { default } from "../../../api/v1/package.js";
|
||||
119
services/openwork-share/pages/b/[id].js
Normal file
119
services/openwork-share/pages/b/[id].js
Normal 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} />;
|
||||
}
|
||||
42
services/openwork-share/pages/index.js
Normal file
42
services/openwork-share/pages/index.js
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
673
services/openwork-share/styles/globals.css
Normal file
673
services/openwork-share/styles/globals.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user