diff --git a/DESIGN-LANGUAGE.md b/DESIGN-LANGUAGE.md index 31e09fd7..888c7134 100644 --- a/DESIGN-LANGUAGE.md +++ b/DESIGN-LANGUAGE.md @@ -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` diff --git a/packages/landing/components/landing-app-demo-panel.tsx b/packages/landing/components/landing-app-demo-panel.tsx new file mode 100644 index 00000000..f9c41fb4 --- /dev/null +++ b/packages/landing/components/landing-app-demo-panel.tsx @@ -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; + 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 ( +
+
+
+
+
+ {activeFlow.activeAgent.name} +
+ Active +
+ + {activeFlow.agents.map((agent) => ( +
+
+
+ {agent.name} +
+ {agent.desc} +
+ ))} + +
+
+ {props.flows.map((flow) => { + const isActive = flow.id === activeFlow.id; + + return ( + + ); + })} +
+
+
+ +
+
+ {activeFlow.chatHistory.map((message, index) => { + if (message.role === "user") { + return ( +
+ {message.content} +
+ ); + } + + if (message.role === "timeline") { + return ( +
+ {message.items.map((item) => ( +
+ + {item} +
+ ))} +
+ ); + } + + return ( +
+ {message.content} +
+ ); + })} +
+ +
+
Describe your task
+
+ {activeFlow.task} [task] {activeFlow.context}{" "} + [context] {activeFlow.output}{" "} + [result] +
+
+ +
+
+
+
+ ); +} diff --git a/packages/landing/components/landing-cloud-workers-card.tsx b/packages/landing/components/landing-cloud-workers-card.tsx new file mode 100644 index 00000000..ffbaa58f --- /dev/null +++ b/packages/landing/components/landing-cloud-workers-card.tsx @@ -0,0 +1,78 @@ +type Props = { + className?: string; +}; + +function SlackGlyph() { + return ( + + ); +} + +export function LandingCloudWorkersCard(props: Props) { + return ( +
+
+ +
+ +
+
+
+
+ Founder Ops Pilot +
+
+
+ READY +
+
+
Assists with operations and onboarding.
+ +
+ + +
+
+ +
+
+
+ Marketing Copilot +
+
+
+ OFFLINE +
+
+
Creates draft campaigns from Notion docs.
+
+
+
+ ); +} diff --git a/packages/landing/components/landing-demo-flows.ts b/packages/landing/components/landing-demo-flows.ts new file mode 100644 index 00000000..581ec2d5 --- /dev/null +++ b/packages/landing/components/landing-demo-flows.ts @@ -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 = { + "browser-automation": "1s ago", + "data-analysis": "15m ago", + "outreach-creation": "22h ago" +}; + +export const defaultLandingDemoFlowId = landingDemoFlows[0]?.id ?? ""; diff --git a/packages/landing/components/landing-home.tsx b/packages/landing/components/landing-home.tsx index 0a67a22a..d5b2a129 100644 --- a/packages/landing/components/landing-home.tsx +++ b/packages/landing/components/landing-home.tsx @@ -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(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) {
-
-
-
-
-
- - {activeDemo.activeAgent.name} - -
- Active -
- - {activeDemo.agents.map((agent) => ( -
-
-
- {agent.name} -
- {agent.desc} -
- ))} - -
-
- {demoFlows.map((flow, idx) => { - const isActive = flow.id === activeDemo.id; - const timeAgo = - idx === 0 ? "1s ago" : idx === 1 ? "15m ago" : "22h ago"; - - return ( - - ); - })} -
-
-
- -
-
- {activeDemo.chatHistory.map((message, idx) => { - if (message.role === "user") { - return ( -
- {message.content} -
- ); - } - - if (message.role === "timeline") { - return ( -
- {message.items.map((item) => ( -
- - {item} -
- ))} -
- ); - } - - return ( -
- {message.content} -
- ); - })} -
- -
-
- Describe your task -
-
- {activeDemo.task}{" "} - [task]{" "} - {activeDemo.context}{" "} - [context]{" "} - {activeDemo.output}{" "} - [result] -
-
- -
-
-
-
+
- {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 ? ( -
-
-

- Package Your Worker -

-

- Drag and drop skills, agents, or MCPs here to bundle - them. -

-
+ {activeUseCase === 0 ? : null} -
-
- - - - - -
-
- Drop OpenWork files here -
-
- or click to browse local files -
-
- -
-
- Included -
-
-
- -
-
-
- Sales Inbound -
-
- Agent · v1.2.0 -
-
- -
-
- - -
- ) : null} - - {activeUseCase === 1 ? ( -
-
- -
- -
-
-
-
- Founder Ops Pilot -
-
-
- - READY - -
-
-
- Assists with operations and onboarding. -
- -
- - -
-
- -
-
-
- Marketing Copilot -
-
-
- - OFFLINE - -
-
-
- Creates draft campaigns from Notion docs. -
-
-
-
- ) : null} + {activeUseCase === 1 ? : null} {activeUseCase === 2 ? (
diff --git a/packages/landing/components/landing-share-package-card.tsx b/packages/landing/components/landing-share-package-card.tsx new file mode 100644 index 00000000..e84bc8aa --- /dev/null +++ b/packages/landing/components/landing-share-package-card.tsx @@ -0,0 +1,88 @@ +import { Check, Shield } from "lucide-react"; + +type Props = { + className?: string; +}; + +function LinkIcon() { + return ( + + ); +} + +export function LandingSharePackageCard(props: Props) { + return ( +
+
+

Package Your Worker

+

+ Drag and drop skills, agents, or MCPs here to bundle them. +

+
+ +
+
+ +
+
Drop OpenWork files here
+
or click to browse local files
+
+ +
+
Included
+
+
+ +
+
+
Sales Inbound
+
Agent · v1.2.0
+
+ +
+
+ + +
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d965b23..91685be3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/services/openwork-share/.gitignore b/services/openwork-share/.gitignore index f1300770..86b338be 100644 --- a/services/openwork-share/.gitignore +++ b/services/openwork-share/.gitignore @@ -1,3 +1,4 @@ node_modules/ +.next/ .vercel/ .vercel diff --git a/services/openwork-share/README.md b/services/openwork-share/README.md index a9acad1b..de211229 100644 --- a/services/openwork-share/README.md +++ b/services/openwork-share/README.md @@ -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 diff --git a/services/openwork-share/components/share-bundle-page.js b/services/openwork-share/components/share-bundle-page.js new file mode 100644 index 00000000..e6a5bbe2 --- /dev/null +++ b/services/openwork-share/components/share-bundle-page.js @@ -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 ( + <> + + {pageTitle} + + + + + + + + + + + + {props.missing ? null : ( + <> + + + + + + + )} + + +
+ + + {props.missing ? ( +
+ OpenWork Share +

Bundle not found

+

+ This share link does not exist anymore, or the bundle id is invalid. +

+ +
+ ) : ( + <> +
+
+ {props.typeLabel} +

+ {props.title} ready +

+

{props.description}

+ +

{props.installHint}

+
+ +
+
+
+ +
OpenWork
+
+
+
+

Package contents

+
+ {props.items.length ? ( + props.items.map((item) => ( +
+
+
+
+
{item.name}
+
+ {item.kind} · {item.meta} +
+
+
+
+ )) + ) : ( +
+
+
+
+
OpenWork bundle
+
Shared config
+
+
+
+ )} +
+
+
+
+
+
+ +
+
+

Bundle details

+

Stable metadata for parsing and direct OpenWork import.

+
+ {props.metadataRows.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+
+ +
+

Raw endpoints

+

Keep the human page and machine payload side by side.

+
+ +
{props.shareUrl}
+
+
+ + Download JSON + + +
+
+
+ + )} +
+ + ); +} diff --git a/services/openwork-share/components/share-home-client.js b/services/openwork-share/components/share-home-client.js new file mode 100644 index 00000000..388458d4 --- /dev/null +++ b/services/openwork-share/components/share-home-client.js @@ -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 ( + <> +
+
+ OpenWork Share +

+ Package your worker +

+

+ 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. +

+

Secrets stay out. The packager rejects configs that look unsafe to publish.

+
+ +
+
+
+

Create a share link

+

+ Drop OpenWork files, preview the inferred bundle, then generate a public link. +

+
+ + + +
+