diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index ac288ab6e..fe5060399 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -179,6 +179,10 @@ export const DesktopConfig = Config.all({ "BADCODE_DESKTOP_OPEN_DEVTOOLS", "T3CODE_DESKTOP_OPEN_DEVTOOLS", ), + marketingCaptureMode: Config.boolean("THREADLINES_DESKTOP_MARKETING_CAPTURE").pipe( + Config.option, + Config.map((value) => Option.getOrElse(value, () => false)), + ), mockUpdates: optionalBooleanAlias( "THREADLINES_DESKTOP_MOCK_UPDATES", "BADCODE_DESKTOP_MOCK_UPDATES", diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index c718613ea..b4ffb03b3 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -62,6 +62,7 @@ describe("DesktopEnvironment", () => { THREADLINES_DESKTOP_USER_DATA_DIR_NAME: " threadlines-marketing-studio ", THREADLINES_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ", THREADLINES_OTLP_EXPORT_INTERVAL_MS: "2500", + THREADLINES_DESKTOP_MARKETING_CAPTURE: "true", }, ); @@ -102,6 +103,7 @@ describe("DesktopEnvironment", () => { assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces")); assert.equal(environment.otlpExportIntervalMs, 2500); assert.equal(environment.openDevToolsInDevelopment, false); + assert.equal(environment.marketingCaptureMode, true); }), ); @@ -116,6 +118,7 @@ describe("DesktopEnvironment", () => { ); assert.equal(environment.openDevToolsInDevelopment, true); + assert.equal(environment.marketingCaptureMode, false); }), ); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 2bfe5a929..f802c837c 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -71,6 +71,7 @@ export interface DesktopEnvironmentShape { readonly otlpTracesUrl: Option.Option; readonly otlpExportIntervalMs: number; readonly openDevToolsInDevelopment: boolean; + readonly marketingCaptureMode: boolean; readonly branding: DesktopAppBranding; readonly displayName: string; readonly appUserModelId: string; @@ -228,6 +229,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( otlpTracesUrl: config.otlpTracesUrl, otlpExportIntervalMs: config.otlpExportIntervalMs, openDevToolsInDevelopment: config.openDevToolsInDevelopment, + marketingCaptureMode: config.marketingCaptureMode, branding, displayName, appUserModelId: isDevelopment ? DESKTOP_DEVELOPMENT_APP_ID : DESKTOP_RELEASE_APP_ID, diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 05f88b7f8..2576c3207 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -41,6 +41,7 @@ import { confirm, captureScreenshot, getAppBranding, + isMarketingCaptureMode, getLocalEnvironmentBootstrap, openExternal, pickFolder, @@ -80,6 +81,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { const ipc = yield* DesktopIpc.DesktopIpc; yield* ipc.handleSync(getAppBranding); + yield* ipc.handleSync(isMarketingCaptureMode); yield* ipc.handleSync(getLocalEnvironmentBootstrap); yield* ipc.handle(getClientSettings); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index dfbe46647..dd30e33c4 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -39,6 +39,7 @@ export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; +export const IS_MARKETING_CAPTURE_MODE_CHANNEL = "desktop:is-marketing-capture-mode"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index ce7d9216c..aa4de11b6 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -49,6 +49,15 @@ export const getAppBranding = makeSyncIpcMethod({ }), }); +export const isMarketingCaptureMode = makeSyncIpcMethod({ + channel: IpcChannels.IS_MARKETING_CAPTURE_MODE_CHANNEL, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.isMarketingCaptureMode")(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.marketingCaptureMode; + }), +}); + export const getLocalEnvironmentBootstrap = makeSyncIpcMethod({ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, result: Schema.NullOr(DesktopEnvironmentBootstrapSchema), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 5ec5403bb..e4b269069 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -41,6 +41,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + isMarketingCaptureMode: () => + ipcRenderer.sendSync(IpcChannels.IS_MARKETING_CAPTURE_MODE_CHANNEL) === true, getLocalEnvironmentBootstrap: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); if (typeof result !== "object" || result === null) { diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 7bb9f2663..b99b5c5d3 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -62,6 +62,7 @@ function makeFakeBrowserWindow(input?: { }; const window = { + center: vi.fn(), focus: vi.fn(), getBounds: vi.fn(() => bounds), getNormalBounds: vi.fn(() => normalBounds), @@ -80,8 +81,11 @@ function makeFakeBrowserWindow(input?: { once: vi.fn(), restore: vi.fn(), setBackgroundColor: vi.fn(), + setContentSize: vi.fn(), + setResizable: vi.fn(), setTitle: vi.fn(), setTitleBarOverlay: vi.fn(), + setWindowButtonVisibility: vi.fn(), show: vi.fn(), webContents, }; @@ -90,6 +94,10 @@ function makeFakeBrowserWindow(input?: { window: window as unknown as Electron.BrowserWindow, loadURL: window.loadURL, maximize: window.maximize, + center: window.center, + setContentSize: window.setContentSize, + setResizable: window.setResizable, + setWindowButtonVisibility: window.setWindowButtonVisibility, webContentsFocus: webContents.focus, openDevTools: webContents.openDevTools, replaceMisspelling: webContents.replaceMisspelling, @@ -316,6 +324,49 @@ describe("DesktopWindow", () => { }), ); + it.effect("uses exact frameless content geometry in marketing capture mode", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const createOptions = yield* Ref.make< + ReadonlyArray + >([]); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + createOptions, + mainWindow, + env: { + THREADLINES_DESKTOP_MARKETING_CAPTURE: "true", + }, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady; + + const [options] = yield* Ref.get(createOptions); + assert.equal(options?.width, DesktopWindow.MARKETING_CAPTURE_CONTENT_SIZE.width); + assert.equal(options?.height, DesktopWindow.MARKETING_CAPTURE_CONTENT_SIZE.height); + assert.equal(options?.useContentSize, true); + assert.equal(options?.frame, false); + assert.equal(options?.resizable, false); + assert.equal(options?.hasShadow, false); + assert.deepEqual(fakeWindow.setContentSize.mock.calls, [ + [ + DesktopWindow.MARKETING_CAPTURE_CONTENT_SIZE.width, + DesktopWindow.MARKETING_CAPTURE_CONTENT_SIZE.height, + ], + ]); + assert.deepEqual(fakeWindow.setResizable.mock.calls, [[false]]); + assert.equal(fakeWindow.center.mock.calls.length, 1); + assert.deepEqual(fakeWindow.setWindowButtonVisibility.mock.calls, [[false]]); + assert.equal(fakeWindow.maximize.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("applies an accepted spellcheck suggestion without renderer notifications", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index eafcddc78..6a4b640e9 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -50,6 +50,10 @@ const MIN_MAIN_WINDOW_WIDTH = 840; const MIN_MAIN_WINDOW_HEIGHT = 620; const MAX_RESTORED_MAIN_WINDOW_DIMENSION = 10_000; const MAIN_WINDOW_STATE_FILE_NAME = "window-state.json"; +export const MARKETING_CAPTURE_CONTENT_SIZE = { + width: 1600, + height: 934, +} as const; const PRINT_SCREEN_ACCELERATOR = "PrintScreen"; const PRINT_SCREEN_KEYS = new Set(["print", "printscreen"]); export const OPEN_SCREEN_CLIP_MENU_ACTION = "open-screen-clip"; @@ -147,7 +151,14 @@ function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; } -function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarOptions { +function getWindowTitleBarOptions( + shouldUseDarkColors: boolean, + marketingCaptureMode = false, +): WindowTitleBarOptions { + if (marketingCaptureMode) { + return {}; + } + if (process.platform === "darwin") { return { titleBarStyle: "hiddenInset", @@ -260,6 +271,7 @@ function savePersistedMainWindowState( function syncWindowAppearance( window: Electron.BrowserWindow, shouldUseDarkColors: boolean, + marketingCaptureMode: boolean, ): Effect.Effect { return Effect.sync(() => { if (window.isDestroyed()) { @@ -267,7 +279,7 @@ function syncWindowAppearance( } window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); - const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors); + const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors, marketingCaptureMode); if (typeof titleBarOverlay === "object") { window.setTitleBarOverlay(titleBarOverlay); } @@ -345,13 +357,20 @@ const make = Effect.gen(function* () { environment.stateDir, MAIN_WINDOW_STATE_FILE_NAME, ); - const persistedWindowState = yield* loadPersistedMainWindowState(fileSystem, windowStatePath); - const persistedWindowOptions = Option.isSome(persistedWindowState) + const persistedWindowState = environment.marketingCaptureMode + ? Option.none() + : yield* loadPersistedMainWindowState(fileSystem, windowStatePath); + const persistedWindowOptions = environment.marketingCaptureMode ? { - width: persistedWindowState.value.width, - height: persistedWindowState.value.height, + ...MARKETING_CAPTURE_CONTENT_SIZE, + useContentSize: true, } - : defaultMainWindowSize(yield* electronWindow.workAreaSize); + : Option.isSome(persistedWindowState) + ? { + width: persistedWindowState.value.width, + height: persistedWindowState.value.height, + } + : defaultMainWindowSize(yield* electronWindow.workAreaSize); const window = yield* electronWindow.create({ ...persistedWindowOptions, minWidth: MIN_MAIN_WINDOW_WIDTH, @@ -361,7 +380,16 @@ const make = Effect.gen(function* () { backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), ...iconOption, title: environment.displayName, - ...getWindowTitleBarOptions(shouldUseDarkColors), + ...getWindowTitleBarOptions(shouldUseDarkColors, environment.marketingCaptureMode), + ...(environment.marketingCaptureMode + ? { + frame: false, + fullscreenable: false, + hasShadow: false, + maximizable: false, + resizable: false, + } + : {}), webPreferences: { preload: environment.preloadPath, contextIsolation: true, @@ -375,6 +403,18 @@ const make = Effect.gen(function* () { }, }); + if (environment.marketingCaptureMode) { + window.setContentSize( + MARKETING_CAPTURE_CONTENT_SIZE.width, + MARKETING_CAPTURE_CONTENT_SIZE.height, + ); + window.setResizable(false); + window.center(); + if (environment.platform === "darwin") { + window.setWindowButtonVisibility(false); + } + } + // Preview content is untrusted: no Node, no custom preload, and it must run // in the preview partition rather than the app's own session. // @@ -394,11 +434,18 @@ const make = Effect.gen(function* () { } }); - if (Option.isSome(persistedWindowState) && persistedWindowState.value.isMaximized) { + if ( + !environment.marketingCaptureMode && + Option.isSome(persistedWindowState) && + persistedWindowState.value.isMaximized + ) { window.maximize(); } window.on("close", () => { + if (environment.marketingCaptureMode) { + return; + } const bounds = getPersistableMainWindowBounds(window); const width = normalizeRestoredDimension(bounds.width, MIN_MAIN_WINDOW_WIDTH); const height = normalizeRestoredDimension(bounds.height, MIN_MAIN_WINDOW_HEIGHT); @@ -702,7 +749,7 @@ const make = Effect.gen(function* () { syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; yield* electronWindow.syncAllAppearance((window) => - syncWindowAppearance(window, shouldUseDarkColors), + syncWindowAppearance(window, shouldUseDarkColors, environment.marketingCaptureMode), ); }).pipe(Effect.withSpan("desktop.window.syncAppearance")), }); diff --git a/apps/marketing/public/Screenshots/launch/Posters/agent-browser-workflow-dark.webp b/apps/marketing/public/Screenshots/launch/Posters/agent-browser-workflow-dark.webp new file mode 100644 index 000000000..b40e7002a Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/Posters/agent-browser-workflow-dark.webp differ diff --git a/apps/marketing/public/Screenshots/launch/Posters/agent-browser-workflow-light.webp b/apps/marketing/public/Screenshots/launch/Posters/agent-browser-workflow-light.webp new file mode 100644 index 000000000..08ef4dd46 Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/Posters/agent-browser-workflow-light.webp differ diff --git a/apps/marketing/public/Screenshots/launch/Posters/sidebar-attention-states-dark.webp b/apps/marketing/public/Screenshots/launch/Posters/sidebar-attention-states-dark.webp new file mode 100644 index 000000000..9b9055938 Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/Posters/sidebar-attention-states-dark.webp differ diff --git a/apps/marketing/public/Screenshots/launch/Posters/sidebar-attention-states-light.webp b/apps/marketing/public/Screenshots/launch/Posters/sidebar-attention-states-light.webp new file mode 100644 index 000000000..021565e9a Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/Posters/sidebar-attention-states-light.webp differ diff --git a/apps/marketing/public/Screenshots/launch/Posters/workspace-four-panel-overview-dark.webp b/apps/marketing/public/Screenshots/launch/Posters/workspace-four-panel-overview-dark.webp new file mode 100644 index 000000000..a236c826e Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/Posters/workspace-four-panel-overview-dark.webp differ diff --git a/apps/marketing/public/Screenshots/launch/Posters/workspace-four-panel-overview-light.webp b/apps/marketing/public/Screenshots/launch/Posters/workspace-four-panel-overview-light.webp new file mode 100644 index 000000000..c23b33c40 Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/Posters/workspace-four-panel-overview-light.webp differ diff --git a/apps/marketing/public/Screenshots/launch/README.md b/apps/marketing/public/Screenshots/launch/README.md index 5c9d17c5f..868ea7111 100644 --- a/apps/marketing/public/Screenshots/launch/README.md +++ b/apps/marketing/public/Screenshots/launch/README.md @@ -1,13 +1,36 @@ # Threadlines marketing capture retake -This folder contains the refreshed marketing set captured against the July 12, 2026 layout. Every video is exported at a constant 60 fps. `activity-header-dark`, `rich-subagent-chat`, and `project-files-edit` are 3200×1868 Retina exports; the remaining clips are 1600×934. Matching poster frames use the same resolution as their clips and are stored in `Posters/`. - -WebM is the primary format: VP9 Profile 2, 10-bit `yuv420p10le`, CRF 30 with no bitrate cap. MP4 is the Safari fallback: H.264 High profile, 8-bit `yuv420p`, CRF 18 with the slow preset. - -The staged sidebar has two expanded projects and one collapsed project, five or more threads per project, merged and branch indicators, and a running-terminal cue. The calibrated traffic-light clean plate is used on unobstructed full-window captures; its outer curve is mirrored from the untouched native corner at each export resolution, preserving macOS border antialiasing, normal control spacing, and the real sidebar toggle. It is intentionally omitted from the two opaque file-viewer clips so it cannot appear to float above the viewer layer. +This folder contains the refreshed marketing set. The primary 0.3.0 workspace, sidebar, +and browser clips come from the deterministic neutral capture studio at 3200×1868 and a +constant 60 fps. Each scene has matching `-dark` and `-light` captures, and responsive +1600×934 variants carry the `-mobile` suffix after the theme. +`activity-header-dark`, `rich-subagent-chat`, and `project-files-edit` are also +3200×1868 Retina exports; the remaining legacy clips are 1600×934. Matching poster +frames are stored in `Posters/`. + +WebM is the primary format. The new responsive 0.3.0 clips use VP9 Profile 0, +8-bit `yuv420p`, CRF 30 with no bitrate cap; the earlier Retina clips retain their +Profile 2, 10-bit masters. MP4 is the Safari fallback: H.264 High profile, +8-bit `yuv420p`, CRF 18 with the slow preset. + +The 0.3.0 sidebar has five live threads across three projects: input, working, and +background each have a distinct signal, while two threads are deliberately idle. Eleven +older threads sit under Wrapped. New neutral captures are frameless by design and do not +use a composited traffic-light plate. ## Recommended site use +- `workspace-four-panel-overview-{dark,light}` — primary 0.3.0 homepage pair with the + cross-project sidebar, active conversation, matching Orbit browser, and source control + in one frame. The take collapses pending input, reviews browser activity, opens a + source-control diff, and returns to the overview. The standalone PNG remains the + social-card source. +- `sidebar-attention-states-{dark,light}` — five live threads: amber input needed, blue + recently started working, cyan background, and two quiet threads with no status + treatment. +- `agent-browser-workflow-{dark,light}` — annotates the live service-health heading in + the matching-theme Orbit browser, attaches the real element note to the composer, and + shows the agent applying the requested compact green status treatment in place. - `activity-header-dark` — strongest hero candidate. Opens the compact activity dropdown with 4/6 tasks, two active subagents, and one background run, then moves through the live work without expanding the six-step list or leaving focus rings. - `rich-subagent-chat` — full conversation with a substantial Scout subagent result and follow-up responses. - `project-files-edit` — browses the project tree, opens a tab, enters editing by double-click, saves a small change, then selects an exact line range and attaches it to chat; the current edit icon remains visible in the toolbar. @@ -33,7 +56,7 @@ The staged sidebar has two expanded projects and one collapsed project, five or - `*.mp4` — broadly compatible website video. - `*.webm` — smaller modern-browser alternative. -- `Posters/*.png` — selected poster frame at the matching video resolution. +- `Posters/*.webp` — selected poster frame at the matching video resolution. - `*.png` at this folder level — standalone light-mode screenshots. - `poster-contact-sheet.png` — quick visual review of the complete poster and still set. diff --git a/apps/marketing/public/Screenshots/launch/agent-browser-workflow-dark-mobile.mp4 b/apps/marketing/public/Screenshots/launch/agent-browser-workflow-dark-mobile.mp4 new file mode 100644 index 000000000..1e5c9fce9 Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/agent-browser-workflow-dark-mobile.mp4 differ diff --git a/apps/marketing/public/Screenshots/launch/agent-browser-workflow-dark-mobile.webm b/apps/marketing/public/Screenshots/launch/agent-browser-workflow-dark-mobile.webm new file mode 100644 index 000000000..5c706ea8a Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/agent-browser-workflow-dark-mobile.webm differ diff --git a/apps/marketing/public/Screenshots/launch/agent-browser-workflow-dark.mp4 b/apps/marketing/public/Screenshots/launch/agent-browser-workflow-dark.mp4 new file mode 100644 index 000000000..441c039c8 Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/agent-browser-workflow-dark.mp4 differ diff --git a/apps/marketing/public/Screenshots/launch/agent-browser-workflow-dark.webm b/apps/marketing/public/Screenshots/launch/agent-browser-workflow-dark.webm new file mode 100644 index 000000000..00eaf3164 Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/agent-browser-workflow-dark.webm differ diff --git a/apps/marketing/public/Screenshots/launch/agent-browser-workflow-light-mobile.mp4 b/apps/marketing/public/Screenshots/launch/agent-browser-workflow-light-mobile.mp4 new file mode 100644 index 000000000..01da34135 Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/agent-browser-workflow-light-mobile.mp4 differ diff --git a/apps/marketing/public/Screenshots/launch/agent-browser-workflow-light-mobile.webm b/apps/marketing/public/Screenshots/launch/agent-browser-workflow-light-mobile.webm new file mode 100644 index 000000000..d86a6b72f Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/agent-browser-workflow-light-mobile.webm differ diff --git a/apps/marketing/public/Screenshots/launch/agent-browser-workflow-light.mp4 b/apps/marketing/public/Screenshots/launch/agent-browser-workflow-light.mp4 new file mode 100644 index 000000000..8a0792ba8 Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/agent-browser-workflow-light.mp4 differ diff --git a/apps/marketing/public/Screenshots/launch/agent-browser-workflow-light.webm b/apps/marketing/public/Screenshots/launch/agent-browser-workflow-light.webm new file mode 100644 index 000000000..71e50629e Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/agent-browser-workflow-light.webm differ diff --git a/apps/marketing/public/Screenshots/launch/sidebar-attention-states-dark-mobile.mp4 b/apps/marketing/public/Screenshots/launch/sidebar-attention-states-dark-mobile.mp4 new file mode 100644 index 000000000..31bac8c5f Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/sidebar-attention-states-dark-mobile.mp4 differ diff --git a/apps/marketing/public/Screenshots/launch/sidebar-attention-states-dark-mobile.webm b/apps/marketing/public/Screenshots/launch/sidebar-attention-states-dark-mobile.webm new file mode 100644 index 000000000..f80d04985 Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/sidebar-attention-states-dark-mobile.webm differ diff --git a/apps/marketing/public/Screenshots/launch/sidebar-attention-states-dark.mp4 b/apps/marketing/public/Screenshots/launch/sidebar-attention-states-dark.mp4 new file mode 100644 index 000000000..df888d431 Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/sidebar-attention-states-dark.mp4 differ diff --git a/apps/marketing/public/Screenshots/launch/sidebar-attention-states-dark.webm b/apps/marketing/public/Screenshots/launch/sidebar-attention-states-dark.webm new file mode 100644 index 000000000..d932edf7a Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/sidebar-attention-states-dark.webm differ diff --git a/apps/marketing/public/Screenshots/launch/sidebar-attention-states-light-mobile.mp4 b/apps/marketing/public/Screenshots/launch/sidebar-attention-states-light-mobile.mp4 new file mode 100644 index 000000000..2d046240a Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/sidebar-attention-states-light-mobile.mp4 differ diff --git a/apps/marketing/public/Screenshots/launch/sidebar-attention-states-light-mobile.webm b/apps/marketing/public/Screenshots/launch/sidebar-attention-states-light-mobile.webm new file mode 100644 index 000000000..f009f9287 Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/sidebar-attention-states-light-mobile.webm differ diff --git a/apps/marketing/public/Screenshots/launch/sidebar-attention-states-light.mp4 b/apps/marketing/public/Screenshots/launch/sidebar-attention-states-light.mp4 new file mode 100644 index 000000000..27424020e Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/sidebar-attention-states-light.mp4 differ diff --git a/apps/marketing/public/Screenshots/launch/sidebar-attention-states-light.webm b/apps/marketing/public/Screenshots/launch/sidebar-attention-states-light.webm new file mode 100644 index 000000000..9d1a77155 Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/sidebar-attention-states-light.webm differ diff --git a/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-dark-mobile.mp4 b/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-dark-mobile.mp4 new file mode 100644 index 000000000..1f9c9544d Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-dark-mobile.mp4 differ diff --git a/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-dark-mobile.webm b/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-dark-mobile.webm new file mode 100644 index 000000000..dfd4faac1 Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-dark-mobile.webm differ diff --git a/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-dark.mp4 b/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-dark.mp4 new file mode 100644 index 000000000..ad4dcdad0 Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-dark.mp4 differ diff --git a/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-dark.webm b/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-dark.webm new file mode 100644 index 000000000..c859d18d9 Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-dark.webm differ diff --git a/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-light-mobile.mp4 b/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-light-mobile.mp4 new file mode 100644 index 000000000..10efc7020 Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-light-mobile.mp4 differ diff --git a/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-light-mobile.webm b/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-light-mobile.webm new file mode 100644 index 000000000..d97991c9c Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-light-mobile.webm differ diff --git a/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-light.mp4 b/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-light.mp4 new file mode 100644 index 000000000..0d32175c0 Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-light.mp4 differ diff --git a/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-light.webm b/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-light.webm new file mode 100644 index 000000000..a71771635 Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview-light.webm differ diff --git a/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview.png b/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview.png new file mode 100644 index 000000000..0e73e4f30 Binary files /dev/null and b/apps/marketing/public/Screenshots/launch/workspace-four-panel-overview.png differ diff --git a/apps/marketing/public/og.png b/apps/marketing/public/og.png index f3a7c2429..26ed6734d 100644 Binary files a/apps/marketing/public/og.png and b/apps/marketing/public/og.png differ diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index 05f02e52d..94403e2a5 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -52,7 +52,7 @@ const websiteStructuredData = { @@ -467,11 +467,10 @@ const websiteStructuredData = { color: var(--accent-contrast); font-size: 13px; font-weight: 600; - transition: transform 0.2s ease, filter 0.2s ease; + transition: filter 0.2s ease; } .nav-dl:hover { - transform: translateY(-1px); filter: brightness(1.08); } diff --git a/apps/marketing/src/lib/site.ts b/apps/marketing/src/lib/site.ts index 316f4e5a5..e94b8eb5f 100644 --- a/apps/marketing/src/lib/site.ts +++ b/apps/marketing/src/lib/site.ts @@ -3,7 +3,7 @@ export const SITE_URL = "https://www.threadlines.dev"; export const SITE_NAME = "Threadlines"; export const DEFAULT_SITE_DESCRIPTION = - "Threadlines is a free, open-source desktop workspace for Claude Code and Codex: parallel agent threads with real source control, a file editor, and full visibility into every task, subagent, and background run."; + "Threadlines is a free, open-source desktop workspace for Claude Code and Codex, combining agent threads, a live browser, project files, and real source control in one local workspace."; export const SITE_SOCIAL_IMAGE = "/og.png"; diff --git a/apps/marketing/src/pages/download.astro b/apps/marketing/src/pages/download.astro index 086a3121a..a42d4d306 100644 --- a/apps/marketing/src/pages/download.astro +++ b/apps/marketing/src/pages/download.astro @@ -103,14 +103,16 @@ import Layout from "../layouts/Layout.astro"; const list = document.getElementById("dl-list")!; const fallback = document.getElementById("dl-fallback")!; + const userAgent = navigator.userAgent.toLowerCase(); const platform = ( - (navigator as { userAgentData?: { platform?: string } }).userAgentData?.platform || - navigator.platform || - "" + (navigator as { userAgentData?: { platform?: string } }).userAgentData?.platform ?? "" ).toLowerCase(); - const isWindows = platform.includes("win"); - const isMac = platform.includes("mac"); - const isLinux = platform.includes("linux"); + const isMobile = /android|iphone|ipad|ipod/.test(userAgent); + const isWindows = platform.includes("win") || userAgent.includes("windows"); + const isMac = + !isMobile && (platform.includes("mac") || userAgent.includes("macintosh")); + const isLinux = + !isMobile && (platform.includes("linux") || userAgent.includes("linux")); try { const release = await fetchLatestRelease(); @@ -202,11 +204,11 @@ import Layout from "../layouts/Layout.astro"; border-radius: 10px; padding: 12px 22px; text-decoration: none; - transition: transform 0.2s ease; + transition: filter 0.2s ease; } .dl-primary:hover { - transform: translateY(-1px); + filter: brightness(1.08); } .dl-primary[hidden] { diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index b6027b4cc..eabadf18d 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -43,7 +43,7 @@ const playGlyphs = ` @@ -111,9 +111,9 @@ const playGlyphs = `Every thread leaves a line.

- Threadlines is a local desktop workspace that runs Claude Code and Codex in - parallel threads, with real source control at the - center and nothing hidden. Bring the subscriptions you already pay for. + Threadlines is a local desktop workspace for Claude Code and Codex. Keep the + agent conversation, a live browser, project files, and + source control open together.

Download for macOS @@ -148,26 +148,143 @@ const playGlyphs = ` - - + + + +
- A subagent reporting back, with source control one panel away. + The 0.3 workspace: sidebar, agent conversation, live browser, and source control in + one frame.
+
+
+

Cross-project sidebar

+

See what needs you. Let quiet work stay quiet.

+

+ Five active threads across three projects; input needed, working, and background are + distinct at a glance, while idle work stays unlabelled and everything older folds + into Wrapped. +

+
+
+
+ +
+
+
+ +
+
+

A browser built into the work

+

Review what the agent changed without changing context

+

+ Point to the exact element that needs work, attach the annotation to the + conversation, and watch the agent update the live preview beside its thread. +

+
+
+
+ +
+
+
+

Project files, first-class

@@ -325,6 +442,7 @@ const playGlyphs = ` { @@ -735,28 +856,30 @@ const playGlyphs = ` { - if (!themed) return; - const name = - document.documentElement.dataset.theme === "light" - ? "activity-header-light" - : "activity-header-dark"; - if (themed.dataset.clip === name) return; - themed.dataset.clip = name; - const wasPlaying = !themed.paused; - themed.poster = `/Screenshots/launch/Posters/${name}.webp`; - for (const source of themed.querySelectorAll("source")) { - const ext = source.type === "video/webm" ? "webm" : "mp4"; - source.src = `/Screenshots/launch/${name}.${ext}`; + // --- theme-following captures ---------------------------------------------- + const applyThemedClips = () => { + const theme = document.documentElement.dataset.theme === "light" ? "light" : "dark"; + for (const themed of document.querySelectorAll( + "video[data-theme-swap]", + )) { + const base = themed.dataset.clipBase; + if (!base) continue; + const name = `${base}-${theme}`; + if (themed.dataset.clip === name) continue; + themed.dataset.clip = name; + const wasPlaying = !themed.paused; + themed.poster = `/Screenshots/launch/Posters/${name}.webp`; + for (const source of themed.querySelectorAll("source")) { + const ext = source.type === "video/webm" ? "webm" : "mp4"; + const size = source.dataset.size === "mobile" ? "-mobile" : ""; + source.src = `/Screenshots/launch/${name}${size}.${ext}`; + } + themed.load(); + if (wasPlaying) themed.play().catch(() => {}); } - themed.load(); - if (wasPlaying) themed.play().catch(() => {}); }; - document.addEventListener("tl-themechange", applyThemedClip); - applyThemedClip(); + document.addEventListener("tl-themechange", applyThemedClips); + applyThemedClips();
+
{activeProjectName && ( diff --git a/apps/web/src/desktopChrome.test.ts b/apps/web/src/desktopChrome.test.ts index e37743122..0a07dbd09 100644 --- a/apps/web/src/desktopChrome.test.ts +++ b/apps/web/src/desktopChrome.test.ts @@ -24,4 +24,11 @@ describe("resolveElectronSidebarWordmarkLayout", () => { }); expect(layout.wordmarkRowClassName).toContain("pl-[var(--workspace-titlebar-content-left)]"); }); + + it("removes macOS traffic-light clearance in marketing capture mode", () => { + expect(resolveElectronSidebarWordmarkLayout("MacIntel", true)).toEqual({ + spacerClassName: null, + wordmarkRowClassName: WINDOWS_SIDEBAR_WORDMARK_ROW_CLASS, + }); + }); }); diff --git a/apps/web/src/desktopChrome.ts b/apps/web/src/desktopChrome.ts index b0f8a4095..f4350654c 100644 --- a/apps/web/src/desktopChrome.ts +++ b/apps/web/src/desktopChrome.ts @@ -1,4 +1,4 @@ -import { isElectron } from "./env"; +import { isElectron, isMarketingCaptureMode } from "./env"; import { isMacPlatform, isWindowsPlatform } from "./lib/utils"; export const isMacElectron = @@ -12,14 +12,17 @@ export const WINDOWS_SIDEBAR_WORDMARK_ROW_CLASS = "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] items-center pr-3 pl-[var(--workspace-titlebar-content-left)]"; export function needsMacTrafficLightClearance(sidebarOpen: boolean): boolean { - return isMacElectron && !sidebarOpen; + return isMacElectron && !isMarketingCaptureMode && !sidebarOpen; } -export function resolveElectronSidebarWordmarkLayout(platform: string): { +export function resolveElectronSidebarWordmarkLayout( + platform: string, + marketingCaptureMode = isMarketingCaptureMode, +): { spacerClassName: string | null; wordmarkRowClassName: string; } { - if (isWindowsPlatform(platform)) { + if (marketingCaptureMode || isWindowsPlatform(platform)) { return { spacerClassName: null, wordmarkRowClassName: WINDOWS_SIDEBAR_WORDMARK_ROW_CLASS, diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index fb2e493ca..9820ef650 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -6,3 +6,10 @@ export const isElectron = typeof window !== "undefined" && (window.desktopBridge !== undefined || window.nativeApi !== undefined); + +export const isMarketingCaptureMode = + isElectron && window.desktopBridge?.isMarketingCaptureMode?.() === true; + +if (typeof document !== "undefined" && isMarketingCaptureMode) { + document.documentElement.dataset.marketingCapture = "true"; +} diff --git a/docs/brand/social/og-card.html b/docs/brand/social/og-card.html index d7776f98f..100adf656 100644 --- a/docs/brand/social/og-card.html +++ b/docs/brand/social/og-card.html @@ -1,103 +1,101 @@ - + + + Threadlines social card -
-
- - Threadlines -
-
- The open-source workspace for Claude Code and - Codex -
-
- Parallel agent threads with real source control, a file editor, and nothing running invisibly. -
-
- Free & MIT - macOS + Windows - Local-first -
-
- -
+ +
+
+ + Threadlines +
+
+ Agents, browser, and source control. One workspace. +
+
v0.3.0
+
+
+ +
diff --git a/docs/marketing-capture-studio.md b/docs/marketing-capture-studio.md index 4c82a4e85..12a664b52 100644 --- a/docs/marketing-capture-studio.md +++ b/docs/marketing-capture-studio.md @@ -1,252 +1,342 @@ # Marketing Capture Studio -The Marketing Studio is a disposable desktop profile and synthetic repository for -recording Threadlines without exposing personal projects, sessions, branch names, or -browser state. It runs beside the normal release app and uses independent ports and data -directories. +The Marketing Studio is a disposable desktop profile, three synthetic repositories, and +a deterministic media pipeline for recording Threadlines without exposing personal +projects, sessions, browser state, or filesystem paths. + +The studio favors real product behavior over decorative fixtures: + +- Orbit is a runnable local product at `http://127.0.0.1:4173/` with live reload. +- Thread histories contain real orchestration messages, sessions, turns, approvals, + pending input, plans, background work, completions, and failures. +- Git branches, worktrees, staged files, history, and pull-request status use the same + application paths as normal work. +- The primary capture window has exact platform-neutral geometry. +- Native macOS, Windows, and Linux controls are reserved for explicit platform-proof + screenshots. ## Start the studio -From the Threadlines source checkout: +From the Threadlines checkout: ```sh vp run marketing:studio ``` -The first launch creates the studio at: +The default root deliberately avoids the operator's username: + +- macOS: `/Users/Shared/Threadlines Marketing Studio` +- Windows: `%PUBLIC%\Documents\Threadlines Marketing Studio` +- Linux: `/tmp/Threadlines Marketing Studio` + +Set `THREADLINES_MARKETING_STUDIO_DIR` to use another reviewed location. A location under +the personal home directory fails capture preflight unless +`--allow-personal-path` is supplied explicitly. + +The studio contains: ```text -~/Threadlines Marketing Studio/ -├── Orbit/ primary synthetic Git repository -├── Lumen/ companion project with an amber favicon -├── Northstar/ companion project with a cyan favicon -├── .worktrees/ branch worktrees behind the seeded thread history +Threadlines Marketing Studio/ +├── Orbit/ runnable synthetic product and primary Git repository +├── Lumen/ synthetic feature-delivery project +├── Northstar/ synthetic observability project +├── .worktrees/ branch worktrees used by seeded threads +├── Capture Plan.json copy of the source-controlled scene manifest ├── Captures/ -│ ├── Masters/ untouched recordings and full-resolution screenshots -│ ├── Exports/ cropped and compressed site assets -│ └── Posters/ video poster frames -└── .threadlines/ isolated server and session state +│ ├── Masters/ untouched source recordings and clean PNG stills +│ ├── Exports/ generated desktop/mobile delivery assets +│ ├── Posters/ lossless poster masters +│ └── QA/ reports, contact sheets, OCR, and review frames +└── .threadlines/ isolated server and orchestration state ``` -On macOS, the isolated Electron browser profile lives at -~/Library/Application Support/threadlines-marketing-studio so Chromium's sandboxed -network process can write its cache. Other platforms use their standard application-data -directory. It has its own ownership marker and is cleared by the guarded reset. +The Chromium profile is isolated separately in the platform application-data directory. +The studio never reads the regular Threadlines project registry or browser profile. +GitHub responses come from a local deterministic fixture and do not use a personal +GitHub login. -Use a different location by setting THREADLINES_MARKETING_STUDIO_DIR before running a -studio command. THREADLINES_MARKETING_STUDIO_APP_DATA_DIR can move the browser profile; -its final directory name must contain only letters, numbers, dots, dashes, or underscores. +Print every resolved path: -The launch profile is intentionally isolated in four ways: +```sh +vp run marketing:studio:paths +``` + +The source-controlled capture plan is +`scripts/fixtures/marketing-studio/capture-scenes.json`. It declares release, geometry, +theme, window mode, cursor mode, project, thread, browser URL, source-control state, +duration, visible labels, and story for each shot. Change the manifest rather than +relying on a private shot checklist. -- THREADLINES_DEV_INSTANCE gives it deterministic ports separate from normal development. -- THREADLINES_HOME separates project, thread, session, server, and window state. -- THREADLINES_DESKTOP_APP_DATA_DIR and THREADLINES_DESKTOP_USER_DATA_DIR_NAME separate - Electron browser state from every other development profile. -- THREADLINES_DESKTOP_BACKEND_CWD makes Orbit the auto-bootstrapped project. +## Neutral motion versus native platform proof -All three repositories include a root favicon.svg, using the same favicon discovery path -as real projects. The studio seeds intentionally uneven thread histories: four threads in -Orbit, three in Northstar, and two in Lumen. Three branch-backed threads resolve through -the normal Git-status path to merged pull requests, so the violet merged icon in the -sidebar is the real Threadlines status indicator rather than decorative capture markup. -The GitHub responses come from an isolated deterministic fixture on the studio's PATH; -they never use a personal GitHub login or require network access. +Primary feature videos use the neutral studio: -The seeded threads also provide two deliberate model treatments for capture variety: +```sh +vp run marketing:studio +``` -- Claude Fable 5 at High: Project file editing, Release guard, Deploy health, Trace - sampling, and Evaluation cache. -- GPT-5.6-Sol at Max: Checkout recovery, Usage insights, Group noisy alerts, and Rollout - cohorts. +Capture mode creates a frameless window with an exact `1600 × 934` content area, disables +resizing and shadows, ignores persisted/maximized state, centers the window, and removes +the macOS traffic-light clearance from the web layout. At 2× display scale, the source +surface is `3200 × 1868`. -The composer reads these from each thread's real model selection, so switching threads is -enough to change both the model and reasoning chips for a shot. +Do not composite replacement traffic lights or reconstruct native corners. Neutral motion +is one honest cross-platform representation of the product. -Print all resolved locations without creating or launching anything: +For a genuine macOS screenshot: ```sh -vp run marketing:studio:paths +vp run marketing:studio:native ``` -## Reset to the canonical shot state +Use the `platform-macos` scene and capture the whole native window with an OS or OBS window +source. The clean-still command intentionally refuses native scenes because renderer-only +screenshots cannot include genuine OS controls. Repeat the native still on Windows and +Linux for the download page or platform strip. + +## Recording specification + +| Setting | Required value | +| -------------------- | ------------------------------------------------- | +| Logical content area | `1600 × 934` | +| Display scale | `2×` | +| Source dimensions | `3200 × 1868` | +| Frame rate | true constant `60 fps` | +| Color | SDR, BT.709 | +| Audio | disabled | +| Motion source | individual Threadlines window | +| Preferred master | ProRes 422 HQ or FFV1/lossless-quality source | +| Delivery | VP9 Profile 0 WebM and H.264 High MP4 | +| Mobile delivery | `1600 × 934` | +| Keyframe interval | two seconds | +| Poster | first trimmed frame; PNG master and WebP delivery | + +The export command rejects a nominal 120/240 fps recording whose average timing is not +60 fps. It also rejects H.264 4:2:0 as a source master by default. Those files are useful +delivery encodes, but colored UI text and hairline borders should not begin the editing +pipeline after 4:2:0 compression. + +The complete `1600 × 934` window must fit inside one display in logical points. The +default `1512 × 982` mode on a 3024-pixel MacBook display is too narrow: centering the +window leaves 88 horizontal points outside the display, and macOS pads the missing edge +black in a window recording. Select the 2× “More Space” mode (`1800 × 1169` on that +panel), or use a Retina external display with enough logical space, before launching the +studio. Preflight rejects any window that crosses a display edge. + +### OBS setup on macOS + +Use OBS's macOS Screen Capture source in Window mode: + +1. Select only the Threadlines window, never the full display. +2. Keep OBS and its controls on a second display. +3. Set Base Canvas and Output Resolution to `3200 × 1868`. +4. Set Common FPS Value to `60`. +5. Disable output rescaling, HDR, audio tracks, and microphone capture. +6. Turn off True Tone, Night Shift, auto brightness, and notification banners. +7. Record MKV for crash recovery. Select ProRes 422 HQ when available; otherwise use a + lossless or visually indistinguishable intraframe setting that does not reduce the + source to H.264 4:2:0. +8. Include the cursor only for a story where it performs an action. Exclude or park it for + scroll-only footage. + +Capture one second of stillness before and after the action. Keep masters uncropped. +Rehearse and retake human cursor movement; do not synthesize a perfectly smooth cursor or +repair controls with a clean plate. + +### Native pointer profile + +Use the normal small macOS pointer for scenes whose manifest says `"cursorMode": "native"`. +It is the system pointer with a black fill and thin white outline, not a special dark-mode +asset. + +Before recording, open **System Settings → Accessibility → Display → Pointer**: + +1. Put Pointer size at the default/first position. +2. Choose Reset Colors so the outline is white and the fill is black. +3. Turn off Shake mouse pointer to locate for the recording session. A quick rehearsal + sweep can otherwise make the pointer balloon in the middle of a take. + +In the recorder, select the raw system/native cursor at `100%`. Disable cursor +replacement, enlargement, smoothing, magnetic movement, click rings, and highlights. In +OBS, use the source's **Show cursor** checkbox only for scenes marked `native`; turn it +off for scenes marked `hidden`. + +Record a three-second calibration pass over both the light sidebar and dark browser +surface. Review it at 100% before the real take. If the pointer is mostly white, enlarged, +or changes size while moving, fix the OS/recorder setting instead of correcting it in +post. + +## Scene workflow + +Every featured motion story has separate `-dark` and `-light` scene IDs. Record both +members of a pair: preparation switches the Threadlines UI theme, and browser scenes +load the same theme in the synthetic Orbit preview. The marketing page selects the +matching asset whenever its theme changes. + +Launch the studio in one terminal. In another terminal, prepare a scene: -The reset command refuses to run unless the directory contains the studio ownership -marker and the explicit force flag is present: +```sh +vp run marketing:capture:prepare -- --scene workspace-four-panel-overview-dark +``` + +Preparation fixes the scene theme, stores the active scene marker, opens the named +thread, arranges the browser, and opens or closes source control exactly as declared. +Review the frame, then run: ```sh -vp run marketing:studio:reset -- --force +vp run marketing:capture:preflight -- --scene workspace-four-panel-overview-dark ``` -It deletes only the two owned studio directories, rebuilds the Orbit history, and -restores the intended working tree: - -- src/theme.ts is staged. -- docs/release-checklist.md is modified but unstaged. -- src/components/CheckoutSummary.tsx is modified but unstaged. -- src/lib/retry.ts is modified but unstaged. -- feature/usage-insights is merged. -- fix/checkout-timeout and feature/project-files remain open. -- v0.7.0, v0.8.0, and v0.9.0-rc.1 provide readable graph landmarks. - -The setup command is otherwise idempotent. Relaunching does not erase capture state. -Stop the running studio before resetting it. - -## Master capture standard - -The studio maximizes its window to the current display's usable area on every launch. -Keep every full-app master at that native geometry: - -- Window: maximized, with the macOS menu and normal window controls still available. -- Screenshot output: the display's native Retina resolution. -- Video: 60 fps when possible; 30 fps is acceptable for a smaller web export. -- Still format: PNG master. -- Video masters: the recorder's highest-quality source format. -- Site exports: WebM first, MP4 fallback, plus a WebP poster (quality 82; PNG stays the - master format, but PNG posters are too heavy to ship as the hero LCP image). -- Posters are the exported clip's FIRST frame, extracted after trimming. Clips autoplay - in place when they scroll into view, so any other poster frame produces a visible - jump the moment playback starts. Keep end-state money frames as standalone stills. -- Record one second of stillness before the first action and after the final state. -- Site exports loop. Trim them so no more than ~1.5 seconds of stillness remains at - either end; a long static tail reads as a frozen page, not a hold. -- Show the pointer only when it performs the story's actions. For scroll-only clips, - exclude the cursor from the capture (or park it off-window) — an idle drifting - pointer pulls attention and makes the loop feel driven by a ghost operator. Leave - the composer unfocused unless typing is part of the story, so no caret blinks. - -Do not crop masters. Save the full maximized window and derive crops from it. This keeps -replacements flexible if the release-site layout changes. - -Before recording: - -1. Use the isolated Threadlines (Dev) app and confirm the visible project is Orbit. -2. Confirm the window is maximized; the studio restores this on every launch. -3. Close unrelated popovers and keep only the tabs needed for the story. -4. Put the pointer in neutral empty space before the first frame. -5. Avoid hovering controls long enough to show accidental tooltips. -6. Prefer one deliberate action per beat; the UI should be readable without narration. - -### Preserve the native window corners - -The rounded window frame is system-rendered on macOS. Do not recreate its corner with a -hard circular mask or a guessed numeric radius. If a recording badge obscures the window -controls, derive the replacement plate from the untouched opposite corner at the same -resolution so the continuous curve, border, and antialiasing remain native. Composite the -traffic lights above that native corner raster rather than replacing the curve itself. - -Before publishing refreshed media, compare both top corners and both vertical edges: +Preflight fails unless all of the following are true: + +- The path contains an owned Marketing Studio. +- Orbit, Lumen, and Northstar are isolated Git repositories with expected fake remotes + and reserved `.example` author addresses. +- Git history passes `gitleaks`. +- `ffmpeg`, `ffprobe`, and `gitleaks` are installed. +- The Orbit local app is responding. +- Neutral/native mode matches the scene. +- The active project and thread match exactly. +- Logical dimensions, display scale, theme, browser URL, source-control state, and + expected visible labels match the manifest. + +The result is written to `Captures/QA/-preflight.json` with the Threadlines source +revision and runtime geometry. + +### Clean stills + +For a neutral scene: ```sh -vp run marketing:media:audit-corners +vp run marketing:capture:still -- --scene workspace-four-panel-overview-dark ``` -The audit checks every launch poster, MP4, WebM, and standalone still. A corner plate must -match the opposite edge's antialias and solid-border transition within the small tolerance -needed for video chroma subsampling. +This captures the renderer directly through the Electron debugging surface, verifies the +PNG is exactly `3200 × 1868`, and saves the untouched image plus a SHA-256 sidecar in +`Captures/Masters`. -## Capture stories +### Motion masters -### 01 — Project files are a workspace, not a side panel +On macOS, record the prepared window directly from the manifest: -Target: an 8–10 second clip plus a full-frame still. +```sh +vp run marketing:capture:record -- --scene workspace-four-panel-overview-dark +``` -1. Open the Project file editing thread; its composer should show Claude Fable 5 and High. -2. Start with the file popover closed. -3. Open Browse project files. -4. Search for featureFlags. -5. Open src/config/featureFlags.ts. -6. Open src/components/CheckoutSummary.tsx so two proper tabs are visible. -7. Return to featureFlags.ts, enter edit mode, change releaseGuard from false to true, - and save. -8. Hold on the saved state with both tabs visible. +The recorder resolves the exact Threadlines window rather than the display, performs the +scene's deterministic actions, and retains the raw macOS capture under +`Captures/Masters/Raw/`. It then creates a true constant-60-fps FFV1 master: -The important frame is the end: popover, file tree/search, stable tabs, editor controls, -and the surrounding conversation should all be visible together. +```text +Captures/Masters/workspace-four-panel-overview-dark.mkv +``` + +Scenes marked `hidden` keep the pointer outside the window. Scenes marked `native` use +the small system pointer and its real movement. OBS remains a manual cross-platform +fallback; use the manifest scene ID as the filename and do not trim, denoise, sharpen, +rescale, or use an H.264 delivery encode as the archive master. + +Generate delivery assets: + +```sh +vp run marketing:media:export -- --scene workspace-four-panel-overview-dark +``` -Reset the studio after this clip so the source-control assets return to their canonical -four-file state. +Or pass an explicit source: -### 02 — Exact code context into chat +```sh +vp run marketing:media:export -- \ + --scene workspace-four-panel-overview-dark \ + --input "/reviewed/path/workspace-four-panel-overview-dark.mov" +``` + +The exporter creates: -Target: a 6–8 second clip and a tight still of the selection action. +- `3200 × 1868` H.264 MP4 with fast start. +- `3200 × 1868` VP9 Profile 0 WebM. +- `1600 × 934` mobile variants. +- Lossless PNG and delivery WebP posters. +- First, middle, and last QA frames. +- A contact sheet. +- SHA-256, codec, frame-rate, dimensions, pixel-format, color, and audio verification. +- OCR text and a sensitive-path/token scan on macOS, or with Tesseract when available. -1. Open the Checkout recovery thread; its composer should show GPT-5.6-Sol and Max. -2. Open src/components/CheckoutSummary.tsx. -3. Select the total calculation and the nearby render lines. -4. Use Add selection to chat. -5. Close the file popover. -6. Hold on the composer with the structured file-and-line attachment visible. +Run verification again without re-encoding: -Keep the selection small enough that the attachment label and source range remain -readable. This is stronger than attaching an entire file because the viewer immediately -understands the precision. +```sh +vp run marketing:media:postflight -- --scene workspace-four-panel-overview-dark +``` -### 03 — Source control organized by file +The final human review remains mandatory. Inspect the QA frames and contact sheet at full +size for notifications, account names, private paths, recorder overlays, accidental +tooltips, and cursor artifacts. -Target: an 8–10 second clip and one portrait crop for the feature carousel. +## Publish-safe project policy -1. Use the Checkout recovery thread so GPT-5.6-Sol and Max remain visible. -2. Open Source Control with the canonical staged and unstaged groups visible. -3. Expand CheckoutSummary.tsx and inspect its additions and deletions. -4. Move to retry.ts, then back to the grouped file list. -5. Reveal the per-file undo action on retry.ts. -6. Hold before clicking, or click only when the clip explicitly demonstrates recovery. +Use real interactions, not a private daily worktree. -The clip should make the hierarchy obvious: staged changes, unstaged changes, individual -files, file-level diffs, and file-level recovery. Avoid opening the commit action menu in -this story; it competes with the differentiated part of the UI. +The safest choices are: -### 04 — Git history you can understand visually +1. The synthetic runnable Orbit project. +2. A fresh clone of a completely public repository. +3. An allowlisted export copied into a new repository with synthetic history. -Target: a 12–16 second clip and a full-height graph crop. +For a private source project, export approved files only. Do not clone its private Git +history into the studio. Remove `.env` files, cloud configuration, SSH data, submodules, +LFS objects, remotes, issue references, authors, and account-specific URLs. Rebuild Git +history with a reserved `.example` identity and local-only remote. -1. Use the Release guard thread and keep Scout's completed release-risk review visible - so the graph is shown beside a real subagent result, not an empty conversation. -2. Open the Git graph within Source Control. -3. Pause on the merge lane connecting feature/usage-insights. -4. Select the checkout timeout commit on its unmerged branch. -5. Open commit detail so author, SHA, message, and changed files are readable. -6. Return to the graph and hold on all three branch lanes. +The governing rule is: -The branch and tag labels were deliberately kept short for this frame. Keep the panel -wide enough that labels do not truncate. +> If an accidental click, hover, terminal expansion, or full-frame capture would be +> unacceptable, the project cannot enter the studio. -### 05 — A subagent reporting back (hero loop) +Blurring is not a safety strategy because the original master still contains the data. -Target: an 8–10 second clip plus the end-state frame as the poster. +## Seeded realism -1. Open the Release guard thread; its composer should show Claude Fable 5 and High. -2. Keep Source Control open on the right so the changes list and commit graph frame - the conversation. -3. Start at the top of the thread with the opening user message visible. -4. Scroll down at a steady reading pace until Scout's completed release-risk review - and the follow-up exchange fill the frame. -5. Hold on the end state: Scout's subagent card, the rollout-policy reply, and the - graph all visible together. +The inbox is intentionally inhabited but restrained: five threads are live, three have +a current signal, and two are quiet. -This is the autoplaying hero loop: capture it cursor-less (the only motion is the -scroll), leave the composer unfocused, and keep the hold short — the loop restarts -from the top-of-thread state, so a long freeze reads as a stall. Like every looping -export, the poster is the trimmed clip's first frame. +- Checkout recovery waits for a regional baseline choice. +- Project file editing has only recently started working. +- Deploy health continues a longer regional check in the background. +- Usage insights and Rollout cohorts contain finished context but have no status pill. +- The remaining eleven older threads sit under Wrapped. -## Suggested site asset names +These are real orchestration projections rather than DOM-only demo labels. Opening the +threads reveals reviewed prompts and assistant output, and Orbit's browser preview is +served by a real local process with live reload. -Use names based on the story, not the UI implementation: +Keep evergreen scenes free of exact release versions, dates, or model names when they are +not part of the story. Release scenes can show 0.3.0-specific UI. Capture horizontal +website footage and vertical social footage separately instead of deriving both from one +composition. -```text -project-files-workspace.png -project-files-edit.webm -project-files-edit.mp4 -project-files-edit-poster.png -code-selection-to-chat.png -code-selection-to-chat.webm -source-control-by-file.png -source-control-by-file.webm -git-history-visual.png -git-history-visual.webm +## Reset + +Reset is guarded by ownership markers and an explicit force flag: + +```sh +vp run marketing:studio:reset -- --force +``` + +It deletes only the owned studio and isolated app-data roots, rebuilds all synthetic +history, and restores the canonical staged and unstaged changes. Stop the studio before +resetting. + +Older studios created under `~/Threadlines Marketing Studio` are not moved or deleted +automatically. Review and archive their masters, then use the guarded reset with an +explicit `THREADLINES_MARKETING_STUDIO_DIR` if they should be removed. + +## Legacy corner audit + +Previously published assets that contain composited macOS corner plates can still be +checked with: + +```sh +vp run marketing:media:audit-corners ``` -Keep intermediate attempts in Captures/Masters. Only reviewed crops and encodes belong in -Captures/Exports, and nothing is copied into apps/marketing/public until the site work is -explicitly started. +New neutral motion and genuine native platform stills should not require that repair +workflow. diff --git a/package.json b/package.json index ade57c97b..8cc3feb8d 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,17 @@ "start:marketing": "vp run --filter @threadlines/marketing preview", "start:mock-update-server": "node scripts/mock-update-server.ts", "marketing:studio": "node scripts/marketing-studio.ts launch", + "marketing:studio:native": "node scripts/marketing-studio.ts launch-native", "marketing:studio:paths": "node scripts/marketing-studio.ts paths", "marketing:studio:reset": "node scripts/marketing-studio.ts reset", "marketing:studio:setup": "node scripts/marketing-studio.ts setup", "marketing:media:audit-corners": "node scripts/audit-marketing-media-corners.ts", + "marketing:capture:prepare": "node scripts/marketing-media.ts prepare", + "marketing:capture:preflight": "node scripts/marketing-media.ts preflight", + "marketing:capture:record": "node scripts/marketing-media.ts record", + "marketing:capture:still": "node scripts/marketing-media.ts still", + "marketing:media:export": "node scripts/marketing-media.ts export", + "marketing:media:postflight": "node scripts/marketing-media.ts postflight", "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-threadlines' --filter './scripts' build", "check:web-bundle-size": "node scripts/check-web-bundle-size.ts", "build:marketing": "vp run --filter @threadlines/marketing build", diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 6b4f26753..3a0e997d3 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -889,6 +889,8 @@ export const PickFolderOptionsSchema = Schema.Struct({ export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; + /** Present in desktop builds that can identify the isolated marketing capture runtime. */ + isMarketingCaptureMode?: () => boolean; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; getClientSettings: () => Promise; setClientSettings: (settings: ClientSettings) => Promise; diff --git a/scripts/fixtures/marketing-studio/capture-scenes.json b/scripts/fixtures/marketing-studio/capture-scenes.json new file mode 100644 index 000000000..88a3e6f01 --- /dev/null +++ b/scripts/fixtures/marketing-studio/capture-scenes.json @@ -0,0 +1,141 @@ +{ + "schemaVersion": 1, + "release": "0.3.0", + "geometry": { + "logicalWidth": 1600, + "logicalHeight": 934, + "deviceScaleFactor": 2, + "masterWidth": 3200, + "masterHeight": 1868, + "framesPerSecond": 60, + "colorSpace": "bt709" + }, + "capture": { + "debugPort": 9223, + "demoUrl": "http://127.0.0.1:4173/", + "preRollSeconds": 1, + "postRollSeconds": 1 + }, + "delivery": { + "desktopWidth": 3200, + "mobileWidth": 1600, + "keyframeIntervalSeconds": 2, + "posterQuality": 82 + }, + "scenes": [ + { + "id": "workspace-four-panel-overview-dark", + "kind": "motion", + "audience": "release", + "windowMode": "neutral", + "theme": "dark", + "cursorMode": "native", + "project": "Orbit", + "threadTitle": "Checkout recovery", + "browserUrl": "http://127.0.0.1:4173/?theme=dark", + "sourceControlOpen": true, + "durationSeconds": 20, + "expectedLabels": ["Checkout recovery", "Orbit", "Changes"], + "story": "Show the complete dark Threadlines workspace, collapse the pending input, review live browser activity, inspect a source-control diff, and return to the four-panel overview." + }, + { + "id": "workspace-four-panel-overview-light", + "kind": "motion", + "audience": "release", + "windowMode": "neutral", + "theme": "light", + "cursorMode": "native", + "project": "Orbit", + "threadTitle": "Checkout recovery", + "browserUrl": "http://127.0.0.1:4173/?theme=light", + "sourceControlOpen": true, + "durationSeconds": 20, + "expectedLabels": ["Checkout recovery", "Orbit", "Changes"], + "story": "Show the complete light Threadlines workspace, collapse the pending input, review live browser activity, inspect a source-control diff, and return to the four-panel overview." + }, + { + "id": "sidebar-attention-states-dark", + "kind": "motion", + "audience": "release", + "windowMode": "neutral", + "theme": "dark", + "cursorMode": "hidden", + "project": "Orbit", + "threadTitle": "Checkout recovery", + "sourceControlOpen": false, + "durationSeconds": 9, + "expectedLabels": [ + "Checkout recovery", + "Project file editing", + "Deploy health", + "Usage insights", + "Rollout cohorts" + ], + "story": "Show input needed, recently started working, and background signals beside two quiet threads in a natural five-item dark sidebar." + }, + { + "id": "sidebar-attention-states-light", + "kind": "motion", + "audience": "release", + "windowMode": "neutral", + "theme": "light", + "cursorMode": "hidden", + "project": "Orbit", + "threadTitle": "Checkout recovery", + "sourceControlOpen": false, + "durationSeconds": 9, + "expectedLabels": [ + "Checkout recovery", + "Project file editing", + "Deploy health", + "Usage insights", + "Rollout cohorts" + ], + "story": "Show input needed, recently started working, and background signals beside two quiet threads in a natural five-item light sidebar." + }, + { + "id": "agent-browser-workflow-dark", + "kind": "motion", + "audience": "evergreen", + "windowMode": "neutral", + "theme": "dark", + "cursorMode": "native", + "project": "Orbit", + "threadTitle": "Project file editing", + "browserUrl": "http://127.0.0.1:4173/?theme=dark", + "sourceControlOpen": false, + "durationSeconds": 20, + "expectedLabels": ["Project file editing"], + "story": "Annotate the service-health heading in the dark built-in browser, attach the real element note to the composer, and show the agent applying that exact change to the live preview." + }, + { + "id": "agent-browser-workflow-light", + "kind": "motion", + "audience": "evergreen", + "windowMode": "neutral", + "theme": "light", + "cursorMode": "native", + "project": "Orbit", + "threadTitle": "Project file editing", + "browserUrl": "http://127.0.0.1:4173/?theme=light", + "sourceControlOpen": false, + "durationSeconds": 20, + "expectedLabels": ["Project file editing"], + "story": "Annotate the service-health heading in the light built-in browser, attach the real element note to the composer, and show the agent applying that exact change to the live preview." + }, + { + "id": "platform-macos", + "kind": "still", + "audience": "evergreen", + "windowMode": "native", + "theme": "dark", + "cursorMode": "hidden", + "project": "Orbit", + "threadTitle": "Project file editing", + "browserUrl": "http://127.0.0.1:4173/", + "sourceControlOpen": false, + "expectedLabels": ["Project file editing"], + "story": "Authentic macOS platform proof with genuine native window controls." + } + ] +} diff --git a/scripts/fixtures/marketing-studio/find-capture-window.swift b/scripts/fixtures/marketing-studio/find-capture-window.swift new file mode 100644 index 000000000..9343c4253 --- /dev/null +++ b/scripts/fixtures/marketing-studio/find-capture-window.swift @@ -0,0 +1,83 @@ +import CoreGraphics +import Foundation + +let expectedOwner = "Threadlines (Dev)" +let expectedWidth = CommandLine.arguments.dropFirst().first.flatMap(Int.init) ?? 1600 +let expectedHeight = CommandLine.arguments.dropFirst(2).first.flatMap(Int.init) ?? 934 +let options: CGWindowListOption = [.optionAll, .excludeDesktopElements] +let windows = + CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? [] + +let match = windows.first { window in + let owner = window[kCGWindowOwnerName as String] as? String + let layer = window[kCGWindowLayer as String] as? Int + guard + owner == expectedOwner, + layer == 0, + let bounds = window[kCGWindowBounds as String] as? [String: Any], + let width = bounds["Width"] as? Int, + let height = bounds["Height"] as? Int + else { + return false + } + return width == expectedWidth && height == expectedHeight +} + +guard + let match, + let windowNumber = match[kCGWindowNumber as String] as? Int, + let rawBounds = match[kCGWindowBounds as String] as? [String: Any], + let x = rawBounds["X"] as? Int, + let y = rawBounds["Y"] as? Int, + let width = rawBounds["Width"] as? Int, + let height = rawBounds["Height"] as? Int +else { + FileHandle.standardError.write( + Data( + "Could not find the \(expectedWidth)×\(expectedHeight) Threadlines Marketing Studio window.\n" + .utf8 + ) + ) + exit(1) +} + +var displayCount: UInt32 = 0 +guard CGGetActiveDisplayList(0, nil, &displayCount) == .success else { + FileHandle.standardError.write(Data("Could not enumerate active displays.\n".utf8)) + exit(1) +} +var displayIds = Array(repeating: CGDirectDisplayID(), count: Int(displayCount)) +guard + CGGetActiveDisplayList(displayCount, &displayIds, &displayCount) == .success +else { + FileHandle.standardError.write(Data("Could not read active display bounds.\n".utf8)) + exit(1) +} + +let displayBounds = displayIds.prefix(Int(displayCount)).map { displayId in + let bounds = CGDisplayBounds(displayId) + return [ + "x": Int(bounds.origin.x), + "y": Int(bounds.origin.y), + "width": Int(bounds.width), + "height": Int(bounds.height), + ] +} +let result: [String: Any] = [ + "windowId": windowNumber, + "bounds": [ + "x": x, + "y": y, + "width": width, + "height": height, + ], + "displays": displayBounds, +] + +do { + let data = try JSONSerialization.data(withJSONObject: result, options: [.sortedKeys]) + print(String(decoding: data, as: UTF8.self)) +} catch { + FileHandle.standardError.write(Data("Could not serialize capture-window geometry.\n".utf8)) + exit(1) +} diff --git a/scripts/fixtures/marketing-studio/move-native-pointer.swift b/scripts/fixtures/marketing-studio/move-native-pointer.swift new file mode 100644 index 000000000..7b63b2f39 --- /dev/null +++ b/scripts/fixtures/marketing-studio/move-native-pointer.swift @@ -0,0 +1,55 @@ +import CoreGraphics +import Foundation + +guard + CommandLine.arguments.count >= 4, + let targetX = Double(CommandLine.arguments[1]), + let targetY = Double(CommandLine.arguments[2]), + let duration = Double(CommandLine.arguments[3]), + targetX.isFinite, + targetY.isFinite, + duration.isFinite, + duration >= 0 +else { + FileHandle.standardError.write( + Data("Usage: move-native-pointer.swift [click]\n".utf8) + ) + exit(1) +} + +let shouldClick = CommandLine.arguments.dropFirst(4).first == "click" +let source = CGEventSource(stateID: .hidSystemState) +let start = CGEvent(source: source)?.location ?? .zero +let frameCount = max(1, Int((duration * 60).rounded())) + +for frame in 1 ... frameCount { + let progress = Double(frame) / Double(frameCount) + let eased = progress < 0.5 + ? 4 * progress * progress * progress + : 1 - pow(-2 * progress + 2, 3) / 2 + let point = CGPoint( + x: start.x + (targetX - start.x) * eased, + y: start.y + (targetY - start.y) * eased + ) + CGWarpMouseCursorPosition(point) + if frame < frameCount, duration > 0 { + usleep(useconds_t(duration * 1_000_000 / Double(frameCount))) + } +} + +if shouldClick { + let target = CGPoint(x: targetX, y: targetY) + CGEvent( + mouseEventSource: source, + mouseType: .leftMouseDown, + mouseCursorPosition: target, + mouseButton: .left + )?.post(tap: .cghidEventTap) + usleep(80_000) + CGEvent( + mouseEventSource: source, + mouseType: .leftMouseUp, + mouseCursorPosition: target, + mouseButton: .left + )?.post(tap: .cghidEventTap) +} diff --git a/scripts/fixtures/marketing-studio/ocr-frames.swift b/scripts/fixtures/marketing-studio/ocr-frames.swift new file mode 100644 index 000000000..7ebd7908d --- /dev/null +++ b/scripts/fixtures/marketing-studio/ocr-frames.swift @@ -0,0 +1,33 @@ +import AppKit +import Foundation +import Vision + +for path in CommandLine.arguments.dropFirst() { + let url = URL(fileURLWithPath: path) + guard + let image = NSImage(contentsOf: url), + let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff), + let cgImage = bitmap.cgImage + else { + FileHandle.standardError.write(Data("Could not read \(path)\n".utf8)) + exit(2) + } + + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + request.usesLanguageCorrection = false + let handler = VNImageRequestHandler(cgImage: cgImage) + + do { + try handler.perform([request]) + } catch { + FileHandle.standardError.write(Data("OCR failed for \(path): \(error)\n".utf8)) + exit(3) + } + + for observation in request.results ?? [] { + guard let candidate = observation.topCandidates(1).first else { continue } + print("\(url.lastPathComponent)\t\(candidate.string)") + } +} diff --git a/scripts/lib/marketing-studio-paths.ts b/scripts/lib/marketing-studio-paths.ts new file mode 100644 index 000000000..a668af2e9 --- /dev/null +++ b/scripts/lib/marketing-studio-paths.ts @@ -0,0 +1,39 @@ +import * as NodePath from "node:path"; + +export function resolveDefaultMarketingStudioRoot(input: { + readonly platform: NodeJS.Platform; + readonly homeDirectory: string; + readonly publicDirectory?: string | undefined; +}): string { + if (input.platform === "darwin") { + return NodePath.join("/Users", "Shared", "Threadlines Marketing Studio"); + } + if (input.platform === "win32") { + return NodePath.join( + input.publicDirectory && input.publicDirectory.length > 0 + ? input.publicDirectory + : NodePath.join(NodePath.dirname(input.homeDirectory), "Public"), + "Documents", + "Threadlines Marketing Studio", + ); + } + return NodePath.join("/tmp", "Threadlines Marketing Studio"); +} + +export function resolveMarketingStudioRoot(input: { + readonly configuredRoot?: string | undefined; + readonly platform?: NodeJS.Platform | undefined; + readonly homeDirectory?: string | undefined; + readonly publicDirectory?: string | undefined; +}): string { + const configuredRoot = input.configuredRoot?.trim(); + return NodePath.resolve( + configuredRoot && configuredRoot.length > 0 + ? configuredRoot + : resolveDefaultMarketingStudioRoot({ + platform: input.platform ?? process.platform, + homeDirectory: input.homeDirectory ?? process.cwd(), + publicDirectory: input.publicDirectory, + }), + ); +} diff --git a/scripts/marketing-media.test.ts b/scripts/marketing-media.test.ts new file mode 100644 index 000000000..44de66497 --- /dev/null +++ b/scripts/marketing-media.test.ts @@ -0,0 +1,167 @@ +import * as ChildProcess from "node:child_process"; +import * as FileSystem from "node:fs"; +import * as NodePath from "node:path"; +import { fileURLToPath } from "node:url"; + +import { assert, describe, it } from "@effect/vitest"; + +import { resolveDefaultMarketingStudioRoot } from "./lib/marketing-studio-paths.ts"; +import { assertCaptureWindowFullyVisible, parseCaptureManifest } from "./marketing-media.ts"; + +const repoRoot = NodePath.resolve(NodePath.dirname(fileURLToPath(import.meta.url)), ".."); +const fixturePath = NodePath.join( + repoRoot, + "scripts/fixtures/marketing-studio/capture-scenes.json", +); +const marketingPublicRoot = NodePath.join(repoRoot, "apps/marketing/public"); +const launchAssetRoot = NodePath.join(marketingPublicRoot, "Screenshots/launch"); + +describe("marketing media", () => { + it("loads a coherent deterministic capture manifest", () => { + const manifest = parseCaptureManifest(JSON.parse(FileSystem.readFileSync(fixturePath, "utf8"))); + + assert.equal(manifest.release, "0.3.0"); + assert.deepEqual(manifest.geometry, { + logicalWidth: 1600, + logicalHeight: 934, + deviceScaleFactor: 2, + masterWidth: 3200, + masterHeight: 1868, + framesPerSecond: 60, + colorSpace: "bt709", + }); + assert.equal(manifest.scenes.filter((scene) => scene.kind === "motion").length, 6); + assert.equal( + manifest.scenes.find((scene) => scene.id === "platform-macos")?.windowMode, + "native", + ); + assert.equal( + manifest.scenes.find((scene) => scene.id === "sidebar-attention-states-dark")?.cursorMode, + "hidden", + ); + assert.equal( + manifest.scenes.find((scene) => scene.id === "agent-browser-workflow-light")?.cursorMode, + "native", + ); + assert.equal( + manifest.scenes.find((scene) => scene.id === "agent-browser-workflow-dark")?.durationSeconds, + 20, + ); + assert.match( + manifest.scenes.find((scene) => scene.id === "agent-browser-workflow-dark")?.story ?? "", + /Annotate.+attach.+composer.+live preview/i, + ); + assert.equal( + manifest.scenes.find((scene) => scene.id === "workspace-four-panel-overview-dark") + ?.sourceControlOpen, + true, + ); + assert.equal( + manifest.scenes.find((scene) => scene.id === "workspace-four-panel-overview-dark") + ?.cursorMode, + "native", + ); + assert.equal( + manifest.scenes.find((scene) => scene.id === "workspace-four-panel-overview-dark") + ?.durationSeconds, + 20, + ); + assert.equal( + manifest.scenes + .filter((scene) => scene.kind === "motion") + .every((scene) => + manifest.scenes.some( + (candidate) => + candidate.id === + scene.id.replace(/-(?:dark|light)$/, `-${scene.theme === "dark" ? "light" : "dark"}`), + ), + ), + true, + ); + }); + + it("rejects manifests whose master and logical geometry disagree", () => { + const raw = JSON.parse(FileSystem.readFileSync(fixturePath, "utf8")) as { + geometry: { masterWidth: number }; + }; + raw.geometry.masterWidth = 3199; + + assert.throws(() => parseCaptureManifest(raw), /Master geometry/); + }); + + it("rejects a recording window that macOS would crop at a display edge", () => { + assert.throws( + () => + assertCaptureWindowFullyVisible({ + windowId: 42, + bounds: { x: -44, y: 24, width: 1600, height: 934 }, + displays: [{ x: 0, y: 0, width: 1512, height: 982 }], + }), + /pad the off-screen edge black/, + ); + assert.doesNotThrow(() => + assertCaptureWindowFullyVisible({ + windowId: 42, + bounds: { x: 100, y: 100, width: 1600, height: 934 }, + displays: [{ x: 0, y: 0, width: 1800, height: 1169 }], + }), + ); + }); + + it("uses neutral publish-safe default studio roots", () => { + assert.equal( + resolveDefaultMarketingStudioRoot({ + platform: "darwin", + homeDirectory: "/Users/alice", + }), + "/Users/Shared/Threadlines Marketing Studio", + ); + assert.equal( + resolveDefaultMarketingStudioRoot({ + platform: "linux", + homeDirectory: "/home/alice", + }), + "/tmp/Threadlines Marketing Studio", + ); + }); + + it("ships complete theme-matched marketing media pairs", () => { + for (const base of [ + "workspace-four-panel-overview", + "sidebar-attention-states", + "agent-browser-workflow", + ]) { + for (const theme of ["dark", "light"]) { + for (const suffix of [".mp4", ".webm", "-mobile.mp4", "-mobile.webm"]) { + const assetPath = NodePath.join(launchAssetRoot, `${base}-${theme}${suffix}`); + assert.equal(FileSystem.existsSync(assetPath), true, assetPath); + assert.isAbove(FileSystem.statSync(assetPath).size, 10_000, assetPath); + } + const posterPath = NodePath.join(launchAssetRoot, "Posters", `${base}-${theme}.webp`); + assert.equal(FileSystem.existsSync(posterPath), true, posterPath); + assert.isAbove(FileSystem.statSync(posterPath).size, 10_000, posterPath); + } + } + + const socialImagePath = NodePath.join(marketingPublicRoot, "og.png"); + assert.equal(FileSystem.existsSync(socialImagePath), true); + assert.isAbove(FileSystem.statSync(socialImagePath).size, 10_000); + assert.equal(FileSystem.existsSync(NodePath.join(marketingPublicRoot, "og-v030.png")), false); + }); + + it("prints command help without touching the studio", () => { + const result = ChildProcess.spawnSync( + process.execPath, + [NodePath.join(repoRoot, "scripts/marketing-media.ts"), "help"], + { + cwd: repoRoot, + encoding: "utf8", + }, + ); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /preflight --scene/); + assert.match(result.stdout, /record\s+--scene/); + assert.match(result.stdout, /allow-compressed-master/); + }); +}); diff --git a/scripts/marketing-media.ts b/scripts/marketing-media.ts new file mode 100644 index 000000000..61fa4f01c --- /dev/null +++ b/scripts/marketing-media.ts @@ -0,0 +1,2260 @@ +#!/usr/bin/env node +// @effect-diagnostics globalConsole:off globalDate:off globalTimers:off nodeBuiltinImport:off + +import * as ChildProcess from "node:child_process"; +import * as Crypto from "node:crypto"; +import * as FileSystem from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { fileURLToPath } from "node:url"; + +import { resolveMarketingStudioRoot } from "./lib/marketing-studio-paths.ts"; + +type SceneKind = "motion" | "still"; +type SceneAudience = "evergreen" | "release"; +type SceneWindowMode = "neutral" | "native"; +type SceneTheme = "light" | "dark"; +type SceneCursorMode = "hidden" | "native"; + +interface CaptureScene { + readonly id: string; + readonly kind: SceneKind; + readonly audience: SceneAudience; + readonly windowMode: SceneWindowMode; + readonly theme: SceneTheme; + readonly cursorMode: SceneCursorMode; + readonly project: string; + readonly threadTitle: string; + readonly browserUrl?: string | undefined; + readonly sourceControlOpen: boolean; + readonly durationSeconds?: number | undefined; + readonly expectedLabels: ReadonlyArray; + readonly story: string; +} + +interface CaptureManifest { + readonly schemaVersion: 1; + readonly release: string; + readonly geometry: { + readonly logicalWidth: number; + readonly logicalHeight: number; + readonly deviceScaleFactor: number; + readonly masterWidth: number; + readonly masterHeight: number; + readonly framesPerSecond: number; + readonly colorSpace: "bt709"; + }; + readonly capture: { + readonly debugPort: number; + readonly demoUrl: string; + readonly preRollSeconds: number; + readonly postRollSeconds: number; + }; + readonly delivery: { + readonly desktopWidth: number; + readonly mobileWidth: number; + readonly keyframeIntervalSeconds: number; + readonly posterQuality: number; + }; + readonly scenes: ReadonlyArray; +} + +interface DevToolsTarget { + readonly id: string; + readonly title: string; + readonly type: string; + readonly url: string; + readonly webSocketDebuggerUrl?: string | undefined; +} + +interface RuntimeSnapshot { + readonly captureMode: boolean; + readonly width: number; + readonly height: number; + readonly deviceScaleFactor: number; + readonly theme: "light" | "dark"; + readonly readyState: string; + readonly activeProjectName: string | null; + readonly activeThreadTitle: string | null; + readonly text: string; + readonly browserUrl: string | null; + readonly sourceControlOpen: boolean; +} + +interface CaptureRectangle { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +interface CaptureWindowInfo { + readonly windowId: number; + readonly bounds: CaptureRectangle; + readonly displays: ReadonlyArray; +} + +interface ProbeStream { + readonly codec_name?: string | undefined; + readonly codec_type?: string | undefined; + readonly profile?: string | undefined; + readonly width?: number | undefined; + readonly height?: number | undefined; + readonly pix_fmt?: string | undefined; + readonly avg_frame_rate?: string | undefined; + readonly r_frame_rate?: string | undefined; + readonly color_space?: string | undefined; + readonly color_transfer?: string | undefined; + readonly color_primaries?: string | undefined; +} + +interface ProbeResult { + readonly streams: ReadonlyArray; + readonly format?: { + readonly duration?: string | undefined; + readonly format_name?: string | undefined; + }; +} + +const REPO_ROOT = NodePath.resolve(NodePath.dirname(fileURLToPath(import.meta.url)), ".."); +const DEFAULT_MANIFEST_PATH = NodePath.join( + REPO_ROOT, + "scripts", + "fixtures", + "marketing-studio", + "capture-scenes.json", +); +const STUDIO_METADATA_FILE = ".threadlines-marketing-studio.json"; +const EXPECTED_PROJECTS = ["Orbit", "Lumen", "Northstar"] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function readPositiveNumber(record: Record, key: string, context: string): number { + const value = record[key]; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + throw new Error(`${context}.${key} must be a positive number.`); + } + return value; +} + +export function parseCaptureManifest(value: unknown): CaptureManifest { + if ( + !isRecord(value) || + value.schemaVersion !== 1 || + typeof value.release !== "string" || + !isRecord(value.geometry) || + !isRecord(value.capture) || + !isRecord(value.delivery) || + !Array.isArray(value.scenes) + ) { + throw new Error("Marketing capture manifest has an invalid top-level shape."); + } + + const geometryValues = { + logicalWidth: readPositiveNumber(value.geometry, "logicalWidth", "geometry"), + logicalHeight: readPositiveNumber(value.geometry, "logicalHeight", "geometry"), + deviceScaleFactor: readPositiveNumber(value.geometry, "deviceScaleFactor", "geometry"), + masterWidth: readPositiveNumber(value.geometry, "masterWidth", "geometry"), + masterHeight: readPositiveNumber(value.geometry, "masterHeight", "geometry"), + framesPerSecond: readPositiveNumber(value.geometry, "framesPerSecond", "geometry"), + colorSpace: value.geometry.colorSpace, + }; + if (geometryValues.colorSpace !== "bt709") { + throw new Error("geometry.colorSpace must be bt709."); + } + if ( + geometryValues.masterWidth !== geometryValues.logicalWidth * geometryValues.deviceScaleFactor || + geometryValues.masterHeight !== geometryValues.logicalHeight * geometryValues.deviceScaleFactor + ) { + throw new Error("Master geometry must equal logical geometry multiplied by device scale."); + } + const geometry: CaptureManifest["geometry"] = { + ...geometryValues, + colorSpace: "bt709", + }; + + const captureValues = { + debugPort: readPositiveNumber(value.capture, "debugPort", "capture"), + demoUrl: value.capture.demoUrl, + preRollSeconds: readPositiveNumber(value.capture, "preRollSeconds", "capture"), + postRollSeconds: readPositiveNumber(value.capture, "postRollSeconds", "capture"), + }; + if (typeof captureValues.demoUrl !== "string") { + throw new Error("capture.demoUrl must be a URL string."); + } + if (!URL.canParse(captureValues.demoUrl)) { + throw new Error("capture.demoUrl must be a valid URL."); + } + const capture: CaptureManifest["capture"] = { + ...captureValues, + demoUrl: captureValues.demoUrl, + }; + + const delivery = { + desktopWidth: readPositiveNumber(value.delivery, "desktopWidth", "delivery"), + mobileWidth: readPositiveNumber(value.delivery, "mobileWidth", "delivery"), + keyframeIntervalSeconds: readPositiveNumber( + value.delivery, + "keyframeIntervalSeconds", + "delivery", + ), + posterQuality: readPositiveNumber(value.delivery, "posterQuality", "delivery"), + }; + + const sceneIds = new Set(); + const scenes = value.scenes.map((rawScene, index): CaptureScene => { + if ( + !isRecord(rawScene) || + typeof rawScene.id !== "string" || + (rawScene.kind !== "motion" && rawScene.kind !== "still") || + (rawScene.audience !== "evergreen" && rawScene.audience !== "release") || + (rawScene.windowMode !== "neutral" && rawScene.windowMode !== "native") || + (rawScene.theme !== "light" && rawScene.theme !== "dark") || + (rawScene.cursorMode !== "hidden" && rawScene.cursorMode !== "native") || + typeof rawScene.project !== "string" || + typeof rawScene.threadTitle !== "string" || + (rawScene.browserUrl !== undefined && typeof rawScene.browserUrl !== "string") || + typeof rawScene.sourceControlOpen !== "boolean" || + (rawScene.durationSeconds !== undefined && + (typeof rawScene.durationSeconds !== "number" || rawScene.durationSeconds <= 0)) || + !Array.isArray(rawScene.expectedLabels) || + !rawScene.expectedLabels.every((label) => typeof label === "string") || + typeof rawScene.story !== "string" + ) { + throw new Error(`Marketing capture scene ${index + 1} is invalid.`); + } + if (sceneIds.has(rawScene.id)) { + throw new Error(`Marketing capture scene id is duplicated: ${rawScene.id}`); + } + if (rawScene.kind === "motion" && rawScene.durationSeconds === undefined) { + throw new Error(`Motion scene '${rawScene.id}' needs durationSeconds.`); + } + if (rawScene.browserUrl !== undefined) { + if (!URL.canParse(rawScene.browserUrl)) { + throw new Error(`Scene '${rawScene.id}' has an invalid browserUrl.`); + } + } + sceneIds.add(rawScene.id); + return rawScene as unknown as CaptureScene; + }); + + return { + schemaVersion: 1, + release: value.release, + geometry, + capture, + delivery, + scenes, + }; +} + +function readManifest(): CaptureManifest { + const manifestPath = + process.env.THREADLINES_MARKETING_CAPTURE_MANIFEST?.trim() || DEFAULT_MANIFEST_PATH; + return parseCaptureManifest(JSON.parse(FileSystem.readFileSync(manifestPath, "utf8"))); +} + +function studioPaths() { + const root = resolveMarketingStudioRoot({ + configuredRoot: process.env.THREADLINES_MARKETING_STUDIO_DIR, + homeDirectory: NodeOS.homedir(), + publicDirectory: process.env.PUBLIC?.trim(), + }); + const captures = NodePath.join(root, "Captures"); + return { + root, + masters: NodePath.join(captures, "Masters"), + rawMasters: NodePath.join(captures, "Masters", "Raw"), + exports: NodePath.join(captures, "Exports"), + posters: NodePath.join(captures, "Posters"), + qa: NodePath.join(captures, "QA"), + }; +} + +function argument(name: string): string | undefined { + const index = process.argv.indexOf(`--${name}`); + return index === -1 ? undefined : process.argv[index + 1]; +} + +function hasFlag(name: string): boolean { + return process.argv.includes(`--${name}`); +} + +function requiredArgument(name: string): string { + const value = argument(name)?.trim(); + if (!value) { + throw new Error(`Missing required --${name} argument.`); + } + return value; +} + +function resolveScene(manifest: CaptureManifest): CaptureScene { + const sceneId = requiredArgument("scene"); + const scene = manifest.scenes.find((candidate) => candidate.id === sceneId); + if (!scene) { + throw new Error( + `Unknown capture scene '${sceneId}'. Expected one of: ${manifest.scenes + .map((candidate) => candidate.id) + .join(", ")}`, + ); + } + return scene; +} + +function commandExists(command: string): boolean { + const result = ChildProcess.spawnSync(command, ["--version"], { stdio: "ignore" }); + return result.error === undefined || (result.error as NodeJS.ErrnoException).code !== "ENOENT"; +} + +function run( + command: string, + args: ReadonlyArray, + options: { + readonly cwd?: string | undefined; + readonly allowFailure?: boolean | undefined; + } = {}, +): string { + const result = ChildProcess.spawnSync(command, [...args], { + cwd: options.cwd, + encoding: "utf8", + maxBuffer: 128 * 1024 * 1024, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0 && !options.allowFailure) { + const detail = String(result.stderr || result.stdout || "") + .trim() + .slice(-4_000); + throw new Error( + `${command} failed with code ${String(result.status)}${detail ? `:\n${detail}` : ""}`, + ); + } + return String(result.stdout ?? ""); +} + +function probe(filePath: string): ProbeResult { + return JSON.parse( + run("ffprobe", ["-v", "error", "-show_streams", "-show_format", "-of", "json", filePath]), + ) as ProbeResult; +} + +function frameRate(value: string | undefined): number { + if (!value) return 0; + const [numeratorText, denominatorText] = value.split("/"); + const numerator = Number(numeratorText); + const denominator = Number(denominatorText ?? 1); + return Number.isFinite(numerator) && Number.isFinite(denominator) && denominator !== 0 + ? numerator / denominator + : 0; +} + +function videoStream(result: ProbeResult): ProbeStream { + const stream = result.streams.find((candidate) => candidate.codec_type === "video"); + if (!stream) { + throw new Error("Media file has no video stream."); + } + return stream; +} + +function sha256(filePath: string): string { + const hash = Crypto.createHash("sha256"); + hash.update(FileSystem.readFileSync(filePath)); + return hash.digest("hex"); +} + +async function getDevToolsTargets(port: number): Promise> { + const response = await fetch(`http://127.0.0.1:${port}/json/list`, { + signal: AbortSignal.timeout(2_000), + }); + if (!response.ok) { + throw new Error(`Electron debug endpoint returned ${String(response.status)}.`); + } + const value: unknown = await response.json(); + if (!Array.isArray(value)) { + throw new Error("Electron debug endpoint returned an invalid target list."); + } + return value.filter(isRecord).map( + (target): DevToolsTarget => ({ + id: typeof target.id === "string" ? target.id : "", + title: typeof target.title === "string" ? target.title : "", + type: typeof target.type === "string" ? target.type : "", + url: typeof target.url === "string" ? target.url : "", + webSocketDebuggerUrl: + typeof target.webSocketDebuggerUrl === "string" ? target.webSocketDebuggerUrl : undefined, + }), + ); +} + +function resolveMainTarget(targets: ReadonlyArray): DevToolsTarget { + const target = + targets.find( + (candidate) => + candidate.type === "page" && + candidate.webSocketDebuggerUrl !== undefined && + candidate.url.includes("127.0.0.1:6066"), + ) ?? + targets.find( + (candidate) => + candidate.type === "page" && + candidate.webSocketDebuggerUrl !== undefined && + candidate.title.toLowerCase().includes("threadlines"), + ); + if (!target) { + throw new Error("Could not find the Threadlines renderer on the Electron debug endpoint."); + } + return target; +} + +async function withCdp( + target: DevToolsTarget, + use: (send: (method: string, params?: unknown) => Promise) => Promise, +): Promise { + if (!target.webSocketDebuggerUrl) { + throw new Error("Selected Electron target has no debugger WebSocket."); + } + + const socket = new WebSocket(target.webSocketDebuggerUrl); + let nextId = 0; + const pending = new Map< + number, + { readonly resolve: (value: unknown) => void; readonly reject: (error: Error) => void } + >(); + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Timed out connecting to Electron CDP.")), + 5_000, + ); + socket.addEventListener("open", () => { + clearTimeout(timeout); + resolve(); + }); + socket.addEventListener("error", () => { + clearTimeout(timeout); + reject(new Error("Failed to connect to Electron CDP.")); + }); + }); + socket.addEventListener("message", (event) => { + const message = JSON.parse(String(event.data)) as { + readonly id?: number | undefined; + readonly result?: unknown; + readonly error?: { readonly message?: string | undefined } | undefined; + }; + if (message.id === undefined) return; + const waiter = pending.get(message.id); + if (!waiter) return; + pending.delete(message.id); + if (message.error) { + waiter.reject(new Error(message.error.message ?? "Electron CDP command failed.")); + } else { + waiter.resolve(message.result); + } + }); + + const send = (method: string, params: unknown = {}): Promise => { + const id = ++nextId; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + socket.send(JSON.stringify({ id, method, params })); + }); + }; + + try { + return await use(send); + } finally { + socket.close(); + } +} + +function unwrapRuntimeValue(result: unknown): unknown { + if (!isRecord(result) || !isRecord(result.result) || !("value" in result.result)) { + throw new Error("Electron renderer returned no serializable runtime value."); + } + return result.result.value; +} + +async function rendererSnapshot(manifest: CaptureManifest): Promise<{ + readonly snapshot: RuntimeSnapshot; + readonly targets: ReadonlyArray; + readonly target: DevToolsTarget; +}> { + const targets = await getDevToolsTargets(manifest.capture.debugPort); + const target = resolveMainTarget(targets); + const value = await withCdp(target, async (send) => + unwrapRuntimeValue( + await send("Runtime.evaluate", { + expression: `(() => { + const webview = document.querySelector("webview"); + let browserUrl = null; + try { + browserUrl = typeof webview?.getURL === "function" ? webview.getURL() : webview?.src ?? null; + } catch {} + const activeHeader = document.querySelector("[data-active-thread-title]"); + return { + captureMode: window.desktopBridge?.isMarketingCaptureMode?.() === true, + width: window.innerWidth, + height: window.innerHeight, + deviceScaleFactor: window.devicePixelRatio, + theme: document.documentElement.classList.contains("dark") ? "dark" : "light", + readyState: document.readyState, + activeProjectName: activeHeader?.getAttribute("data-active-project-name") ?? null, + activeThreadTitle: activeHeader?.getAttribute("data-active-thread-title") ?? null, + text: document.body?.innerText ?? "", + browserUrl, + sourceControlOpen: document.querySelector('[aria-label="Source Control"]') !== null, + }; + })()`, + awaitPromise: true, + returnByValue: true, + }), + ), + ); + if ( + !isRecord(value) || + typeof value.captureMode !== "boolean" || + typeof value.width !== "number" || + typeof value.height !== "number" || + typeof value.deviceScaleFactor !== "number" || + (value.theme !== "light" && value.theme !== "dark") || + typeof value.readyState !== "string" || + (value.activeProjectName !== null && typeof value.activeProjectName !== "string") || + (value.activeThreadTitle !== null && typeof value.activeThreadTitle !== "string") || + typeof value.text !== "string" || + (value.browserUrl !== null && typeof value.browserUrl !== "string") || + typeof value.sourceControlOpen !== "boolean" + ) { + throw new Error("Electron renderer snapshot is invalid."); + } + return { snapshot: value as unknown as RuntimeSnapshot, targets, target }; +} + +function assertStudioSafety(paths: ReturnType): void { + const metadataPath = NodePath.join(paths.root, STUDIO_METADATA_FILE); + if (!FileSystem.existsSync(metadataPath)) { + throw new Error(`Marketing Studio metadata is missing: ${metadataPath}`); + } + const metadata: unknown = JSON.parse(FileSystem.readFileSync(metadataPath, "utf8")); + if (!isRecord(metadata) || metadata.kind !== "threadlines-marketing-studio") { + throw new Error("Capture source is not an owned Threadlines Marketing Studio."); + } + + const home = NodePath.resolve(NodeOS.homedir()); + if ( + !hasFlag("allow-personal-path") && + (NodePath.resolve(paths.root) === home || + NodePath.resolve(paths.root).startsWith(`${home}${NodePath.sep}`)) + ) { + throw new Error( + "Marketing Studio lives under the personal home directory. Move it to the neutral default path or pass --allow-personal-path after reviewing every visible path.", + ); + } + + for (const project of EXPECTED_PROJECTS) { + const projectPath = NodePath.join(paths.root, project); + if (!FileSystem.existsSync(NodePath.join(projectPath, ".git"))) { + throw new Error(`Publish-safe project is missing its isolated Git repository: ${project}`); + } + const remote = run("git", ["remote", "get-url", "origin"], { cwd: projectPath }).trim(); + if (!remote.startsWith("https://github.com/threadlines-labs/") && !remote.startsWith("file:")) { + throw new Error(`Unexpected Git remote in ${project}; refusing to capture.`); + } + const email = run("git", ["config", "--get", "user.email"], { cwd: projectPath }).trim(); + if (!email.endsWith(".example")) { + throw new Error(`Git author email in ${project} is not a reserved example address.`); + } + run("gitleaks", ["git", "--no-banner", "--redact=100", projectPath]); + } +} + +const waitForRenderer = async ( + manifest: CaptureManifest, + predicate: (snapshot: RuntimeSnapshot) => boolean, + failureMessage: string, +): Promise => { + let lastError: unknown; + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + const { snapshot } = await rendererSnapshot(manifest); + if (predicate(snapshot)) { + return snapshot; + } + } catch (error) { + lastError = error; + } + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + } + const errorDetail = + lastError instanceof Error ? ` Last renderer error: ${lastError.message}` : ""; + throw new Error(`${failureMessage}${errorDetail}`); +}; + +async function selectSceneThread(manifest: CaptureManifest, scene: CaptureScene): Promise { + const { target } = await rendererSnapshot(manifest); + const selected = await withCdp(target, async (send) => + unwrapRuntimeValue( + await send("Runtime.evaluate", { + expression: `(async () => { + const desiredTitle = ${JSON.stringify(scene.threadTitle)}; + for (let attempt = 0; attempt < 50; attempt += 1) { + const title = Array.from( + document.querySelectorAll( + '[data-testid^="thread-title-"], [data-testid^="done-title-"]', + ), + ).find((candidate) => candidate.textContent?.trim() === desiredTitle); + const row = title?.closest( + '[data-testid^="thread-row-"], [data-testid^="done-row-"]', + ); + if (row instanceof HTMLElement) { + row.click(); + return true; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return false; + })()`, + awaitPromise: true, + returnByValue: true, + }), + ), + ); + if (selected !== true) { + throw new Error( + `Could not find '${scene.threadTitle}' in the visible studio sidebar. Reveal the thread and run prepare again.`, + ); + } + await waitForRenderer( + manifest, + (snapshot) => + snapshot.activeProjectName === scene.project && + snapshot.activeThreadTitle === scene.threadTitle, + `Threadlines did not open '${scene.threadTitle}' in ${scene.project}.`, + ); +} + +async function arrangeSceneBrowser(manifest: CaptureManifest, scene: CaptureScene): Promise { + const { target } = await rendererSnapshot(manifest); + const arrangement = await withCdp(target, async (send) => + unwrapRuntimeValue( + await send("Runtime.evaluate", { + expression: `(async () => { + const desiredUrl = ${JSON.stringify(scene.browserUrl ?? null)}; + const toggle = document.querySelector('[aria-label="Toggle browser preview"]'); + let panel = document.querySelector('[data-testid="browser-panel"]'); + if (desiredUrl === null) { + if (panel instanceof HTMLElement && toggle instanceof HTMLElement) toggle.click(); + return { browserExpected: false, panelFound: panel !== null }; + } + if (panel === null && toggle instanceof HTMLElement) { + toggle.click(); + for (let attempt = 0; attempt < 30; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 50)); + panel = document.querySelector('[data-testid="browser-panel"]'); + if (panel !== null) break; + } + } + const webview = document.querySelector('webview'); + if (typeof webview?.loadURL !== "function") { + return { browserExpected: true, panelFound: panel !== null, webviewFound: false }; + } + try { + await webview.loadURL(desiredUrl); + } catch { + const currentUrl = + typeof webview.getURL === "function" ? webview.getURL() : webview.src ?? ""; + if (!currentUrl.startsWith(desiredUrl)) throw new Error("Browser navigation failed."); + } + await webview.executeJavaScript('window.scrollTo({ top: 0, behavior: "instant" })'); + return { browserExpected: true, panelFound: panel !== null, webviewFound: true }; + })()`, + awaitPromise: true, + returnByValue: true, + }), + ), + ); + if ( + scene.browserUrl && + (!isRecord(arrangement) || arrangement.panelFound !== true || arrangement.webviewFound !== true) + ) { + throw new Error("Could not open the Threadlines browser panel for this scene."); + } + if (scene.browserUrl) { + await waitForRenderer( + manifest, + (snapshot) => snapshot.browserUrl?.startsWith(scene.browserUrl ?? "never") === true, + `The browser panel did not finish opening ${scene.browserUrl}.`, + ); + } +} + +async function arrangeSceneSourceControl( + manifest: CaptureManifest, + scene: CaptureScene, +): Promise { + const { target } = await rendererSnapshot(manifest); + const arrangement = await withCdp(target, async (send) => + unwrapRuntimeValue( + await send("Runtime.evaluate", { + expression: `(() => { + const desiredOpen = ${JSON.stringify(scene.sourceControlOpen)}; + const panelOpen = document.querySelector('[aria-label="Source Control"]') !== null; + const toggle = document.querySelector('[aria-label="Toggle source control panel"]'); + if (panelOpen !== desiredOpen && toggle instanceof HTMLElement) toggle.click(); + return { desiredOpen, panelOpen, toggleFound: toggle !== null }; + })()`, + returnByValue: true, + }), + ), + ); + if (!isRecord(arrangement) || arrangement.toggleFound !== true) { + throw new Error("Could not find the Threadlines source control toggle for this scene."); + } + await waitForRenderer( + manifest, + (snapshot) => snapshot.sourceControlOpen === scene.sourceControlOpen, + `The source control panel did not finish ${scene.sourceControlOpen ? "opening" : "closing"}.`, + ); +} + +async function prepare(): Promise { + const manifest = readManifest(); + const scene = resolveScene(manifest); + const { target } = await rendererSnapshot(manifest); + await withCdp(target, async (send) => { + await send("Runtime.evaluate", { + expression: `(() => { + localStorage.setItem("threadlines:theme", ${JSON.stringify(scene.theme)}); + sessionStorage.setItem("threadlines:marketing-capture-scene", ${JSON.stringify(scene.id)}); + location.reload(); + return true; + })()`, + returnByValue: true, + }); + }); + await waitForRenderer( + manifest, + (snapshot) => snapshot.readyState === "complete" && snapshot.theme === scene.theme, + `Threadlines did not finish switching to ${scene.theme} theme.`, + ); + await selectSceneThread(manifest, scene); + await arrangeSceneBrowser(manifest, scene); + await arrangeSceneSourceControl(manifest, scene); + console.log(`Prepared ${scene.id}: ${scene.theme} theme.`); + console.log(`Active thread: ${scene.threadTitle} in ${scene.project}`); + console.log(`Browser panel: ${scene.browserUrl ?? "closed"}`); + console.log(`Source control: ${scene.sourceControlOpen ? "open" : "closed"}`); + console.log(`Cursor: ${scene.cursorMode}`); + console.log("Review the frame, then run preflight."); +} + +async function evaluateRenderer(manifest: CaptureManifest, expression: string): Promise { + const { target } = await rendererSnapshot(manifest); + return withCdp(target, async (send) => + unwrapRuntimeValue( + await send("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true, + }), + ), + ); +} + +async function evaluateBrowser(manifest: CaptureManifest, expression: string): Promise { + return evaluateRenderer( + manifest, + `(async () => { + const webview = document.querySelector("webview"); + if (typeof webview?.executeJavaScript !== "function") { + throw new Error("The browser webview is unavailable."); + } + return await webview.executeJavaScript(${JSON.stringify(expression)}); + })()`, + ); +} + +async function clickVisibleThread(manifest: CaptureManifest, title: string): Promise { + const clicked = await evaluateRenderer( + manifest, + `(() => { + const desiredTitle = ${JSON.stringify(title)}; + const titleElement = Array.from( + document.querySelectorAll('[data-testid^="thread-title-"], [data-testid^="done-title-"]'), + ).find((candidate) => candidate.textContent?.trim() === desiredTitle); + const row = titleElement?.closest( + '[data-testid^="thread-row-"], [data-testid^="done-row-"]', + ); + if (!(row instanceof HTMLElement)) return false; + row.click(); + return true; + })()`, + ); + if (clicked !== true) { + throw new Error(`Could not click '${title}' during the sidebar take.`); + } +} + +const wait = (milliseconds: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); + +interface CapturePoint { + readonly x: number; + readonly y: number; +} + +function parseCapturePoint(value: unknown, label: string): CapturePoint { + if ( + !isRecord(value) || + typeof value.x !== "number" || + !Number.isFinite(value.x) || + typeof value.y !== "number" || + !Number.isFinite(value.y) + ) { + throw new Error(`Could not resolve the ${label} position for the capture.`); + } + return { x: value.x, y: value.y }; +} + +async function rendererElementCenter( + manifest: CaptureManifest, + selector: string, + label: string, +): Promise { + const point = await evaluateRenderer( + manifest, + `(() => { + const element = document.querySelector(${JSON.stringify(selector)}); + if (!(element instanceof HTMLElement)) return null; + const bounds = element.getBoundingClientRect(); + if (bounds.width <= 0 || bounds.height <= 0) return null; + return { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 }; + })()`, + ); + return parseCapturePoint(point, label); +} + +async function browserElementCenter( + manifest: CaptureManifest, + selector: string, + label: string, +): Promise { + const webviewOrigin = parseCapturePoint( + await evaluateRenderer( + manifest, + `(() => { + const webview = document.querySelector("webview"); + if (!(webview instanceof HTMLElement)) return null; + const bounds = webview.getBoundingClientRect(); + return { x: bounds.left, y: bounds.top }; + })()`, + ), + "browser viewport", + ); + const pagePoint = parseCapturePoint( + await evaluateBrowser( + manifest, + `(() => { + const element = document.querySelector(${JSON.stringify(selector)}); + if (!(element instanceof HTMLElement)) return null; + const bounds = element.getBoundingClientRect(); + if (bounds.width <= 0 || bounds.height <= 0) return null; + return { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 }; + })()`, + ), + label, + ); + return { + x: webviewOrigin.x + pagePoint.x, + y: webviewOrigin.y + pagePoint.y, + }; +} + +async function clickRendererElement( + manifest: CaptureManifest, + selector: string, + label: string, +): Promise { + const clicked = await evaluateRenderer( + manifest, + `(() => { + const element = document.querySelector(${JSON.stringify(selector)}); + if (!(element instanceof HTMLElement)) return false; + element.click(); + return true; + })()`, + ); + if (clicked !== true) { + throw new Error(`Could not activate the ${label} during the capture.`); + } +} + +async function clickBrowserElement( + manifest: CaptureManifest, + selector: string, + label: string, +): Promise { + const clicked = await evaluateBrowser( + manifest, + `(() => { + const element = document.querySelector(${JSON.stringify(selector)}); + if (!(element instanceof HTMLElement)) return false; + element.click(); + return true; + })()`, + ); + if (clicked !== true) { + throw new Error(`Could not activate the ${label} during the capture.`); + } +} + +async function waitForRendererCondition( + manifest: CaptureManifest, + expression: string, + failureMessage: string, +): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + if ((await evaluateRenderer(manifest, expression)) === true) return; + await wait(100); + } + throw new Error(failureMessage); +} + +async function waitForBrowserCondition( + manifest: CaptureManifest, + expression: string, + failureMessage: string, +): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + if ((await evaluateBrowser(manifest, expression)) === true) return; + await wait(100); + } + throw new Error(failureMessage); +} + +async function browserShadowElementCenter( + manifest: CaptureManifest, + hostSelector: string, + shadowSelector: string, + label: string, +): Promise { + const webviewOrigin = parseCapturePoint( + await evaluateRenderer( + manifest, + `(() => { + const webview = document.querySelector("webview"); + if (!(webview instanceof HTMLElement)) return null; + const bounds = webview.getBoundingClientRect(); + return { x: bounds.left, y: bounds.top }; + })()`, + ), + "browser viewport", + ); + const pagePoint = parseCapturePoint( + await evaluateBrowser( + manifest, + `(() => { + const host = document.querySelector(${JSON.stringify(hostSelector)}); + const element = host?.shadowRoot?.querySelector(${JSON.stringify(shadowSelector)}); + if (!(element instanceof HTMLElement)) return null; + const bounds = element.getBoundingClientRect(); + if (bounds.width <= 0 || bounds.height <= 0) return null; + return { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 }; + })()`, + ), + label, + ); + return { + x: webviewOrigin.x + pagePoint.x, + y: webviewOrigin.y + pagePoint.y, + }; +} + +async function pickBrowserElement( + manifest: CaptureManifest, + selector: string, + label: string, +): Promise { + const picked = await evaluateBrowser( + manifest, + `(() => { + const element = document.querySelector(${JSON.stringify(selector)}); + if (!(element instanceof HTMLElement)) return false; + const bounds = element.getBoundingClientRect(); + element.dispatchEvent(new MouseEvent("click", { + bubbles: true, + cancelable: true, + composed: true, + clientX: bounds.left + bounds.width / 2, + clientY: bounds.top + bounds.height / 2, + view: window, + })); + return true; + })()`, + ); + if (picked !== true) { + throw new Error(`Could not pick the ${label} during the capture.`); + } +} + +async function typeBrowserShadowInput( + manifest: CaptureManifest, + hostSelector: string, + shadowSelector: string, + text: string, +): Promise { + const typed = await evaluateBrowser( + manifest, + `(async () => { + const host = document.querySelector(${JSON.stringify(hostSelector)}); + const input = host?.shadowRoot?.querySelector(${JSON.stringify(shadowSelector)}); + if (!(input instanceof HTMLInputElement)) return false; + input.focus(); + input.value = ""; + for (const character of ${JSON.stringify(text)}) { + input.setRangeText(character, input.selectionStart ?? input.value.length, input.selectionEnd ?? input.value.length, "end"); + input.dispatchEvent(new InputEvent("input", { + bubbles: true, + data: character, + inputType: "insertText", + })); + await new Promise((resolve) => setTimeout(resolve, 24)); + } + return true; + })()`, + ); + if (typed !== true) { + throw new Error("Could not type the browser annotation during the capture."); + } +} + +async function clickBrowserShadowElement( + manifest: CaptureManifest, + hostSelector: string, + shadowSelector: string, + label: string, +): Promise { + const clicked = await evaluateBrowser( + manifest, + `(() => { + const host = document.querySelector(${JSON.stringify(hostSelector)}); + const element = host?.shadowRoot?.querySelector(${JSON.stringify(shadowSelector)}); + if (!(element instanceof HTMLElement)) return false; + element.click(); + return true; + })()`, + ); + if (clicked !== true) { + throw new Error(`Could not activate the ${label} during the capture.`); + } +} + +async function typeRendererText( + manifest: CaptureManifest, + selector: string, + text: string, + label: string, +): Promise { + const { target } = await rendererSnapshot(manifest); + const focused = await withCdp(target, async (send) => { + const result = unwrapRuntimeValue( + await send("Runtime.evaluate", { + expression: `(() => { + const element = document.querySelector(${JSON.stringify(selector)}); + if (!(element instanceof HTMLElement)) return false; + element.focus(); + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(element); + range.collapse(false); + selection?.removeAllRanges(); + selection?.addRange(range); + return true; + })()`, + returnByValue: true, + }), + ); + if (result !== true) return false; + for (const character of text) { + await send("Input.insertText", { text: character }); + await wait(18); + } + return true; + }); + if (!focused) { + throw new Error(`Could not type into the ${label} during the capture.`); + } +} + +async function clearRendererComposer(manifest: CaptureManifest): Promise { + const { target } = await rendererSnapshot(manifest); + await withCdp(target, async (send) => { + const focused = unwrapRuntimeValue( + await send("Runtime.evaluate", { + expression: `(() => { + const element = document.querySelector('[data-testid="composer-editor"]'); + if (!(element instanceof HTMLElement)) return false; + element.focus(); + return true; + })()`, + returnByValue: true, + }), + ); + if (focused !== true) return; + await send("Input.dispatchKeyEvent", { + type: "rawKeyDown", + key: "a", + code: "KeyA", + modifiers: 4, + }); + await send("Input.dispatchKeyEvent", { + type: "keyUp", + key: "a", + code: "KeyA", + modifiers: 4, + }); + await send("Input.dispatchKeyEvent", { + type: "rawKeyDown", + key: "Backspace", + code: "Backspace", + }); + await send("Input.dispatchKeyEvent", { + type: "keyUp", + key: "Backspace", + code: "Backspace", + }); + }); +} + +function parseCaptureRectangle(value: unknown, label: string): CaptureRectangle { + if (!isRecord(value)) { + throw new Error(`The capture-window helper returned invalid ${label}.`); + } + const rectangle = { + x: value.x, + y: value.y, + width: value.width, + height: value.height, + }; + if ( + !Number.isInteger(rectangle.x) || + !Number.isInteger(rectangle.y) || + !Number.isInteger(rectangle.width) || + !Number.isInteger(rectangle.height) || + (rectangle.width as number) <= 0 || + (rectangle.height as number) <= 0 + ) { + throw new Error(`The capture-window helper returned invalid ${label}.`); + } + return rectangle as CaptureRectangle; +} + +function captureWindowInfo(manifest: CaptureManifest): CaptureWindowInfo { + if (process.platform !== "darwin") { + throw new Error("Automated window recording currently requires macOS."); + } + const helper = NodePath.join( + REPO_ROOT, + "scripts", + "fixtures", + "marketing-studio", + "find-capture-window.swift", + ); + const rawResult = JSON.parse( + run("swift", [ + helper, + String(manifest.geometry.logicalWidth), + String(manifest.geometry.logicalHeight), + ]), + ) as unknown; + if ( + !isRecord(rawResult) || + !Number.isInteger(rawResult.windowId) || + !Array.isArray(rawResult.displays) + ) { + throw new Error("The capture-window helper returned an invalid result."); + } + return { + windowId: rawResult.windowId as number, + bounds: parseCaptureRectangle(rawResult.bounds, "window bounds"), + displays: rawResult.displays.map((display, index) => + parseCaptureRectangle(display, `display bounds at index ${String(index)}`), + ), + }; +} + +export function assertCaptureWindowFullyVisible(input: CaptureWindowInfo): void { + const windowRight = input.bounds.x + input.bounds.width; + const windowBottom = input.bounds.y + input.bounds.height; + const containingDisplay = input.displays.find((display) => { + const displayRight = display.x + display.width; + const displayBottom = display.y + display.height; + return ( + input.bounds.x >= display.x && + input.bounds.y >= display.y && + windowRight <= displayRight && + windowBottom <= displayBottom + ); + }); + if (containingDisplay) return; + + const displays = input.displays + .map( + (display) => + `${String(display.width)}×${String(display.height)} at ${String(display.x)},${String(display.y)}`, + ) + .join("; "); + throw new Error( + `Capture window ${String(input.bounds.width)}×${String(input.bounds.height)} at ${String(input.bounds.x)},${String(input.bounds.y)} extends beyond every display (${displays}). macOS would pad the off-screen edge black. Choose a display mode with at least 1600×934 usable points, then relaunch the studio.`, + ); +} + +function moveNativePointer(x: number, y: number, durationSeconds: number): void { + const helper = NodePath.join( + REPO_ROOT, + "scripts", + "fixtures", + "marketing-studio", + "move-native-pointer.swift", + ); + run("swift", [helper, String(x), String(y), String(durationSeconds)]); +} + +async function performSceneActions( + manifest: CaptureManifest, + scene: CaptureScene, + windowBounds: CaptureRectangle, +): Promise { + const sceneFamily = scene.id.replace(/-(?:dark|light)$/, ""); + switch (sceneFamily) { + case "workspace-four-panel-overview": { + moveNativePointer(10, 10, 0); + await wait(1_200); + + const collapseQuestions = await rendererElementCenter( + manifest, + 'button[aria-label="Collapse questions"]', + "collapse questions button", + ); + moveNativePointer( + windowBounds.x + collapseQuestions.x, + windowBounds.y + collapseQuestions.y, + 0.8, + ); + await clickRendererElement( + manifest, + 'button[aria-label="Collapse questions"]', + "collapse questions button", + ); + await wait(800); + + const activityButton = await browserElementCenter( + manifest, + ".launch-button", + "browser activity button", + ); + moveNativePointer(windowBounds.x + activityButton.x, windowBounds.y + activityButton.y, 1); + await clickBrowserElement(manifest, ".launch-button", "browser activity button"); + await wait(1_000); + await evaluateBrowser( + manifest, + `(() => { + window.scrollTo({ top: 520, behavior: "smooth" }); + return true; + })()`, + ); + await wait(1_600); + await evaluateBrowser( + manifest, + `(() => { + window.scrollTo({ top: 0, behavior: "smooth" }); + return true; + })()`, + ); + await wait(800); + + const changedFile = await rendererElementCenter( + manifest, + 'button[aria-label="Open diff for src/theme.ts"]', + "source-control file", + ); + moveNativePointer(windowBounds.x + changedFile.x, windowBounds.y + changedFile.y, 1); + await clickRendererElement( + manifest, + 'button[aria-label="Open diff for src/theme.ts"]', + "source-control file", + ); + await wait(2_400); + + const backToSourceControl = await rendererElementCenter( + manifest, + 'button[aria-label="Back to source control"]', + "back to source control button", + ); + moveNativePointer( + windowBounds.x + backToSourceControl.x, + windowBounds.y + backToSourceControl.y, + 0.8, + ); + await clickRendererElement( + manifest, + 'button[aria-label="Back to source control"]', + "back to source control button", + ); + await wait(1_000); + + const expandQuestions = await rendererElementCenter( + manifest, + 'button[aria-label="Expand questions"]', + "expand questions button", + ); + moveNativePointer( + windowBounds.x + expandQuestions.x, + windowBounds.y + expandQuestions.y, + 0.8, + ); + await clickRendererElement( + manifest, + 'button[aria-label="Expand questions"]', + "expand questions button", + ); + await wait(800); + await evaluateBrowser( + manifest, + `(() => { + const button = document.querySelector(".launch-button"); + if (!(button instanceof HTMLElement)) return false; + button.click(); + window.scrollTo({ top: 0, behavior: "instant" }); + return true; + })()`, + ); + await wait(300); + moveNativePointer(10, 10, 1); + return; + } + case "sidebar-attention-states": { + await wait(1_400); + await clickVisibleThread(manifest, "Project file editing"); + await wait(300); + await arrangeSceneBrowser(manifest, { ...scene, browserUrl: undefined }); + await arrangeSceneSourceControl(manifest, { ...scene, sourceControlOpen: false }); + await wait(1_200); + await clickVisibleThread(manifest, "Deploy health"); + await wait(300); + await arrangeSceneBrowser(manifest, { ...scene, browserUrl: undefined }); + await arrangeSceneSourceControl(manifest, { ...scene, sourceControlOpen: false }); + await wait(1_200); + await clickVisibleThread(manifest, "Checkout recovery"); + return; + } + case "agent-browser-workflow": { + const pickOverlay = "#__threadlines-pick-overlay"; + const annotationTarget = ".readiness h2"; + const annotationNote = "Make this a compact green status banner."; + const composerPrompt = "Apply this treatment and keep the health details below."; + + await evaluateBrowser( + manifest, + `(() => { + if (typeof window.__threadlinesResetAnnotationDemo !== "function") return false; + window.__threadlinesResetAnnotationDemo(); + return true; + })()`, + ); + await evaluateRenderer( + manifest, + `(() => { + for (const button of document.querySelectorAll('[data-testid^="picked-element-remove-"]')) { + if (button instanceof HTMLElement) button.click(); + } + return true; + })()`, + ); + await clearRendererComposer(manifest); + moveNativePointer(10, 10, 0); + await wait(1_200); + + const annotateTool = await rendererElementCenter( + manifest, + '[data-testid="browser-page-tool-arm"]', + "browser annotate tool", + ); + moveNativePointer(windowBounds.x + annotateTool.x, windowBounds.y + annotateTool.y, 0.75); + await clickRendererElement( + manifest, + '[data-testid="browser-page-tool-arm"]', + "browser annotate tool", + ); + await waitForBrowserCondition( + manifest, + `document.querySelector(${JSON.stringify(pickOverlay)}) !== null`, + "The browser annotation overlay did not appear.", + ); + await wait(300); + + const targetHeading = await browserElementCenter( + manifest, + annotationTarget, + "service-health heading", + ); + moveNativePointer(windowBounds.x + targetHeading.x, windowBounds.y + targetHeading.y, 0.85); + await pickBrowserElement(manifest, annotationTarget, "service-health heading"); + await waitForBrowserCondition( + manifest, + `document.querySelector(${JSON.stringify(pickOverlay)})?.shadowRoot?.querySelector(".input") !== null`, + "The browser annotation note field did not appear.", + ); + await wait(250); + await typeBrowserShadowInput(manifest, pickOverlay, ".input", annotationNote); + await wait(450); + + const attachButton = await browserShadowElementCenter( + manifest, + pickOverlay, + ".attach", + "annotation attach button", + ); + moveNativePointer(windowBounds.x + attachButton.x, windowBounds.y + attachButton.y, 0.65); + await clickBrowserShadowElement(manifest, pickOverlay, ".attach", "annotation attach button"); + await waitForRendererCondition( + manifest, + `document.querySelector('[data-testid="picked-element-chips"]') !== null`, + "The picked browser element was not attached to the composer.", + ); + await wait(450); + + const composer = await rendererElementCenter( + manifest, + '[data-testid="composer-editor"]', + "message composer", + ); + moveNativePointer(windowBounds.x + composer.x, windowBounds.y + composer.y, 0.65); + await typeRendererText( + manifest, + '[data-testid="composer-editor"]', + composerPrompt, + "message composer", + ); + await waitForRendererCondition( + manifest, + `document.querySelector('[data-testid="composer-editor"]')?.textContent?.includes(${JSON.stringify(composerPrompt)}) === true`, + "The annotation follow-up did not appear in the composer.", + ); + await wait(1_100); + + await clearRendererComposer(manifest); + await evaluateRenderer( + manifest, + `(() => { + const remove = document.querySelector('[data-testid^="picked-element-remove-"]'); + if (!(remove instanceof HTMLElement)) return false; + remove.click(); + return true; + })()`, + ); + await evaluateBrowser( + manifest, + `(() => { + if (typeof window.__threadlinesApplyAnnotationDemo !== "function") return false; + window.__threadlinesApplyAnnotationDemo(); + return true; + })()`, + ); + moveNativePointer(10, 10, 0.8); + await waitForBrowserCondition( + manifest, + `document.querySelector(".readiness")?.dataset.agentState === "applied"`, + "The simulated agent change did not reach the live preview.", + ); + await wait(5_000); + return; + } + default: + throw new Error(`Scene '${scene.id}' does not have an automated motion take.`); + } +} + +async function record(): Promise { + const manifest = readManifest(); + const { scene, captureWindow } = await preflight({ quiet: true }); + if (scene.kind !== "motion" || scene.durationSeconds === undefined) { + throw new Error(`Scene '${scene.id}' is not a motion scene.`); + } + if (!captureWindow) { + throw new Error("Automated recording requires a verified macOS capture window."); + } + const paths = studioPaths(); + FileSystem.mkdirSync(paths.rawMasters, { recursive: true }); + FileSystem.mkdirSync(paths.masters, { recursive: true }); + + const rawPath = NodePath.join( + paths.rawMasters, + `${scene.id}-screen-${new Date().toISOString().replaceAll(":", "-")}.mov`, + ); + const masterPath = NodePath.join(paths.masters, `${scene.id}.mkv`); + const captureArgs = [ + "-x", + "-v", + "-o", + ...(scene.cursorMode === "native" ? ["-C"] : []), + `-l${String(captureWindow.windowId)}`, + `-V${String(scene.durationSeconds)}`, + rawPath, + ]; + + if (scene.cursorMode === "native") { + moveNativePointer(10, 10, 0); + } + + console.log(`Recording ${scene.id} for ${String(scene.durationSeconds)} seconds...`); + const capture = ChildProcess.spawn("screencapture", captureArgs, { + stdio: ["ignore", "pipe", "pipe"], + }); + const captureResult = new Promise((resolve, reject) => { + let stderr = ""; + capture.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + capture.once("error", reject); + capture.once("exit", (code) => { + if (code === 0) { + resolve(); + } else { + reject( + new Error( + `macOS window recording failed with code ${String(code)}${ + stderr.trim() ? `:\n${stderr.trim().slice(-4_000)}` : "" + }`, + ), + ); + } + }); + }); + + let actionError: unknown; + try { + await performSceneActions(manifest, scene, captureWindow.bounds); + } catch (error) { + actionError = error; + } + await captureResult; + if (actionError !== undefined) throw actionError; + + run("ffmpeg", [ + "-y", + "-i", + rawPath, + "-map", + "0:v:0", + "-an", + "-vf", + `fps=${String(manifest.geometry.framesPerSecond)},format=yuv444p10le`, + "-c:v", + "ffv1", + "-level", + "3", + "-coder", + "1", + "-context", + "1", + "-g", + "1", + "-color_primaries", + "bt709", + "-color_trc", + "bt709", + "-colorspace", + "bt709", + masterPath, + ]); + const master = assertMaster(masterPath, manifest); + console.log( + `Recorded CFR master: ${masterPath} (${master.duration.toFixed(2)}s, ${String(master.stream.width)}×${String(master.stream.height)})`, + ); +} + +async function preflight(options: { readonly quiet?: boolean } = {}): Promise<{ + readonly scene: CaptureScene; + readonly snapshot: RuntimeSnapshot; + readonly captureWindow: CaptureWindowInfo | undefined; +}> { + const manifest = readManifest(); + const scene = resolveScene(manifest); + const paths = studioPaths(); + + for (const command of ["ffmpeg", "ffprobe", "gitleaks"]) { + if (!commandExists(command)) { + throw new Error(`Required capture tool is not installed: ${command}`); + } + } + assertStudioSafety(paths); + + const demoResponse = await fetch(manifest.capture.demoUrl, { + signal: AbortSignal.timeout(2_000), + }); + if (!demoResponse.ok) { + throw new Error(`Orbit demo returned ${String(demoResponse.status)}.`); + } + + const { snapshot, targets } = await rendererSnapshot(manifest); + const expectsCaptureMode = scene.windowMode === "neutral"; + if (snapshot.captureMode !== expectsCaptureMode) { + throw new Error( + scene.windowMode === "neutral" + ? "Scene requires the neutral capture window. Launch with `vp run marketing:studio`." + : "Scene requires genuine native controls. Launch with `vp run marketing:studio:native`.", + ); + } + if ( + expectsCaptureMode && + (snapshot.width !== manifest.geometry.logicalWidth || + snapshot.height !== manifest.geometry.logicalHeight) + ) { + throw new Error( + `Renderer is ${snapshot.width}×${snapshot.height}; expected ${manifest.geometry.logicalWidth}×${manifest.geometry.logicalHeight}.`, + ); + } + if ( + expectsCaptureMode && + Math.abs(snapshot.deviceScaleFactor - manifest.geometry.deviceScaleFactor) > 0.01 + ) { + throw new Error( + `Display scale is ${snapshot.deviceScaleFactor}×; expected ${manifest.geometry.deviceScaleFactor}×. Move Threadlines to a Retina/HiDPI display and relaunch.`, + ); + } + const captureWindow = + expectsCaptureMode && scene.kind === "motion" && process.platform === "darwin" + ? captureWindowInfo(manifest) + : undefined; + if (captureWindow) { + assertCaptureWindowFullyVisible(captureWindow); + } + if (snapshot.theme !== scene.theme) { + throw new Error( + `Scene requires ${scene.theme} theme, but Threadlines is ${snapshot.theme}. Run capture:prepare first.`, + ); + } + if ( + snapshot.activeProjectName !== scene.project || + snapshot.activeThreadTitle !== scene.threadTitle + ) { + const activeContext = + snapshot.activeProjectName && snapshot.activeThreadTitle + ? `'${snapshot.activeThreadTitle}' in ${snapshot.activeProjectName}` + : "no active thread"; + throw new Error( + `Scene requires '${scene.threadTitle}' in ${scene.project}, but the renderer has ${activeContext}.`, + ); + } + const missingLabels = scene.expectedLabels.filter((label) => !snapshot.text.includes(label)); + if (missingLabels.length > 0) { + throw new Error( + `Scene is not arranged yet; missing visible labels: ${missingLabels.join(", ")}`, + ); + } + if (scene.browserUrl) { + const visibleBrowserUrl = + snapshot.browserUrl ?? + targets.find((target) => target.url.startsWith(scene.browserUrl ?? "never"))?.url ?? + null; + if (!visibleBrowserUrl?.startsWith(scene.browserUrl)) { + throw new Error(`Browser scene requires ${scene.browserUrl}; open it before recording.`); + } + } + if (snapshot.sourceControlOpen !== scene.sourceControlOpen) { + throw new Error( + `Scene requires source control ${scene.sourceControlOpen ? "open" : "closed"}; run capture:prepare first.`, + ); + } + + FileSystem.mkdirSync(paths.qa, { recursive: true }); + const gitSha = run("git", ["rev-parse", "HEAD"], { cwd: REPO_ROOT }).trim(); + const reportPath = NodePath.join(paths.qa, `${scene.id}-preflight.json`); + FileSystem.writeFileSync( + reportPath, + `${JSON.stringify( + { + sceneId: scene.id, + release: manifest.release, + checkedAt: new Date().toISOString(), + gitSha, + platform: process.platform, + cursorMode: scene.cursorMode, + captureWindow, + snapshot: { + captureMode: snapshot.captureMode, + width: snapshot.width, + height: snapshot.height, + deviceScaleFactor: snapshot.deviceScaleFactor, + theme: snapshot.theme, + activeProjectName: snapshot.activeProjectName, + activeThreadTitle: snapshot.activeThreadTitle, + browserUrl: snapshot.browserUrl, + sourceControlOpen: snapshot.sourceControlOpen, + }, + }, + null, + 2, + )}\n`, + ); + if (!options.quiet) { + console.log(`Preflight passed for ${scene.id}.`); + console.log( + `Geometry: ${snapshot.width}×${snapshot.height} at ${snapshot.deviceScaleFactor}×`, + ); + console.log(`Capture window: ${scene.windowMode}`); + console.log(`Safety report: ${reportPath}`); + } + return { scene, snapshot, captureWindow }; +} + +function pngDimensions(buffer: Buffer): { readonly width: number; readonly height: number } { + if (buffer.length < 24 || buffer.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a") { + throw new Error("Captured still is not a valid PNG."); + } + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; +} + +async function still(): Promise { + const manifest = readManifest(); + const { scene, snapshot } = await preflight({ quiet: true }); + if (scene.windowMode === "native") { + throw new Error( + "CDP captures renderer content only. Capture native platform proof with an OS/OBS window source so genuine controls remain visible.", + ); + } + const paths = studioPaths(); + const { target } = await rendererSnapshot(manifest); + const capture = await withCdp(target, async (send) => + send("Page.captureScreenshot", { + format: "png", + fromSurface: true, + captureBeyondViewport: false, + }), + ); + if (!isRecord(capture) || typeof capture.data !== "string") { + throw new Error("Electron did not return PNG screenshot data."); + } + const buffer = Buffer.from(capture.data, "base64"); + const dimensions = pngDimensions(buffer); + if ( + dimensions.width !== manifest.geometry.masterWidth || + dimensions.height !== manifest.geometry.masterHeight + ) { + throw new Error( + `Captured PNG is ${dimensions.width}×${dimensions.height}; expected ${manifest.geometry.masterWidth}×${manifest.geometry.masterHeight}.`, + ); + } + + FileSystem.mkdirSync(paths.masters, { recursive: true }); + const outputPath = NodePath.join(paths.masters, `${scene.id}.png`); + FileSystem.writeFileSync(outputPath, buffer); + FileSystem.writeFileSync( + `${outputPath}.json`, + `${JSON.stringify( + { + sceneId: scene.id, + capturedAt: new Date().toISOString(), + width: dimensions.width, + height: dimensions.height, + deviceScaleFactor: snapshot.deviceScaleFactor, + sha256: sha256(outputPath), + sourceRevision: run("git", ["rev-parse", "HEAD"], { cwd: REPO_ROOT }).trim(), + }, + null, + 2, + )}\n`, + ); + console.log(`Captured clean still: ${outputPath}`); +} + +function sourceMediaPath(scene: CaptureScene, paths: ReturnType): string { + const configured = argument("input"); + if (configured) return NodePath.resolve(configured); + for (const extension of ["mov", "mkv"]) { + const candidate = NodePath.join(paths.masters, `${scene.id}.${extension}`); + if (FileSystem.existsSync(candidate)) return candidate; + } + throw new Error( + `No source master found for ${scene.id}. Pass --input or place ${scene.id}.mov/.mkv in ${paths.masters}.`, + ); +} + +function assertMaster( + sourcePath: string, + manifest: CaptureManifest, +): { readonly duration: number; readonly stream: ProbeStream } { + const result = probe(sourcePath); + const stream = videoStream(result); + if ( + stream.width !== manifest.geometry.masterWidth || + stream.height !== manifest.geometry.masterHeight + ) { + throw new Error( + `Master is ${String(stream.width)}×${String(stream.height)}; expected ${manifest.geometry.masterWidth}×${manifest.geometry.masterHeight}.`, + ); + } + const averageFps = frameRate(stream.avg_frame_rate); + const nominalFps = frameRate(stream.r_frame_rate); + if ( + Math.abs(averageFps - manifest.geometry.framesPerSecond) > 0.02 || + Math.abs(nominalFps - manifest.geometry.framesPerSecond) > 0.02 + ) { + throw new Error( + `Master is not true ${manifest.geometry.framesPerSecond} fps CFR (nominal ${nominalFps.toFixed(3)}, average ${averageFps.toFixed(3)}).`, + ); + } + if ( + !hasFlag("allow-compressed-master") && + stream.codec_name === "h264" && + stream.pix_fmt === "yuv420p" + ) { + throw new Error( + "Master is H.264 4:2:0. Record ProRes 422 HQ or FFV1/lossless-quality source, or pass --allow-compressed-master after visual review.", + ); + } + const duration = Number(result.format?.duration); + if (!Number.isFinite(duration) || duration <= 0) { + throw new Error("Master duration is missing or invalid."); + } + return { duration, stream }; +} + +function encodeVideo(input: { + readonly sourcePath: string; + readonly outputPath: string; + readonly width: number; + readonly height: number; + readonly codec: "h264" | "vp9"; + readonly framesPerSecond: number; + readonly keyframeSeconds: number; +}): void { + const keyframeFrames = Math.round(input.framesPerSecond * input.keyframeSeconds); + const common = [ + "-y", + "-i", + input.sourcePath, + "-map", + "0:v:0", + "-an", + "-vf", + `fps=${input.framesPerSecond},scale=${input.width}:${input.height}:flags=lanczos,setsar=1,format=yuv420p`, + "-color_primaries", + "bt709", + "-color_trc", + "bt709", + "-colorspace", + "bt709", + "-g", + String(keyframeFrames), + ]; + const codecArgs = + input.codec === "h264" + ? [ + "-c:v", + "libx264", + "-preset", + "slow", + "-crf", + "18", + "-profile:v", + "high", + "-keyint_min", + String(keyframeFrames), + "-sc_threshold", + "0", + "-movflags", + "+faststart", + ] + : [ + "-c:v", + "libvpx-vp9", + "-b:v", + "0", + "-crf", + "30", + "-deadline", + "good", + "-cpu-used", + "2", + "-row-mt", + "1", + "-tile-columns", + "2", + ]; + run("ffmpeg", [...common, ...codecArgs, input.outputPath]); +} + +function extractQaFrames(input: { + readonly sourcePath: string; + readonly sceneId: string; + readonly duration: number; + readonly qaDirectory: string; +}): void { + FileSystem.mkdirSync(input.qaDirectory, { recursive: true }); + const positions = [ + { name: "first", seconds: 0 }, + { name: "middle", seconds: input.duration / 2 }, + { name: "last", seconds: Math.max(0, input.duration - 1 / 60) }, + ]; + for (const position of positions) { + run("ffmpeg", [ + "-y", + "-ss", + position.seconds.toFixed(3), + "-i", + input.sourcePath, + "-frames:v", + "1", + NodePath.join(input.qaDirectory, `${input.sceneId}-${position.name}.png`), + ]); + } + run("ffmpeg", [ + "-y", + "-i", + input.sourcePath, + "-vf", + "fps=1/2,scale=640:-2:flags=lanczos,tile=4x2:padding=2:margin=0", + "-frames:v", + "1", + NodePath.join(input.qaDirectory, `${input.sceneId}-contact-sheet.png`), + ]); +} + +async function exportMedia(): Promise { + const manifest = readManifest(); + const scene = resolveScene(manifest); + if (scene.kind !== "motion") { + throw new Error(`Scene '${scene.id}' is a still; it has no motion exports.`); + } + const paths = studioPaths(); + const sourcePath = sourceMediaPath(scene, paths); + if (!FileSystem.existsSync(sourcePath)) { + throw new Error(`Source master does not exist: ${sourcePath}`); + } + const master = assertMaster(sourcePath, manifest); + const outputDirectory = NodePath.resolve( + argument("output-dir") ?? NodePath.join(paths.exports, manifest.release, scene.id), + ); + const posterDirectory = NodePath.join(paths.posters, manifest.release); + const qaDirectory = NodePath.join(paths.qa, manifest.release, scene.id); + FileSystem.mkdirSync(outputDirectory, { recursive: true }); + FileSystem.mkdirSync(posterDirectory, { recursive: true }); + + const targets = [ + { + suffix: "", + width: manifest.delivery.desktopWidth, + height: manifest.geometry.masterHeight, + }, + { + suffix: "-mobile", + width: manifest.delivery.mobileWidth, + height: Math.round( + (manifest.delivery.mobileWidth * manifest.geometry.masterHeight) / + manifest.geometry.masterWidth, + ), + }, + ]; + for (const target of targets) { + encodeVideo({ + sourcePath, + outputPath: NodePath.join(outputDirectory, `${scene.id}${target.suffix}.mp4`), + width: target.width, + height: target.height, + codec: "h264", + framesPerSecond: manifest.geometry.framesPerSecond, + keyframeSeconds: manifest.delivery.keyframeIntervalSeconds, + }); + encodeVideo({ + sourcePath, + outputPath: NodePath.join(outputDirectory, `${scene.id}${target.suffix}.webm`), + width: target.width, + height: target.height, + codec: "vp9", + framesPerSecond: manifest.geometry.framesPerSecond, + keyframeSeconds: manifest.delivery.keyframeIntervalSeconds, + }); + } + + const posterPng = NodePath.join(posterDirectory, `${scene.id}.png`); + run("ffmpeg", [ + "-y", + "-i", + sourcePath, + "-frames:v", + "1", + "-vf", + `scale=${manifest.geometry.masterWidth}:${manifest.geometry.masterHeight}:flags=lanczos`, + posterPng, + ]); + const posterWebp = NodePath.join(outputDirectory, `${scene.id}-poster.webp`); + if (commandExists("magick")) { + run("magick", [posterPng, "-quality", String(manifest.delivery.posterQuality), posterWebp]); + } else { + run("ffmpeg", [ + "-y", + "-i", + posterPng, + "-quality", + String(manifest.delivery.posterQuality), + posterWebp, + ]); + } + extractQaFrames({ + sourcePath, + sceneId: scene.id, + duration: master.duration, + qaDirectory, + }); + await postflight({ manifest, scene, outputDirectory, qaDirectory }); + console.log(`Exported ${scene.id}: ${outputDirectory}`); +} + +function hasFastStart(filePath: string): boolean { + const buffer = FileSystem.readFileSync(filePath); + const moov = buffer.indexOf(Buffer.from("moov")); + const mdat = buffer.indexOf(Buffer.from("mdat")); + return moov !== -1 && mdat !== -1 && moov < mdat; +} + +function extractOcrText(framePaths: ReadonlyArray): string | null { + if (process.platform === "darwin" && commandExists("swift")) { + return run("swift", [ + NodePath.join(REPO_ROOT, "scripts", "fixtures", "marketing-studio", "ocr-frames.swift"), + ...framePaths, + ]); + } + if (commandExists("tesseract")) { + return framePaths + .map((framePath) => { + const text = run("tesseract", [framePath, "stdout", "--psm", "11"]); + return `${NodePath.basename(framePath)}\t${text}`; + }) + .join("\n"); + } + return null; +} + +function assertOcrIsPublishSafe(text: string): void { + const riskyPatterns = [ + { label: "personal macOS path", pattern: /\/Users\/(?!Shared(?:\/|$))[^/\s]+/i }, + { label: "personal Windows path", pattern: /[A-Z]:\\Users\\(?!Public(?:\\|$))[^\\\s]+/i }, + { label: "AWS access key", pattern: /\bAKIA[0-9A-Z]{16}\b/ }, + { label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ }, + { label: "API key", pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/ }, + ]; + for (const risk of riskyPatterns) { + if (risk.pattern.test(text)) { + throw new Error(`OCR safety scan found a possible ${risk.label}. Review QA frames.`); + } + } + + const emails = text.match(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi) ?? []; + const unsafeEmail = emails.find((email) => !email.toLowerCase().endsWith(".example")); + if (unsafeEmail) { + throw new Error("OCR safety scan found a non-example email address. Review QA frames."); + } +} + +async function postflight(supplied?: { + readonly manifest: CaptureManifest; + readonly scene: CaptureScene; + readonly outputDirectory: string; + readonly qaDirectory: string; +}): Promise { + const manifest = supplied?.manifest ?? readManifest(); + const scene = supplied?.scene ?? resolveScene(manifest); + const paths = studioPaths(); + const outputDirectory = NodePath.resolve( + argument("output-dir") ?? + supplied?.outputDirectory ?? + NodePath.join(paths.exports, manifest.release, scene.id), + ); + const qaDirectory = supplied?.qaDirectory ?? NodePath.join(paths.qa, manifest.release, scene.id); + const expected = [ + { + name: `${scene.id}.mp4`, + codec: "h264", + width: manifest.delivery.desktopWidth, + height: manifest.geometry.masterHeight, + }, + { + name: `${scene.id}.webm`, + codec: "vp9", + width: manifest.delivery.desktopWidth, + height: manifest.geometry.masterHeight, + }, + { + name: `${scene.id}-mobile.mp4`, + codec: "h264", + width: manifest.delivery.mobileWidth, + height: Math.round( + (manifest.delivery.mobileWidth * manifest.geometry.masterHeight) / + manifest.geometry.masterWidth, + ), + }, + { + name: `${scene.id}-mobile.webm`, + codec: "vp9", + width: manifest.delivery.mobileWidth, + height: Math.round( + (manifest.delivery.mobileWidth * manifest.geometry.masterHeight) / + manifest.geometry.masterWidth, + ), + }, + ]; + + const files = expected.map((item) => { + const filePath = NodePath.join(outputDirectory, item.name); + if (!FileSystem.existsSync(filePath)) { + throw new Error(`Missing delivery export: ${filePath}`); + } + const result = probe(filePath); + const stream = videoStream(result); + if ( + stream.codec_name !== item.codec || + stream.width !== item.width || + stream.height !== item.height + ) { + throw new Error( + `${item.name} has ${String(stream.codec_name)} ${String(stream.width)}×${String(stream.height)}; expected ${item.codec} ${item.width}×${item.height}.`, + ); + } + const fps = frameRate(stream.avg_frame_rate); + if (Math.abs(fps - manifest.geometry.framesPerSecond) > 0.02) { + throw new Error(`${item.name} is ${fps.toFixed(3)} fps instead of true 60 fps.`); + } + if (result.streams.some((candidate) => candidate.codec_type === "audio")) { + throw new Error(`${item.name} unexpectedly contains audio.`); + } + if (stream.pix_fmt !== "yuv420p") { + throw new Error(`${item.name} uses ${String(stream.pix_fmt)} instead of yuv420p.`); + } + if ( + stream.color_primaries !== "bt709" || + stream.color_transfer !== "bt709" || + stream.color_space !== "bt709" + ) { + throw new Error(`${item.name} is missing explicit BT.709 color tags.`); + } + if (item.codec === "h264" && !hasFastStart(filePath)) { + throw new Error(`${item.name} is not fast-start optimized.`); + } + return { + ...item, + path: filePath, + fps, + pixelFormat: stream.pix_fmt, + sha256: sha256(filePath), + sizeBytes: FileSystem.statSync(filePath).size, + }; + }); + + const qaFrames = ["first", "middle", "last", "contact-sheet"].map((suffix) => + NodePath.join(qaDirectory, `${scene.id}-${suffix}.png`), + ); + for (const qaFrame of qaFrames) { + if (!FileSystem.existsSync(qaFrame)) { + throw new Error(`Missing QA frame: ${qaFrame}`); + } + } + const ocrText = extractOcrText(qaFrames); + if (ocrText !== null) { + assertOcrIsPublishSafe(ocrText); + FileSystem.writeFileSync( + NodePath.join(qaDirectory, `${scene.id}-ocr.txt`), + ocrText.endsWith("\n") ? ocrText : `${ocrText}\n`, + ); + } else { + console.warn("OCR is unavailable on this platform; perform the frame safety review manually."); + } + + const reportPath = NodePath.join(qaDirectory, `${scene.id}-postflight.json`); + FileSystem.writeFileSync( + reportPath, + `${JSON.stringify( + { + sceneId: scene.id, + release: manifest.release, + checkedAt: new Date().toISOString(), + files, + qaFrames, + ocrSafetyScan: ocrText === null ? "unavailable" : "passed", + manualChecks: [ + "Review every QA frame and the contact sheet at full size.", + "Confirm the first and last frames support the intended playback behavior.", + "Confirm no notification, account, personal path, or recorder overlay is visible.", + ], + }, + null, + 2, + )}\n`, + ); + console.log(`Postflight passed for ${scene.id}: ${reportPath}`); +} + +function printHelp(): void { + console.log( + [ + "Threadlines marketing capture tools", + "", + "Commands:", + " prepare --scene Set deterministic theme and scene marker", + " preflight --scene Verify studio, safety, geometry, and visible state", + " still --scene Capture a clean 2× renderer still", + " record --scene Record an automated macOS window take", + " export --scene [--input ] Create desktop/mobile WebM, MP4, and posters", + " postflight --scene [--output-dir ] Verify delivery files and QA frames", + "", + "Useful flags:", + " --allow-personal-path Allow a reviewed studio path under the home directory", + " --allow-compressed-master Allow a reviewed H.264 4:2:0 source master", + ].join("\n"), + ); +} + +async function main(): Promise { + switch (process.argv[2]) { + case "prepare": + await prepare(); + return; + case "preflight": + await preflight(); + return; + case "still": + await still(); + return; + case "record": + await record(); + return; + case "export": + await exportMedia(); + return; + case "postflight": + await postflight(); + return; + case "help": + case "--help": + case "-h": + case undefined: + printHelp(); + return; + default: + throw new Error(`Unknown marketing media command: ${String(process.argv[2])}`); + } +} + +if (import.meta.main) { + try { + await main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/marketing-studio.test.ts b/scripts/marketing-studio.test.ts index cb929691b..f2f4f733f 100644 --- a/scripts/marketing-studio.test.ts +++ b/scripts/marketing-studio.test.ts @@ -50,6 +50,21 @@ describe("marketing-studio", () => { projectName + " should be a Git repository", ); } + assert.equal(FileSystem.existsSync(NodePath.join(project, "scripts/demo-server.mjs")), true); + assert.equal(FileSystem.existsSync(NodePath.join(project, "src/demoData.js")), true); + const demoData = FileSystem.readFileSync(NodePath.join(project, "src/demoData.js"), "utf8"); + const demoScript = FileSystem.readFileSync(NodePath.join(project, "src/demo.js"), "utf8"); + const demoStyles = FileSystem.readFileSync(NodePath.join(project, "src/styles.css"), "utf8"); + assert.equal(/\$|revenue/i.test(demoData), false); + assert.equal(/0\.3\.0|production/i.test(demoData), false); + assert.match(demoData, /P95 latency/); + assert.equal(/launch|release readiness/i.test(demoData), false); + assert.match(demoScript, /requestedTheme/); + assert.match(demoScript, /__threadlinesApplyAnnotationDemo/); + assert.match(demoScript, /Agent applying annotation/); + assert.match(demoScript, /All services healthy/); + assert.match(demoStyles, /:root\[data-theme="light"\]/); + assert.match(demoStyles, /readiness\[data-agent-state="applied"\]/); assert.equal( FileSystem.existsSync(NodePath.join(root, ".threadlines-marketing-projects-seeded")), true, @@ -69,11 +84,18 @@ describe("marketing-studio", () => { readonly threads: ReadonlyArray<{ readonly title: string; readonly branch: string | null; + readonly createdAt: string; readonly modelSelection: { readonly instanceId: string; readonly model: string; readonly options: ReadonlyArray<{ readonly id: string; readonly value: string }>; }; + readonly interactionMode?: "default" | "plan"; + readonly scenario?: { + readonly status: string; + readonly prompt: string; + readonly assistantText?: string; + }; }>; }>; }; @@ -112,6 +134,31 @@ describe("marketing-studio", () => { options: [{ id: "reasoningEffort", value: "max" }], }, ); + assert.equal( + seededThreads.find((thread) => thread.title === "Checkout recovery")?.scenario?.status, + "awaiting-input", + ); + const workingThread = seededThreads.find((thread) => thread.title === "Project file editing"); + const workingThreadAgeMs = Date.now() - Date.parse(workingThread?.createdAt ?? ""); + assert.isAtLeast(workingThreadAgeMs, 0); + assert.isAtMost(workingThreadAgeMs, 5 * 60_000); + assert.equal( + seededThreads.find((thread) => thread.title === "Deploy health")?.scenario?.status, + "background", + ); + assert.equal( + seededThreads.find((thread) => thread.title === "Usage insights")?.scenario?.status, + "idle", + ); + assert.equal( + seededThreads.find((thread) => thread.title === "Rollout cohorts")?.scenario?.status, + "idle", + ); + assert.equal( + seededThreads.find((thread) => thread.title === "Rollout cohorts")?.interactionMode, + "plan", + ); + assert.equal(seededThreads.filter((thread) => thread.scenario !== undefined).length, 5); for (const worktree of [ ["Orbit", "project-files"], ["Orbit", "usage-insights"], @@ -188,9 +235,9 @@ describe("marketing-studio", () => { ), ), { - width: 1624, - height: 995, - isMaximized: true, + width: 1600, + height: 934, + isMaximized: false, }, ); diff --git a/scripts/marketing-studio.ts b/scripts/marketing-studio.ts index 0231d5c19..0315dd321 100644 --- a/scripts/marketing-studio.ts +++ b/scripts/marketing-studio.ts @@ -7,10 +7,13 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { resolveMarketingStudioRoot } from "./lib/marketing-studio-paths.ts"; + const STUDIO_KIND = "threadlines-marketing-studio"; const APP_DATA_KIND = "threadlines-marketing-studio-app-data"; const STUDIO_VERSION = 1; -const THREAD_SEED_VERSION = 4; +const THREAD_SEED_VERSION = 6; +const MARKETING_CAPTURE_DEBUG_PORT = "9223"; const PROJECT_NAME = "Orbit"; const USER_DATA_DIR_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/; const REPO_ROOT = NodePath.resolve(NodePath.dirname(fileURLToPath(import.meta.url)), ".."); @@ -36,6 +39,7 @@ interface StudioPaths { readonly captureMasters: string; readonly captureExports: string; readonly capturePosters: string; + readonly capturePlan: string; } interface StudioMetadata { @@ -77,11 +81,11 @@ const resolveDefaultAppDataRoot = (): string => { const resolveStudioPaths = (): StudioPaths => { const configuredRoot = process.env.THREADLINES_MARKETING_STUDIO_DIR?.trim(); const configuredAppData = process.env.THREADLINES_MARKETING_STUDIO_APP_DATA_DIR?.trim(); - const root = NodePath.resolve( - configuredRoot && configuredRoot.length > 0 - ? configuredRoot - : NodePath.join(NodeOS.homedir(), "Threadlines Marketing Studio"), - ); + const root = resolveMarketingStudioRoot({ + configuredRoot, + homeDirectory: NodeOS.homedir(), + publicDirectory: process.env.PUBLIC?.trim(), + }); const captures = NodePath.join(root, "Captures"); const appData = NodePath.resolve( configuredAppData && configuredAppData.length > 0 @@ -110,6 +114,7 @@ const resolveStudioPaths = (): StudioPaths => { captureMasters: NodePath.join(captures, "Masters"), captureExports: NodePath.join(captures, "Exports"), capturePosters: NodePath.join(captures, "Posters"), + capturePlan: NodePath.join(root, "Capture Plan.json"), }; }; @@ -319,9 +324,9 @@ const ensureStudioRoot = (): void => { paths.windowState, JSON.stringify( { - width: 1624, - height: 995, - isMaximized: true, + width: 1600, + height: 934, + isMaximized: false, }, null, 2, @@ -345,23 +350,32 @@ const writeStudioReadme = (): void => { "- Northstar: companion observability project with a cyan favicon", "- .worktrees: branch-specific workspaces behind the seeded thread history", "- Captures/Masters: untouched source recordings and full-resolution screenshots", - "- Captures/Exports: cropped and compressed site-ready assets", + "- Captures/Exports: generated desktop and mobile delivery assets", "- Captures/Posters: still frames and video poster images", + "- Capture Plan.json: deterministic 0.3.0 scene, geometry, and safety requirements", "- .threadlines: isolated Threadlines server and session state", "- Electron browser state: " + paths.appData, "", "## Capture stories", "", - "1. Browse project files, open tabs, edit a feature flag, and save.", - "2. Select a few useful lines and attach the selection to chat.", - "3. Inspect staged and unstaged changes by file, then undo one file.", - "4. Read the visual Git graph and inspect the open feature branches.", - "5. Show the inhabited sidebar with 6 Orbit, 5 Northstar, and 5 Lumen threads.", + "1. Show the full workspace: project sidebar, agent conversation, browser, and source control.", + "2. Show three live signals beside two quiet threads in a five-item inbox.", + "3. Move from Project file editing into the live Orbit browser preview.", + "4. Edit publish-safe data and watch the browser update.", + "5. Use the browser review tools to attach exact visual context.", + "6. Capture genuine native controls only for explicit platform-proof stills.", "", "Run from the Threadlines source checkout:", "", " vp run marketing:studio", "", + "Prepare and verify a scene before recording:", + "", + " vp run marketing:capture:prepare -- --scene workspace-four-panel-overview-dark", + " vp run marketing:capture:preflight -- --scene workspace-four-panel-overview-dark", + "", + "The full operating guide is docs/marketing-capture-studio.md in the Threadlines checkout.", + "", "Rebuild the synthetic project and clear only this isolated profile:", "", " vp run marketing:studio:reset -- --force", @@ -391,7 +405,7 @@ const writeFoundation = (): void => { version: "0.9.0", type: "module", scripts: { - dev: "vite", + dev: "node scripts/demo-server.mjs", test: "vitest run", typecheck: "tsc --noEmit", }, @@ -432,9 +446,9 @@ const writeFoundation = (): void => { lines( "# Orbit", "", - "A calm operations dashboard for teams shipping subscription products.", + "A calm systems dashboard for software teams.", "", - "Orbit brings checkout health, usage limits, and release readiness into one focused", + "Orbit brings service health, background work, and operational signals into one focused", "workspace. This repository is a synthetic demo used for Threadlines product captures.", "", "## Product principles", @@ -444,6 +458,314 @@ const writeFoundation = (): void => { "- Prefer a useful default over another settings screen.", ), ); + writeProjectFile( + "index.html", + lines( + "", + '', + " ", + ' ', + ' ', + ' ', + ' ', + " Orbit · System overview", + " ", + " ", + '
', + ' ', + " ", + "", + ), + ); + writeProjectFile( + "src/demoData.js", + lines( + "export const release = {", + ' name: "System overview",', + ' environment: "Live workspace",', + ' status: "All systems operational",', + " progress: 100,", + "};", + "", + "export const metrics = [", + ' { label: "P95 latency", value: "184ms", change: "−12ms" },', + ' { label: "Error rate", value: "0.08%", change: "−0.02%" },', + ' { label: "Queue depth", value: "12", change: "normal" },', + "];", + "", + "export const checks = [", + ' { label: "API gateway", detail: "Stable · 38ms", state: "passed" },', + ' { label: "Worker queue", detail: "12 jobs in flight", state: "passed" },', + ' { label: "Data sync", detail: "Updated 3 minutes ago", state: "passed" },', + "];", + ), + ); + writeProjectFile( + "src/demo.js", + lines( + 'import { checks, metrics, release } from "./demoData.js";', + "", + 'const requestedTheme = new URLSearchParams(window.location.search).get("theme");', + 'document.documentElement.dataset.theme = requestedTheme === "light" ? "light" : "dark";', + "", + 'const app = document.querySelector("#app");', + "", + "app.innerHTML = `", + '
', + '
Orbit
', + ' ', + ' ', + ' ', + "
", + '
', + "
", + '

${release.environment} · Updated now

', + "

${release.name}

", + '

Three services are healthy and background work is within expected limits.

', + "
", + ' ', + "
", + '
', + " ${metrics", + " .map(", + " (metric) => `", + "

${metric.label}

${metric.value}${metric.change}
", + " `,", + " )", + ' .join("")}', + "
", + '
', + '
', + "
", + '

Service health

', + "

${release.status}

", + "
", + " ${release.progress}%", + "
", + '
', + '
', + " ${checks", + " .map(", + " (check) => `", + '
${check.label}${check.detail}
', + " `,", + " )", + ' .join("")}', + "
", + "
", + "`;", + "", + 'const activityButton = document.querySelector(".launch-button");', + 'const statusHeading = document.querySelector(".readiness h2");', + 'const statusEyebrow = document.querySelector(".readiness .eyebrow");', + 'const statusProgress = document.querySelector(".readiness-heading > strong");', + 'const progressBar = document.querySelector(".progress span");', + 'const introSummary = document.querySelector(".lede");', + 'const readiness = document.querySelector(".readiness");', + 'const agentUpdate = document.querySelector(".agent-update");', + 'const agentUpdateLabel = document.querySelector(".agent-update strong");', + "let activityReviewed = false;", + "let annotationTimer;", + "let annotationStatusTimer;", + "", + "const renderActivityState = (reviewed) => {", + " activityReviewed = reviewed;", + ' activityButton.textContent = reviewed ? "Activity checked" : "View activity";', + ' activityButton.style.background = reviewed ? "var(--positive)" : "";', + ' activityButton.style.color = reviewed ? "var(--accent-contrast)" : "";', + ' statusHeading.textContent = reviewed ? "No issues detected" : release.status;', + " statusProgress.textContent = `${release.progress}%`;", + " progressBar.style.width = `${release.progress}%`;", + " introSummary.textContent = reviewed", + ' ? "All services are healthy and recent activity has been reviewed."', + ' : "Three services are healthy and background work is within expected limits.";', + "};", + "", + 'activityButton.addEventListener("click", () => renderActivityState(!activityReviewed));', + "", + "window.__threadlinesApplyAnnotationDemo = () => {", + " clearTimeout(annotationTimer);", + " clearTimeout(annotationStatusTimer);", + ' readiness.dataset.agentState = "working";', + " agentUpdate.hidden = false;", + ' agentUpdate.dataset.state = "working";', + ' agentUpdateLabel.textContent = "Agent applying annotation…";', + " annotationTimer = setTimeout(() => {", + ' readiness.dataset.agentState = "applied";', + ' statusEyebrow.textContent = "Live service health";', + ' statusHeading.textContent = "All services healthy";', + ' statusProgress.textContent = "3 / 3 online";', + ' introSummary.textContent = "Service health is clear at a glance, with the same operational detail close by.";', + ' agentUpdate.dataset.state = "applied";', + ' agentUpdateLabel.textContent = "Preview updated";', + " annotationStatusTimer = setTimeout(() => {", + " agentUpdate.hidden = true;", + " }, 1800);", + " }, 1200);", + "};", + "", + "window.__threadlinesResetAnnotationDemo = () => {", + " clearTimeout(annotationTimer);", + " clearTimeout(annotationStatusTimer);", + " delete readiness.dataset.agentState;", + ' statusEyebrow.textContent = "Service health";', + " statusHeading.textContent = release.status;", + " statusProgress.textContent = `${release.progress}%`;", + " progressBar.style.width = `${release.progress}%`;", + ' introSummary.textContent = "Three services are healthy and background work is within expected limits.";', + " agentUpdate.hidden = true;", + " delete agentUpdate.dataset.state;", + ' agentUpdateLabel.textContent = "Agent applying annotation…";', + ' window.scrollTo({ top: 0, behavior: "instant" });', + "};", + "", + 'const events = new EventSource("/__threadlines_reload");', + 'events.addEventListener("change", () => window.location.reload());', + ), + ); + writeProjectFile( + "src/styles.css", + lines( + ":root {", + " color-scheme: dark;", + ' font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;', + " --background: #0b0d10;", + " --foreground: #f5f7fa;", + " --muted: #8d99a6;", + " --border: #29313a;", + " --surface: #171b20;", + " --surface-border: #39434d;", + " --accent: #8b9cff;", + " --accent-strong: #a78bfa;", + " --accent-contrast: #0d0f13;", + " --positive: #5ee6a8;", + " --warning: #fbbf24;", + " --progress-track: #242a31;", + " --ambient: rgb(139 156 255 / 12%);", + " background: var(--background);", + " color: var(--foreground);", + "}", + ':root[data-theme="light"] {', + " color-scheme: light;", + " --background: #f7f8fa;", + " --foreground: #17191d;", + " --muted: #626b76;", + " --border: #d8dde5;", + " --surface: #ffffff;", + " --surface-border: #c8ced8;", + " --accent: #586bda;", + " --accent-strong: #6552c7;", + " --accent-contrast: #ffffff;", + " --positive: #16805f;", + " --warning: #a16207;", + " --progress-track: #e4e7ec;", + " --ambient: rgb(88 107 218 / 9%);", + "}", + "* { box-sizing: border-box; }", + "body { margin: 0; min-width: 0; min-height: 100vh; overflow-x: hidden; background: radial-gradient(circle at 88% -8%, var(--ambient), transparent 34%), var(--background); }", + "button { font: inherit; }", + "#app { width: min(1120px, calc(100% - 64px)); margin: 0 auto; }", + ".topbar { height: 68px; display: flex; align-items: center; border-bottom: 1px solid var(--border); }", + ".brand { display: flex; align-items: center; gap: 10px; font-weight: 700; letter-spacing: -0.02em; }", + ".orbit-mark { width: 18px; height: 18px; border: 4px solid var(--accent-strong); border-radius: 50%; transform: rotate(-24deg) scaleY(.55); }", + ".topbar nav { display: flex; gap: 28px; margin-left: 48px; color: var(--muted); font-size: 14px; }", + ".topbar nav span:first-child { color: var(--foreground); }", + ".agent-update { display: flex; align-items: center; gap: 7px; margin-left: auto; color: var(--muted); font-size: 12px; }", + ".agent-update[hidden] { display: none; }", + ".agent-update span { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 14%, transparent); }", + ".agent-update strong { font-weight: 600; }", + '.agent-update[data-state="working"] span { animation: agent-pulse 900ms ease-in-out infinite alternate; }', + '.agent-update[data-state="applied"] { color: var(--positive); }', + '.agent-update[data-state="applied"] span { background: var(--positive); box-shadow: 0 0 0 4px color-mix(in srgb, var(--positive) 14%, transparent); }', + ".avatar { margin-left: auto; width: 32px; height: 32px; border: 1px solid var(--surface-border); border-radius: 50%; background: var(--surface); color: var(--foreground); font-size: 11px; }", + ".agent-update + .avatar { margin-left: 16px; }", + ".intro { display: flex; align-items: flex-end; justify-content: space-between; padding: 52px 0 38px; border-bottom: 1px solid var(--border); }", + ".eyebrow { margin: 0 0 10px; color: var(--accent); font-size: 12px; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; }", + "h1 { margin: 0; font-size: 40px; line-height: 1.05; letter-spacing: -0.035em; }", + ".lede { max-width: 620px; margin: 14px 0 0; color: var(--muted); font-size: 16px; line-height: 1.55; }", + ".launch-button { border: 0; border-radius: 8px; padding: 11px 16px; background: var(--accent); color: var(--accent-contrast); font-weight: 700; }", + ".metrics { display: grid; grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid var(--border); }", + ".metrics article { padding: 28px 0; }", + ".metrics article + article { padding-left: 28px; border-left: 1px solid var(--border); }", + ".metrics p { margin: 0 0 10px; color: var(--muted); font-size: 13px; }", + ".metrics strong { font-size: 26px; letter-spacing: -0.025em; }", + ".metrics span { margin-left: 10px; color: var(--positive); font-size: 12px; }", + ".readiness { padding: 34px 0; }", + ".readiness-heading { display: flex; align-items: flex-end; justify-content: space-between; transition: background-color 180ms ease, border-color 180ms ease, padding 180ms ease; }", + ".readiness h2 { margin: 0; font-size: 20px; }", + ".readiness-heading > strong { color: var(--accent-strong); font-size: 24px; }", + ".progress { height: 4px; margin: 20px 0 28px; overflow: hidden; background: var(--progress-track); }", + ".progress span { display: block; height: 100%; background: var(--accent); }", + '.readiness[data-agent-state="working"] .readiness-heading { outline: 2px solid color-mix(in srgb, var(--accent) 72%, transparent); outline-offset: 7px; }', + '.readiness[data-agent-state="applied"] .readiness-heading { align-items: center; padding: 13px 15px; border: 1px solid color-mix(in srgb, var(--positive) 38%, var(--border)); border-radius: 8px; background: color-mix(in srgb, var(--positive) 10%, var(--surface)); }', + '.readiness[data-agent-state="applied"] .readiness-heading .eyebrow { margin-bottom: 5px; color: var(--positive); }', + '.readiness[data-agent-state="applied"] .readiness-heading > strong { color: var(--positive); font-size: 13px; letter-spacing: .02em; }', + '.readiness[data-agent-state="applied"] .progress { height: 0; margin: 16px 0 12px; opacity: 0; }', + '.readiness[data-agent-state="applied"] .checks { margin-top: 14px; }', + ".checks { border-top: 1px solid var(--border); }", + ".check { display: grid; grid-template-columns: 12px 1fr auto; align-items: center; gap: 12px; padding: 17px 0; border-bottom: 1px solid var(--border); }", + ".check strong { font-size: 14px; }", + ".check small { color: var(--muted); }", + ".check-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--warning); }", + ".check-dot.passed { background: var(--positive); }", + "@keyframes agent-pulse { from { opacity: .45; transform: scale(.8); } to { opacity: 1; transform: scale(1); } }", + "@media (max-width: 800px) { #app { width: calc(100% - 36px); } .topbar { height: 60px; } .topbar nav { gap: 16px; margin-left: 24px; } .intro { align-items: flex-start; flex-direction: column; gap: 20px; padding: 36px 0 28px; } h1 { font-size: 34px; } .metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); } .metrics article { min-width: 0; padding: 22px 14px; } .metrics article:first-child { padding-left: 0; } .metrics article + article { padding-left: 14px; } .metrics strong { font-size: 24px; } .metrics span { display: block; margin: 6px 0 0; } .check small { text-align: right; } }", + "@media (max-width: 520px) { .topbar nav span:last-child { display: none; } .metrics { grid-template-columns: 1fr; } .metrics article { padding: 18px 0; } .metrics article + article { padding-left: 0; border-left: 0; border-top: 1px solid var(--border); } }", + ), + ); + writeProjectFile( + "scripts/demo-server.mjs", + lines( + 'import { createServer } from "node:http";', + 'import { readFile, stat, writeFile } from "node:fs/promises";', + 'import { watch } from "node:fs";', + 'import { dirname, extname, join, normalize } from "node:path";', + 'import { fileURLToPath } from "node:url";', + "", + 'const root = normalize(join(dirname(fileURLToPath(import.meta.url)), ".."));', + 'const port = Number.parseInt(process.env.PORT ?? "4173", 10);', + "const readyFile = process.env.THREADLINES_MARKETING_DEMO_READY_FILE;", + "const clients = new Set();", + "const contentTypes = new Map([", + ' [".html", "text/html; charset=utf-8"],', + ' [".js", "text/javascript; charset=utf-8"],', + ' [".css", "text/css; charset=utf-8"],', + ' [".svg", "image/svg+xml"],', + "]);", + "", + "const server = createServer(async (request, response) => {", + ' const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");', + ' if (requestUrl.pathname === "/__threadlines_reload") {', + ' response.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });', + ' response.write(": connected\\n\\n");', + " clients.add(response);", + ' request.on("close", () => clients.delete(response));', + " return;", + " }", + ' const relativePath = requestUrl.pathname === "/" ? "index.html" : requestUrl.pathname.slice(1);', + " const filePath = normalize(join(root, relativePath));", + " if (!filePath.startsWith(root)) { response.writeHead(403).end(); return; }", + " try {", + " const info = await stat(filePath);", + ' if (!info.isFile()) throw new Error("not a file");', + " const body = await readFile(filePath);", + ' response.writeHead(200, { "content-type": contentTypes.get(extname(filePath)) ?? "application/octet-stream", "cache-control": "no-store" });', + " response.end(body);", + " } catch {", + ' response.writeHead(404).end("Not found");', + " }", + "});", + "", + "watch(root, { recursive: true }, (_event, fileName) => {", + ' if (!fileName || fileName.startsWith(".git") || fileName.includes("node_modules")) return;', + ' for (const client of clients) client.write("event: change\\ndata: reload\\n\\n");', + "});", + "", + 'server.listen(port, "127.0.0.1", async () => {', + ' if (readyFile) await writeFile(readyFile, String(process.pid), "utf8");', + "});", + ), + ); writeProjectFile( "src/config/featureFlags.ts", lines( @@ -1146,6 +1468,21 @@ interface MarketingThreadSeed { readonly worktreePath: string | null; readonly createdAt: string; readonly modelSelection: MarketingModelSelection; + readonly interactionMode?: "default" | "plan"; + readonly scenario?: { + readonly status: + | "idle" + | "working" + | "starting" + | "completed" + | "pending-approval" + | "awaiting-input" + | "plan-ready" + | "background" + | "failed"; + readonly prompt: string; + readonly assistantText?: string; + }; } interface MarketingProjectThreadSeed { @@ -1242,13 +1579,26 @@ const buildProjectThreadSeeds = (): ReadonlyArray => worktreePath: paths.project, createdAt: createdAtMinutesAgo(now, 8), modelSelection: GPT_SOL_MAX, + scenario: { + status: "awaiting-input", + prompt: + "Compare checkout recovery health across regions and ask which region should be the baseline.", + assistantText: + "The recovery paths are healthy. Which region should I use as the comparison baseline?", + }, }, { title: "Project file editing", branch: "feature/project-files", worktreePath: threadWorktreePath("Orbit", "project-files"), - createdAt: createdAtMinutesAgo(now, 36), + createdAt: createdAtMinutesAgo(now, 2), modelSelection: CLAUDE_FABLE_HIGH, + scenario: { + status: "working", + prompt: "Make project file selection labels clearer and verify the editor flow.", + assistantText: + "I updated the selection labels and am checking the file-to-chat flow across the editor.", + }, }, { title: "Usage insights", @@ -1256,19 +1606,25 @@ const buildProjectThreadSeeds = (): ReadonlyArray => worktreePath: threadWorktreePath("Orbit", "usage-insights"), createdAt: createdAtMinutesAgo(now, 160), modelSelection: GPT_SOL_MAX, + scenario: { + status: "idle", + prompt: "Add an at-a-glance usage summary for the launch dashboard.", + assistantText: + "Usage now reads clearly at a glance, with limits capped correctly and boundary coverage added.", + }, }, { title: "Release guard", branch: null, worktreePath: null, - createdAt: createdAtMinutesAgo(now, 1_380), + createdAt: createdAtMinutesAgo(now, 3_360), modelSelection: CLAUDE_FABLE_HIGH, }, { title: "Design token cleanup", branch: null, worktreePath: null, - createdAt: createdAtMinutesAgo(now, 2_160), + createdAt: createdAtMinutesAgo(now, 4_260), modelSelection: GPT_SOL_MAX, }, { @@ -1289,19 +1645,25 @@ const buildProjectThreadSeeds = (): ReadonlyArray => worktreePath: paths.northstarProject, createdAt: createdAtMinutesAgo(now, 52), modelSelection: CLAUDE_FABLE_HIGH, + scenario: { + status: "background", + prompt: "Compare deploy health across the last three production releases and regions.", + assistantText: + "The initial comparison is complete. A longer regional health check is still running in the background.", + }, }, { title: "Group noisy alerts", branch: "studio/alert-grouping", worktreePath: threadWorktreePath("Northstar", "alert-grouping"), - createdAt: createdAtMinutesAgo(now, 310), + createdAt: createdAtMinutesAgo(now, 3_180), modelSelection: GPT_SOL_MAX, }, { title: "Trace sampling", branch: null, worktreePath: null, - createdAt: createdAtMinutesAgo(now, 2_880), + createdAt: createdAtMinutesAgo(now, 3_720), modelSelection: CLAUDE_FABLE_HIGH, }, { @@ -1329,12 +1691,19 @@ const buildProjectThreadSeeds = (): ReadonlyArray => worktreePath: paths.lumenProject, createdAt: createdAtMinutesAgo(now, 210), modelSelection: GPT_SOL_MAX, + interactionMode: "plan", + scenario: { + status: "idle", + prompt: "Plan a safer way to define overlapping rollout cohorts.", + assistantText: + "I mapped the overlap cases and documented an explicit precedence rule for the next pass.", + }, }, { title: "Evaluation cache", branch: "studio/evaluation-cache", worktreePath: threadWorktreePath("Lumen", "evaluation-cache"), - createdAt: createdAtMinutesAgo(now, 4_320), + createdAt: createdAtMinutesAgo(now, 4_080), modelSelection: CLAUDE_FABLE_HIGH, }, { @@ -1486,6 +1855,10 @@ const setupStudio = (): void => { ensureCompanionRepositories(); ensureThreadWorktrees(); installFixtureCommands(); + FileSystem.copyFileSync( + NodePath.join(REPO_ROOT, "scripts/fixtures/marketing-studio/capture-scenes.json"), + paths.capturePlan, + ); seedStudioProjects(); seedStudioThreads(); @@ -1501,43 +1874,88 @@ const printPaths = (): void => { console.log("Capture masters: " + paths.captureMasters); console.log("Capture exports: " + paths.captureExports); console.log("Poster frames: " + paths.capturePosters); + console.log("Capture plan: " + paths.capturePlan); +}; + +const waitForPath = (filePath: string, child: ChildProcess.ChildProcess): void => { + const sleeper = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)); + for (let attempt = 0; attempt < 60; attempt += 1) { + if (FileSystem.existsSync(filePath)) { + return; + } + if (child.exitCode !== null) { + throw new Error( + "Orbit demo server exited before it was ready (code " + String(child.exitCode) + ").", + ); + } + Atomics.wait(sleeper, 0, 0, 50); + } + throw new Error("Orbit demo server did not become ready within 3 seconds."); }; -const launchStudio = (): void => { +const launchStudio = (marketingCaptureMode = true): void => { setupStudio(); console.log(""); console.log("Launching isolated Threadlines Marketing Studio..."); - const result = ChildProcess.spawnSync( + const demoReadyFile = NodePath.join(paths.root, ".orbit-demo-server-ready"); + FileSystem.rmSync(demoReadyFile, { force: true }); + const demoServer = ChildProcess.spawn( process.execPath, - [ - NodePath.join(REPO_ROOT, "scripts/dev-runner.ts"), - "dev:desktop", - "--auto-bootstrap-project-from-cwd", - ], + [NodePath.join(paths.project, "scripts/demo-server.mjs")], { - cwd: REPO_ROOT, + cwd: paths.project, env: { ...process.env, - THREADLINES_DEV_INSTANCE: "marketing-studio", - THREADLINES_HOME: paths.threadlinesHome, - THREADLINES_DESKTOP_APP_DATA_DIR: NodePath.dirname(paths.appData), - THREADLINES_DESKTOP_USER_DATA_DIR_NAME: NodePath.basename(paths.appData), - THREADLINES_DESKTOP_BACKEND_CWD: paths.project, - THREADLINES_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "1", - THREADLINES_DESKTOP_OPEN_DEVTOOLS: "0", - THREADLINES_DESKTOP_RESTART_ON_REBUILD: - process.env.THREADLINES_DESKTOP_RESTART_ON_REBUILD ?? "0", - THREADLINES_DISABLE_AUTO_UPDATE: "1", - PATH: paths.fixtureBin + NodePath.delimiter + (process.env.PATH ?? ""), - // Keep this path whitespace-free: terminal shell resolution treats the - // configured value as a command and the studio root intentionally has - // spaces in its name. The fixture bin is already prepended to PATH. - SHELL: "threadlines-studio-shell", + PORT: "4173", + THREADLINES_MARKETING_DEMO_READY_FILE: demoReadyFile, }, - stdio: "inherit", + stdio: "ignore", }, ); + demoServer.on("error", () => undefined); + + let result: ChildProcess.SpawnSyncReturns; + try { + waitForPath(demoReadyFile, demoServer); + console.log("Orbit demo: http://127.0.0.1:4173"); + result = ChildProcess.spawnSync( + process.execPath, + [ + NodePath.join(REPO_ROOT, "scripts/dev-runner.ts"), + "dev:desktop", + "--auto-bootstrap-project-from-cwd", + ], + { + cwd: REPO_ROOT, + env: { + ...process.env, + THREADLINES_DEV_INSTANCE: "marketing-studio", + THREADLINES_HOME: paths.threadlinesHome, + THREADLINES_DESKTOP_APP_DATA_DIR: NodePath.dirname(paths.appData), + THREADLINES_DESKTOP_USER_DATA_DIR_NAME: NodePath.basename(paths.appData), + THREADLINES_DESKTOP_BACKEND_CWD: paths.project, + THREADLINES_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "1", + THREADLINES_DESKTOP_OPEN_DEVTOOLS: "0", + THREADLINES_DESKTOP_MARKETING_CAPTURE: marketingCaptureMode ? "1" : "0", + THREADLINES_CAPTURE_DEBUG_PORT: + process.env.THREADLINES_CAPTURE_DEBUG_PORT ?? MARKETING_CAPTURE_DEBUG_PORT, + THREADLINES_DESKTOP_RESTART_ON_REBUILD: + process.env.THREADLINES_DESKTOP_RESTART_ON_REBUILD ?? "0", + THREADLINES_DISABLE_AUTO_UPDATE: "1", + PATH: paths.fixtureBin + NodePath.delimiter + (process.env.PATH ?? ""), + // Keep this path whitespace-free: terminal shell resolution treats the + // configured value as a command and the studio root intentionally has + // spaces in its name. The fixture bin is already prepended to PATH. + SHELL: "threadlines-studio-shell", + }, + stdio: "inherit", + }, + ); + } finally { + demoServer.kill(); + FileSystem.rmSync(demoReadyFile, { force: true }); + } if (result.error) { throw result.error; @@ -1587,6 +2005,7 @@ const printHelp = (): void => { "", "Usage:", " node scripts/marketing-studio.ts launch", + " node scripts/marketing-studio.ts launch-native", " node scripts/marketing-studio.ts setup", " node scripts/marketing-studio.ts paths", " node scripts/marketing-studio.ts reset --force", @@ -1603,6 +2022,9 @@ const main = (): void => { case "launch": launchStudio(); break; + case "launch-native": + launchStudio(false); + break; case "setup": setupStudio(); break;