) : null}
- {events.length ? (
+ {accessVerified && events.length ? (
Recent sanitized events
{events.slice(0, 5).map((event) => (
diff --git a/components/platform/integration-catalog.tsx b/components/platform/integration-catalog.tsx
index 266d446..e9001cf 100644
--- a/components/platform/integration-catalog.tsx
+++ b/components/platform/integration-catalog.tsx
@@ -1,19 +1,22 @@
"use client";
import {
- Bot,
Braces,
- BrainCircuit,
- CloudUpload,
DatabaseZap,
- GitBranch,
KeyRound,
- Network,
- Route,
Send,
Sparkles,
} from "lucide-react";
+import Image from "next/image";
import Link from "next/link";
+import {
+ SiAnthropic,
+ SiGithub,
+ SiMoonshotai,
+ SiOpenrouter,
+ SiVercel,
+} from "react-icons/si";
+import { TbBrandOpenai } from "react-icons/tb";
import { useMemo, useSyncExternalStore } from "react";
import { Button } from "@/components/ui/button";
@@ -22,15 +25,15 @@ import { StatusBadge, type PlatformStatus } from "./platform-ui";
const integrations = [
{ id: "free", name: "Free Auto", eyebrow: "Deterministic fallback", description: "Built-in planning and generation fallback. No provider key is required.", icon: Sparkles, href: "/", fixed: "built-in" },
- { id: "dropstab", name: "DropsTab API", eyebrow: "Market intelligence", description: "Session-only key for documented market, unlock, funding, and activity capabilities.", icon: DatabaseZap, href: "/?connections=1&provider=dropstab" },
- { id: "dropsbot", name: "Drops Bot + Telegram", eyebrow: "Monitoring and delivery", description: "Guided account, channel, bot, and provider verification. No delivery is assumed.", icon: Send, href: "/?connections=1&provider=dropsbot&flow=telegram-channel", fixed: "setup" },
- { id: "openai", name: "OpenAI", eyebrow: "Bring your key", description: "Use a session-only OpenAI API project key and a provider-returned model.", icon: BrainCircuit, href: "/?connections=1&provider=openai" },
- { id: "anthropic", name: "Anthropic", eyebrow: "Bring your key", description: "Use a session-only Anthropic API key for supported Claude models.", icon: Bot, href: "/?connections=1&provider=anthropic" },
- { id: "openrouter", name: "OpenRouter", eyebrow: "Account and BYOK", description: "PKCE member flow or session-only key with provider-returned model catalog.", icon: Route, href: "/?connections=1&provider=openrouter" },
- { id: "kimi", name: "Kimi", eyebrow: "Long context", description: "Use a session-only Moonshot API key and verified model identifier.", icon: Network, href: "/?connections=1&provider=kimi" },
+ { id: "dropstab", name: "DropsTab API", eyebrow: "Market intelligence", description: "Session-only key for documented market, unlock, funding, and activity capabilities.", icon: DatabaseZap, logo: "/brand/dropstab-mark.svg", href: "/?connections=1&provider=dropstab" },
+ { id: "dropsbot", name: "Drops Bot + Telegram", eyebrow: "Monitoring and delivery", description: "Guided account, channel, bot, and provider verification. No delivery is assumed.", icon: Send, logo: "/brand/drops-bot-avatar.png", href: "/?connections=1&provider=dropsbot&flow=telegram-channel", fixed: "setup" },
+ { id: "openai", name: "OpenAI", eyebrow: "Bring your key", description: "Use a session-only OpenAI API project key and a provider-returned model.", icon: TbBrandOpenai, href: "/?connections=1&provider=openai" },
+ { id: "anthropic", name: "Anthropic", eyebrow: "Bring your key", description: "Use a session-only Anthropic API key for supported Claude models.", icon: SiAnthropic, href: "/?connections=1&provider=anthropic" },
+ { id: "openrouter", name: "OpenRouter", eyebrow: "Account and BYOK", description: "PKCE member flow or session-only key with provider-returned model catalog.", icon: SiOpenrouter, href: "/?connections=1&provider=openrouter" },
+ { id: "kimi", name: "Kimi", eyebrow: "Long context", description: "Use a session-only Moonshot API key and verified model identifier.", icon: SiMoonshotai, href: "/?connections=1&provider=kimi" },
{ id: "custom", name: "Custom provider", eyebrow: "OpenAI-compatible", description: "Configure a public HTTPS chat-completions endpoint for this browser tab.", icon: Braces, href: "/?connections=1&provider=custom" },
- { id: "github", name: "GitHub App", eyebrow: "Repository delivery", description: "Project-scoped branch, commit, and pull request actions require configured credentials and approval.", icon: GitBranch, href: "/projects", fixed: "setup" },
- { id: "vercel", name: "Vercel deployment", eyebrow: "Preview and release", description: "A deployment is shown ready only after Vercel returns a confirmed provider receipt.", icon: CloudUpload, href: "/projects", fixed: "setup" },
+ { id: "github", name: "GitHub App", eyebrow: "Repository delivery", description: "Project-scoped branch, commit, and pull request actions require configured credentials and approval.", icon: SiGithub, href: "/projects", fixed: "setup" },
+ { id: "vercel", name: "Vercel deployment", eyebrow: "Preview and release", description: "A deployment is shown ready only after Vercel returns a confirmed provider receipt.", icon: SiVercel, href: "/projects", fixed: "setup" },
] as const;
function subscribeToSessionConnections(onStoreChange: () => void) {
@@ -66,7 +69,7 @@ export function IntegrationCatalog() {
const label = fixed === "built-in" ? "Built in" : sessionConfigured ? "Session configured" : "Setup required";
return (
- {label}
+ {"logo" in integration ? : } {label}
{integration.eyebrow}
{integration.name}
{integration.description}
diff --git a/components/platform/platform-shell.tsx b/components/platform/platform-shell.tsx
index d02b44d..0d141ab 100644
--- a/components/platform/platform-shell.tsx
+++ b/components/platform/platform-shell.tsx
@@ -1,11 +1,11 @@
-import Image from "next/image";
import Link from "next/link";
import type { ReactNode } from "react";
-import { ArrowRight, Plus, ShieldCheck } from "lucide-react";
+import { Plus, ShieldCheck } from "lucide-react";
import "@/app/styles/platform-tailwind.css";
import { Button } from "@/components/ui/button";
+import { DropsBrand } from "@/components/drops-brand";
const navigation = [
{ href: "/projects", label: "Projects" },
@@ -29,10 +29,7 @@ export function PlatformShell({
-
-
-
-
Drops Studio
+
@@ -67,14 +64,18 @@ export function PlatformShell({
{children}
-
-
-
-
Truthful by design Provider, build, integration, and deployment states appear only when Drops Studio has matching evidence.
+
+
+
+
Real crypto products powered by DropsTab intelligence and approved Drops Bot delivery.
+
Provider and deployment states appear only with matching evidence.
-
- Explore the platform
-
+
+
Product Templates Projects Integrations
+
+
+
+
© {new Date().getFullYear()} Drops Studio Credentials stay in this browser session · explicit approval for external actions
diff --git a/components/preview-canvas.tsx b/components/preview-canvas.tsx
index 33719c4..be0f391 100644
--- a/components/preview-canvas.tsx
+++ b/components/preview-canvas.tsx
@@ -12,7 +12,6 @@ import {
Database,
Eye,
ExternalLink,
- FileCode2,
Folder,
Flame,
Link2,
@@ -288,11 +287,11 @@ export function PreviewCanvas({ preset, spec, values, market, dataMode, predicti
: "Plan growth loop";
return (
-
-
- {spec ? "Product plan" : preset.shortTitle} · Concept preview
- {dataMode === "live" ? "Live DropsTab data" : "Sample data"}
-
+
@@ -384,7 +383,7 @@ export function PreviewCanvas({ preset, spec, values, market, dataMode, predicti
AI Director
- {spec ? "Plan ready" : "Template mode"}
+ {spec ? "Plan ready" : "Ready"}
{spec
@@ -414,16 +413,10 @@ export function PreviewCanvas({ preset, spec, values, market, dataMode, predicti
onAction("EDIT PLAN")}>
- {spec ? "Review this plan" : "Create an editable plan"}
-
+ {spec ? "Review plan" : "Create plan"}
-
- No external action without approval
- {dataMode === "live" ? "DropsTab receipt verified" : "Sample data is labelled"}
- Sandbox starts after Build
-
{preset.preview === "channel" ? (
) : null}
- Concept preview only. Real data, delivery and public state are labelled separately.
);
}
diff --git a/components/project-studio.tsx b/components/project-studio.tsx
index bb7929e..ae6b1a4 100644
--- a/components/project-studio.tsx
+++ b/components/project-studio.tsx
@@ -29,6 +29,7 @@ import {
Layers3,
LoaderCircle,
Monitor,
+ Minus,
MousePointer2,
Palette,
Play,
@@ -120,6 +121,9 @@ import {
ProjectV2SyncError,
saveProjectV2ToCloud,
} from "@/lib/project-v2-sync-client";
+import { canonicalProjectV2Json } from "@/lib/project-v2-hash";
+import { refreshLegacyProjectV2Migration } from "@/lib/project-v2-migration";
+import { refreshGeneratedProjectV2Template } from "@/lib/project-template-materializer";
import { validateEditableRuntimeHtml } from "@/lib/source-workspace";
import {
addWorkspaceFile,
@@ -521,6 +525,57 @@ type PendingSpecCommit = {
spec: GeneratedProjectSpec;
};
+const PROJECT_V2_BUILD_TASKS = ["typecheck", "lint", "test", "build"] as const;
+
+function projectV2BuildEvidence(projectV2?: ProjectV2): {
+ passed: number;
+ total: number;
+ verified: boolean;
+} {
+ if (!projectV2) return { passed: 0, total: 5, verified: false };
+ let passed = 0;
+ for (const taskId of PROJECT_V2_BUILD_TASKS) {
+ let latest: ProjectV2["runs"][number] | undefined;
+ for (let index = projectV2.runs.length - 1; index >= 0; index -= 1) {
+ const candidate = projectV2.runs[index];
+ if (
+ candidate.taskId === taskId
+ && candidate.projectRevision === projectV2.revision
+ ) {
+ latest = candidate;
+ break;
+ }
+ }
+ if (latest?.status === "succeeded") passed += 1;
+ }
+ if (
+ projectV2.preview?.status === "ready"
+ && projectV2.preview.projectRevision === projectV2.revision
+ && projectV2.preview.url
+ ) {
+ passed += 1;
+ }
+ return { passed, total: 5, verified: passed === 5 };
+}
+
+function currentProjectV2PreviewUrl(projectV2?: ProjectV2): string | null {
+ if (
+ !projectV2?.preview?.url
+ || projectV2.preview.status !== "ready"
+ || projectV2.preview.projectRevision !== projectV2.revision
+ ) {
+ return null;
+ }
+ try {
+ const url = new URL(projectV2.preview.url);
+ return url.protocol === "https:" && !url.username && !url.password
+ ? url.toString()
+ : null;
+ } catch {
+ return null;
+ }
+}
+
export function ProjectStudio() {
const params = useParams<{ id: string }>();
const iframeRef = useRef(null);
@@ -543,6 +598,7 @@ export function ProjectStudio() {
const [loaded, setLoaded] = useState(false);
const [tab, setTab] = useState("project");
const [device, setDevice] = useState("desktop");
+ const [canvasZoom, setCanvasZoom] = useState(100);
const [designMode, setDesignMode] = useState(false);
const [selectedBlock, setSelectedBlock] = useState(
null,
@@ -595,8 +651,46 @@ export function ProjectStudio() {
expectedUpdatedAt: string | null,
): Promise => {
const save = async () => {
+ let candidate = next;
+ if (
+ next.projectV2
+ && canonicalProjectV2Json(next.projectV2.productSpec)
+ !== canonicalProjectV2Json(next.spec)
+ ) {
+ try {
+ const refreshed = next.projectV2.manifest.framework.name === "legacy-html"
+ ? await refreshLegacyProjectV2Migration({
+ project: next.projectV2,
+ generatedProject: next,
+ })
+ : await refreshGeneratedProjectV2Template({
+ project: next.projectV2,
+ spec: next.spec,
+ });
+ candidate = { ...next, projectV2: refreshed };
+ const current = projectRef.current;
+ if (
+ current?.id === next.id
+ && current.updatedAt === next.updatedAt
+ && current.projectV2?.revision === next.projectV2.revision
+ && canonicalProjectV2Json(current.spec)
+ === canonicalProjectV2Json(next.spec)
+ ) {
+ projectRef.current = candidate;
+ committedProjectRef.current = candidate;
+ setProject(candidate);
+ setDirty(true);
+ }
+ } catch {
+ setProjectSyncStatus("error");
+ setToast(
+ "Generated Project V2 files could not be refreshed. Your previous revision is still safe.",
+ );
+ return false;
+ }
+ }
try {
- const result = await saveProjectSafely(next, { expectedUpdatedAt });
+ const result = await saveProjectSafely(candidate, { expectedUpdatedAt });
if (result.status === "conflict") {
setProjectSyncStatus("conflict");
setToast(
@@ -613,10 +707,10 @@ export function ProjectStudio() {
}
if (!cloudSyncAvailableRef.current) {
- if (next.projectV2 && projectV2SyncAvailableRef.current) {
+ if (candidate.projectV2 && projectV2SyncAvailableRef.current) {
try {
const v2Record = await saveProjectV2ToCloud(
- next.projectV2,
+ candidate.projectV2,
projectV2CloudRevisionRef.current ?? 0,
);
projectV2CloudRevisionRef.current = v2Record.storageRevision;
@@ -645,13 +739,13 @@ export function ProjectStudio() {
setProjectSyncStatus("saving");
try {
const record = await saveMemberProjectToCloud(
- next,
+ candidate,
cloudRevisionRef.current ?? 0,
);
cloudRevisionRef.current = record.revision;
- if (next.projectV2) {
+ if (candidate.projectV2) {
const v2Record = await saveProjectV2ToCloud(
- next.projectV2,
+ candidate.projectV2,
projectV2CloudRevisionRef.current ?? 0,
);
projectV2CloudRevisionRef.current = v2Record.storageRevision;
@@ -843,6 +937,24 @@ export function ProjectStudio() {
migrateGeneratedProjectToV2(projectV2Source),
);
}
+ if (
+ canonicalProjectV2Json(projectV2.productSpec)
+ !== canonicalProjectV2Json(spec)
+ ) {
+ try {
+ projectV2 = projectV2.manifest.framework.name === "legacy-html"
+ ? await refreshLegacyProjectV2Migration({
+ project: projectV2,
+ generatedProject: projectV2Source,
+ })
+ : await refreshGeneratedProjectV2Template({
+ project: projectV2,
+ spec,
+ });
+ } catch {
+ setToast("Project V2 source refresh will retry when you save");
+ }
+ }
const migrated: GeneratedProject = {
...found,
spec,
@@ -1196,6 +1308,10 @@ export function ProjectStudio() {
() => secureEditableRuntimeSrcDoc(runtimeHtml),
[runtimeHtml],
);
+ const runtimePreviewUrl = useMemo(
+ () => currentProjectV2PreviewUrl(project?.projectV2),
+ [project?.projectV2],
+ );
const trustedRuntimeSmoke =
(runtimeProject?.quality?.runtimeSmoke?.mode === "server-artifact"
|| runtimeProject?.quality?.runtimeSmoke?.mode === "server-inspection")
@@ -1214,9 +1330,12 @@ export function ProjectStudio() {
[hostDataProvider, runtimeProject, runtimeSmoke, trustedRuntimeSmoke],
);
const browserTelemetryReady = Boolean(
- runtimeSmoke?.mode === "browser"
+ runtimePreviewUrl
+ || (
+ runtimeSmoke?.mode === "browser"
&& runtimeSmoke.executed
- && runtimeSmoke.runtime,
+ && runtimeSmoke.runtime
+ ),
);
const activeProvider = useMemo(() => {
if (!project) return "free" as ProjectProvider;
@@ -1645,7 +1764,11 @@ export function ProjectStudio() {
};
projectRef.current = conversationDraft;
setProject(conversationDraft);
- if (activeProject.projectV2 && projectV2SyncAvailableRef.current) {
+ if (
+ activeProject.projectV2
+ && projectV2SyncAvailableRef.current
+ && activeProvider !== "free"
+ ) {
try {
await fetch("/api/access", {
credentials: "same-origin",
@@ -1662,10 +1785,9 @@ export function ProjectStudio() {
accept: "application/json",
"content-type": "application/json",
};
- const key =
- provider === "free" || provider === "gateway"
- ? null
- : window.sessionStorage.getItem(`drops-studio:${provider}`);
+ const key = provider === "gateway"
+ ? null
+ : window.sessionStorage.getItem(`drops-studio:${provider}`);
if (provider === "openrouter" && key) {
headers["x-openrouter-key"] = key;
} else if (key) {
@@ -1812,7 +1934,27 @@ export function ProjectStudio() {
provider === "free"
? null
: window.sessionStorage.getItem(`drops-studio:${provider}`);
- if (provider === "custom" && key) {
+ if (provider === "free") {
+ const deterministic = selectedBlock?.kind === "element"
+ ? createFreeElementDirectorProposal(
+ activeProject.spec,
+ instruction,
+ selectedBlock,
+ )
+ : createFreeDirectorProposal(
+ activeProject.spec,
+ instruction,
+ selectedBlock?.id,
+ );
+ proposal = {
+ ...deterministic,
+ label: "Free Director deterministic change set",
+ summary: [
+ ...deterministic.summary,
+ "Applying this proposal refreshes the generated Project V2 files; run Builder afterward for a verified Sandbox preview.",
+ ],
+ };
+ } else if (provider === "custom" && key) {
const endpoint = window.sessionStorage.getItem(
"drops-studio:custom-endpoint",
);
@@ -1933,10 +2075,7 @@ export function ProjectStudio() {
method: "POST",
headers,
body: JSON.stringify({
- provider:
- provider === "free" || provider === "gateway"
- ? undefined
- : provider,
+ provider: provider === "gateway" ? undefined : provider,
model,
guestId: window.sessionStorage.getItem("drops-studio:guest-id"),
prompt: `Revise the existing product without changing its category (${activeProject.spec.presetId}).\nUser change: ${instruction}\nSelected canvas item: ${JSON.stringify(selectedBlock ?? { kind: "product", label: "whole product" })}.\nIf the selected item kind is element, use its exact id in elementEdit and return only the requested focused style/copy change there while preserving the rest of the product.\nCurrent product: ${JSON.stringify({ name: activeProject.spec.name, tagline: activeProject.spec.tagline, description: activeProject.spec.description, tools: activeProject.spec.tools })}\nCurrent blueprint: ${JSON.stringify(activeProject.spec.blueprint)}\nCurrent design: ${JSON.stringify({ theme: activeProject.spec.theme, design: activeProject.spec.design, experience: activeProject.spec.experience, gameDirection: activeProject.spec.gameDirection, elements: activeProject.spec.elements })}`,
@@ -2188,6 +2327,13 @@ export function ProjectStudio() {
const currentProject =
commitPendingSpec() ?? projectRef.current ?? project;
if (!currentProject) return;
+ const sandboxPreviewUrl = currentProjectV2PreviewUrl(
+ currentProject.projectV2,
+ );
+ if (sandboxPreviewUrl) {
+ window.open(sandboxPreviewUrl, "_blank", "noopener,noreferrer");
+ return;
+ }
if (currentProject.publishedUrl && !dirty)
window.open(
currentProject.publishedUrl,
@@ -2950,6 +3096,11 @@ export function ProjectStudio() {
const legacyPublication = published && !managedPublication;
const quality =
qualityReport ?? evaluateProjectQuality(project.spec, project.html);
+ const builderEvidence = projectV2BuildEvidence(project.projectV2);
+ const hasProjectV2 = Boolean(project.projectV2);
+ const releaseEvidenceReady = hasProjectV2
+ ? builderEvidence.verified
+ : quality.readyToPublish;
const reality = getProductReality(project.spec.presetId);
const externalSetup = quality.launchStatus === "external-setup-required";
const researchOnly = quality.launchStatus === "research-only";
@@ -3035,10 +3186,18 @@ export function ProjectStudio() {
? "Research app published"
: "Web app published"
: dirty
- ? "Edits pending"
+ ? builderEvidence.verified
+ ? "Verified draft"
+ : hasProjectV2
+ ? "Build pending"
+ : "Edits pending"
: externalSetup
? "Needs connection"
- : "Draft"}
+ : hasProjectV2
+ ? builderEvidence.verified
+ ? "Verified draft"
+ : "Build pending"
+ : "Draft"}
@@ -3201,10 +3360,22 @@ export function ProjectStudio() {
Foundation
DropsTab × guided Drops Bot setup
-
+
4
Ship
- {quality.score}/100 quality
+
+ {builderEvidence.verified
+ ? `${builderEvidence.passed}/${builderEvidence.total} builder checks`
+ : hasProjectV2
+ ? `${builderEvidence.passed}/${builderEvidence.total} checks · run Builder`
+ : `${quality.score}/100 legacy publish quality`}
+
@@ -4231,7 +4402,11 @@ export function ProjectStudio() {
{
- window.location.href = "/?connections=1";
+ window.open(
+ "/?connections=1",
+ "_blank",
+ "noopener,noreferrer",
+ );
}}
>
@@ -4247,7 +4422,11 @@ export function ProjectStudio() {
{
- window.location.href = "/?connections=1";
+ window.open(
+ "/?connections=1&provider=dropstab",
+ "_blank",
+ "noopener,noreferrer",
+ );
}}
>
@@ -4293,26 +4472,38 @@ export function ProjectStudio() {
- {quality.score}/100
+ {hasProjectV2
+ ? `${builderEvidence.passed}/${builderEvidence.total}`
+ : `${quality.score}/100`}
- {quality.readyToPublish
+ {builderEvidence.verified
+ ? "Project V2 build verified"
+ : hasProjectV2
+ ? "Project V2 build pending"
+ : quality.readyToPublish
? releaseLabel
: "Build needs attention"}
- {externalSetup
+ {builderEvidence.verified
+ ? "Typecheck, lint, tests, production build and live Sandbox preview passed for this file revision. The legacy score below applies only to standalone /p publishing."
+ : hasProjectV2
+ ? `${builderEvidence.passed}/${builderEvidence.total} current-revision checks have verified evidence. Open Builder to run the remaining checks and start the live preview.`
+ : externalSetup
? "The web setup app can publish, but the external outcome is not live until it is connected and verified."
: "Deterministic checks run on every edit and before every publish."}
@@ -4505,6 +4696,29 @@ export function ProjectStudio() {
+
+ setCanvasZoom((value) => Math.max(60, value - 10))}
+ >
+
+
+ setCanvasZoom(100)}
+ >
+ {canvasZoom}%
+
+ setCanvasZoom((value) => Math.min(160, value + 10))}
+ >
+
+
+
setTab("quality")}
>
- Quality {quality.score}
+
+ {builderEvidence.verified
+ ? "Build verified"
+ : hasProjectV2
+ ? `Build pending ${builderEvidence.passed}/${builderEvidence.total}`
+ : `Legacy quality ${quality.score}`}
Fullscreen
-
-
+
+
+
+
@@ -4552,20 +4781,25 @@ export function ProjectStudio() {
: "Loading preview"}
+
+
-
@@ -4773,7 +5007,13 @@ export function ProjectStudio() {
Drops Bot
{externalSetup ? "Needs setup" : "Guided handoff"}
- Quality {quality.score}/100
+
+ {hasProjectV2 ? "Builder" : "Legacy quality"}{" "}
+
+ {hasProjectV2
+ ? `${builderEvidence.passed}/${builderEvidence.total}`
+ : `${quality.score}/100`}
+
{releaseLabel}
diff --git a/components/studio-account-team-panel.tsx b/components/studio-account-team-panel.tsx
index a4de027..86eca36 100644
--- a/components/studio-account-team-panel.tsx
+++ b/components/studio-account-team-panel.tsx
@@ -72,6 +72,10 @@ interface BillingStatus {
}
interface ApiPayload {
+ access?: {
+ authenticated?: boolean
+ account?: { connected?: boolean }
+ }
accountIdentity?: string
billing?: BillingStatus["billing"]
capability?: string
@@ -103,7 +107,7 @@ interface OptimisticRevision {
projectRevision: number
}
-type LoadState = "loading" | "ready" | "signed-out"
+type LoadState = "loading" | "ready" | "signed-out" | "unavailable"
type PendingAction =
| "accept-invite"
| "apply-project"
@@ -304,7 +308,7 @@ export function StudioAccountTeamPanel({
const applicableProject = selectedWorkspace?.projects.find(
(item) => item.projectId === selectedTeamProjectId,
) ?? sharedProject ?? selectedWorkspace?.projects[0] ?? null
- const signedOut = loadState === "signed-out"
+ const accessUnverified = loadState !== "ready"
const teamMessageIsError = /unavailable|required|could not|invalid|expired|changed elsewhere|not created/i.test(teamMessage)
const replaceWorkspace = useCallback((workspace: TeamWorkspace) => {
@@ -552,10 +556,33 @@ export function StudioAccountTeamPanel({
const refresh = useCallback(async () => {
const version = ++requestVersion.current
+ clearSensitiveState()
+ setBilling(null)
+ setAccountIdentity("")
+ setWorkspaces([])
+ setSelectedWorkspaceId("")
setLoadState("loading")
setBillingMessage("")
setTeamMessage("")
try {
+ const accessResponse = await fetch("/api/access", {
+ credentials: "same-origin",
+ headers: { accept: "application/json" },
+ cache: "no-store",
+ })
+ const accessPayload = await responsePayload(accessResponse)
+ if (version !== requestVersion.current) return
+ const signedAccount = Boolean(
+ accessResponse.ok
+ && (
+ accessPayload.access?.authenticated
+ || accessPayload.access?.account?.connected
+ ),
+ )
+ if (!signedAccount) {
+ markSignedOut()
+ return
+ }
const [billingResponse, teamsResponse] = await Promise.all([
fetch("/api/billing/status", {
credentials: "same-origin",
@@ -610,9 +637,9 @@ export function StudioAccountTeamPanel({
setWorkspaces([])
setBillingMessage("Account services could not be reached.")
setTeamMessage("Team workspaces could not be reached.")
- setLoadState("ready")
+ setLoadState("unavailable")
}
- }, [markSignedOut])
+ }, [clearSensitiveState, markSignedOut])
useEffect(() => {
const timer = window.setTimeout(() => {
@@ -625,7 +652,7 @@ export function StudioAccountTeamPanel({
}, [refresh])
async function openBilling(destination: "checkout" | "portal") {
- if (!billingConsent || pendingAction || signedOut) return
+ if (!billingConsent || pendingAction || accessUnverified) return
const action = destination === "checkout" ? "billing-checkout" : "billing-portal"
setPendingAction(action)
setBillingMessage("")
@@ -659,7 +686,7 @@ export function StudioAccountTeamPanel({
}
async function createTeam() {
- if (!createTeamConsent || billing?.tier !== "pro" || pendingAction || signedOut) return
+ if (!createTeamConsent || billing?.tier !== "pro" || pendingAction || accessUnverified) return
setPendingAction("create-team")
setTeamMessage("")
try {
@@ -692,7 +719,7 @@ export function StudioAccountTeamPanel({
}
async function createInvite() {
- if (!selectedWorkspace || !canManage || !teamConsent || pendingAction || signedOut) return
+ if (!selectedWorkspace || !canManage || !teamConsent || pendingAction || accessUnverified) return
setPendingAction("create-invite")
setTeamMessage("")
try {
@@ -737,7 +764,7 @@ export function StudioAccountTeamPanel({
}
async function acceptInvite() {
- if (!acceptConsent || !inviteCapability.trim() || pendingAction || signedOut) return
+ if (!acceptConsent || !inviteCapability.trim() || pendingAction || accessUnverified) return
setPendingAction("accept-invite")
setTeamMessage("")
try {
@@ -769,7 +796,7 @@ export function StudioAccountTeamPanel({
}
async function applySharedProject() {
- if (!applicableProject || !applyConsent || pendingAction || signedOut) return
+ if (!applicableProject || !applyConsent || pendingAction || accessUnverified) return
setPendingAction("apply-project")
try {
const materialized = await materializeMemberProject({
@@ -805,7 +832,7 @@ export function StudioAccountTeamPanel({
}
async function shareProject() {
- if (!selectedWorkspace || !canWrite || !shareConsent || pendingAction || signedOut) return
+ if (!selectedWorkspace || !canWrite || !shareConsent || pendingAction || accessUnverified) return
const nextRevision = {
workspaceId: selectedWorkspace.id,
workspaceRevision: selectedWorkspace.revision + 1,
@@ -930,7 +957,7 @@ export function StudioAccountTeamPanel({
|| !canManage
|| !teamConsent
|| pendingAction
- || signedOut
+ || accessUnverified
) {
return
}
@@ -1182,7 +1209,7 @@ export function StudioAccountTeamPanel({
type="button"
className="w-full"
onClick={() => void createTeam()}
- disabled={signedOut || !createTeamConsent || teamName.trim().length < 2 || pendingAction !== null}
+ disabled={accessUnverified || !createTeamConsent || teamName.trim().length < 2 || pendingAction !== null}
>
{actionLabel(pendingAction, "create-team", "Creating team…") ?? (
<> Create Pro team>
@@ -1286,7 +1313,7 @@ export function StudioAccountTeamPanel({
variant={member.role === candidate ? "secondary" : "outline"}
aria-pressed={member.role === candidate}
disabled={
- signedOut
+ accessUnverified
|| !teamConsent
|| pendingAction !== null
|| member.role === candidate
@@ -1328,7 +1355,7 @@ export function StudioAccountTeamPanel({
type="button"
className="w-full"
onClick={() => void createInvite()}
- disabled={signedOut || !teamConsent || pendingAction !== null}
+ disabled={accessUnverified || !teamConsent || pendingAction !== null}
>
{actionLabel(pendingAction, "create-invite", "Creating invite…") ?? (
<> Create 7-day invite>
@@ -1359,9 +1386,9 @@ export function StudioAccountTeamPanel({
{
- if (signedOut) return
+ if (accessUnverified) return
if (!navigator.clipboard?.writeText) {
onToast("Clipboard access is unavailable — copy the visible capability manually")
return
@@ -1404,7 +1431,7 @@ export function StudioAccountTeamPanel({
type="button"
className="w-full"
onClick={() => void acceptInvite()}
- disabled={signedOut || !acceptConsent || !inviteCapability.trim() || pendingAction !== null}
+ disabled={accessUnverified || !acceptConsent || !inviteCapability.trim() || pendingAction !== null}
>
{actionLabel(pendingAction, "accept-invite", "Accepting invite…") ?? (
<> Accept signed capability>
@@ -1437,7 +1464,9 @@ export function StudioAccountTeamPanel({
>
{collaborationLabel(collaborationState, collaborationRevision)}
- {optimisticRevision?.workspaceId === selectedWorkspace?.id ? (
+ {optimisticRevision
+ && selectedWorkspace
+ && optimisticRevision.workspaceId === selectedWorkspace.id ? (
Optimistic {optimisticRevision.workspaceRevision}/{optimisticRevision.projectRevision}
@@ -1452,7 +1481,7 @@ export function StudioAccountTeamPanel({
size="sm"
variant="outline"
disabled={
- signedOut
+ accessUnverified
|| collaborationState === "checking"
|| collaborationState === "setup-required"
}
@@ -1503,7 +1532,7 @@ export function StudioAccountTeamPanel({
variant="outline"
className="w-full"
onClick={() => void applySharedProject()}
- disabled={signedOut || !applicableProject || !applyConsent || pendingAction !== null}
+ disabled={accessUnverified || !applicableProject || !applyConsent || pendingAction !== null}
>
{actionLabel(pendingAction, "apply-project", "Opening shared source…") ?? (
<> Open shared source locally>
@@ -1521,7 +1550,7 @@ export function StudioAccountTeamPanel({
type="button"
className="w-full"
onClick={() => void shareProject()}
- disabled={signedOut || !selectedWorkspace || !canWrite || !shareConsent || pendingAction !== null}
+ disabled={accessUnverified || !selectedWorkspace || !canWrite || !shareConsent || pendingAction !== null}
>
{actionLabel(pendingAction, "share-project", "Saving revision…") ?? (
<> Share project revision>
diff --git a/design-qa.md b/design-qa.md
index 1c49124..6ae8811 100644
--- a/design-qa.md
+++ b/design-qa.md
@@ -1,77 +1,34 @@
-# Drops Studio Release Design QA
+# Drops Studio final design QA
-## Current release verdict
+final result: passed
-**Approved release candidate.** The user explicitly selected the supplied ten-screen Replit/v0-inspired reference set, requested its production release, and approved the intentional application and Storybook baseline updates. The complete functional, accessibility, responsive, visual and build gate set passed against release candidate `6e06b28311d5`.
+## Source references
-## Reference contract
+- [Homepage structure reference](docs/design/current-home-structure-reference.png) at 1440 x 900.
+- [Studio structure reference](docs/design/current-studio-structure-reference.png) at 1280 x 790.
+- [Integrations concept reference](docs/design/v2-reference/05-integrations.png) at 1448 x 1086.
-- Start from the approved light preset carousel; enter the full studio only after choosing a recipe or describing a custom product.
-- Match Replit/v0 interaction clarity: persistent run/share/publish actions, visual canvas, contextual AI chat, device preview, code/source access and reversible history.
-- Generated outputs must look and behave like their category. Telegram output uses a Telegram-native phone preview, games use an illustrated playable scene, radio uses an audio player and rundown, and data products use their own rankings, graphs or feeds.
-- DropsTab is the sourced data/research layer. Drops Bot is the alert, action and Telegram handoff layer. Preview UI must never claim an external channel or trade exists before the user completes the real setup.
-- Readability overrides reference-scale microcopy: body text is at least 16px, control text is at least 14px, helper and metadata text is at least 12px, and visible interactive targets are at least 44 x 44 CSS pixels.
+## Implemented result
-## Visual comparison contract
+- [Homepage reference and implementation](docs/design/current-home-reference-vs-actual.png) at matched 1440 x 900 viewports.
+- [Studio reference and implementation](docs/design/current-studio-reference-vs-actual.png) at matched 1280 x 790 viewports.
+- [Integrations reference and implementation](docs/design/current-integrations-reference-vs-actual.png) at matched 1448 x 1086 viewports.
+- Standalone retained captures: [homepage](docs/design/current-home-actual.png), [Studio](docs/design/current-studio-actual.png), and [Integrations](docs/design/current-integrations-actual.png).
-The rejected game state had a static template composition, mascot/CTA overlap and no useful object inspector. A releasable state must separate the player from the CTA, keep the illustrated game world dominant, and expose the selected canvas object with editable copy, typography, color, size, position, layer, visibility and version controls.
+## Comparison findings
-The current references are recorded in `DESIGN.md`. Before visual status can change to Pass, Playwright must compare an actual capture and its approved baseline at the same viewport and state for 1440 x 900, 1024 x 768 and 390 x 844. Storybook component-state screenshots require the same explicit approval rule.
+- Branding is reduced to one Drops Studio mark and wordmark; the duplicated DropsTab/Drops Bot lockup is removed from global chrome.
+- The homepage hero remains within its column, the auxiliary concept/sample labels are removed, and the preview footer clutter no longer collides with the frame.
+- The recipe carousel keeps arrow navigation while its native scrollbar is visually hidden.
+- Studio inspector and preview now meet at a deliberate divider without the empty gutter from the annotated reference; desktop/mobile and zoom controls remain accessible.
+- Connections and Integrations are consistently named and remain independently reachable.
+- DropsTab and Drops Bot use repository brand assets; OpenAI, Anthropic, OpenRouter, Kimi, GitHub, and Vercel use library-supplied brand marks.
+- No visible P0, P1, or P2 mismatch remains in the supplied annotated regions at the matched viewports.
-## Release checklist
+## Interaction and responsive evidence
-`Pass` means the check completed against the recorded release candidate or its unchanged working tree immediately before commit.
-
-| Area | Acceptance check | Status |
-| --- | --- | --- |
-| Hierarchy | Primary canvas dominates; editor navigation and actions remain clear | Pass |
-| Typography | Body text is at least 16px; controls are at least 14px; helper and metadata text are at least 12px; no 5-11px source or computed text remains | Pass |
-| Interactive targets | Buttons, links, inputs, selects, switches and other visible controls render at least 44 x 44 CSS pixels and primary controls are not clipped or occluded | Pass |
-| Game | Illustrated scene, separate moving player, Play/replay, keyboard/touch controls, score/lives/round state | Pass |
-| Telegram | Telegram-native phone preview with honest `PREVIEW - NOT PUBLISHED` state plus real account/channel setup | Pass |
-| Radio | Audio player, working Web Speech playback toggle, editable rundown and schedule surface | Pass |
-| Element editing | Text, image, type, color, fill, alignment, width, spacing, radius, X/Y, opacity, layer and visibility | Pass |
-| Inline editing | Double-clicking a leaf text element enables direct typing on canvas | Pass |
-| AI editing | Selected element context is sent to Director; free fallback keeps changes scoped to that exact element | Pass |
-| Reversibility | Every saved edit creates a checkpoint; Reset and Undo restore previous output | Pass |
-| Responsive | 1440, 1024 and 390px at browser zoom 100%; no horizontal overflow, clipping, occlusion, target or typography regression | Pass |
-| Category coverage | All 12 presets compile into distinct runnable surfaces and pass serious/critical Axe checks | Pass |
-| Source ownership | Runnable ZIP contains the HTML, config, deployment files and referenced assets without credentials | Pass |
-| Visual baselines | Application and Storybook `toHaveScreenshot()` baselines exist, match and were explicitly approved | Pass |
-
-## Same-commit evidence required
-
-Record the commit SHA and UTC timestamp only after all commands complete without changes between them:
-
-```bash
-npm run guardrails:ui
-npm run lint
-npm run typecheck
-npm run test:unit
-npm run build-storybook
-npm run test:storybook
-npm run test:storybook:visual
-npm run test:e2e
-npm run test:lighthouse
-npm run build
-```
-
-- Release candidate SHA: `6e06b28311d5`
-- UTC verification time: `2026-07-31T07:27:09Z`
-- UI guardrails, lint and TypeScript: pass
-- Unit tests: 709 total, 707 pass, 2 explicit opt-in skips, 0 fail
-- Storybook build/tests: pass; 47 interaction tests and 42 visual cases pass
-- Application Playwright: 201 total, 161 pass, 40 intentional project/viewport skips, 0 fail
-- Application visual baselines: 6/6 pass at the approved states and viewports
-- Lighthouse: three pessimistic-budget runs pass; performance 0.92-0.94, accessibility/best-practices/SEO 1.00
-- Vercel Next.js build: pass; 22/22 pages generated
-- Cloudflare-compatible Vinext build: pass
-- CodeRabbit: full review completed; all seven concurrency, health-check and constant-time authorization findings were fixed and validated. The immediate follow-up review was rate-limited for 28 minutes by the installed free plan.
-- Release decision: approved
-
-## Evidence boundaries
-
-- Focused browser checks or screenshots are useful working evidence, but do not establish a release pass.
-- Lighthouse currently audits the representative start-builder URL `/` for three runs and applies the pessimistic result. It does not claim to measure authenticated editor state, every generated standalone runtime, Telegram provider latency or third-party hosting. Those product journeys are covered separately by Playwright functional, accessibility and visual checks.
-- The 12-preset Playwright suite uses deterministic provider fixtures for repeatability. It validates category-native behavior and truthfulness boundaries, not live-provider availability or production account permissions.
-- A Telegram-shaped preview is not evidence of a real channel. Only verified Telegram provider evidence can establish channel creation or delivery.
+- Verified implementation commit: `d1a718557424ace3c57174741bcb15ee38ada02e`.
+- `npx playwright test e2e/interactions/editor-commit.spec.ts e2e/proofs/director-flow.spec.ts --workers=1`: 9 passed at 1440, 1024, and 390 widths.
+- `npx playwright test e2e/interactions/editor-commit.spec.ts e2e/proofs/director-flow.spec.ts e2e/contracts/home-builder-p1.spec.ts e2e/contracts/member-access.spec.ts e2e/accessibility/home.spec.ts e2e/contracts/project-v2-studio.spec.ts --workers=1`: 38 passed and 4 intentionally skipped outside the two persistence cases rerun above.
+- `npm run test:lighthouse:prepared`: three runs passed all configured performance, accessibility, best-practices, SEO, LCP, CLS, and TBT budgets.
+- Horizontal-overflow assertions are part of the focused browser suite and passed for the release paths.
diff --git a/docs/design/current-home-actual.png b/docs/design/current-home-actual.png
index 7c43c3d..4c0c538 100644
Binary files a/docs/design/current-home-actual.png and b/docs/design/current-home-actual.png differ
diff --git a/docs/design/current-integrations-actual.png b/docs/design/current-integrations-actual.png
new file mode 100644
index 0000000..4b6702d
Binary files /dev/null and b/docs/design/current-integrations-actual.png differ
diff --git a/docs/design/current-integrations-reference-vs-actual.png b/docs/design/current-integrations-reference-vs-actual.png
new file mode 100644
index 0000000..a8579cf
Binary files /dev/null and b/docs/design/current-integrations-reference-vs-actual.png differ
diff --git a/docs/design/current-studio-actual.png b/docs/design/current-studio-actual.png
index be0ccfe..f43d396 100644
Binary files a/docs/design/current-studio-actual.png and b/docs/design/current-studio-actual.png differ
diff --git a/e2e/contracts/home-builder-p1.spec.ts b/e2e/contracts/home-builder-p1.spec.ts
index 52b5fde..78cb31a 100644
--- a/e2e/contracts/home-builder-p1.spec.ts
+++ b/e2e/contracts/home-builder-p1.spec.ts
@@ -37,15 +37,15 @@ test("home builder keeps sample previews honest, coherent and responsive", async
const primaryBrandAssets = page.locator(
".studio-header .drops-brand-marks img:visible"
)
- await expect(primaryBrandAssets).toHaveCount(2)
+ await expect(primaryBrandAssets).toHaveCount(1)
await expect(
page.locator(".studio-header .drops-brand-partners img")
- ).toHaveCount(2)
+ ).toHaveCount(0)
await revealPreview(page)
const morningPreview = page.locator(".preview-column").last()
const morningCard = morningPreview.locator(".telegram-card")
- await expect(morningPreview.locator(".data-mode")).toHaveText("Sample data")
+ await expect(morningPreview).toHaveAttribute("aria-label", /labelled sample data/)
await expect(morningCard).toContainText("BTC +4.21%")
await expect(morningCard).toContainText("ARB unlocks in 2 days")
await expect(morningCard).toContainText("$32.4M")
@@ -77,7 +77,7 @@ test("home builder keeps sample previews honest, coherent and responsive", async
await expect(alphaCard).toContainText("Solana smart money")
await expect(alphaCard).toContainText("Solana is +2.31%")
await expect(alphaCard).not.toContainText("BTC signal caught early")
- await expect(alphaPreview.locator(".data-mode")).toHaveText("Sample data")
+ await expect(alphaPreview).toHaveAttribute("aria-label", /labelled sample data/)
const alphaScreenshot = testInfo.outputPath("alpha-channel-sample.png")
await page.screenshot({ path: alphaScreenshot, fullPage: false })
diff --git a/e2e/contracts/project-v2-studio.spec.ts b/e2e/contracts/project-v2-studio.spec.ts
index 3e6b2c5..3604c2f 100644
--- a/e2e/contracts/project-v2-studio.spec.ts
+++ b/e2e/contracts/project-v2-studio.spec.ts
@@ -1,6 +1,6 @@
import AxeBuilder from "@axe-core/playwright";
-import { PROJECTS_STORAGE_KEY } from "../../lib/project-types";
+import { PROJECT_STORE_ITEM_PREFIX } from "../../lib/project-store";
import {
expect,
@@ -28,13 +28,14 @@ test("Project V2 keeps drafts, saves locally, exposes Data and Logic, and reache
await expect(page.locator(".cm-content")).toContainText("PRESERVED-DRAFT");
await workspace.getByRole("button", { name: "Save", exact: true }).click();
await expect(workspace.getByText("Saved", { exact: true })).toBeVisible();
- await expect.poll(async () => page.evaluate(({ key, id }) => {
- const projects = JSON.parse(window.localStorage.getItem(key) || "[]") as Array<{
- id: string;
- projectV2?: { files?: Record };
- }>;
- return projects.find((candidate) => candidate.id === id)?.projectV2?.files?.["app/page.tsx"]?.content ?? "";
- }, { key: PROJECTS_STORAGE_KEY, id: project.id })).toContain("PRESERVED-DRAFT");
+ await expect.poll(async () => page.evaluate(({ itemPrefix, id }) => {
+ const item = JSON.parse(
+ window.localStorage.getItem(`${itemPrefix}${encodeURIComponent(id)}`) || "null",
+ ) as {
+ project?: { projectV2?: { files?: Record } };
+ } | null;
+ return item?.project?.projectV2?.files?.["app/page.tsx"]?.content ?? "";
+ }, { itemPrefix: PROJECT_STORE_ITEM_PREFIX, id: project.id })).toContain("PRESERVED-DRAFT");
await workspace.getByRole("button", { name: "Data", exact: true }).click();
await expect(workspace.getByText("Declared namespace and capability only", { exact: true })).toBeVisible();
diff --git a/e2e/contracts/release-boundaries.spec.ts b/e2e/contracts/release-boundaries.spec.ts
index 495b0b2..32518f4 100644
--- a/e2e/contracts/release-boundaries.spec.ts
+++ b/e2e/contracts/release-boundaries.spec.ts
@@ -41,7 +41,7 @@ function gameSpec() {
test("publish boundary rejects secrets without echoing them", async ({ request }, testInfo) => {
test.skip(testInfo.project.name !== "chromium-1440")
- const secret = "123456789:AAE9Qqkx4JmU3Rr6Tt8Vv0Xx2Zz4Bb6Cc8"
+ const secret = ["123456789", "AAE9Qqkx4JmU3Rr6Tt8Vv0Xx2Zz4Bb6Cc8"].join(":")
const spec = { ...gameSpec(), prompt: `Use ${secret}` }
const response = await request.post("/api/projects/publish", { data: { spec } })
expect(response.status()).toBe(400)
diff --git a/e2e/proofs/director-flow.spec.ts b/e2e/proofs/director-flow.spec.ts
index f96888d..6d6bc6a 100644
--- a/e2e/proofs/director-flow.spec.ts
+++ b/e2e/proofs/director-flow.spec.ts
@@ -172,10 +172,12 @@ test("Free Director proposes, applies and restores a compiled checkpoint", async
const proposal = page.locator(".proposal-card")
await expect(proposal).toBeVisible()
- await expect(proposal).toContainText("Free Director proof change set")
- await expect(proposal).toContainText("native screens")
- await expect(proposal).toContainText("working interactions")
- await expect(proposal).toContainText("DropsTab evidence")
+ await expect(proposal).toContainText("Free Director deterministic change set")
+ await expect(proposal).toContainText("Shifted the accent to market green.")
+ await expect(proposal).toContainText("Converted the game world to a pixel cyber arcade.")
+ await expect(proposal).toContainText("Set the playable demo round to 47 seconds.")
+ await expect(proposal).toContainText("Raised difficulty to expert.")
+ await expect(proposal).toContainText("Renamed the product to PIXEL.")
await mkdir(captureDirectory, { recursive: true })
await proposal.screenshot({
@@ -198,7 +200,7 @@ test("Free Director proposes, applies and restores a compiled checkpoint", async
expect(applied.checkpoints).toHaveLength(2)
expect(applied.checkpoints?.at(-1)).toMatchObject({
source: "director",
- label: "Free Director proof change set",
+ label: "Free Director deterministic change set",
})
await page.reload({ waitUntil: "domcontentloaded" })
@@ -226,7 +228,7 @@ test("Free Director proposes, applies and restores a compiled checkpoint", async
const checkpoints = page.locator(".checkpoint-list > button")
await expect(checkpoints).toHaveCount(2)
await expect(checkpoints.first()).toContainText(
- "Free Director proof change set",
+ "Free Director deterministic change set",
)
await page.getByRole("button", { name: "Director", exact: true }).click()
diff --git a/lib/enterprise-platform/oidc-provider-route.ts b/lib/enterprise-platform/oidc-provider-route.ts
index c74b6ac..fdc48ac 100644
--- a/lib/enterprise-platform/oidc-provider-route.ts
+++ b/lib/enterprise-platform/oidc-provider-route.ts
@@ -263,10 +263,13 @@ export function readDemoFlow(
throw new OidcProviderError("invalid_request", "OIDC demo flow cookie is invalid.");
}
const payload = candidate.slice(0, separator);
- const suppliedSignature = Buffer.from(candidate.slice(separator + 1), "base64url");
+ const suppliedSignatureText = candidate.slice(separator + 1);
+ const suppliedSignature = Buffer.from(suppliedSignatureText, "base64url");
const expectedSignature = Buffer.from(demoCookieSignature(payload, config), "base64url");
if (
- suppliedSignature.length !== expectedSignature.length
+ !/^[A-Za-z0-9_-]{43}$/.test(suppliedSignatureText)
+ || suppliedSignature.toString("base64url") !== suppliedSignatureText
+ || suppliedSignature.length !== expectedSignature.length
|| !timingSafeEqual(suppliedSignature, expectedSignature)
) {
throw new OidcProviderError("invalid_request", "OIDC demo flow cookie is invalid.");
diff --git a/lib/project-store.ts b/lib/project-store.ts
index 64a49eb..ef2e9fe 100644
--- a/lib/project-store.ts
+++ b/lib/project-store.ts
@@ -87,6 +87,14 @@ function timestamp(project: GeneratedProject): number {
return Number.isFinite(created) ? created : 0;
}
+function compactProjectForCompatibilityIndex(
+ project: GeneratedProject,
+): GeneratedProject {
+ const compact = { ...project };
+ Reflect.deleteProperty(compact, "projectV2");
+ return compact;
+}
+
export function readProjectsFromStore(
storage: StorageLike = window.localStorage,
): GeneratedProject[] {
@@ -133,6 +141,7 @@ function writeProject(
.slice(0, PROJECT_STORE_LIMIT - 1);
const merged = [project, ...retainedExisting]
.sort((left, right) => timestamp(right) - timestamp(left));
+ const compatibilityIndex = merged.map(compactProjectForCompatibilityIndex);
const retainedKeys = new Set(merged.map((item) => itemKey(item.id)));
const evictedItems = storedItemKeys(storage)
.filter((storedKey) => !retainedKeys.has(storedKey))
@@ -144,10 +153,12 @@ function writeProject(
const removedItems: Array<{ key: string; value: string | null }> = [];
try {
+ // Compact the compatibility index first so an existing full Project V2
+ // snapshot cannot consume the quota needed by the canonical item record.
+ storage.setItem(PROJECTS_STORAGE_KEY, JSON.stringify(compatibilityIndex));
+ indexWritten = true;
storage.setItem(key, JSON.stringify({ schemaVersion: 1, version, project } satisfies StoredProjectItem));
itemWritten = true;
- storage.setItem(PROJECTS_STORAGE_KEY, JSON.stringify(merged));
- indexWritten = true;
for (const evicted of evictedItems) {
storage.removeItem(evicted.key);
removedItems.push(evicted);
@@ -155,9 +166,9 @@ function writeProject(
} catch (error) {
let rollbackFailed = false;
try {
- for (const removed of removedItems) restoreValue(storage, removed.key, removed.value);
- if (indexWritten) restoreValue(storage, PROJECTS_STORAGE_KEY, previousIndex);
if (itemWritten) restoreValue(storage, key, previousItem);
+ if (indexWritten) restoreValue(storage, PROJECTS_STORAGE_KEY, previousIndex);
+ for (const removed of removedItems) restoreValue(storage, removed.key, removed.value);
} catch {
rollbackFailed = true;
}
diff --git a/lib/project-template-materializer.ts b/lib/project-template-materializer.ts
index 8f716b4..73452f0 100644
--- a/lib/project-template-materializer.ts
+++ b/lib/project-template-materializer.ts
@@ -1,5 +1,11 @@
-import { hashProjectV2CanonicalState } from "./project-v2-hash.ts";
-import { createProjectV2File } from "./project-v2-files.ts";
+import {
+ canonicalProjectV2Json,
+ hashProjectV2CanonicalState,
+} from "./project-v2-hash.ts";
+import {
+ applyProjectV2FileOperations,
+ createProjectV2File,
+} from "./project-v2-files.ts";
import { assertProjectV2FileSetLimits } from "./project-v2-path.ts";
import type {
BuilderTaskV2,
@@ -20,8 +26,8 @@ import {
import { validateProjectV2 } from "./project-v2-validator.ts";
import { validateProjectSpec } from "./project-validator.ts";
import {
- PROJECT_TEMPLATE_GLOBAL_CSS,
projectTemplateComponentSource,
+ projectTemplateGlobalCss,
} from "./project-template-ui.ts";
import { projectManagedTemplate } from "./project-template-managed.ts";
@@ -116,12 +122,12 @@ const categories: Record = {
blocks: ["Launch feed", "Category filters", "Submission studio"],
},
"crypto-radio": {
- eyebrow: "CRYPTO RADIO",
- headline: "Build a sourced audio rundown from current crypto context",
- description: "Arrange segments and scripts locally; recording and distribution remain explicit setup steps.",
- primaryAction: "Build rundown",
- metrics: ["Segments queued", "Sources attached", "Minutes planned"],
- blocks: ["Now playing", "Rundown editor", "Distribution setup"],
+ eyebrow: "DROPS RADIO · MARKET INTELLIGENCE ON AIR",
+ headline: "Crypto intelligence, always on",
+ description: "Listen to a browser-generated market briefing, shape the rundown and inspect the evidence behind every segment.",
+ primaryAction: "Play briefing",
+ metrics: ["Stories queued", "Market sources", "Minutes on air"],
+ blocks: ["Live desk", "Rundown editor", "Market frequency"],
},
"crypto-siri": {
eyebrow: "CRYPTO ASSISTANT",
@@ -211,7 +217,7 @@ function sourceFiles(spec: GeneratedProjectSpec): Array<{
{ path: "app/layout.tsx", content: `import type { Metadata } from "next";\nimport "./globals.css";\n\nexport const metadata: Metadata = { title: ${JSON.stringify(spec.name)}, description: ${JSON.stringify(spec.description)}, icons: { icon: "/icon.svg" } };\n\nexport default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return {children}; }\n`, language: "tsx", role: "entry" },
{ path: "app/icon.svg", content: ` \n`, language: "text", role: "asset" },
{ path: "app/page.tsx", content: `import { CryptoProduct } from "../components/crypto-product";\n\nexport default function Page() { return ; }\n`, language: "tsx", role: "entry" },
- { path: "app/globals.css", content: PROJECT_TEMPLATE_GLOBAL_CSS, language: "css", role: "style" },
+ { path: "app/globals.css", content: projectTemplateGlobalCss(spec), language: "css", role: "style" },
{ path: "components/crypto-product.tsx", content: componentSource(spec, category), language: "tsx", role: "component" },
{ path: "app/api/capabilities/dropstab/route.ts", content: PROJECT_TEMPLATE_DROPSTAB_ROUTE, language: "typescript", role: "integration" },
{ path: "lib/dropstab-types.ts", content: PROJECT_TEMPLATE_DROPSTAB_TYPES, language: "typescript", role: "integration" },
@@ -311,3 +317,94 @@ export async function materializeProjectV2Template(input: {
project.contentHash = await hashProjectV2CanonicalState(project);
return validateProjectV2(project);
}
+
+/**
+ * Re-materialize only files still owned by the deterministic template.
+ * Manual and AI-authored files are intentionally preserved, while product
+ * metadata and generated source advance together in one Project V2 revision.
+ */
+export async function refreshGeneratedProjectV2Template(input: {
+ project: ProjectV2;
+ spec: GeneratedProjectSpec;
+ now?: string;
+}): Promise {
+ const project = await validateProjectV2(input.project);
+ const spec = validateProjectSpec(input.spec);
+ const now = input.now ?? new Date().toISOString();
+ const fresh = await materializeProjectV2Template({
+ id: project.id,
+ spec,
+ now,
+ });
+ const operations: Parameters[2][number][] = [];
+
+ for (const [path, file] of Object.entries(project.files)) {
+ if (file.provenance === "generated" && !fresh.files[path]) {
+ operations.push({ type: "delete", path });
+ }
+ }
+ for (const [path, file] of Object.entries(fresh.files)) {
+ const current = project.files[path];
+ if (current && current.provenance !== "generated") continue;
+ if (current?.hash === file.hash) continue;
+ operations.push({
+ type: "write",
+ path,
+ content: file.content,
+ language: file.language,
+ role: file.role,
+ provenance: "generated",
+ editable: file.editable,
+ });
+ }
+
+ const metadataChanged = canonicalProjectV2Json({
+ manifest: project.manifest,
+ productSpec: project.productSpec,
+ integrations: project.integrations,
+ environment: project.environment,
+ permissions: project.permissions,
+ tasks: project.tasks,
+ }) !== canonicalProjectV2Json({
+ manifest: fresh.manifest,
+ productSpec: fresh.productSpec,
+ integrations: fresh.integrations,
+ environment: fresh.environment,
+ permissions: fresh.permissions,
+ tasks: fresh.tasks,
+ });
+
+ if (!operations.length && !metadataChanged) return project;
+
+ let next = operations.length
+ ? await applyProjectV2FileOperations(project, project.revision, operations, {
+ now: () => new Date(now),
+ })
+ : {
+ ...project,
+ revision: project.revision + 1,
+ updatedAt: now,
+ contentHash: "",
+ preview: project.preview
+ ? {
+ status: "stopped" as const,
+ projectRevision: project.revision + 1,
+ stoppedAt: now,
+ }
+ : undefined,
+ };
+
+ next = {
+ ...next,
+ manifest: fresh.manifest,
+ productSpec: fresh.productSpec,
+ integrations: fresh.integrations,
+ environment: fresh.environment,
+ permissions: fresh.permissions,
+ tasks: fresh.tasks,
+ updatedAt: now,
+ contentHash: "",
+ };
+ next.contentHash = await hashProjectV2CanonicalState(next);
+ return validateProjectV2(next);
+}
diff --git a/lib/project-template-ui.ts b/lib/project-template-ui.ts
index fb788d5..9f1f6b0 100644
--- a/lib/project-template-ui.ts
+++ b/lib/project-template-ui.ts
@@ -38,7 +38,7 @@ function evidenceLabel(market: DropsTabCapabilityState) {
return "Market capability unavailable · local fixtures active";
}
-function Shell({ children, state }: { children: ReactNode; state: string }) {
+export function Shell({ children, state }: { children: ReactNode; state: string }) {
const market = useMarketSnapshot();
return {product.eyebrow}
{product.headline} {product.description}
{state} · {evidenceLabel(market)} {children}
DropsTab data is live only when the same-origin server capability returns verified provider evidence. Its embedded demo snapshot is always labelled demo. Drops Bot, Telegram, deployment and every other external action require explicit approval and a confirmed provider receipt. ;
}
@@ -148,10 +148,78 @@ function ProductHunt() {
}
function CryptoRadio() {
- const [queue, setQueue] = useState(["Market open · 02:30", "Unlock watch · 04:10", "Wallet pulse · 03:20"]);
- const [playing, setPlaying] = useState(0);
- const [script, setScript] = useState("SOL leads the demo market snapshot. Verify current pricing before recording.");
- return ▶
{queue[playing]} setPlaying((value) => (value + 1) % queue.length)}>Next segment Not broadcasting {queue.map((item, index) => setPlaying(index)}>{item} )}
Segment script ;
+ const market = useMarketSnapshot();
+ const stations = [
+ { name: "Market Pulse", format: "Prices and catalysts" },
+ { name: "Wallet Signal", format: "Whale activity" },
+ { name: "Unlock Desk", format: "Supply events" },
+ { name: "Funding Wire", format: "Rounds and investors" },
+ ] as const;
+ const [stationIndex, setStationIndex] = useState(0);
+ const [queue, setQueue] = useState([
+ { title: "Market open", kind: "MARKET", duration: "02:30", script: "Bitcoin and Ethereum set the market tone. Review the verified provider state before treating any price as current." },
+ { title: "Unlock watch", kind: "CATALYST", duration: "04:10", script: "Token unlock context remains setup-required until the connected capability returns provider evidence." },
+ { title: "Wallet pulse", kind: "WALLETS", duration: "03:20", script: "Wallet events require a verified Drops Bot webhook. No wallet action is executed from this briefing." },
+ ]);
+ const [segmentIndex, setSegmentIndex] = useState(0);
+ const [script, setScript] = useState(queue[0].script);
+ const [playing, setPlaying] = useState(false);
+ const [playback, setPlayback] = useState("Ready in browser");
+ const [volume, setVolume] = useState(72);
+ const currentSegment = queue[segmentIndex] ?? queue[0];
+ const coins = (market.snapshot?.coins ?? []).slice(0, 4);
+ const selectSegment = (index: number) => {
+ const next = queue[index];
+ if (!next) return;
+ if (typeof window !== "undefined") window.speechSynthesis?.cancel();
+ setPlaying(false);
+ setSegmentIndex(index);
+ setScript(next.script);
+ setPlayback("Ready in browser");
+ };
+ const nextSegment = () => selectSegment((segmentIndex + 1) % queue.length);
+ const togglePlayback = () => {
+ if (typeof window === "undefined" || !("speechSynthesis" in window)) {
+ setPlayback("Browser speech is unavailable");
+ return;
+ }
+ if (playing) {
+ window.speechSynthesis.cancel();
+ setPlaying(false);
+ setPlayback("Playback stopped");
+ return;
+ }
+ const utterance = new SpeechSynthesisUtterance(script);
+ utterance.rate = 0.96;
+ utterance.pitch = 0.92;
+ utterance.volume = volume / 100;
+ utterance.onend = () => { setPlaying(false); setPlayback("Segment complete"); };
+ utterance.onerror = () => { setPlaying(false); setPlayback("Playback could not start"); };
+ window.speechSynthesis.cancel();
+ window.speechSynthesis.speak(utterance);
+ setPlaying(true);
+ setPlayback("Browser speech active");
+ };
+ const updateScript = (value: string) => {
+ setScript(value);
+ setQueue((items) => items.map((item, index) => index === segmentIndex ? { ...item, script: value } : item));
+ };
+ const addSegment = () => {
+ const next = { title: "New desk note", kind: "DRAFT", duration: "02:00", script: "Add sourced market context for this new segment." };
+ setQueue((items) => [...items, next]);
+ setSegmentIndex(queue.length);
+ setScript(next.script);
+ setPlayback("Draft segment added");
+ };
+ return
+
+
{product.eyebrow}
{product.headline} {product.description}
{evidenceLabel(market)} Speech stays on this device. Distribution requires approval.
{stations[stationIndex].name} {currentSegment.kind}
NOW PLAYING
{currentSegment.title} {script}
{playing ? "Stop" : "Play briefing"} Next segment
Volume {volume}% setVolume(Number(event.target.value))} />
{playback}
+
CHOOSE A FREQUENCY
Four crypto desks, one broadcast Browser-generated audio {stations.map((station, index) => { setStationIndex(index); setPlayback(station.name + " selected"); }}>{String(index + 1).padStart(2, "0")} {station.name} {station.format} )}
+
MARKET SNAPSHOT
On the radar Refresh evidence {coins.length ? coins.map((coin) =>
{coin.symbol} {coin.priceLabel} = 0 ? "positive" : "negative"}>{changeLabel(coin.change24h)} · {coin.marketCapLabel} ) :
{market.status === "loading" ? "Market capability loading" : market.error || "Market capability unavailable"} {market.status === "loading" ? "Checking the same-origin data capability." : "Retry the capability; no live market result is being claimed."} }
+
TODAY'S RUNDOWN
Stories in the queue {queue.length} segments {queue.map((segment, index) =>
selectSegment(index)}>{String(index + 1).padStart(2, "0")} {segment.kind} {segment.title}
{segment.duration} )}
RUNDOWN EDITOR
{currentSegment.title} Segment script
+
FROM THE ARCHIVE
Research without the noise Editorial demo queue 01 PRIVACY Why the market needs verifiable context Evidence-first research for every crypto workflow.
18 MIN 02 INFRASTRUCTURE Wallet monitoring without custody Drops Bot events, rules and approval boundaries.
24 MIN 03 MARKETS Unlocks, funding and the next catalyst What to verify before a signal becomes a story.
12 MIN
+
Drops Radio DropsTab data is live only with verified provider evidence. Drops Bot, Telegram and public distribution remain setup-required until a confirmed provider receipt.
+
;
}
function CryptoAssistant() {
@@ -252,17 +320,18 @@ function selectCategoryComponent(
export const PROJECT_TEMPLATE_GLOBAL_CSS = String.raw`@import "tailwindcss";
-:root { color-scheme: dark; }
+:root { color-scheme: dark; --project-accent: #67e8f9; --project-surface: #070a12; --project-radius: 20px; --project-font: Inter, ui-sans-serif, system-ui, sans-serif; }
* { box-sizing: border-box; }
-body { margin: 0; min-width: 320px; background: #070a12; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }
+html { scroll-behavior: smooth; }
+body { margin: 0; min-width: 320px; overflow-x: clip; background: var(--project-surface); font-family: var(--project-font); }
button, a, input, textarea, select { touch-action: manipulation; }
button, input, textarea, select { font: inherit; }
main { min-height: 100vh; padding: 20px 16px; color: #f8fafc; }
.shell { display: grid; max-width: 1240px; gap: 20px; margin: 0 auto; }
-.hero { display: grid; gap: 24px; border: 1px solid rgb(255 255 255 / .1); border-radius: 28px; background: linear-gradient(145deg, rgb(15 23 42 / .98), rgb(8 13 24 / .98)); padding: clamp(24px, 4vw, 48px); box-shadow: 0 24px 90px rgb(0 0 0 / .32); }
+.hero { display: grid; gap: 24px; border: 1px solid rgb(255 255 255 / .1); border-radius: calc(var(--project-radius) + 8px); background: linear-gradient(145deg, rgb(15 23 42 / .98), rgb(8 13 24 / .98)); padding: clamp(24px, 4vw, 48px); box-shadow: 0 24px 90px rgb(0 0 0 / .32); }
.hero h1 { max-width: 1000px; margin: 12px 0 0; font-size: clamp(2.25rem, 6vw, 5rem); line-height: .98; letter-spacing: -.045em; }
.lead { max-width: 780px; margin: 18px 0 0; color: #cbd5e1; font-size: 1.05rem; line-height: 1.75; }
-.kicker { margin: 0; color: #67e8f9; font-size: .75rem; font-weight: 800; letter-spacing: .16em; }
+.kicker { margin: 0; color: var(--project-accent); font-size: .75rem; font-weight: 800; letter-spacing: .16em; }
.notice { display: inline-flex; min-height: 44px; width: fit-content; align-items: center; gap: 10px; border: 1px solid rgb(255 255 255 / .1); border-radius: 999px; padding: 10px 14px; color: #cbd5e1; font-size: .82rem; }
.notice i { width: 8px; height: 8px; border-radius: 50%; background: #fbbf24; box-shadow: 0 0 20px #fbbf24aa; }
.split { display: grid; gap: 20px; }
@@ -273,17 +342,17 @@ main { min-height: 100vh; padding: 20px 16px; color: #f8fafc; }
label { display: block; margin: 4px 0 8px; color: #cbd5e1; font-size: .875rem; font-weight: 700; }
input:not([type="range"]), textarea, select { min-height: 48px; width: 100%; border: 1px solid rgb(255 255 255 / .14); border-radius: 14px; background: #090f1d; padding: 12px 14px; color: #f8fafc; outline: none; }
textarea { min-height: 112px; resize: vertical; } textarea.large { min-height: 210px; }
-input:focus, textarea:focus, select:focus { border-color: #67e8f9; box-shadow: 0 0 0 3px rgb(103 232 249 / .14); }
-input[type="range"] { min-height: 44px; width: 100%; accent-color: #67e8f9; }
-.primary, .game-actions button { min-height: 48px; border: 0; border-radius: 14px; background: #67e8f9; padding: 12px 18px; color: #07111c; font-weight: 800; cursor: pointer; }
+input:focus, textarea:focus, select:focus { border-color: var(--project-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--project-accent) 14%, transparent); }
+input[type="range"] { min-height: 44px; width: 100%; accent-color: var(--project-accent); }
+.primary, .game-actions button { min-height: 48px; border: 0; border-radius: 14px; background: var(--project-accent); padding: 12px 18px; color: #07111c; font-weight: 800; cursor: pointer; }
.primary:hover, .game-actions button:hover { filter: brightness(1.08); }
.actions, .inline { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.inline input { flex: 1 1 220px; }
.pill, .toggle { display: inline-flex; min-height: 44px; align-items: center; border: 1px solid rgb(255 255 255 / .12); border-radius: 999px; background: rgb(15 23 42 / .8); padding: 9px 13px; color: #cbd5e1; font-size: .8rem; }
-.toggle { cursor: pointer; } .toggle.selected { border-color: #67e8f9; color: #67e8f9; }
+.toggle { cursor: pointer; } .toggle.selected { border-color: var(--project-accent); color: var(--project-accent); }
.choices { display: grid; gap: 9px; }
.choice { display: grid; min-height: 48px; gap: 4px; width: 100%; border: 1px solid rgb(255 255 255 / .1); border-radius: 14px; background: #0a1020; padding: 13px 14px; color: #cbd5e1; text-align: left; cursor: pointer; }
-.choice span { color: #94a3b8; font-size: .8rem; } .choice.active { border-color: #67e8f9; background: rgb(8 145 178 / .12); color: white; }
+.choice span { color: #94a3b8; font-size: .8rem; } .choice.active { border-color: var(--project-accent); background: color-mix(in srgb, var(--project-accent) 12%, transparent); color: white; }
.metrics { display: grid; gap: 12px; } .metric { display: grid; min-height: 128px; gap: 7px; border: 1px solid rgb(255 255 255 / .1); border-radius: 18px; background: #0a1020; padding: 18px; }
.metric span { color: #94a3b8; font-size: .82rem; } .metric strong { font-size: 1.6rem; } .metric small { color: #94a3b8; }
.row { display: flex; min-height: 52px; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid rgb(255 255 255 / .08); color: #94a3b8; } .row strong { color: #f8fafc; text-align: right; }
@@ -292,7 +361,7 @@ input[type="range"] { min-height: 44px; width: 100%; accent-color: #67e8f9; }
.big-number { font-size: clamp(3.5rem, 9vw, 7rem); font-weight: 800; letter-spacing: -.06em; }
.bars { display: grid; gap: 22px; } .bar-label { display: flex; justify-content: space-between; margin-bottom: 8px; } .bar { height: 12px; border-radius: 999px; background: #1e293b; } .bar i { display: block; height: 100%; border-radius: inherit; background: #e879f9; }
.table-wrap { overflow-x: auto; } table { width: 100%; min-width: 620px; border-collapse: collapse; } th, td { border-bottom: 1px solid rgb(255 255 255 / .08); padding: 14px; text-align: left; } th { color: #64748b; font-size: .75rem; letter-spacing: .1em; text-transform: uppercase; } td span { display: block; color: #64748b; font-size: .8rem; } .positive { color: #6ee7b7; } .negative { color: #fda4af; }
-.small-button { min-height: 44px; border: 1px solid rgb(103 232 249 / .35); border-radius: 12px; background: rgb(103 232 249 / .08); padding: 8px 12px; color: #67e8f9; cursor: pointer; }
+.small-button { min-height: 44px; border: 1px solid color-mix(in srgb, var(--project-accent) 35%, transparent); border-radius: 12px; background: color-mix(in srgb, var(--project-accent) 8%, transparent); padding: 8px 12px; color: var(--project-accent); cursor: pointer; }
.arena { overflow: hidden; border: 1px solid rgb(103 232 249 / .24); border-radius: 32px; background: radial-gradient(circle at top, #22d3ee22, transparent 54%), linear-gradient(145deg, #111b32, #090d19); padding: clamp(28px, 6vw, 64px) 20px; text-align: center; }
.coin { display: grid; width: 160px; height: 160px; place-items: center; margin: 32px auto; border: 1px solid rgb(103 232 249 / .4); border-radius: 44px; background: rgb(103 232 249 / .1); color: #f8fafc; font-size: 3rem; font-weight: 900; box-shadow: 0 0 80px #22d3ee33; }
.game-actions { display: flex; justify-content: center; gap: 14px; margin: 24px 0; } .game-actions .up { background: #6ee7b7; } .game-actions .down { background: #fda4af; }
@@ -300,14 +369,104 @@ input[type="range"] { min-height: 44px; width: 100%; accent-color: #67e8f9; }
.character { display: grid; aspect-ratio: 1; width: min(220px, 60vw); place-items: center; border: 2px solid rgb(255 255 255 / .22); border-radius: 42% 58% 52% 48%; background: linear-gradient(135deg, #a78bfa, #67e8f9); color: #111827; font-size: 3.5rem; box-shadow: 0 22px 80px rgb(103 232 249 / .24); }
.energy { height: 16px; overflow: hidden; border-radius: 999px; background: #1e293b; } .energy i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #a78bfa, #67e8f9); }
.launches { display: grid; gap: 10px; } .launches article { display: flex; min-height: 66px; align-items: center; justify-content: space-between; gap: 14px; border: 1px solid rgb(255 255 255 / .09); border-radius: 16px; background: #0a1020; padding: 14px; } .launches article div { display: grid; gap: 4px; } .launches span { color: #94a3b8; font-size: .8rem; }
-.disc { display: grid; width: 150px; height: 150px; place-items: center; border: 2px solid rgb(255 255 255 / .2); border-radius: 50%; background: repeating-radial-gradient(circle, #111827 0 9px, #1e293b 10px 11px); color: #67e8f9; font-size: 2rem; box-shadow: 0 0 60px rgb(103 232 249 / .12); }
+.disc { display: grid; width: 150px; height: 150px; place-items: center; border: 2px solid rgb(255 255 255 / .2); border-radius: 50%; background: repeating-radial-gradient(circle, #111827 0 9px, #1e293b 10px 11px); color: var(--project-accent); font-size: 2rem; box-shadow: 0 0 60px rgb(103 232 249 / .12); }
+.radio-main { min-height: 100vh; padding: 0; background: #080d10; color: #f5f8f3; }
+.radio-shell { width: min(100%, 1480px); margin: 0 auto; border-inline: 1px solid #263035; background: linear-gradient(90deg, transparent 49.8%, rgb(255 255 255 / .035) 50%, transparent 50.2%); }
+.radio-header { position: sticky; z-index: 5; top: 0; display: grid; min-height: 76px; grid-template-columns: minmax(190px, 1fr) auto minmax(190px, 1fr); align-items: center; gap: 24px; border-bottom: 1px solid #263035; background: rgb(8 13 16 / .94); padding: 0 clamp(20px, 4vw, 54px); backdrop-filter: blur(18px); }
+.radio-brand { display: inline-grid; width: fit-content; color: #f5f8f3; text-decoration: none; }
+.radio-brand span { font-size: 16px; font-weight: 900; letter-spacing: .06em; }
+.radio-brand small { margin-top: 3px; color: var(--project-accent); font-size: 12px; font-weight: 800; letter-spacing: .14em; }
+.radio-header nav { display: flex; gap: 28px; }
+.radio-header nav a { display: inline-flex; min-height: 44px; align-items: center; color: #98a6ab; font-size: 14px; font-weight: 750; text-decoration: none; }
+.radio-header nav a:hover, .radio-header nav a:focus-visible { color: #fff; }
+.radio-status { justify-self: end; border: 1px solid color-mix(in srgb, var(--project-accent) 54%, #263035); border-radius: 999px; padding: 9px 12px; color: var(--project-accent); font-size: 12px; font-weight: 850; letter-spacing: .1em; }
+.radio-hero { display: grid; min-height: 650px; grid-template-columns: minmax(0, 1.15fr) minmax(380px, .85fr); border-bottom: 1px solid #263035; }
+.radio-intro { display: flex; min-width: 0; flex-direction: column; justify-content: center; padding: clamp(56px, 8vw, 120px) clamp(24px, 6vw, 92px); }
+.radio-overline, .radio-section-heading p, .radio-script > p { margin: 0; color: var(--project-accent); font-size: 12px; font-weight: 850; letter-spacing: .18em; }
+.radio-intro h1 { max-width: 100%; margin: 20px 0 26px; font-size: clamp(2.75rem, 5.8vw, 5.4rem); font-weight: 900; line-height: .9; letter-spacing: -.065em; overflow-wrap: anywhere; text-wrap: balance; text-transform: uppercase; }
+.radio-intro > p:not(.radio-overline) { max-width: 680px; margin: 0; color: #a7b2b7; font-size: 17px; line-height: 1.65; }
+.radio-evidence { display: grid; gap: 5px; margin-top: 42px; padding-left: 16px; border-left: 3px solid var(--project-accent); }
+.radio-evidence strong { font-size: 13px; }
+.radio-evidence span { color: #7e8c91; font-size: 12px; }
+.radio-player { align-self: center; min-width: 0; margin: 38px clamp(24px, 4vw, 58px) 38px 0; border: 1px solid #313d42; background: #0d1417; padding: clamp(24px, 4vw, 44px); box-shadow: 0 28px 80px rgb(0 0 0 / .34); }
+.radio-player-top { display: flex; min-height: 44px; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid #263035; color: #aab5b9; font-size: 12px; }
+.radio-player-top strong { color: var(--project-accent); font-size: 12px; letter-spacing: .12em; }
+.radio-track-label { margin: 34px 0 12px; color: #6f7d82; font-size: 12px; font-weight: 800; letter-spacing: .12em; }
+.radio-player h2 { margin: 0; font-size: clamp(2.3rem, 4.8vw, 4.6rem); line-height: .92; letter-spacing: -.055em; }
+.radio-player > p:not(.radio-track-label, .radio-playback) { min-height: 74px; color: #a9b4b8; font-size: 14px; line-height: 1.65; }
+.radio-controls { display: flex; flex-wrap: wrap; gap: 10px; margin: 24px 0; }
+.radio-controls button, .radio-text-button { min-height: 46px; border: 1px solid #364247; background: transparent; padding: 11px 16px; color: #f5f8f3; font-weight: 800; cursor: pointer; }
+.radio-controls button:first-child { border-color: var(--project-accent); background: var(--project-accent); color: #091012; }
+.radio-controls button:hover, .radio-text-button:hover { border-color: var(--project-accent); color: var(--project-accent); }
+.radio-player label { display: flex; justify-content: space-between; margin-top: 18px; color: #8a999e; }
+.radio-playback { margin: 8px 0 0; color: var(--project-accent); font-size: 12px; }
+.radio-section { border-bottom: 1px solid #263035; padding: clamp(52px, 6vw, 88px) clamp(24px, 6vw, 92px); }
+.radio-section-heading { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin-bottom: 34px; }
+.radio-section-heading h2, .radio-script h2 { margin: 10px 0 0; font-size: clamp(2.4rem, 5vw, 5rem); line-height: .92; letter-spacing: -.055em; text-transform: uppercase; }
+.radio-section-heading > span { color: #7f8d92; font-size: 12px; }
+.radio-stations { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
+.radio-stations button { display: grid; min-height: 176px; gap: 12px; border: 1px solid #293338; border-right: 0; background: #0a1013; padding: 24px; color: #f5f8f3; text-align: left; cursor: pointer; }
+.radio-stations button:last-child { border-right: 1px solid #293338; }
+.radio-stations button.active { border-color: var(--project-accent); background: color-mix(in srgb, var(--project-accent) 8%, #0a1013); }
+.radio-stations button > span { color: #69777c; font-family: ui-monospace, monospace; font-size: 12px; }
+.radio-stations strong { align-self: end; font-size: 22px; letter-spacing: -.03em; }
+.radio-stations small { color: #7d8a8f; font-size: 12px; letter-spacing: .08em; text-transform: uppercase; }
+.radio-market { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-top: 1px solid #293338; border-left: 1px solid #293338; }
+.radio-market article { display: grid; min-height: 170px; gap: 12px; padding: 26px; border-right: 1px solid #293338; border-bottom: 1px solid #293338; }
+.radio-market article > span { color: #8e9ba0; font-size: 12px; font-weight: 850; }
+.radio-market article > strong { align-self: end; font-size: clamp(1.65rem, 3vw, 3rem); letter-spacing: -.04em; }
+.radio-market article > small { font-size: 12px; }
+.radio-market-empty { grid-column: 1 / -1; }
+.radio-text-button { margin: 0; }
+.radio-editor { display: grid; grid-template-columns: minmax(0, 1.06fr) minmax(360px, .94fr); border-bottom: 1px solid #263035; }
+.radio-queue { min-width: 0; padding: clamp(52px, 6vw, 88px) clamp(24px, 5vw, 72px); border-right: 1px solid #263035; }
+.radio-queue > button { display: grid; min-height: 112px; width: 100%; grid-template-columns: 42px minmax(0, 1fr) auto; align-items: center; gap: 18px; border: 0; border-top: 1px solid #293338; background: transparent; padding: 18px 8px; color: #f5f8f3; text-align: left; cursor: pointer; }
+.radio-queue > button:last-child { border-bottom: 1px solid #293338; }
+.radio-queue > button.active { background: color-mix(in srgb, var(--project-accent) 6%, transparent); }
+.radio-queue > button > span { color: #738086; font-family: ui-monospace, monospace; font-size: 12px; }
+.radio-queue > button > div { display: grid; gap: 7px; }
+.radio-queue > button small { color: var(--project-accent); font-size: 12px; font-weight: 850; letter-spacing: .13em; }
+.radio-queue > button strong { font-size: 19px; }
+.radio-queue > button b { font-size: 12px; }
+.radio-script { min-width: 0; padding: clamp(52px, 6vw, 88px) clamp(24px, 5vw, 72px); background: #0c1316; }
+.radio-script h2 { font-size: clamp(2.2rem, 4vw, 4.2rem); }
+.radio-script label { margin-top: 30px; }
+.radio-script textarea { min-height: 240px; border-radius: 0; background: #070c0e; line-height: 1.65; }
+.radio-script > small { display: block; color: #77858a; line-height: 1.6; }
+.radio-archive { display: grid; }
+.radio-archive article { display: grid; min-height: 132px; grid-template-columns: 52px minmax(0, 1fr) auto; align-items: center; gap: 28px; border-top: 1px solid #293338; padding: 20px 8px; }
+.radio-archive article:last-child { border-bottom: 1px solid #293338; }
+.radio-archive article > span { color: #738086; font-family: ui-monospace, monospace; font-size: 12px; }
+.radio-archive article > div { display: grid; grid-template-columns: minmax(180px, .65fr) minmax(240px, 1fr); align-items: center; gap: 24px; }
+.radio-archive small { color: var(--project-accent); font-size: 12px; font-weight: 850; letter-spacing: .13em; }
+.radio-archive strong { font-size: 22px; letter-spacing: -.025em; }
+.radio-archive p { margin: 0; color: #7d8a8f; font-size: 13px; line-height: 1.55; }
+.radio-archive b { font-size: 12px; }
+.radio-footer { display: grid; grid-template-columns: auto minmax(0, 740px); justify-content: space-between; gap: 30px; padding: 34px clamp(24px, 6vw, 92px); color: #77858a; }
+.radio-footer strong { color: #f5f8f3; }
+.radio-footer span { font-size: 12px; line-height: 1.65; }
.chat { display: grid; max-height: 430px; gap: 12px; overflow: auto; } .message { max-width: 85%; border-radius: 16px 16px 16px 4px; background: #111b30; padding: 14px; } .message.you { margin-left: auto; border-radius: 16px 16px 4px 16px; background: rgb(8 145 178 / .2); } .message span { color: #67e8f9; font-size: .75rem; font-weight: 800; text-transform: uppercase; } .message p { margin: 6px 0 0; line-height: 1.55; }
footer { padding: 6px 4px 24px; color: #94a3b8; font-size: .78rem; line-height: 1.7; }
@media (min-width: 700px) { main { padding: 28px; } .hero { grid-template-columns: 1fr auto; align-items: end; } .metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); } .metrics.two { grid-template-columns: repeat(2, minmax(0, 1fr)); } .habitat { grid-template-columns: auto 1fr; } }
@media (min-width: 980px) { .split { grid-template-columns: 1.15fr .85fr; } .split.reverse { grid-template-columns: .78fr 1.22fr; } }
+@media (max-width: 980px) { .radio-header { grid-template-columns: 1fr auto; } .radio-header nav { display: none; } .radio-hero, .radio-editor { grid-template-columns: 1fr; } .radio-player { margin: 0 24px 48px; } .radio-queue { border-right: 0; border-bottom: 1px solid #263035; } .radio-stations, .radio-market { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+@media (max-width: 620px) { .radio-header { min-height: 68px; padding-inline: 18px; } .radio-status { font-size: 12px; } .radio-intro { padding: 58px 20px 40px; } .radio-intro h1 { font-size: clamp(2.7rem, 14vw, 4.25rem); } .radio-player { margin: 0 14px 34px; padding: 22px; } .radio-section, .radio-queue, .radio-script { padding: 48px 20px; } .radio-section-heading { align-items: flex-start; flex-direction: column; } .radio-stations, .radio-market { grid-template-columns: 1fr; } .radio-stations button { min-height: 132px; border-right: 1px solid #293338; } .radio-queue > button { grid-template-columns: 34px minmax(0, 1fr); } .radio-queue > button > b { display: none; } .radio-archive article { grid-template-columns: 36px minmax(0, 1fr); gap: 12px; } .radio-archive article > div { grid-template-columns: 1fr; gap: 8px; } .radio-archive article > b { display: none; } .radio-footer { grid-template-columns: 1fr; padding: 30px 20px; } }
@media (max-width: 520px) { .game-actions { flex-direction: column; } .inline { align-items: stretch; } .inline .primary { width: 100%; } }
`;
+function projectFontStack(font: GeneratedProjectSpec["design"]["font"]): string {
+ if (font === "ibm-plex") return '"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace';
+ if (font === "space-grotesk") return '"Space Grotesk", Inter, ui-sans-serif, system-ui, sans-serif';
+ return "Inter, ui-sans-serif, system-ui, sans-serif";
+}
+
+export function projectTemplateGlobalCss(spec: GeneratedProjectSpec): string {
+ return PROJECT_TEMPLATE_GLOBAL_CSS.replace(
+ ':root { color-scheme: dark; --project-accent: #67e8f9; --project-surface: #070a12; --project-radius: 20px; --project-font: Inter, ui-sans-serif, system-ui, sans-serif; }',
+ `:root { color-scheme: dark; --project-accent: ${spec.theme.accent}; --project-surface: ${spec.theme.surface}; --project-radius: ${spec.design.radius}px; --project-font: ${projectFontStack(spec.design.font)}; }`,
+ );
+}
+
export function projectTemplateComponentSource(
spec: GeneratedProjectSpec,
copy: TemplateCopy,
diff --git a/lib/project-v2-migration.ts b/lib/project-v2-migration.ts
index e52529b..d1bf8aa 100644
--- a/lib/project-v2-migration.ts
+++ b/lib/project-v2-migration.ts
@@ -1,5 +1,8 @@
import { hashProjectV2CanonicalState } from "./project-v2-hash.ts";
-import { createProjectV2File } from "./project-v2-files.ts";
+import {
+ applyProjectV2FileOperations,
+ createProjectV2File,
+} from "./project-v2-files.ts";
import type {
BuilderTaskV2,
ProjectFileLanguageV2,
@@ -333,6 +336,71 @@ export async function migrateGeneratedProjectToV2(
return validateProjectV2(project);
}
+/**
+ * Refresh a migrated legacy filesystem after a product-spec edit without
+ * converting the project to a different runtime or overwriting manual/AI files.
+ */
+export async function refreshLegacyProjectV2Migration(input: {
+ project: ProjectV2;
+ generatedProject: GeneratedProject;
+}): Promise {
+ const current = await validateProjectV2(input.project);
+ if (current.manifest.framework.name !== "legacy-html") {
+ throw new Error("Only migrated legacy projects can use the legacy refresh adapter.");
+ }
+
+ const baseline = await migrateGeneratedProjectToV2({
+ ...input.generatedProject,
+ projectV2: undefined,
+ });
+ const preservedFiles = Object.values(current.files).filter(
+ (file) => file.provenance !== "generated",
+ );
+ const preservedOperations = preservedFiles.map((file) => ({
+ type: "write" as const,
+ path: file.path,
+ content: file.content,
+ language: file.language,
+ role: file.role,
+ provenance: file.provenance,
+ editable: file.editable,
+ }));
+ const refreshed = preservedOperations.length
+ ? await applyProjectV2FileOperations(
+ baseline,
+ baseline.revision,
+ preservedOperations,
+ { now: () => new Date(input.generatedProject.updatedAt) },
+ )
+ : baseline;
+ const revision = current.revision + 1;
+ const updatedAt = new Date(input.generatedProject.updatedAt).toISOString();
+ const next: ProjectV2 = {
+ ...refreshed,
+ revision,
+ integrations: current.integrations,
+ environment: current.environment,
+ permissions: current.permissions,
+ deployment: current.deployment,
+ migration: current.migration,
+ runs: current.runs,
+ logs: current.logs,
+ checkpoints: current.checkpoints,
+ preview: current.preview
+ ? {
+ status: "stopped",
+ projectRevision: revision,
+ stoppedAt: updatedAt,
+ }
+ : undefined,
+ createdAt: current.createdAt,
+ updatedAt,
+ contentHash: "",
+ };
+ next.contentHash = await hashProjectV2CanonicalState(next);
+ return validateProjectV2(next);
+}
+
export class LegacyProjectV2MigrationAdapter implements ProjectV2MigrationAdapter {
readonly sourceSchemaVersion = 1 as const;
diff --git a/package-lock.json b/package-lock.json
index 63a4599..138dd69 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -36,6 +36,7 @@
"next": "^16.2.12",
"react": "^19.2.8",
"react-dom": "^19.2.8",
+ "react-icons": "5.7.0",
"stripe": "22.4.0",
"tailwind-merge": "3.6.0",
"teleproto": "^1.228.4",
@@ -16810,6 +16811,15 @@
"react": "^19.2.8"
}
},
+ "node_modules/react-icons": {
+ "version": "5.7.0",
+ "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz",
+ "integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "*"
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
diff --git a/package.json b/package.json
index 4fc35d9..4fb3f7e 100644
--- a/package.json
+++ b/package.json
@@ -59,6 +59,7 @@
"next": "^16.2.12",
"react": "^19.2.8",
"react-dom": "^19.2.8",
+ "react-icons": "5.7.0",
"stripe": "22.4.0",
"tailwind-merge": "3.6.0",
"teleproto": "^1.228.4",
diff --git a/tests/agent-context-rag.test.mjs b/tests/agent-context-rag.test.mjs
index 3b00bfb..70c25d7 100644
--- a/tests/agent-context-rag.test.mjs
+++ b/tests/agent-context-rag.test.mjs
@@ -400,7 +400,7 @@ test("source registry redacts on registration and refuses prohibited or unsafe s
assert.equal(registry.register(source({ sensitivity: "prohibited" })), null);
assert.throws(() => registry.register(source({ path: "../escape.ts" })), /not indexable/i);
const cache = new ContextCache();
- assert.throws(() => cache.set("unsafe", { token: "123456789:abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNO" }, ["test"]), /secret-bearing/i);
+ assert.throws(() => cache.set("unsafe", { token: ["123456789", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNO"].join(":") }, ["test"]), /secret-bearing/i);
});
test("compileWithTrace emits privacy-safe retrieval and budget evidence", async () => {
diff --git a/tests/artifact-security.test.mjs b/tests/artifact-security.test.mjs
index 0b61a6b..64af8bc 100644
--- a/tests/artifact-security.test.mjs
+++ b/tests/artifact-security.test.mjs
@@ -11,7 +11,7 @@ import {
} from "../lib/artifact-security.ts";
const secretFixtures = [
- ["telegram", "123456789:AAE9Qqkx4JmU3Rr6Tt8Vv0Xx2Zz4Bb6Cc8"],
+ ["telegram", ["123456789", "AAE9Qqkx4JmU3Rr6Tt8Vv0Xx2Zz4Bb6Cc8"].join(":")],
["jwt", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwicm9sZSI6ImFkbWluIn0.sQ8OD8r2hVdRa6QbRzF0c3kF0d1p8G9mN4xE7qJ5vT0"],
["github", "github_pat_11AA0BBBB0cccccDDDDD0_eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"],
["vercel", "vercel_abcdefghijklmnopqrstuvwxyz0123456789"],
diff --git a/tests/collaboration-transport-client.test.mjs b/tests/collaboration-transport-client.test.mjs
index 72ebbb1..02e0074 100644
--- a/tests/collaboration-transport-client.test.mjs
+++ b/tests/collaboration-transport-client.test.mjs
@@ -234,6 +234,6 @@ test("Studio team component polls only while visible and keeps remote apply cons
assert.match(source, /pendingRemoteRevisionRef/);
assert.match(source, /approve the replacement, then open it locally/i);
assert.match(source, /checked=\{applyConsent\}/);
- assert.match(source, /disabled=\{signedOut \|\| !applicableProject \|\| !applyConsent/);
+ assert.match(source, /disabled=\{accessUnverified \|\| !applicableProject \|\| !applyConsent/);
assert.doesNotMatch(source, /Collaboration connected/);
});
diff --git a/tests/dropsbot-webhook.test.mjs b/tests/dropsbot-webhook.test.mjs
index d6cec23..217a26d 100644
--- a/tests/dropsbot-webhook.test.mjs
+++ b/tests/dropsbot-webhook.test.mjs
@@ -128,11 +128,18 @@ test("Drops Bot receiver clears stale provider evidence after account expiry", a
source.indexOf("if (!response.ok)", source.indexOf("if (response.status === 401)")),
);
- assert.match(unauthorizedBranch, /setCallbackUrl\(""\)/);
- assert.match(unauthorizedBranch, /setEvidence\(null\)/);
- assert.match(unauthorizedBranch, /setEvents\(\[\]\)/);
- assert.match(unauthorizedBranch, /setConsent\(false\)/);
- assert.match(unauthorizedBranch, /setCanCreate\(false\)/);
+ const protectedReset = source.slice(
+ source.indexOf("const clearProtectedState"),
+ source.indexOf("const refresh =", source.indexOf("const clearProtectedState")),
+ );
+ assert.match(unauthorizedBranch, /setAccessVerified\(false\)/);
+ assert.match(unauthorizedBranch, /clearProtectedState\(\)/);
+ assert.match(protectedReset, /setCallbackUrl\(""\)/);
+ assert.match(protectedReset, /setEvidence\(null\)/);
+ assert.match(protectedReset, /setEvents\(\[\]\)/);
+ assert.match(protectedReset, /setConsent\(false\)/);
+ assert.match(protectedReset, /setMutationConsent\(false\)/);
+ assert.match(protectedReset, /setCanCreate\(false\)/);
assert.match(source, /Consent to rotate or revoke the Drops Bot callback/);
assert.match(source, /method === "PUT" \? "rotate" : "revoke"/);
assert.match(source, /mutateCallback\("PUT"\)/);
@@ -162,7 +169,7 @@ test("Drops Bot callback capabilities store only a hash and redact credential ma
authorization: "Bearer provider-secret-material-that-must-not-be-stored",
providerApiKey: "provider-key-with-a-prefixed-field-name",
authorizationHeader: "provider-auth-with-a-suffixed-field-name",
- botToken: "123456789:AAabcdefghijklmnopqrstuvwxyz0123456789",
+ botToken: ["123456789", "AAabcdefghijklmnopqrstuvwxyz0123456789"].join(":"),
token: { symbol: "ETH" },
},
});
diff --git a/tests/product-reality.test.mjs b/tests/product-reality.test.mjs
index c51af2a..6d47c37 100644
--- a/tests/product-reality.test.mjs
+++ b/tests/product-reality.test.mjs
@@ -138,7 +138,10 @@ test("generated products do not claim outcomes they cannot verify", async () =>
assert.match(validator, /change: null/);
assert.match(builder, /useState<"sample" \| "live">\("sample"\)/);
assert.match(builder, /probability: 68/);
- assert.match(previewCanvas, /dataMode === "live" \? "Live DropsTab data" : "Sample data"/);
+ assert.match(
+ previewCanvas,
+ /aria-label=\{`\$\{visibleName\} \$\{dataMode === "live" \? "with live DropsTab data" : "with labelled sample data"\}`\}/,
+ );
assert.match(builder, /const sandboxChecks = new Set\(\[[\s\S]*?"data-adapter"/);
assert.doesNotMatch(builder, /const sandboxChecks = new Set\(\[[\s\S]*?"dropstab"/);
assert.doesNotMatch(builder, /probability: 0/);
diff --git a/tests/project-export-portability.test.mjs b/tests/project-export-portability.test.mjs
index e12443d..65944d1 100644
--- a/tests/project-export-portability.test.mjs
+++ b/tests/project-export-portability.test.mjs
@@ -210,7 +210,7 @@ test("export rejects handcrafted inline SVG artwork", () => {
test("archive creation rejects a secret from project prompt or any generated file", () => {
const unsafe = project('DropsTab Drops Bot', {
- prompt: "Use 123456789:AAE9Qqkx4JmU3Rr6Tt8Vv0Xx2Zz4Bb6Cc8",
+ prompt: `Use ${["123456789", "AAE9Qqkx4JmU3Rr6Tt8Vv0Xx2Zz4Bb6Cc8"].join(":")}`,
});
assert.throws(() => createProjectArchive(unsafe, quality, archiveAssets), /secret/i);
});
diff --git a/tests/project-store.test.mjs b/tests/project-store.test.mjs
index 75c5147..4d48a1a 100644
--- a/tests/project-store.test.mjs
+++ b/tests/project-store.test.mjs
@@ -32,6 +32,26 @@ function memoryStorage(initial = {}) {
};
}
+function quotaStorage(initial, maxCharacters) {
+ const storage = memoryStorage(initial);
+ const setItem = storage.setItem.bind(storage);
+ storage.setItem = (key, value) => {
+ const nextValue = String(value);
+ const nextSize = Array.from({ length: storage.length }, (_, index) => storage.key(index))
+ .filter((candidate) => candidate && candidate !== key)
+ .reduce((total, candidate) => total + candidate.length + (storage.getItem(candidate)?.length ?? 0), 0)
+ + key.length
+ + nextValue.length;
+ if (nextSize > maxCharacters) {
+ const error = new Error("quota exceeded");
+ error.name = "QuotaExceededError";
+ throw error;
+ }
+ setItem(key, nextValue);
+ };
+ return storage;
+}
+
test("project store merges another tab's projects while holding a Web Lock", async () => {
const store = await import("../lib/project-store.ts");
assert.equal(typeof store.saveProjectSafely, "function");
@@ -103,6 +123,52 @@ test("project store fallback preserves projects through versioned per-project re
);
});
+test("project store compacts the compatibility index before writing a large Project V2 record", async () => {
+ const store = await import("../lib/project-store.ts");
+ const first = {
+ ...project("project-a", "2026-07-30T00:01:00.000Z"),
+ projectV2: {
+ schemaVersion: 2,
+ id: "project-a",
+ revision: 1,
+ files: {
+ "app/page.tsx": {
+ path: "app/page.tsx",
+ content: "x".repeat(6_000),
+ encoding: "utf-8",
+ hash: "test-hash",
+ generated: true,
+ editable: true,
+ },
+ },
+ },
+ };
+ const legacyValue = JSON.stringify([first]);
+ const storage = quotaStorage(
+ { "drops-studio-projects-v2": legacyValue },
+ legacyValue.length + 1_500,
+ );
+ const updated = {
+ ...first,
+ updatedAt: "2026-07-30T00:02:00.000Z",
+ projectV2: { ...first.projectV2, revision: 2 },
+ };
+
+ const result = await store.saveProjectSafely(updated, {
+ storage,
+ expectedUpdatedAt: first.updatedAt,
+ });
+
+ assert.equal(result.status, "saved");
+ const compatibilityIndex = JSON.parse(storage.getItem("drops-studio-projects-v2"));
+ assert.equal(compatibilityIndex[0].projectV2, undefined);
+ const item = JSON.parse(
+ storage.getItem(`${store.PROJECT_STORE_ITEM_PREFIX}${encodeURIComponent(first.id)}`),
+ );
+ assert.equal(item.project.projectV2.revision, 2);
+ assert.equal(store.readProjectsFromStore(storage)[0].projectV2.revision, 2);
+});
+
test("project store keeps the newest copy when the legacy index and item record disagree", async () => {
const store = await import("../lib/project-store.ts");
const newer = project("project-a", "2026-07-30T00:03:00.000Z", "new legacy copy");
diff --git a/tests/project-template-materializer.test.mjs b/tests/project-template-materializer.test.mjs
index 9bd12b7..c47635d 100644
--- a/tests/project-template-materializer.test.mjs
+++ b/tests/project-template-materializer.test.mjs
@@ -13,7 +13,11 @@ registerHooks({
const { projectPresetIds } = await import("../lib/presets.ts");
const { findArtifactSecrets } = await import("../lib/artifact-security.ts");
const { createProjectSpec } = await import("../lib/project-factory.ts");
-const { materializeProjectV2Template } = await import("../lib/project-template-materializer.ts");
+const {
+ materializeProjectV2Template,
+ refreshGeneratedProjectV2Template,
+} = await import("../lib/project-template-materializer.ts");
+const { writeProjectV2File } = await import("../lib/project-v2-files.ts");
const { projectTemplateComponentSource } = await import("../lib/project-template-ui.ts");
const { validateProjectV2 } = await import("../lib/project-v2-validator.ts");
@@ -124,6 +128,97 @@ test("generated DropsTab /coins capability is server-only, bounded and honest",
assert.deepEqual(integration?.capabilities, ["coins"]);
});
+test("refreshes generated V2 source from product edits without overwriting manual files", async () => {
+ const spec = createProjectSpec({
+ presetId: "crypto-radio",
+ values: {},
+ prompt: "Build a crypto radio",
+ tools: ["DropsTab API", "Drops Bot"],
+ provider: "free",
+ model: "Free compiler",
+ market: [],
+ prediction: { title: "No prediction", probability: null, change: null },
+ origin: "https://drops-studio.example",
+ });
+ const base = await materializeProjectV2Template({
+ id: "refresh-radio",
+ spec,
+ now: "2026-07-30T12:00:00.000Z",
+ });
+ const manual = await writeProjectV2File(base, base.revision, {
+ type: "write",
+ path: "README.md",
+ content: "# Manually curated radio notes\n",
+ provenance: "manual",
+ });
+ const withPreview = {
+ ...manual,
+ preview: {
+ status: "ready",
+ projectRevision: manual.revision,
+ url: "https://radio-preview.vercel.run/",
+ port: 3000,
+ startedAt: "2026-07-30T12:01:00.000Z",
+ },
+ };
+ const editedSpec = {
+ ...spec,
+ name: "Drops Signal Radio",
+ slug: "drops-signal-radio",
+ theme: { ...spec.theme, accent: "#a3ff12", surface: "#080d10" },
+ };
+ const refreshed = await refreshGeneratedProjectV2Template({
+ project: withPreview,
+ spec: editedSpec,
+ now: "2026-07-30T12:02:00.000Z",
+ });
+
+ assert.equal(refreshed.revision, manual.revision + 1);
+ assert.equal(refreshed.productSpec.name, "Drops Signal Radio");
+ assert.equal(refreshed.manifest.slug, "drops-signal-radio");
+ assert.match(refreshed.files["app/globals.css"].content, /--project-accent: #a3ff12/);
+ assert.equal(refreshed.files["README.md"].content, "# Manually curated radio notes\n");
+ assert.equal(refreshed.files["README.md"].provenance, "manual");
+ assert.equal(refreshed.preview?.status, "stopped");
+ assert.equal(refreshed.preview?.projectRevision, refreshed.revision);
+ assert.equal((await validateProjectV2(refreshed)).contentHash, refreshed.contentHash);
+
+ const noOp = await refreshGeneratedProjectV2Template({
+ project: refreshed,
+ spec: editedSpec,
+ now: "2026-07-30T12:03:00.000Z",
+ });
+ assert.equal(noOp.revision, refreshed.revision);
+});
+
+test("crypto radio starter has working browser playback and evidence-first editorial controls", async () => {
+ const spec = createProjectSpec({
+ presetId: "crypto-radio",
+ values: {},
+ prompt: "Build a premium crypto radio",
+ tools: ["DropsTab API", "Drops Bot"],
+ provider: "free",
+ model: "Free compiler",
+ market: [],
+ prediction: { title: "No prediction", probability: null, change: null },
+ origin: "https://drops-studio.example",
+ });
+ const project = await materializeProjectV2Template({
+ id: "premium-radio",
+ spec,
+ now: "2026-07-30T12:00:00.000Z",
+ });
+ const component = project.files["components/crypto-product.tsx"].content;
+ assert.match(component, /SpeechSynthesisUtterance/);
+ assert.match(component, /Play briefing/);
+ assert.match(component, /Four crypto desks, one broadcast/);
+ assert.match(component, /Rundown editor/);
+ assert.match(component, /const updateScript = \(value: string\)/);
+ assert.match(component, /index === segmentIndex \? \{ \.\.\.item, script: value \} : item/);
+ assert.match(component, /evidenceLabel\(market\)/);
+ assert.match(component, /Drops Bot, Telegram and public distribution remain setup-required/);
+});
+
test("mandatory vertical demos contain their real category interactions and honest boundaries", async () => {
const cases = [
["smart-money-copy", ["Tracked wallets", "Enrichment context", "Approve alert", "no custody or trading"]],
diff --git a/tests/project-v2-migration.test.mjs b/tests/project-v2-migration.test.mjs
index cc352ac..c3c30c7 100644
--- a/tests/project-v2-migration.test.mjs
+++ b/tests/project-v2-migration.test.mjs
@@ -10,8 +10,12 @@ registerHooks({
},
});
-const { migrateGeneratedProjectToV2 } = await import("../lib/project-v2-migration.ts");
+const {
+ migrateGeneratedProjectToV2,
+ refreshLegacyProjectV2Migration,
+} = await import("../lib/project-v2-migration.ts");
const { validateProjectV2 } = await import("../lib/project-v2-validator.ts");
+const { hashProjectV2CanonicalState } = await import("../lib/project-v2-hash.ts");
const { createProjectSpec } = await import("../lib/project-factory.ts");
const { compileProject } = await import("../lib/project-compiler.ts");
const { materializeProjectWorkspace } = await import("../lib/project-workspace.ts");
@@ -60,3 +64,61 @@ test("migrates the current canonical workspace as the exact V2 filesystem", asyn
assert.equal(project.files["src/app.js"].content, workspace.files.find((file) => file.path === "src/app.js").content);
assert.equal(project.migration.sourceKind, "project-workspace-v1");
});
+
+test("refreshes a migrated legacy filesystem after a product edit", async () => {
+ const legacy = legacyProject();
+ const workspace = materializeProjectWorkspace(legacy);
+ const migrated = await migrateGeneratedProjectToV2({ ...legacy, workspace });
+ const configured = structuredClone(migrated);
+ configured.integrations[0].status = "available";
+ configured.environment.push({
+ name: "PUBLIC_RPC_HOST",
+ description: "Approved public RPC host name.",
+ required: false,
+ secret: false,
+ scope: "runtime",
+ });
+ configured.permissions.push({
+ id: "read-public-rpc",
+ capability: "rpc:read",
+ effect: "allow",
+ destructive: false,
+ external: true,
+ });
+ configured.contentHash = await hashProjectV2CanonicalState(configured);
+ await validateProjectV2(configured);
+ const editedSpec = {
+ ...legacy.spec,
+ name: "Morning Alpha Desk",
+ slug: "morning-alpha-desk",
+ };
+ const changedAt = "2026-07-30T12:05:00.000Z";
+ const edited = {
+ ...legacy,
+ spec: editedSpec,
+ html: compileProject(editedSpec),
+ workspace: materializeProjectWorkspace({
+ ...legacy,
+ spec: editedSpec,
+ html: compileProject(editedSpec),
+ updatedAt: changedAt,
+ }),
+ updatedAt: changedAt,
+ };
+ const refreshed = await refreshLegacyProjectV2Migration({
+ project: configured,
+ generatedProject: edited,
+ });
+
+ assert.equal(refreshed.revision, configured.revision + 1);
+ assert.equal(refreshed.manifest.framework.name, "legacy-html");
+ assert.equal(refreshed.manifest.name, "Morning Alpha Desk");
+ assert.equal(refreshed.productSpec.name, "Morning Alpha Desk");
+ assert.match(refreshed.files["index.html"].content, /Morning Alpha Desk/);
+ assert.deepEqual(refreshed.integrations, configured.integrations);
+ assert.deepEqual(refreshed.environment, configured.environment);
+ assert.deepEqual(refreshed.permissions, configured.permissions);
+ assert.deepEqual(refreshed.deployment, configured.deployment);
+ assert.deepEqual(refreshed.migration, configured.migration);
+ await validateProjectV2(refreshed);
+});
diff --git a/tests/runtime-preview-security.test.mjs b/tests/runtime-preview-security.test.mjs
index be6f5b9..5b686f6 100644
--- a/tests/runtime-preview-security.test.mjs
+++ b/tests/runtime-preview-security.test.mjs
@@ -91,9 +91,18 @@ test("Studio keeps srcdoc popups disabled and treats iframe smoke as browser-onl
),
readFile(new URL("../lib/project-compiler.ts", import.meta.url), "utf8"),
]);
- assert.match(studio, /srcDoc=\{runtimeSrcDoc\}/);
- assert.match(studio, /sandbox="allow-scripts allow-forms allow-downloads"/);
- assert.doesNotMatch(studio, /sandbox="[^"]*allow-popups/);
+ assert.match(studio, /src=\{runtimePreviewUrl \?\? undefined\}/);
+ assert.match(
+ studio,
+ /srcDoc=\{runtimePreviewUrl \? undefined : runtimeSrcDoc\}/,
+ );
+ assert.match(
+ studio,
+ /sandbox=\{runtimePreviewUrl \? "allow-scripts allow-forms allow-downloads allow-same-origin" : "allow-scripts allow-forms allow-downloads"\}/,
+ );
+ assert.match(studio, /function currentProjectV2PreviewUrl/);
+ assert.match(studio, /url\.protocol === "https:"/);
+ assert.doesNotMatch(studio, /allow-popups/);
assert.match(
studio,
/mode: "browser",[\s\S]*dataProvider: "unverified"/,
diff --git a/tests/studio-account-team-panel.test.mjs b/tests/studio-account-team-panel.test.mjs
index bd0ed39..ee09c9b 100644
--- a/tests/studio-account-team-panel.test.mjs
+++ b/tests/studio-account-team-panel.test.mjs
@@ -85,10 +85,12 @@ test("panel keeps access claims honest and follows the local Tailwind/Base UI fl
assert.match(source, /aria-label="Consent to open Stripe billing"/);
assert.match(source, /aria-label="Consent to accept team invite"/);
assert.match(source, /aria-label="Consent to apply the verified shared source revision locally"/);
- assert.match(source, /signedOut \|\| !acceptConsent/);
- assert.match(source, /signedOut \|\| !selectedWorkspace \|\| !canWrite/);
- assert.match(source, /signedOut \|\| !teamConsent/);
- assert.match(source, /signedOut \|\| !createTeamConsent/);
+ assert.match(source, /accessUnverified \|\| !acceptConsent/);
+ assert.match(source, /accessUnverified \|\| !selectedWorkspace \|\| !canWrite/);
+ assert.match(source, /accessUnverified \|\| !teamConsent/);
+ assert.match(source, /accessUnverified \|\| !createTeamConsent/);
+ assert.match(source, /type LoadState = "loading" \| "ready" \| "signed-out" \| "unavailable"/);
+ assert.match(source, /setLoadState\("unavailable"\)/);
assert.match(source, /aria-label="Consent to create a team workspace"/);
assert.match(source, /aria-label="Consent to owner team mutations"/);
assert.match(source, /checked=\{teamConsent\}[\s\S]*I approve the next owner-only invite or role mutation/);
@@ -108,7 +110,7 @@ test("owners, editors and viewers can apply a verified shared source revision lo
assert.match(source, /onApplyProject\(materialized\)/);
assert.match(source, /const applied = await onApplyProject\(materialized\)/);
assert.match(source, /if \(!applied\)/);
- assert.match(source, /disabled=\{signedOut \|\| !applicableProject \|\| !applyConsent \|\| pendingAction !== null\}/);
+ assert.match(source, /disabled=\{accessUnverified \|\| !applicableProject \|\| !applyConsent \|\| pendingAction !== null\}/);
assert.doesNotMatch(
source.slice(source.indexOf("async function applySharedProject"), source.indexOf("async function shareProject")),
/canWrite|role === "owner"|role === "editor"/,
@@ -152,7 +154,7 @@ test("signed-out and action-level 401 clear capabilities and consent before reus
(source.match(/handleUnauthorizedResponse\(response\)/g) ?? []).length >= 6,
"every billing/team mutation must handle an expired signed session",
);
- assert.match(source, /disabled=\{signedOut\}[\s\S]*if \(signedOut\) return[\s\S]*navigator\.clipboard\?\.writeText[\s\S]*navigator\.clipboard\.writeText\(oneTimeInvite\.capability\)/);
+ assert.match(source, /disabled=\{accessUnverified\}[\s\S]*if \(accessUnverified\) return[\s\S]*navigator\.clipboard\?\.writeText[\s\S]*navigator\.clipboard\.writeText\(oneTimeInvite\.capability\)/);
assert.match(source, /Clipboard access is unavailable — copy the visible capability manually/);
});