From 153120e2b371c99e62329eb48573609b5757dd09 Mon Sep 17 00:00:00 2001 From: ru-code-dev <53821477+ru-code-dev@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:43:00 -0400 Subject: [PATCH] pixso-integration --- .gitignore | 2 + apps/web/src/components/AppSidebarLayout.tsx | 14 + apps/web/src/components/Sidebar.tsx | 2 + apps/web/src/ru-fork/pixso-move/api.ts | 63 ++ .../pixso-move/components/CodeCollapsible.tsx | 49 ++ .../pixso-move/components/GalleryView.tsx | 109 ++++ .../pixso-move/components/NodeDetail.tsx | 104 ++++ .../pixso-move/components/PixsoNavGroup.tsx | 40 ++ .../pixso-move/components/PixsoPanel.tsx | 78 +++ .../components/PixsoPanelInlineSidebar.tsx | 46 ++ .../pixso-move/components/ResultBlock.tsx | 32 + .../pixso-move/components/SettingsView.tsx | 82 +++ apps/web/src/ru-fork/pixso-move/format.ts | 31 + apps/web/src/ru-fork/pixso-move/index.ts | 12 + apps/web/src/ru-fork/pixso-move/queries.ts | 43 ++ apps/web/src/ru-fork/pixso-move/store.ts | 89 +++ pixso-move/.gitignore | 2 + pixso-move/STATUS.md | 129 ++++ pixso-move/contracts/package.json | 23 + pixso-move/contracts/src/base.ts | 19 + pixso-move/contracts/src/errors.ts | 20 + pixso-move/contracts/src/ids.ts | 16 + pixso-move/contracts/src/index.ts | 6 + pixso-move/contracts/src/ingest.ts | 19 + pixso-move/contracts/src/node.ts | 24 + pixso-move/contracts/src/processing.ts | 21 + pixso-move/contracts/tests/base.test.ts | 26 + pixso-move/contracts/tests/decode.ts | 25 + pixso-move/contracts/tests/errors.test.ts | 22 + pixso-move/contracts/tests/ids.test.ts | 41 ++ pixso-move/contracts/tests/index.test.ts | 23 + pixso-move/contracts/tests/ingest.test.ts | 47 ++ pixso-move/contracts/tests/node.test.ts | 46 ++ pixso-move/contracts/tests/processing.test.ts | 52 ++ pixso-move/contracts/tsconfig.json | 4 + pixso-move/contracts/vitest.config.ts | 3 + pixso-move/plugin/manifest.json | 8 + pixso-move/plugin/package.json | 36 ++ pixso-move/plugin/src/code/base64.ts | 18 + pixso-move/plugin/src/code/code.ts | 64 ++ pixso-move/plugin/src/code/nodeProps.ts | 61 ++ pixso-move/plugin/src/code/selection.ts | 21 + pixso-move/plugin/src/code/serialize.ts | 49 ++ pixso-move/plugin/src/code/settings.ts | 31 + pixso-move/plugin/src/pixso.d.ts | 37 ++ pixso-move/plugin/src/shared/messages.ts | 39 ++ pixso-move/plugin/src/ui/App.tsx | 77 +++ pixso-move/plugin/src/ui/api.ts | 59 ++ pixso-move/plugin/src/ui/bridge.ts | 26 + .../plugin/src/ui/components/Toaster.tsx | 39 ++ .../ui/components/settings/settingsLayout.tsx | 137 +++++ .../plugin/src/ui/components/ui/alert.tsx | 73 +++ .../plugin/src/ui/components/ui/button.tsx | 75 +++ .../plugin/src/ui/components/ui/card.tsx | 197 ++++++ .../plugin/src/ui/components/ui/field.tsx | 60 ++ .../plugin/src/ui/components/ui/input.tsx | 74 +++ .../plugin/src/ui/components/ui/label.tsx | 25 + .../plugin/src/ui/components/ui/select.tsx | 258 ++++++++ .../plugin/src/ui/components/ui/tooltip.tsx | 60 ++ pixso-move/plugin/src/ui/index.css | 565 ++++++++++++++++++ pixso-move/plugin/src/ui/index.html | 46 ++ pixso-move/plugin/src/ui/key.ts | 27 + pixso-move/plugin/src/ui/lib/utils.ts | 7 + pixso-move/plugin/src/ui/main.tsx | 17 + pixso-move/plugin/src/ui/screens/Body.tsx | 74 +++ .../plugin/src/ui/screens/MainScreen.tsx | 47 ++ .../plugin/src/ui/screens/SettingsHeader.tsx | 30 + .../plugin/src/ui/screens/SettingsScreen.tsx | 120 ++++ .../plugin/src/ui/screens/ThemeSettings.tsx | 99 +++ pixso-move/plugin/src/ui/state/reducer.ts | 83 +++ pixso-move/plugin/src/ui/state/types.ts | 27 + pixso-move/plugin/src/ui/theme.ts | 54 ++ pixso-move/plugin/src/ui/themes/aurora.css | 104 ++++ pixso-move/plugin/src/ui/themes/caffeine.css | 99 +++ pixso-move/plugin/src/ui/themes/grayscale.css | 93 +++ pixso-move/plugin/src/ui/themes/onyx.css | 105 ++++ .../plugin/src/ui/themes/pastel-dreams.css | 93 +++ pixso-move/plugin/src/ui/themes/ru-fork.css | 64 ++ pixso-move/plugin/src/ui/themes/vs-code.css | 105 ++++ pixso-move/plugin/tests/api.test.ts | 53 ++ pixso-move/plugin/tests/base64.test.ts | 27 + pixso-move/plugin/tests/key.test.ts | 16 + pixso-move/plugin/tests/reducer.test.ts | 128 ++++ pixso-move/plugin/tests/selection.test.ts | 26 + pixso-move/plugin/tests/serialize.test.ts | 60 ++ pixso-move/plugin/tests/settings.test.ts | 48 ++ pixso-move/plugin/tests/theme.test.ts | 28 + pixso-move/plugin/tsconfig.json | 13 + pixso-move/plugin/vite.code.config.ts | 18 + pixso-move/plugin/vite.ui.config.ts | 37 ++ pixso-move/plugin/vitest.config.ts | 9 + pixso-move/processor/package.json | 25 + .../src/acp/acpRunnerLive.integration.ts | 85 +++ pixso-move/processor/src/acp/collect.ts | 12 + pixso-move/processor/src/acp/handshake.ts | 32 + pixso-move/processor/src/acp/runner.ts | 10 + pixso-move/processor/src/config.ts | 16 + pixso-move/processor/src/drain.ts | 45 ++ pixso-move/processor/src/engine.ts | 101 ++++ pixso-move/processor/src/extract.ts | 14 + pixso-move/processor/src/index.ts | 13 + pixso-move/processor/src/noop.ts | 13 + pixso-move/processor/src/processor.ts | 16 + pixso-move/processor/src/prompt.ts | 20 + pixso-move/processor/src/reconcile.ts | 32 + pixso-move/processor/src/types.ts | 68 +++ pixso-move/processor/tests/collect.test.ts | 34 ++ pixso-move/processor/tests/drain.test.ts | 70 +++ pixso-move/processor/tests/engine.test.ts | 207 +++++++ pixso-move/processor/tests/extract.test.ts | 26 + pixso-move/processor/tests/fakes.ts | 129 ++++ pixso-move/processor/tests/handshake.test.ts | 50 ++ pixso-move/processor/tests/noop.test.ts | 15 + pixso-move/processor/tests/prompt.test.ts | 23 + pixso-move/processor/tests/reconcile.test.ts | 55 ++ pixso-move/processor/tsconfig.json | 4 + pixso-move/processor/vitest.config.ts | 3 + pixso-move/server/package.json | 28 + pixso-move/server/src/bin.ts | 44 ++ pixso-move/server/src/config.ts | 55 ++ pixso-move/server/src/http/auth.ts | 15 + pixso-move/server/src/http/cors.ts | 12 + pixso-move/server/src/http/ingest.ts | 44 ++ pixso-move/server/src/http/nodes.ts | 41 ++ pixso-move/server/src/http/processing.ts | 34 ++ pixso-move/server/src/http/query.ts | 13 + pixso-move/server/src/http/respond.ts | 12 + pixso-move/server/src/http/route.ts | 30 + pixso-move/server/src/http/routes.ts | 13 + pixso-move/server/src/httpServer.ts | 17 + pixso-move/server/src/persistence/migrate.ts | 13 + .../src/persistence/migrations/001_nodes.ts | 18 + .../src/persistence/migrations/002_results.ts | 29 + pixso-move/server/src/persistence/sqlite.ts | 37 ++ pixso-move/server/src/server.ts | 37 ++ pixso-move/server/src/serverLogger.ts | 15 + pixso-move/server/src/services/nodeStore.ts | 33 + .../server/src/services/nodeStoreLive.ts | 94 +++ .../server/src/services/processorLive.ts | 35 ++ pixso-move/server/src/services/resultStore.ts | 34 ++ .../server/src/services/resultStoreLive.ts | 129 ++++ pixso-move/server/src/time.ts | 6 + .../server/src/vendor/NodeSqliteClient.ts | 281 +++++++++ pixso-move/server/tests/config.test.ts | 25 + pixso-move/server/tests/embed.test.ts | 95 +++ pixso-move/server/tests/fakeAcpRunner.ts | 10 + pixso-move/server/tests/http/harness.ts | 50 ++ pixso-move/server/tests/http/routes.test.ts | 106 ++++ pixso-move/server/tests/nodeStore.test.ts | 64 ++ pixso-move/server/tests/persistence.test.ts | 29 + pixso-move/server/tests/resultStore.test.ts | 96 +++ pixso-move/server/tests/server.test.ts | 44 ++ pixso-move/server/tsconfig.json | 5 + pixso-move/server/tsdown.config.ts | 13 + pixso-move/server/vitest.config.ts | 3 + pixso-move/specs/00-overview.md | 164 +++++ pixso-move/specs/01-scaffold.md | 209 +++++++ pixso-move/specs/02-contracts.md | 104 ++++ pixso-move/specs/03-server.md | 214 +++++++ pixso-move/specs/04-processor.md | 144 +++++ pixso-move/specs/05-embed.md | 90 +++ pixso-move/specs/06-plugin-build.md | 110 ++++ pixso-move/specs/07-plugin-code.md | 123 ++++ pixso-move/specs/08-plugin-ui.md | 103 ++++ pixso-move/specs/09-end-to-end.md | 59 ++ pixso-move/specs/README.md | 85 +++ pixso-move/specs/conventions.md | 200 +++++++ pixso-move/tsconfig.base.json | 8 + pixso-move/vitest.base.ts | 37 ++ pnpm-workspace.yaml | 1 + 170 files changed, 9638 insertions(+) create mode 100644 apps/web/src/ru-fork/pixso-move/api.ts create mode 100644 apps/web/src/ru-fork/pixso-move/components/CodeCollapsible.tsx create mode 100644 apps/web/src/ru-fork/pixso-move/components/GalleryView.tsx create mode 100644 apps/web/src/ru-fork/pixso-move/components/NodeDetail.tsx create mode 100644 apps/web/src/ru-fork/pixso-move/components/PixsoNavGroup.tsx create mode 100644 apps/web/src/ru-fork/pixso-move/components/PixsoPanel.tsx create mode 100644 apps/web/src/ru-fork/pixso-move/components/PixsoPanelInlineSidebar.tsx create mode 100644 apps/web/src/ru-fork/pixso-move/components/ResultBlock.tsx create mode 100644 apps/web/src/ru-fork/pixso-move/components/SettingsView.tsx create mode 100644 apps/web/src/ru-fork/pixso-move/format.ts create mode 100644 apps/web/src/ru-fork/pixso-move/index.ts create mode 100644 apps/web/src/ru-fork/pixso-move/queries.ts create mode 100644 apps/web/src/ru-fork/pixso-move/store.ts create mode 100644 pixso-move/.gitignore create mode 100644 pixso-move/STATUS.md create mode 100644 pixso-move/contracts/package.json create mode 100644 pixso-move/contracts/src/base.ts create mode 100644 pixso-move/contracts/src/errors.ts create mode 100644 pixso-move/contracts/src/ids.ts create mode 100644 pixso-move/contracts/src/index.ts create mode 100644 pixso-move/contracts/src/ingest.ts create mode 100644 pixso-move/contracts/src/node.ts create mode 100644 pixso-move/contracts/src/processing.ts create mode 100644 pixso-move/contracts/tests/base.test.ts create mode 100644 pixso-move/contracts/tests/decode.ts create mode 100644 pixso-move/contracts/tests/errors.test.ts create mode 100644 pixso-move/contracts/tests/ids.test.ts create mode 100644 pixso-move/contracts/tests/index.test.ts create mode 100644 pixso-move/contracts/tests/ingest.test.ts create mode 100644 pixso-move/contracts/tests/node.test.ts create mode 100644 pixso-move/contracts/tests/processing.test.ts create mode 100644 pixso-move/contracts/tsconfig.json create mode 100644 pixso-move/contracts/vitest.config.ts create mode 100644 pixso-move/plugin/manifest.json create mode 100644 pixso-move/plugin/package.json create mode 100644 pixso-move/plugin/src/code/base64.ts create mode 100644 pixso-move/plugin/src/code/code.ts create mode 100644 pixso-move/plugin/src/code/nodeProps.ts create mode 100644 pixso-move/plugin/src/code/selection.ts create mode 100644 pixso-move/plugin/src/code/serialize.ts create mode 100644 pixso-move/plugin/src/code/settings.ts create mode 100644 pixso-move/plugin/src/pixso.d.ts create mode 100644 pixso-move/plugin/src/shared/messages.ts create mode 100644 pixso-move/plugin/src/ui/App.tsx create mode 100644 pixso-move/plugin/src/ui/api.ts create mode 100644 pixso-move/plugin/src/ui/bridge.ts create mode 100644 pixso-move/plugin/src/ui/components/Toaster.tsx create mode 100644 pixso-move/plugin/src/ui/components/settings/settingsLayout.tsx create mode 100644 pixso-move/plugin/src/ui/components/ui/alert.tsx create mode 100644 pixso-move/plugin/src/ui/components/ui/button.tsx create mode 100644 pixso-move/plugin/src/ui/components/ui/card.tsx create mode 100644 pixso-move/plugin/src/ui/components/ui/field.tsx create mode 100644 pixso-move/plugin/src/ui/components/ui/input.tsx create mode 100644 pixso-move/plugin/src/ui/components/ui/label.tsx create mode 100644 pixso-move/plugin/src/ui/components/ui/select.tsx create mode 100644 pixso-move/plugin/src/ui/components/ui/tooltip.tsx create mode 100644 pixso-move/plugin/src/ui/index.css create mode 100644 pixso-move/plugin/src/ui/index.html create mode 100644 pixso-move/plugin/src/ui/key.ts create mode 100644 pixso-move/plugin/src/ui/lib/utils.ts create mode 100644 pixso-move/plugin/src/ui/main.tsx create mode 100644 pixso-move/plugin/src/ui/screens/Body.tsx create mode 100644 pixso-move/plugin/src/ui/screens/MainScreen.tsx create mode 100644 pixso-move/plugin/src/ui/screens/SettingsHeader.tsx create mode 100644 pixso-move/plugin/src/ui/screens/SettingsScreen.tsx create mode 100644 pixso-move/plugin/src/ui/screens/ThemeSettings.tsx create mode 100644 pixso-move/plugin/src/ui/state/reducer.ts create mode 100644 pixso-move/plugin/src/ui/state/types.ts create mode 100644 pixso-move/plugin/src/ui/theme.ts create mode 100644 pixso-move/plugin/src/ui/themes/aurora.css create mode 100644 pixso-move/plugin/src/ui/themes/caffeine.css create mode 100644 pixso-move/plugin/src/ui/themes/grayscale.css create mode 100644 pixso-move/plugin/src/ui/themes/onyx.css create mode 100644 pixso-move/plugin/src/ui/themes/pastel-dreams.css create mode 100644 pixso-move/plugin/src/ui/themes/ru-fork.css create mode 100644 pixso-move/plugin/src/ui/themes/vs-code.css create mode 100644 pixso-move/plugin/tests/api.test.ts create mode 100644 pixso-move/plugin/tests/base64.test.ts create mode 100644 pixso-move/plugin/tests/key.test.ts create mode 100644 pixso-move/plugin/tests/reducer.test.ts create mode 100644 pixso-move/plugin/tests/selection.test.ts create mode 100644 pixso-move/plugin/tests/serialize.test.ts create mode 100644 pixso-move/plugin/tests/settings.test.ts create mode 100644 pixso-move/plugin/tests/theme.test.ts create mode 100644 pixso-move/plugin/tsconfig.json create mode 100644 pixso-move/plugin/vite.code.config.ts create mode 100644 pixso-move/plugin/vite.ui.config.ts create mode 100644 pixso-move/plugin/vitest.config.ts create mode 100644 pixso-move/processor/package.json create mode 100644 pixso-move/processor/src/acp/acpRunnerLive.integration.ts create mode 100644 pixso-move/processor/src/acp/collect.ts create mode 100644 pixso-move/processor/src/acp/handshake.ts create mode 100644 pixso-move/processor/src/acp/runner.ts create mode 100644 pixso-move/processor/src/config.ts create mode 100644 pixso-move/processor/src/drain.ts create mode 100644 pixso-move/processor/src/engine.ts create mode 100644 pixso-move/processor/src/extract.ts create mode 100644 pixso-move/processor/src/index.ts create mode 100644 pixso-move/processor/src/noop.ts create mode 100644 pixso-move/processor/src/processor.ts create mode 100644 pixso-move/processor/src/prompt.ts create mode 100644 pixso-move/processor/src/reconcile.ts create mode 100644 pixso-move/processor/src/types.ts create mode 100644 pixso-move/processor/tests/collect.test.ts create mode 100644 pixso-move/processor/tests/drain.test.ts create mode 100644 pixso-move/processor/tests/engine.test.ts create mode 100644 pixso-move/processor/tests/extract.test.ts create mode 100644 pixso-move/processor/tests/fakes.ts create mode 100644 pixso-move/processor/tests/handshake.test.ts create mode 100644 pixso-move/processor/tests/noop.test.ts create mode 100644 pixso-move/processor/tests/prompt.test.ts create mode 100644 pixso-move/processor/tests/reconcile.test.ts create mode 100644 pixso-move/processor/tsconfig.json create mode 100644 pixso-move/processor/vitest.config.ts create mode 100644 pixso-move/server/package.json create mode 100644 pixso-move/server/src/bin.ts create mode 100644 pixso-move/server/src/config.ts create mode 100644 pixso-move/server/src/http/auth.ts create mode 100644 pixso-move/server/src/http/cors.ts create mode 100644 pixso-move/server/src/http/ingest.ts create mode 100644 pixso-move/server/src/http/nodes.ts create mode 100644 pixso-move/server/src/http/processing.ts create mode 100644 pixso-move/server/src/http/query.ts create mode 100644 pixso-move/server/src/http/respond.ts create mode 100644 pixso-move/server/src/http/route.ts create mode 100644 pixso-move/server/src/http/routes.ts create mode 100644 pixso-move/server/src/httpServer.ts create mode 100644 pixso-move/server/src/persistence/migrate.ts create mode 100644 pixso-move/server/src/persistence/migrations/001_nodes.ts create mode 100644 pixso-move/server/src/persistence/migrations/002_results.ts create mode 100644 pixso-move/server/src/persistence/sqlite.ts create mode 100644 pixso-move/server/src/server.ts create mode 100644 pixso-move/server/src/serverLogger.ts create mode 100644 pixso-move/server/src/services/nodeStore.ts create mode 100644 pixso-move/server/src/services/nodeStoreLive.ts create mode 100644 pixso-move/server/src/services/processorLive.ts create mode 100644 pixso-move/server/src/services/resultStore.ts create mode 100644 pixso-move/server/src/services/resultStoreLive.ts create mode 100644 pixso-move/server/src/time.ts create mode 100644 pixso-move/server/src/vendor/NodeSqliteClient.ts create mode 100644 pixso-move/server/tests/config.test.ts create mode 100644 pixso-move/server/tests/embed.test.ts create mode 100644 pixso-move/server/tests/fakeAcpRunner.ts create mode 100644 pixso-move/server/tests/http/harness.ts create mode 100644 pixso-move/server/tests/http/routes.test.ts create mode 100644 pixso-move/server/tests/nodeStore.test.ts create mode 100644 pixso-move/server/tests/persistence.test.ts create mode 100644 pixso-move/server/tests/resultStore.test.ts create mode 100644 pixso-move/server/tests/server.test.ts create mode 100644 pixso-move/server/tsconfig.json create mode 100644 pixso-move/server/tsdown.config.ts create mode 100644 pixso-move/server/vitest.config.ts create mode 100644 pixso-move/specs/00-overview.md create mode 100644 pixso-move/specs/01-scaffold.md create mode 100644 pixso-move/specs/02-contracts.md create mode 100644 pixso-move/specs/03-server.md create mode 100644 pixso-move/specs/04-processor.md create mode 100644 pixso-move/specs/05-embed.md create mode 100644 pixso-move/specs/06-plugin-build.md create mode 100644 pixso-move/specs/07-plugin-code.md create mode 100644 pixso-move/specs/08-plugin-ui.md create mode 100644 pixso-move/specs/09-end-to-end.md create mode 100644 pixso-move/specs/README.md create mode 100644 pixso-move/specs/conventions.md create mode 100644 pixso-move/tsconfig.base.json create mode 100644 pixso-move/vitest.base.ts diff --git a/.gitignore b/.gitignore index 30cfe9b64c8..13e95639f10 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ node_modules apps/*/dist .astro packages/*/dist +# all build output, any package depth (e.g. pixso-move/*/dist) +dist/ .env .env.local build/ diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index d98f30a1e5c..ad6f208d08d 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -2,7 +2,11 @@ import { useEffect, type ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; import ThreadSidebar from "./Sidebar"; +import { RightPanelSheet } from "./RightPanelSheet"; import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; +import { useMediaQuery } from "../hooks/useMediaQuery"; +import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; +import { PixsoPanel, PixsoPanelInlineSidebar, usePixsoStore } from "../ru-fork/pixso-move"; import { clearShortcutModifierState, syncShortcutModifierStateFromKeyboardEvent, @@ -13,6 +17,9 @@ const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + const pixsoOpen = usePixsoStore((state) => state.panelOpen); + const closePixso = usePixsoStore((state) => state.closePanel); + const useRightPanelSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { @@ -70,6 +77,13 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {children} + {useRightPanelSheet ? ( + + + + ) : ( + + )} ); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b5e634a179c..24fabb9d7e1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -11,6 +11,7 @@ import { TerminalIcon, TriangleAlertIcon, } from "lucide-react"; +import { PixsoNavGroup } from "../ru-fork/pixso-move"; import { ChangeRequestStatusIcon, prStatusIndicator, @@ -2657,6 +2658,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( + {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( diff --git a/apps/web/src/ru-fork/pixso-move/api.ts b/apps/web/src/ru-fork/pixso-move/api.ts new file mode 100644 index 00000000000..855296714be --- /dev/null +++ b/apps/web/src/ru-fork/pixso-move/api.ts @@ -0,0 +1,63 @@ +/** + * Pixso Move — typed HTTP client for the pixso-move server (a separate Effect/sqlite service, + * default http://127.0.0.1:7787). Every read is gated by the `x-designer-id` header, which is + * the designer's shared key. Kept dependency-free (plain fetch) and isolated in ru-fork. + */ + +export interface PixsoNodeSummary { + readonly nodeId: string; + readonly rootName: string; + readonly addedAt: string; + /** base64 PNG (no data: prefix). */ + readonly preview: string; +} + +export interface PixsoNodeRecord { + readonly nodeId: string; + readonly designerId: string; + readonly rootName: string; + readonly nodesJson: string; + readonly preview: string; + readonly addedAt: string; +} + +export type PixsoProcessingStatus = "pending" | "processing" | "done" | "error"; + +export interface PixsoProcessingResult { + readonly nodeId: string; + readonly resultTag: string; + readonly status: PixsoProcessingStatus; + readonly attempts: number; + readonly result: string | null; + readonly error: string | null; + readonly createdAt: string; + readonly startedAt: string | null; + readonly finishedAt: string | null; +} + +const base = (serverUrl: string): string => serverUrl.trim().replace(/\/+$/, ""); + +const getJson = async (serverUrl: string, designerId: string, path: string): Promise => { + const response = await fetch(`${base(serverUrl)}${path}`, { + headers: { "x-designer-id": designerId }, + }); + if (!response.ok) { + throw new Error(`Сервер ответил ${response.status}`); + } + return (await response.json()) as T; +}; + +export const fetchNodes = (serverUrl: string, designerId: string) => + getJson>(serverUrl, designerId, "/nodes"); + +export const fetchNode = (serverUrl: string, designerId: string, nodeId: string) => + getJson(serverUrl, designerId, `/node?id=${encodeURIComponent(nodeId)}`); + +export const fetchProcessing = (serverUrl: string, designerId: string, nodeId: string) => + getJson>( + serverUrl, + designerId, + `/processing-data?nodeId=${encodeURIComponent(nodeId)}`, + ); + +export const previewDataUrl = (preview: string): string => `data:image/png;base64,${preview}`; diff --git a/apps/web/src/ru-fork/pixso-move/components/CodeCollapsible.tsx b/apps/web/src/ru-fork/pixso-move/components/CodeCollapsible.tsx new file mode 100644 index 00000000000..1c5227b7944 --- /dev/null +++ b/apps/web/src/ru-fork/pixso-move/components/CodeCollapsible.tsx @@ -0,0 +1,49 @@ +import { ChevronDownIcon } from "lucide-react"; +import { useState, type ReactNode } from "react"; +import { Collapsible, CollapsibleContent } from "~/components/ui/collapsible"; +import { cn } from "~/lib/utils"; + +/** + * A collapsible code panel, matching the MCP `ToolList` accordion exactly: a single rounded, + * `overflow-hidden` border; a plain header button (title + optional trailing badge + a chevron + * that rotates with the controlled open state); and the body separated by a top border (no + * second border / corners of its own). The body is height-capped and vertically scrollable. + */ +export function CodeCollapsible({ + title, + trailing, + defaultOpen = false, + children, +}: { + title: ReactNode; + trailing?: ReactNode; + defaultOpen?: boolean; + children: ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen); + + return ( +
+ + + +
{children}
+
+
+
+ ); +} diff --git a/apps/web/src/ru-fork/pixso-move/components/GalleryView.tsx b/apps/web/src/ru-fork/pixso-move/components/GalleryView.tsx new file mode 100644 index 00000000000..ce631b9ab2a --- /dev/null +++ b/apps/web/src/ru-fork/pixso-move/components/GalleryView.tsx @@ -0,0 +1,109 @@ +import { LayersIcon } from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyMedia, + EmptyTitle, +} from "~/components/ui/empty"; +import { ScrollArea } from "~/components/ui/scroll-area"; +import { Spinner } from "~/components/ui/spinner"; +import { cn } from "~/lib/utils"; +import { previewDataUrl } from "../api"; +import { formatAddedAt } from "../format"; +import { usePixsoNodes } from "../queries"; +import { usePixsoStore } from "../store"; + +/** Catalog of stored macets. Fetches only after the user presses refresh (manual sync). */ +export function GalleryView() { + const settings = usePixsoStore((state) => state.settings); + const nonce = usePixsoStore((state) => state.refreshNonce); + const openNode = usePixsoStore((state) => state.openNode); + const openSettings = usePixsoStore((state) => state.openSettings); + const hasKey = settings.designerId.trim().length > 0; + const query = usePixsoNodes(settings.serverUrl, settings.designerId, nonce); + + if (!hasKey) { + return ( + + + + + Pixso Move + + Используйте макеты прямо из Pixso при помощи Pixso Move. Добавьте идентификатор в + настройках. + + + + + + ); + } + + if (nonce === 0) { + return ( + + Нет данных + Нажмите «Обновить», чтобы загрузить макеты. + + ); + } + + if (query.isPending) { + return ( +
+ +
+ ); + } + + if (query.isError) { + return ( + + Не удалось загрузить + {(query.error as Error).message} + + ); + } + + if (query.data.length === 0) { + return ( + + Пока пусто + Отправьте макет из плагина Pixso. + + ); + } + + return ( + +
    + {query.data.map((node) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/apps/web/src/ru-fork/pixso-move/components/NodeDetail.tsx b/apps/web/src/ru-fork/pixso-move/components/NodeDetail.tsx new file mode 100644 index 00000000000..0a05ada9220 --- /dev/null +++ b/apps/web/src/ru-fork/pixso-move/components/NodeDetail.tsx @@ -0,0 +1,104 @@ +import { ChevronLeftIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import ChatMarkdown from "~/components/ChatMarkdown"; +import { Button } from "~/components/ui/button"; +import { ScrollArea } from "~/components/ui/scroll-area"; +import { Spinner } from "~/components/ui/spinner"; +import { previewDataUrl } from "../api"; +import { usePixsoNode, usePixsoProcessing } from "../queries"; +import { usePixsoStore } from "../store"; +import { CodeCollapsible } from "./CodeCollapsible"; +import { ResultBlock } from "./ResultBlock"; + +/** A node's detail: preview, its raw node JSON (formatted), and the LLM result blocks. */ +export function NodeDetail() { + const settings = usePixsoStore((state) => state.settings); + const nodeId = usePixsoStore((state) => state.selectedNodeId); + const back = usePixsoStore((state) => state.backToGallery); + const node = usePixsoNode(settings.serverUrl, settings.designerId, nodeId); + const processing = usePixsoProcessing(settings.serverUrl, settings.designerId, nodeId); + + return ( +
+
+ +
+ + +
+ {node.isPending ? ( +
+ +
+ ) : node.isError ? ( +

{(node.error as Error).message}

+ ) : ( + <> +

{node.data.rootName}

+ {node.data.rootName} + + JSON узла + + } + > + + + + )} + +
+ {processing.isPending ? ( + + ) : processing.isError ? ( +

Не удалось загрузить результаты.

+ ) : processing.data.length === 0 ? ( +

Пока нет результатов.

+ ) : ( +
    + {processing.data.map((result) => ( +
  • + +
  • + ))} +
+ )} +
+
+
+
+ ); +} + +// Pretty-print the stored (minified) node JSON so the code block renders multi-line. The +// code renderer only highlights — it doesn't re-indent — so this is what makes it readable. +function formatJson(raw: string): string { + try { + return JSON.stringify(JSON.parse(raw), null, 2); + } catch { + return raw; + } +} + +function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} diff --git a/apps/web/src/ru-fork/pixso-move/components/PixsoNavGroup.tsx b/apps/web/src/ru-fork/pixso-move/components/PixsoNavGroup.tsx new file mode 100644 index 00000000000..fced781b3ab --- /dev/null +++ b/apps/web/src/ru-fork/pixso-move/components/PixsoNavGroup.tsx @@ -0,0 +1,40 @@ +import { LayersIcon, ServerIcon } from "lucide-react"; +import { + SidebarGroup, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "~/components/ui/sidebar"; +import { usePixsoStore } from "../store"; + +/** + * Left-nav block (under search, above the projects list): "Макеты Pixso" opens the right + * panel; "MCP Серверы" is an inert placeholder for now (does nothing). + */ +export function PixsoNavGroup() { + const openPanel = usePixsoStore((state) => state.openPanel); + + return ( + + + + + + Макеты Pixso + + + + + + MCP Серверы + + + + + ); +} diff --git a/apps/web/src/ru-fork/pixso-move/components/PixsoPanel.tsx b/apps/web/src/ru-fork/pixso-move/components/PixsoPanel.tsx new file mode 100644 index 00000000000..e7843e8558e --- /dev/null +++ b/apps/web/src/ru-fork/pixso-move/components/PixsoPanel.tsx @@ -0,0 +1,78 @@ +import { LayersIcon, RefreshCwIcon, SettingsIcon, XIcon } from "lucide-react"; +import { DiffPanelShell, type DiffPanelMode } from "~/components/DiffPanelShell"; +import { Toggle } from "~/components/ui/toggle-group"; +import { usePixsoStore } from "../store"; +import { GalleryView } from "./GalleryView"; +import { NodeDetail } from "./NodeDetail"; +import { SettingsView } from "./SettingsView"; + +/** + * The Pixso Move panel. Built on `DiffPanelShell` so its background and header match the diff + * panel exactly — `bg-background`, the same header row (height + Electron drag-region + + * titlebar handling + `px-4` + bottom border). The action buttons are the same `Toggle` + * (`variant="outline" size="xs"`, `bg-background` fill so they read transparent) the diff + * header uses, run as momentary buttons (`pressed={false}`). The body is a master→detail + * view: gallery → node detail → settings. Shared by the inline sidebar (`mode="sidebar"`) + * and the mobile sheet (`mode="sheet"`). + */ +export function PixsoPanel({ + onClose, + mode = "sidebar", +}: { + onClose: () => void; + mode?: DiffPanelMode; +}) { + const view = usePixsoStore((state) => state.view); + const openSettings = usePixsoStore((state) => state.openSettings); + const refresh = usePixsoStore((state) => state.refresh); + + const header = ( + <> +
+ +

Макеты Pixso

+
+
+ refresh()} + aria-label="Обновить макеты" + > + + + openSettings()} + aria-label="Настройки Pixso Move" + > + + + onClose()} + aria-label="Закрыть панель" + > + + +
+ + ); + + return ( + + {view === "settings" ? ( + + ) : view === "detail" ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/web/src/ru-fork/pixso-move/components/PixsoPanelInlineSidebar.tsx b/apps/web/src/ru-fork/pixso-move/components/PixsoPanelInlineSidebar.tsx new file mode 100644 index 00000000000..b7e3e504f65 --- /dev/null +++ b/apps/web/src/ru-fork/pixso-move/components/PixsoPanelInlineSidebar.tsx @@ -0,0 +1,46 @@ +import type { CSSProperties } from "react"; +import { Sidebar, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; +import { PixsoPanel } from "./PixsoPanel"; + +const PIXSO_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_pixso_sidebar_width"; +const PIXSO_INLINE_DEFAULT_WIDTH = "clamp(24rem,32vw,34rem)"; +const PIXSO_INLINE_SIDEBAR_MIN_WIDTH = 22 * 16; +const PIXSO_INLINE_SIDEBAR_MAX_WIDTH = 40 * 16; + +/** + * Desktop inline right-sidebar host for the Pixso panel. Mirrors the MCP / diff inline + * sidebars so they share the same slot and resize/offcanvas behaviour. + */ +export function PixsoPanelInlineSidebar({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + return ( + { + if (!next) onClose(); + }} + className="min-h-0 w-auto flex-none bg-transparent" + style={{ "--sidebar-width": PIXSO_INLINE_DEFAULT_WIDTH } as CSSProperties} + > + + {open ? : null} + + + + ); +} diff --git a/apps/web/src/ru-fork/pixso-move/components/ResultBlock.tsx b/apps/web/src/ru-fork/pixso-move/components/ResultBlock.tsx new file mode 100644 index 00000000000..6836e309997 --- /dev/null +++ b/apps/web/src/ru-fork/pixso-move/components/ResultBlock.tsx @@ -0,0 +1,32 @@ +import ChatMarkdown from "~/components/ChatMarkdown"; +import { Badge } from "~/components/ui/badge"; +import type { PixsoProcessingResult } from "../api"; +import { statusLabel, statusTone } from "../format"; +import { CodeCollapsible } from "./CodeCollapsible"; + +const fenced = (body: string): string => `\`\`\`\n${body}\n\`\`\``; + +/** One processing result as a collapsible, code-formatted block (open when done). */ +export function ResultBlock({ result }: { result: PixsoProcessingResult }) { + const body = result.result ?? result.error ?? ""; + + return ( + {result.resultTag} + } + trailing={ + + {statusLabel[result.status]} + + } + > + {body.length === 0 ? ( +

Нет данных.

+ ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/ru-fork/pixso-move/components/SettingsView.tsx b/apps/web/src/ru-fork/pixso-move/components/SettingsView.tsx new file mode 100644 index 00000000000..a8b2e928d6b --- /dev/null +++ b/apps/web/src/ru-fork/pixso-move/components/SettingsView.tsx @@ -0,0 +1,82 @@ +import { ChevronLeftIcon } from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { Field, FieldDescription, FieldLabel } from "~/components/ui/field"; +import { Input } from "~/components/ui/input"; +import { + NumberField, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from "~/components/ui/number-field"; +import { ScrollArea } from "~/components/ui/scroll-area"; +import { MIN_SYNC_INTERVAL_MIN, usePixsoStore } from "../store"; + +/** The panel's settings form — real-time (no save button), like the rest of ru-code. */ +export function SettingsView() { + const settings = usePixsoStore((state) => state.settings); + const update = usePixsoStore((state) => state.updateSettings); + const back = usePixsoStore((state) => state.backToGallery); + + return ( +
+
+ +
+ + +
+ + Адрес сервера + update({ serverUrl: event.target.value })} + placeholder="http://127.0.0.1:7787" + aria-label="Адрес сервера" + /> + Сервер Pixso Move, к которому отправляет данные плагин. + + + + Идентификатор дизайнера + update({ designerId: event.target.value })} + placeholder="dz_…" + aria-label="Идентификатор дизайнера" + /> + Ключ дизайнера — тот же, что в плагине Pixso. + + + + Интервал синхронизации, мин + + update({ + syncIntervalMin: Math.max(MIN_SYNC_INTERVAL_MIN, value ?? MIN_SYNC_INTERVAL_MIN), + }) + } + > + + + + + + + + Не меньше {MIN_SYNC_INTERVAL_MIN} минут. Сейчас обновление запускается вручную. + + +
+
+
+ ); +} diff --git a/apps/web/src/ru-fork/pixso-move/format.ts b/apps/web/src/ru-fork/pixso-move/format.ts new file mode 100644 index 00000000000..1336e24650a --- /dev/null +++ b/apps/web/src/ru-fork/pixso-move/format.ts @@ -0,0 +1,31 @@ +import type { PixsoProcessingStatus } from "./api"; + +// Russian labels + badge tone for a processing status. Kept here so every surface agrees. +export const statusLabel: Record = { + pending: "В очереди", + processing: "Обрабатывается", + done: "Готово", + error: "Ошибка", +}; + +export const statusTone: Record< + PixsoProcessingStatus, + "secondary" | "outline" | "default" | "destructive" +> = { + pending: "outline", + processing: "secondary", + done: "default", + error: "destructive", +}; + +// e.g. "14 июн., 16:32" — compact local timestamp for a stored macet. +export function formatAddedAt(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return date.toLocaleString("ru-RU", { + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + }); +} diff --git a/apps/web/src/ru-fork/pixso-move/index.ts b/apps/web/src/ru-fork/pixso-move/index.ts new file mode 100644 index 00000000000..f4554ae233e --- /dev/null +++ b/apps/web/src/ru-fork/pixso-move/index.ts @@ -0,0 +1,12 @@ +/** + * Pixso Move (ru-fork) — public surface. + * + * A left-nav entry ("Макеты Pixso") opens a right panel that reads a designer's macets from + * the pixso-move server: a preview gallery → node detail (preview + JSON + LLM result blocks) + * → settings. Isolated here to keep the impact on the wider web app minimal. + */ + +export { PixsoNavGroup } from "./components/PixsoNavGroup"; +export { PixsoPanel } from "./components/PixsoPanel"; +export { PixsoPanelInlineSidebar } from "./components/PixsoPanelInlineSidebar"; +export { usePixsoStore } from "./store"; diff --git a/apps/web/src/ru-fork/pixso-move/queries.ts b/apps/web/src/ru-fork/pixso-move/queries.ts new file mode 100644 index 00000000000..cc1c7e2c269 --- /dev/null +++ b/apps/web/src/ru-fork/pixso-move/queries.ts @@ -0,0 +1,43 @@ +/** + * Pixso Move — React Query hooks over {@link api}. The gallery only fetches after the user + * presses refresh (gated by `nonce`); detail data fetches on demand when a node is open. + */ + +import { useQuery } from "@tanstack/react-query"; + +import { fetchNode, fetchNodes, fetchProcessing } from "./api"; + +const root = ["pixso-move"] as const; + +export const pixsoQueryKeys = { + nodes: (serverUrl: string, designerId: string, nonce: number) => + [...root, "nodes", serverUrl, designerId, nonce] as const, + node: (serverUrl: string, designerId: string, nodeId: string) => + [...root, "node", serverUrl, designerId, nodeId] as const, + processing: (serverUrl: string, designerId: string, nodeId: string) => + [...root, "processing", serverUrl, designerId, nodeId] as const, +}; + +export function usePixsoNodes(serverUrl: string, designerId: string, nonce: number) { + return useQuery({ + queryKey: pixsoQueryKeys.nodes(serverUrl, designerId, nonce), + queryFn: () => fetchNodes(serverUrl, designerId), + enabled: designerId.trim().length > 0 && nonce > 0, + }); +} + +export function usePixsoNode(serverUrl: string, designerId: string, nodeId: string | null) { + return useQuery({ + queryKey: pixsoQueryKeys.node(serverUrl, designerId, nodeId ?? ""), + queryFn: () => fetchNode(serverUrl, designerId, nodeId as string), + enabled: nodeId !== null && designerId.trim().length > 0, + }); +} + +export function usePixsoProcessing(serverUrl: string, designerId: string, nodeId: string | null) { + return useQuery({ + queryKey: pixsoQueryKeys.processing(serverUrl, designerId, nodeId ?? ""), + queryFn: () => fetchProcessing(serverUrl, designerId, nodeId as string), + enabled: nodeId !== null && designerId.trim().length > 0, + }); +} diff --git a/apps/web/src/ru-fork/pixso-move/store.ts b/apps/web/src/ru-fork/pixso-move/store.ts new file mode 100644 index 00000000000..679843cc92a --- /dev/null +++ b/apps/web/src/ru-fork/pixso-move/store.ts @@ -0,0 +1,89 @@ +/** + * Pixso Move — UI + settings store (zustand). Mirrors the MCP manager store: panel open + * state + a small master→detail view machine, plus the designer's settings persisted to + * localStorage (the web app is not sandboxed, so localStorage is safe here). + */ + +import { create } from "zustand"; + +export interface PixsoSettings { + readonly serverUrl: string; + readonly designerId: string; + /** Auto-sync cadence in minutes — stored for later; refresh is manual for now. Min 5. */ + readonly syncIntervalMin: number; +} + +export type PixsoView = "gallery" | "detail" | "settings"; + +export const MIN_SYNC_INTERVAL_MIN = 5; +export const DEFAULT_SETTINGS: PixsoSettings = { + serverUrl: "http://127.0.0.1:7787", + designerId: "", + syncIntervalMin: 30, +}; + +const STORAGE_KEY = "pixso_move_settings"; + +function loadSettings(): PixsoSettings { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw === null) return DEFAULT_SETTINGS; + const parsed = JSON.parse(raw) as Partial; + return { + serverUrl: parsed.serverUrl ?? DEFAULT_SETTINGS.serverUrl, + designerId: parsed.designerId ?? DEFAULT_SETTINGS.designerId, + syncIntervalMin: Math.max( + MIN_SYNC_INTERVAL_MIN, + parsed.syncIntervalMin ?? DEFAULT_SETTINGS.syncIntervalMin, + ), + }; + } catch { + return DEFAULT_SETTINGS; + } +} + +function persist(settings: PixsoSettings): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + } catch { + // Non-fatal: settings simply won't survive a reload. + } +} + +interface PixsoStore { + readonly panelOpen: boolean; + readonly view: PixsoView; + readonly selectedNodeId: string | null; + /** Bumped by the refresh button; gates the gallery query (manual refresh only). */ + readonly refreshNonce: number; + readonly settings: PixsoSettings; + + readonly openPanel: () => void; + readonly closePanel: () => void; + readonly openSettings: () => void; + readonly openNode: (nodeId: string) => void; + readonly backToGallery: () => void; + readonly refresh: () => void; + readonly updateSettings: (patch: Partial) => void; +} + +export const usePixsoStore = create()((set) => ({ + panelOpen: false, + view: "gallery", + selectedNodeId: null, + refreshNonce: 0, + settings: loadSettings(), + + openPanel: () => set({ panelOpen: true }), + closePanel: () => set({ panelOpen: false }), + openSettings: () => set({ view: "settings" }), + openNode: (nodeId) => set({ selectedNodeId: nodeId, view: "detail" }), + backToGallery: () => set({ view: "gallery" }), + refresh: () => set((state) => ({ refreshNonce: state.refreshNonce + 1, view: "gallery" })), + updateSettings: (patch) => + set((state) => { + const next = { ...state.settings, ...patch }; + persist(next); + return { settings: next }; + }), +})); diff --git a/pixso-move/.gitignore b/pixso-move/.gitignore new file mode 100644 index 00000000000..b0078ecfa63 --- /dev/null +++ b/pixso-move/.gitignore @@ -0,0 +1,2 @@ +# Local server sqlite data (default ServerConfig dbPath: ./.data/pixso.sqlite) + WAL/SHM. +.data/ diff --git a/pixso-move/STATUS.md b/pixso-move/STATUS.md new file mode 100644 index 00000000000..cec33d65370 --- /dev/null +++ b/pixso-move/STATUS.md @@ -0,0 +1,129 @@ +# pixso-move — build status + +Snapshot of what's implemented in this worktree (`ru-fork/pisxo-move`). The design specs live in +[`specs/`](./specs/); this file records the **actual state** + decisions that differ from the specs. + +**Updated:** 2026-06-14 + +## Where we are + +| Package | Lint | Typecheck | Tests | Coverage | Build | Spec | +|---|---|---|---|---|---|---| +| `@pixso-move/contracts` | ✅ 0 | ✅ | 34 | **100%** | — | [02](./specs/02-contracts.md) ✅ | +| `@pixso-move/processor` | ✅ 0 | ✅ | 36 | **100%** | — | [04](./specs/04-processor.md) ✅ | +| `@pixso-move/server` | ✅ 0 | ✅ | 27 | **100%** | ✅ `dist/bin.mjs` | [03](./specs/03-server.md) · [05](./specs/05-embed.md) ✅ | +| `@pixso-move/plugin` | ✅ 0 | ✅ | 32 | (pure helpers) | ✅ `code.js`+`ui.html` | [06–08](./specs/06-plugin-build.md) ✅ | +| web `ru-fork/pixso-move` | ✅ 0 | ✅ | — (no web tests) | — | (typecheck+lint) | reader panel — below | + +**Done:** Tasks 1 (scaffold), 2 (contracts), 3 (server), 4 (processor engine), 5 (embed), +6–8 (plugin), **plus the web "Макеты Pixso" reader panel** (new — see below). +**Deferred:** Task 9 — manual smoke against real qwen on the user's machine. + +## How to run + +Server (from the worktree root): +```bash +# dev (watch) +NODE_OPTIONS='--experimental-strip-types --experimental-sqlite' pnpm --filter @pixso-move/server dev +# built +pnpm --filter @pixso-move/server build +node --experimental-sqlite pixso-move/server/dist/bin.mjs start --port 7787 +# verify +curl -H "x-designer-id: dz_test" http://localhost:7787/nodes # → [] 200 +``` +Plugin: +```bash +pnpm --filter @pixso-move/plugin dev # browser dev server — inspect UI without Pixso +pnpm --filter @pixso-move/plugin build # → dist/code.js + dist/ui.html, import manifest.json into Pixso +``` +Gates: `pnpm -w lint` · `turbo run typecheck --filter='@pixso-move/*'` · `turbo run test --filter='@pixso-move/*'`. + +> **Env gotcha:** in some shells `pnpm install` purges modules and reinstalls **macOS** native +> bindings, breaking oxlint/vite/vitest on linux. Always use +> `pnpm install --config.confirmModulesPurge=false`. (`tsc` is binding-free.) + +## Architecture (actual) + +``` +pixso-move/ + contracts/ effect Schema: ids, ingest, node, processing, tagged errors (DesignerId/NodeId/ResultTag …) + server/ Effect + effect-platform HTTP + node:sqlite (vendored NodeSqliteClient). Embeds the processor seam. + processor/ ONLY the Processor service tag + NoopProcessorLive (real engine = Task 4) + plugin/ Pixso plugin: code/ (sandbox) + ui/ (iframe React, vendored ru-code kit + themes) +``` + +Data model (sqlite, 2 tables): `nodes` (immutable) + `processing_results` (status-tracked ledger). +HTTP: `POST /ingest`, `GET /nodes`, `GET /node?id=`, `GET /processing-data?nodeId=` — all gated by +the `x-designer-id` header. + +## Decisions / deviations from the specs (read before continuing) + +**Server** +- `GET /node?id=` (query param), **not** `/nodes/:id` — a path param is always present when matched, + leaving an uncoverable branch; query-param is fully testable. +- Oversize preview → **400** (invalid payload), not 413 (the `Base64Png` max-length rejects it). +- Atomic claim uses SQL `RETURNING`. DB errors `orDie` → HTTP 500 via the shared `route()`/`respond()`. +- The processor is a **no-op seam** (`NoopProcessorLive`); ingest calls `Processor.notify` (currently + a void). `/processing-data` returns `[]` until Tasks 4–5. + +**Plugin (lots of ru-code-fidelity iteration — keep faithful)** +- **Storage:** settings AND theme persist via `pixso.clientStorage` (sandbox), **never localStorage** — + the Pixso iframe is sandboxed/opaque-origin and `localStorage` throws `SecurityError` (this caused a + black-screen crash). `themeName`+`themeMode` are folded into the `StoredSettings` blob. +- **Theme:** all 7 ru-code themes + `index.css` vendored. The web's `useTheme` hook is **not** reused + (it's localStorage-bound); `ui/theme.ts` is a minimal `applyTheme` + constants instead. Theme picker + uses the vendored `Select`. +- **Settings page = ru-code `GeneralSettingsPanel`:** vendored `settingsLayout.tsx` + (`SettingsSection`/`SettingsRow`/`SettingResetButton`/`SettingsPageContainer`) + `tooltip.tsx`. + Real-time (no Save button); per-field reset icons + a header **"Сбросить настройки"** matching + `routes/settings.tsx`'s `RestoreDefaultsButton`. Back button matches mcp `RegistryTab` (ghost `sm`, + `ChevronLeftIcon size-4`). Settings gear = `variant="outline" size="icon-xs"` (matches mcp/diff + header buttons). UI is Russian, no tech jargon. **Keep base+sm responsive classes as ru-code wrote + them — do NOT change the breakpoint.** +- **Key generation:** `crypto.randomUUID()` throws in the sandbox iframe → `key.ts` builds a v4 UUID + from `getRandomValues` with a `Math.random` fallback. +- **Preview perf:** display preview exports at **capped width 640** (cheap); the **full 1× export + happens only on send** (`handleCollect`) so the server keeps a pixel-perfect copy. Full-res on every + select previously froze the plugin 30–50s. +- **Preview correctness:** state tracks `selectedNodeId`; preview resets when the node changes; + `preview-ready` carries `nodeId` and is ignored if stale (frame switched mid-export). + +## Processor (Tasks 4 + 5 — done) + +- `@pixso-move/processor`: pure helpers (`prompt`/`extract`/`reconcile`/`acp/collect`/`acp/handshake`), + the contained `drain.runOneJob` (any failure/defect → `error` row + `logError`, never escapes), and + `engine.makeProcessor` (recover → reconcile → claim → run loop, `notify` serialized via a `Ref` + state machine, `Schedule.fixed` poll timer, `catchCause` so a tick can't kill the loop). The real + ACP runner is `acp/acpRunnerLive.integration.ts` (the only coverage-excluded file — real qwen + spawn glue), exposed as `makeAcpRunnerLayer(opts)`. +- **Config is the only hardcoded part** (`processor/src/config.ts`): designer + `dz_c07a93f7-2505-4e60-94af-17a2cc068b79`, tag `html-css`, the simple prompt. **CLI path, home, + and auth method are NOT hardcoded** — they come from `ServerConfig` (`--cli-js` / `--cli-home` / + `--cwd` / `--no-ssl` flags; auth defaults to `"openai"`). `cliJs === ""` ⇒ no qwen configured ⇒ + jobs still run but fail gracefully into `error` rows (server never crashes). +- **Embed:** `server/src/services/processorLive.ts` builds `ProcessorDeps` from the stores + the + `AcpRunner` service and starts/stops the processor with the layer (scoped). `server.ts` wires + `ProcessorLive`; `bin.ts` provides the real `makeAcpRunnerLayer` (+ `NodeServices` spawner). + `POST /ingest` calls `Processor.notify`, contained so a notify failure never breaks the request. + Tests provide a `FakeAcpRunner` → end-to-end in-memory (ingest → `done`) with no qwen. + +## Web reader panel (`apps/web/src/ru-fork/pixso-move/`) + +A developer-facing reader, mirroring the MCP right-panel pattern, isolated in one ru-fork folder: +- **Left nav:** `PixsoNavGroup` adds a block under search / above projects — **«Макеты Pixso»** + (opens the panel) and **«MCP Серверы»** (inert placeholder). Hosted **app-wide** from + `AppSidebarLayout` (inline sidebar on desktop, `RightPanelSheet` on narrow screens), so it opens + from anywhere. +- **Panel:** header (refresh / settings gear / close) → gallery of preview thumbnails → + node detail (preview + node JSON via `ChatMarkdown` ```json + collapsible per-result code blocks + from `/processing-data`) → settings (server URL + designer id + sync interval `NumberField`, + min 5, real-time, persisted to `localStorage`). **Manual refresh only** (the sync interval is + stored for later; no polling yet — per the product decision). +- Reads the pixso-move server (`/nodes`, `/node?id=`, `/processing-data?nodeId=`) with React Query, + gated by the `x-designer-id` header. Validated by **typecheck + lint** (apps/web has no test target). + +## Next + +- **Task 9** — manual smoke against real qwen: run the server with `--cli-js ` + (+ `--cli-home` if needed), ingest from the plugin for the configured designer, watch the + `html-css` result transition to `done`, then view it in the web panel. diff --git a/pixso-move/contracts/package.json b/pixso-move/contracts/package.json new file mode 100644 index 00000000000..768092d7a91 --- /dev/null +++ b/pixso-move/contracts/package.json @@ -0,0 +1,23 @@ +{ + "name": "@pixso-move/contracts", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { "types": "./src/index.ts", "import": "./src/index.ts" } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run --coverage", + "test:fast": "vitest run" + }, + "dependencies": { + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "vitest": "catalog:", + "@vitest/coverage-v8": "catalog:" + } +} diff --git a/pixso-move/contracts/src/base.ts b/pixso-move/contracts/src/base.ts new file mode 100644 index 00000000000..d53b24ffb9e --- /dev/null +++ b/pixso-move/contracts/src/base.ts @@ -0,0 +1,19 @@ +// pixso-move: copied from ru-fork packages/contracts/src/baseSchemas.ts:5-16. +// Shared schema primitives so @pixso-move/contracts is standalone. +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; + +export const TrimmedString = Schema.String.pipe( + Schema.decodeTo( + Schema.String, + SchemaTransformation.transformOrFail({ + decode: (value) => Effect.succeed(value.trim()), + encode: (value) => Effect.succeed(value.trim()), + }), + ), +); + +export const TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty()); + +export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); diff --git a/pixso-move/contracts/src/errors.ts b/pixso-move/contracts/src/errors.ts new file mode 100644 index 00000000000..5ad5ebc71c9 --- /dev/null +++ b/pixso-move/contracts/src/errors.ts @@ -0,0 +1,20 @@ +import * as Schema from "effect/Schema"; + +// Domain errors as data. `status` is the HTTP status the server responds with. +export class AuthError extends Schema.TaggedErrorClass()("AuthError", { + message: Schema.String, + status: Schema.Int, // 401 +}) {} + +export class IngestError extends Schema.TaggedErrorClass()("IngestError", { + message: Schema.String, + status: Schema.Int, // 400 | 413 +}) {} + +export class NodeNotFoundError extends Schema.TaggedErrorClass()( + "NodeNotFoundError", + { + message: Schema.String, + status: Schema.Int, // 404 + }, +) {} diff --git a/pixso-move/contracts/src/ids.ts b/pixso-move/contracts/src/ids.ts new file mode 100644 index 00000000000..46701e68a6f --- /dev/null +++ b/pixso-move/contracts/src/ids.ts @@ -0,0 +1,16 @@ +import * as Schema from "effect/Schema"; + +import { TrimmedNonEmptyString } from "./base.ts"; + +export const DesignerId = TrimmedNonEmptyString.check(Schema.isMaxLength(200)).pipe( + Schema.brand("DesignerId"), +); +export type DesignerId = typeof DesignerId.Type; + +export const NodeId = TrimmedNonEmptyString.pipe(Schema.brand("NodeId")); +export type NodeId = typeof NodeId.Type; + +export const ResultTag = TrimmedNonEmptyString.check(Schema.isMaxLength(64)).pipe( + Schema.brand("ResultTag"), +); +export type ResultTag = typeof ResultTag.Type; diff --git a/pixso-move/contracts/src/index.ts b/pixso-move/contracts/src/index.ts new file mode 100644 index 00000000000..e2b9da87caa --- /dev/null +++ b/pixso-move/contracts/src/index.ts @@ -0,0 +1,6 @@ +export * from "./base.ts"; +export * from "./ids.ts"; +export * from "./ingest.ts"; +export * from "./node.ts"; +export * from "./processing.ts"; +export * from "./errors.ts"; diff --git a/pixso-move/contracts/src/ingest.ts b/pixso-move/contracts/src/ingest.ts new file mode 100644 index 00000000000..ee765f89f9d --- /dev/null +++ b/pixso-move/contracts/src/ingest.ts @@ -0,0 +1,19 @@ +import * as Schema from "effect/Schema"; + +import { DesignerId, NodeId } from "./ids.ts"; + +// base64 PNG (no `data:` prefix); ~8MB guard rejects oversize previews at the edge. +export const Base64Png = Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(8 * 1024 * 1024)); +export type Base64Png = typeof Base64Png.Type; + +export const IngestRequest = Schema.Struct({ + designerId: DesignerId, + rootName: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(512)), + // opaque, already-serialized JSON string; stored verbatim (min length 2 = "{}"/"[]"). + nodesJson: Schema.String.check(Schema.isMinLength(2)), + preview: Base64Png, +}); +export type IngestRequest = typeof IngestRequest.Type; + +export const IngestResponse = Schema.Struct({ nodeId: NodeId }); +export type IngestResponse = typeof IngestResponse.Type; diff --git a/pixso-move/contracts/src/node.ts b/pixso-move/contracts/src/node.ts new file mode 100644 index 00000000000..b3613c6efc0 --- /dev/null +++ b/pixso-move/contracts/src/node.ts @@ -0,0 +1,24 @@ +import * as Schema from "effect/Schema"; + +import { Base64Png } from "./ingest.ts"; +import { DesignerId, NodeId } from "./ids.ts"; + +// Lightweight list item (no nodes_json) — returned by GET /nodes. +export const NodeSummary = Schema.Struct({ + nodeId: NodeId, + rootName: Schema.String, + addedAt: Schema.String, + preview: Base64Png, +}); +export type NodeSummary = typeof NodeSummary.Type; + +// Full record (incl. nodes_json) — returned by GET /nodes/:id. +export const NodeRecord = Schema.Struct({ + nodeId: NodeId, + designerId: DesignerId, + rootName: Schema.String, + nodesJson: Schema.String, + preview: Base64Png, + addedAt: Schema.String, +}); +export type NodeRecord = typeof NodeRecord.Type; diff --git a/pixso-move/contracts/src/processing.ts b/pixso-move/contracts/src/processing.ts new file mode 100644 index 00000000000..6b2af52fb57 --- /dev/null +++ b/pixso-move/contracts/src/processing.ts @@ -0,0 +1,21 @@ +import * as Schema from "effect/Schema"; + +import { NonNegativeInt } from "./base.ts"; +import { NodeId, ResultTag } from "./ids.ts"; + +export const ProcessingStatus = Schema.Literals(["pending", "processing", "done", "error"]); +export type ProcessingStatus = typeof ProcessingStatus.Type; + +// One row per (node × configured prompt): both the lifecycle status and the output. +export const ProcessingResult = Schema.Struct({ + nodeId: NodeId, + resultTag: ResultTag, + status: ProcessingStatus, + attempts: NonNegativeInt, + result: Schema.NullOr(Schema.String), + error: Schema.NullOr(Schema.String), + createdAt: Schema.String, + startedAt: Schema.NullOr(Schema.String), + finishedAt: Schema.NullOr(Schema.String), +}); +export type ProcessingResult = typeof ProcessingResult.Type; diff --git a/pixso-move/contracts/tests/base.test.ts b/pixso-move/contracts/tests/base.test.ts new file mode 100644 index 00000000000..d98cea35832 --- /dev/null +++ b/pixso-move/contracts/tests/base.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { NonNegativeInt, TrimmedNonEmptyString } from "../src/base.ts"; +import { decode, encode, rejects } from "./decode.ts"; + +describe("TrimmedNonEmptyString", () => { + it("trims on decode", () => { + expect(decode(TrimmedNonEmptyString, " hi ")).toBe("hi"); + }); + it("trims on encode", () => { + expect(encode(TrimmedNonEmptyString, " hi ")).toBe("hi"); + }); + it("rejects blank", () => { + expect(rejects(TrimmedNonEmptyString, " ")).toBe(true); + }); +}); + +describe("NonNegativeInt", () => { + it("decodes zero and positives", () => { + expect(decode(NonNegativeInt, 0)).toBe(0); + expect(decode(NonNegativeInt, 5)).toBe(5); + }); + it("rejects negatives", () => { + expect(rejects(NonNegativeInt, -1)).toBe(true); + }); +}); diff --git a/pixso-move/contracts/tests/decode.ts b/pixso-move/contracts/tests/decode.ts new file mode 100644 index 00000000000..a8b6cb3397b --- /dev/null +++ b/pixso-move/contracts/tests/decode.ts @@ -0,0 +1,25 @@ +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; + +// Test helper: decode `input` through `schema`, returning the success value or +// throwing if it failed (for happy-path assertions). +export const decode = (schema: Schema.Codec, input: unknown): A => { + const exit = Schema.decodeUnknownExit(schema)(input); + if (Exit.isFailure(exit)) { + throw new Error(`expected decode to succeed: ${JSON.stringify(input)}`); + } + return exit.value; +}; + +// Test helper: true iff decoding `input` through `schema` fails. +export const rejects = (schema: Schema.Codec, input: unknown): boolean => + Exit.isFailure(Schema.decodeUnknownExit(schema)(input)); + +// Test helper: encode `value` (A → I), throwing if it fails. +export const encode = (schema: Schema.Codec, value: unknown): I => { + const exit = Schema.encodeUnknownExit(schema)(value); + if (Exit.isFailure(exit)) { + throw new Error(`expected encode to succeed: ${JSON.stringify(value)}`); + } + return exit.value; +}; diff --git a/pixso-move/contracts/tests/errors.test.ts b/pixso-move/contracts/tests/errors.test.ts new file mode 100644 index 00000000000..18a5afcedab --- /dev/null +++ b/pixso-move/contracts/tests/errors.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { AuthError, IngestError, NodeNotFoundError } from "../src/errors.ts"; + +describe("tagged errors", () => { + it("AuthError carries tag, message, status", () => { + const e = new AuthError({ message: "no key", status: 401 }); + expect(e._tag).toBe("AuthError"); + expect(e.message).toBe("no key"); + expect(e.status).toBe(401); + }); + it("IngestError carries tag, message, status", () => { + const e = new IngestError({ message: "bad body", status: 400 }); + expect(e._tag).toBe("IngestError"); + expect(e.status).toBe(400); + }); + it("NodeNotFoundError carries tag, message, status", () => { + const e = new NodeNotFoundError({ message: "missing", status: 404 }); + expect(e._tag).toBe("NodeNotFoundError"); + expect(e.status).toBe(404); + }); +}); diff --git a/pixso-move/contracts/tests/ids.test.ts b/pixso-move/contracts/tests/ids.test.ts new file mode 100644 index 00000000000..aa2f98551c7 --- /dev/null +++ b/pixso-move/contracts/tests/ids.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { DesignerId, NodeId, ResultTag } from "../src/ids.ts"; +import { decode, rejects } from "./decode.ts"; + +describe("DesignerId", () => { + it("decodes and trims a valid key", async () => { + expect(await decode(DesignerId, " dz_alice ")).toBe("dz_alice"); + }); + it("rejects empty / whitespace-only", async () => { + expect(await rejects(DesignerId, "")).toBe(true); + expect(await rejects(DesignerId, " ")).toBe(true); + }); + it("rejects over 200 chars", async () => { + expect(await rejects(DesignerId, "x".repeat(201))).toBe(true); + expect(await decode(DesignerId, "x".repeat(200))).toBe("x".repeat(200)); + }); + it("rejects non-strings", async () => { + expect(await rejects(DesignerId, 42)).toBe(true); + }); +}); + +describe("NodeId", () => { + it("decodes a non-empty id", async () => { + expect(await decode(NodeId, "node-1")).toBe("node-1"); + }); + it("rejects empty", async () => { + expect(await rejects(NodeId, "")).toBe(true); + }); +}); + +describe("ResultTag", () => { + it("decodes a valid tag", async () => { + expect(await decode(ResultTag, "react")).toBe("react"); + }); + it("rejects empty and over 64 chars", async () => { + expect(await rejects(ResultTag, "")).toBe(true); + expect(await rejects(ResultTag, "t".repeat(65))).toBe(true); + expect(await decode(ResultTag, "t".repeat(64))).toBe("t".repeat(64)); + }); +}); diff --git a/pixso-move/contracts/tests/index.test.ts b/pixso-move/contracts/tests/index.test.ts new file mode 100644 index 00000000000..1531dd79b0c --- /dev/null +++ b/pixso-move/contracts/tests/index.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import * as Contracts from "../src/index.ts"; + +describe("index barrel", () => { + it("re-exports the public schemas and errors", () => { + expect(Contracts.DesignerId).toBeDefined(); + expect(Contracts.NodeId).toBeDefined(); + expect(Contracts.ResultTag).toBeDefined(); + expect(Contracts.IngestRequest).toBeDefined(); + expect(Contracts.IngestResponse).toBeDefined(); + expect(Contracts.NodeSummary).toBeDefined(); + expect(Contracts.NodeRecord).toBeDefined(); + expect(Contracts.ProcessingResult).toBeDefined(); + expect(Contracts.ProcessingStatus).toBeDefined(); + expect(Contracts.AuthError).toBeDefined(); + expect(Contracts.IngestError).toBeDefined(); + expect(Contracts.NodeNotFoundError).toBeDefined(); + expect(Contracts.Base64Png).toBeDefined(); + expect(Contracts.TrimmedNonEmptyString).toBeDefined(); + expect(Contracts.NonNegativeInt).toBeDefined(); + }); +}); diff --git a/pixso-move/contracts/tests/ingest.test.ts b/pixso-move/contracts/tests/ingest.test.ts new file mode 100644 index 00000000000..7f2532888ed --- /dev/null +++ b/pixso-move/contracts/tests/ingest.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { Base64Png, IngestRequest, IngestResponse } from "../src/ingest.ts"; +import { decode, rejects } from "./decode.ts"; + +const valid = { + designerId: "dz_alice", + rootName: "Login Screen", + nodesJson: '{"id":"1"}', + preview: "iVBORw0KGgo=", +}; + +describe("Base64Png", () => { + it("decodes a non-empty string", async () => { + expect(await decode(Base64Png, "abc")).toBe("abc"); + }); + it("rejects empty and oversize", async () => { + expect(await rejects(Base64Png, "")).toBe(true); + expect(await rejects(Base64Png, "x".repeat(8 * 1024 * 1024 + 1))).toBe(true); + }); +}); + +describe("IngestRequest", () => { + it("decodes a valid request", async () => { + const r = await decode(IngestRequest, valid); + expect(r.designerId).toBe("dz_alice"); + expect(r.rootName).toBe("Login Screen"); + }); + it("rejects a missing field", async () => { + const { preview, ...rest } = valid; + void preview; + expect(await rejects(IngestRequest, rest)).toBe(true); + }); + it("rejects empty rootName and over-512 rootName", async () => { + expect(await rejects(IngestRequest, { ...valid, rootName: "" })).toBe(true); + expect(await rejects(IngestRequest, { ...valid, rootName: "n".repeat(513) })).toBe(true); + }); + it("rejects too-short nodesJson", async () => { + expect(await rejects(IngestRequest, { ...valid, nodesJson: "{" })).toBe(true); + }); +}); + +describe("IngestResponse", () => { + it("decodes a node id", async () => { + expect((await decode(IngestResponse, { nodeId: "n-1" })).nodeId).toBe("n-1"); + }); +}); diff --git a/pixso-move/contracts/tests/node.test.ts b/pixso-move/contracts/tests/node.test.ts new file mode 100644 index 00000000000..5a2ad28de0d --- /dev/null +++ b/pixso-move/contracts/tests/node.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { NodeRecord, NodeSummary } from "../src/node.ts"; +import { decode, rejects } from "./decode.ts"; + +describe("NodeSummary", () => { + it("decodes a valid summary", async () => { + const s = await decode(NodeSummary, { + nodeId: "n-1", + rootName: "Card", + addedAt: "2026-06-14T00:00:00.000Z", + preview: "iVBOR", + }); + expect(s.nodeId).toBe("n-1"); + }); + it("rejects a missing field", async () => { + expect(await rejects(NodeSummary, { nodeId: "n-1", rootName: "Card" })).toBe(true); + }); +}); + +describe("NodeRecord", () => { + it("decodes a full record", async () => { + const r = await decode(NodeRecord, { + nodeId: "n-1", + designerId: "dz_a", + rootName: "Card", + nodesJson: '{"id":"1"}', + preview: "iVBOR", + addedAt: "2026-06-14T00:00:00.000Z", + }); + expect(r.designerId).toBe("dz_a"); + expect(r.nodesJson).toBe('{"id":"1"}'); + }); + it("rejects an empty designerId", async () => { + expect( + await rejects(NodeRecord, { + nodeId: "n-1", + designerId: "", + rootName: "Card", + nodesJson: "{}", + preview: "iVBOR", + addedAt: "x", + }), + ).toBe(true); + }); +}); diff --git a/pixso-move/contracts/tests/processing.test.ts b/pixso-move/contracts/tests/processing.test.ts new file mode 100644 index 00000000000..fc00fcb7ec5 --- /dev/null +++ b/pixso-move/contracts/tests/processing.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { ProcessingResult, ProcessingStatus } from "../src/processing.ts"; +import { decode, rejects } from "./decode.ts"; + +const valid = { + nodeId: "n-1", + resultTag: "react", + status: "done", + attempts: 1, + result: "code", + error: null, + createdAt: "2026-06-14T00:00:00.000Z", + startedAt: "2026-06-14T00:00:01.000Z", + finishedAt: "2026-06-14T00:00:02.000Z", +}; + +describe("ProcessingStatus", () => { + it("decodes each known status", async () => { + for (const s of ["pending", "processing", "done", "error"]) { + expect(await decode(ProcessingStatus, s)).toBe(s); + } + }); + it("rejects an unknown status", async () => { + expect(await rejects(ProcessingStatus, "queued")).toBe(true); + }); +}); + +describe("ProcessingResult", () => { + it("decodes a completed result", async () => { + const r = await decode(ProcessingResult, valid); + expect(r.status).toBe("done"); + expect(r.attempts).toBe(1); + }); + it("decodes a pending result with null fields", async () => { + const r = await decode(ProcessingResult, { + ...valid, + status: "pending", + result: null, + startedAt: null, + finishedAt: null, + }); + expect(r.result).toBeNull(); + expect(r.startedAt).toBeNull(); + }); + it("rejects a negative attempts", async () => { + expect(await rejects(ProcessingResult, { ...valid, attempts: -1 })).toBe(true); + }); + it("rejects a non-integer attempts", async () => { + expect(await rejects(ProcessingResult, { ...valid, attempts: 1.5 })).toBe(true); + }); +}); diff --git a/pixso-move/contracts/tsconfig.json b/pixso-move/contracts/tsconfig.json new file mode 100644 index 00000000000..35d9475d0f6 --- /dev/null +++ b/pixso-move/contracts/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["src", "tests"] +} diff --git a/pixso-move/contracts/vitest.config.ts b/pixso-move/contracts/vitest.config.ts new file mode 100644 index 00000000000..857222dfdd7 --- /dev/null +++ b/pixso-move/contracts/vitest.config.ts @@ -0,0 +1,3 @@ +import { makeVitestConfig } from "../vitest.base.ts"; + +export default makeVitestConfig(import.meta.dirname); diff --git a/pixso-move/plugin/manifest.json b/pixso-move/plugin/manifest.json new file mode 100644 index 00000000000..a9900d16a34 --- /dev/null +++ b/pixso-move/plugin/manifest.json @@ -0,0 +1,8 @@ +{ + "name": "PixsoMove", + "id": "REPLACE_WITH_PIXSO_PLUGIN_ID", + "api": "2.0.0", + "editorType": ["pixso"], + "main": "dist/code.js", + "ui": "dist/ui.html" +} diff --git a/pixso-move/plugin/package.json b/pixso-move/plugin/package.json new file mode 100644 index 00000000000..faf0d43c747 --- /dev/null +++ b/pixso-move/plugin/package.json @@ -0,0 +1,36 @@ +{ + "name": "@pixso-move/plugin", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "pnpm build:code && pnpm build:ui", + "build:code": "vite build --config vite.code.config.ts", + "build:ui": "vite build --config vite.ui.config.ts", + "dev": "vite --config vite.ui.config.ts --open", + "watch:code": "vite build --config vite.code.config.ts --watch", + "watch:ui": "vite build --config vite.ui.config.ts --watch", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:fast": "vitest run" + }, + "dependencies": { + "react": "19.2.5", + "react-dom": "19.2.5", + "@base-ui/react": "1.4.1", + "class-variance-authority": "0.7.1", + "tailwind-merge": "3.4.0", + "lucide-react": "0.564.0" + }, + "devDependencies": { + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "vite": "8.0.10", + "@vitejs/plugin-react": "6.0.1", + "tailwindcss": "4.2.4", + "@tailwindcss/vite": "4.2.4", + "vite-plugin-singlefile": "2.3.3", + "vitest": "catalog:", + "typescript": "catalog:" + } +} diff --git a/pixso-move/plugin/src/code/base64.ts b/pixso-move/plugin/src/code/base64.ts new file mode 100644 index 00000000000..0b215f4b054 --- /dev/null +++ b/pixso-move/plugin/src/code/base64.ts @@ -0,0 +1,18 @@ +// Pure base64 encoder. The Pixso sandbox may lack `btoa`, so we encode manually +// from a byte array. Standard alphabet, `=` padding, no `data:` prefix. +const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +export const bytesToBase64 = (bytes: Uint8Array): string => { + let output = ""; + for (let index = 0; index < bytes.length; index += 3) { + const byte0 = bytes[index] ?? 0; + const byte1 = bytes[index + 1]; + const byte2 = bytes[index + 2]; + const triple = (byte0 << 16) | ((byte1 ?? 0) << 8) | (byte2 ?? 0); + output += ALPHABET[(triple >> 18) & 0x3f]; + output += ALPHABET[(triple >> 12) & 0x3f]; + output += byte1 === undefined ? "=" : ALPHABET[(triple >> 6) & 0x3f]; + output += byte2 === undefined ? "=" : ALPHABET[triple & 0x3f]; + } + return output; +}; diff --git a/pixso-move/plugin/src/code/code.ts b/pixso-move/plugin/src/code/code.ts new file mode 100644 index 00000000000..1e93ac9ee35 --- /dev/null +++ b/pixso-move/plugin/src/code/code.ts @@ -0,0 +1,64 @@ +import type { CodeToUi, UiToCode } from "../shared/messages.ts"; +import { bytesToBase64 } from "./base64.ts"; +import { validateSelection } from "./selection.ts"; +import { serializeNode } from "./serialize.ts"; +import { loadSettings, saveSettings } from "./settings.ts"; + +// pixso.ui.postMessage is the Pixso plugin bridge (not window.postMessage); no targetOrigin. +// oxlint-disable-next-line require-post-message-target-origin +const post = (message: CodeToUi): void => pixso.ui.postMessage(message); + +const selectedNode = (): SceneNode | undefined => pixso.currentPage.selection[0]; + +const postSelectionState = (): void => { + post({ type: "selection-state", verdict: validateSelection(pixso.currentPage.selection) }); +}; + +// Display preview: capped width — cheap to rasterize + encode, enough for the panel. +const PREVIEW_MAX_WIDTH = 640; + +const exportPng = async ( + node: SceneNode, + constraint: { readonly type: "SCALE" | "WIDTH"; readonly value: number }, +): Promise => { + const bytes = await node.exportAsync({ format: "PNG", constraint }); + return bytesToBase64(bytes); +}; + +const handlePreviewRequest = async (): Promise => { + const node = selectedNode(); + if (!node) return postSelectionState(); + const preview = await exportPng(node, { type: "WIDTH", value: PREVIEW_MAX_WIDTH }); + post({ type: "preview-ready", nodeId: node.id, preview, rootName: node.name }); +}; + +const handleCollect = async (): Promise => { + const node = selectedNode(); + const verdict = validateSelection(pixso.currentPage.selection); + if (!verdict.ok || !node) return postSelectionState(); + const { nodesJson, nodeCount } = serializeNode(node); + // Full 1× preview only on send — the server keeps a pixel-perfect copy. + const preview = await exportPng(node, { type: "SCALE", value: 1 }); + post({ type: "collected", nodesJson, rootName: node.name, preview, nodeCount }); +}; + +const route = (message: UiToCode): void => { + const run = + message.type === "request-preview" + ? handlePreviewRequest() + : message.type === "collect-and-send-meta" + ? handleCollect() + : saveSettings(pixso.clientStorage, message.settings); + void run.catch((error: unknown) => post({ type: "error", message: String(error) })); +}; + +pixso.showUI(__html__, { width: 380, height: 560 }); +pixso.on("selectionchange", postSelectionState); +// pixso.ui.onmessage is the Pixso bridge callback slot, not a DOM EventTarget. +// oxlint-disable-next-line prefer-add-event-listener +pixso.ui.onmessage = (message) => route(message as UiToCode); + +void loadSettings(pixso.clientStorage) + .then((settings) => post({ type: "settings-loaded", settings })) + .catch((error: unknown) => post({ type: "error", message: String(error) })); +postSelectionState(); diff --git a/pixso-move/plugin/src/code/nodeProps.ts b/pixso-move/plugin/src/code/nodeProps.ts new file mode 100644 index 00000000000..02049a2e93e --- /dev/null +++ b/pixso-move/plugin/src/code/nodeProps.ts @@ -0,0 +1,61 @@ +import type { SceneNodeLike } from "./selection.ts"; + +// A stable subset of node properties worth carrying to the server. Pixso/Figma nodes +// expose many extra fields; we read defensively (any unknown shape) and keep only what's +// present, so the serialized JSON stays stable and small. +const SCALAR_KEYS = [ + "x", + "y", + "width", + "height", + "rotation", + "opacity", + "visible", + "cornerRadius", + "layoutMode", + "layoutAlign", + "layoutGrow", + "primaryAxisAlignItems", + "counterAxisAlignItems", + "itemSpacing", + "paddingTop", + "paddingRight", + "paddingBottom", + "paddingLeft", + "characters", + "fontSize", + "fontName", + "textAlignHorizontal", + "letterSpacing", + "lineHeight", + "componentId", + "componentProperties", + "variantProperties", +] as const; + +const ARRAY_KEYS = ["fills", "strokes", "effects"] as const; + +const isPlainValue = (value: unknown): boolean => + value !== undefined && typeof value !== "function"; + +export interface SerializedProps { + readonly id: string; + readonly name: string; + readonly type: string; + readonly [key: string]: unknown; +} + +// Extract the stable property subset from a single node (no children — the caller recurses). +export const extractNodeProps = (node: SceneNodeLike): SerializedProps => { + const source = node as unknown as Record; + const props: Record = { id: node.id, name: node.name, type: node.type }; + for (const key of SCALAR_KEYS) { + const value = source[key]; + if (isPlainValue(value)) props[key] = value; + } + for (const key of ARRAY_KEYS) { + const value = source[key]; + if (Array.isArray(value) && value.length > 0) props[key] = value; + } + return props as SerializedProps; +}; diff --git a/pixso-move/plugin/src/code/selection.ts b/pixso-move/plugin/src/code/selection.ts new file mode 100644 index 00000000000..43c7dd8080c --- /dev/null +++ b/pixso-move/plugin/src/code/selection.ts @@ -0,0 +1,21 @@ +import type { SelectionVerdict } from "../shared/messages.ts"; + +// Minimal structural node — testable without the Pixso runtime. +export interface SceneNodeLike { + readonly id: string; + readonly name: string; + readonly type: string; + readonly children?: ReadonlyArray; +} + +// Rule: exactly ONE selected node is valid (its whole subtree comes with it). +// Zero -> "empty"; more than one -> "multiple" (ctrl/shift multi-select of unrelated items). +export const validateSelection = ( + selection: ReadonlyArray, +): SelectionVerdict => { + if (selection.length === 0) return { ok: false, reason: "empty" }; + if (selection.length > 1) return { ok: false, reason: "multiple" }; + const node = selection[0]; + if (!node) return { ok: false, reason: "empty" }; + return { ok: true, node: { id: node.id, name: node.name } }; +}; diff --git a/pixso-move/plugin/src/code/serialize.ts b/pixso-move/plugin/src/code/serialize.ts new file mode 100644 index 00000000000..3095ea84453 --- /dev/null +++ b/pixso-move/plugin/src/code/serialize.ts @@ -0,0 +1,49 @@ +import { extractNodeProps } from "./nodeProps.ts"; +import type { SceneNodeLike } from "./selection.ts"; + +// Caps guard against pathological trees: deep nesting or huge child counts. When a cap +// trips, the offending node is marked `truncated: true` and its children are dropped. +export const MAX_DEPTH = 24; +export const MAX_NODES = 5000; + +interface SerializedNode { + readonly [key: string]: unknown; + children?: ReadonlyArray; + truncated?: true; +} + +interface WalkState { + count: number; + truncated: boolean; +} + +const walk = (node: SceneNodeLike, depth: number, state: WalkState): SerializedNode => { + state.count += 1; + const serialized: SerializedNode = { ...extractNodeProps(node) }; + const children = node.children ?? []; + if (children.length === 0) return serialized; + if (depth >= MAX_DEPTH || state.count >= MAX_NODES) { + state.truncated = true; + serialized.truncated = true; + return serialized; + } + serialized.children = children.map((child) => walk(child, depth + 1, state)); + return serialized; +}; + +export interface SerializeResult { + readonly nodesJson: string; + readonly nodeCount: number; + readonly truncated: boolean; +} + +// Recursively serialize a node subtree to a JSON string (becomes IngestRequest.nodesJson). +export const serializeNode = (node: SceneNodeLike): SerializeResult => { + const state: WalkState = { count: 0, truncated: false }; + const tree = walk(node, 0, state); + return { + nodesJson: JSON.stringify(tree), + nodeCount: state.count, + truncated: state.truncated, + }; +}; diff --git a/pixso-move/plugin/src/code/settings.ts b/pixso-move/plugin/src/code/settings.ts new file mode 100644 index 00000000000..13d35118b69 --- /dev/null +++ b/pixso-move/plugin/src/code/settings.ts @@ -0,0 +1,31 @@ +import type { StoredSettings } from "../shared/messages.ts"; + +export const SETTINGS_KEY = "pixso-move.settings"; +export const DEFAULT_SERVER_URL = "http://localhost:7787"; +export const DEFAULT_THEME_NAME = "pastel-dreams"; +export const DEFAULT_THEME_MODE = "system"; + +const stringOr = (value: unknown, fallback: string): string => + typeof value === "string" && value.length > 0 ? value : fallback; + +// Pure: coerce whatever was read from clientStorage into a complete StoredSettings. +// Missing/partial/garbage stored values fall back to safe defaults. +export const parseSettings = (raw: unknown): StoredSettings => { + const source = typeof raw === "object" && raw !== null ? (raw as Record) : {}; + return { + serverUrl: stringOr(source["serverUrl"], DEFAULT_SERVER_URL), + designerId: typeof source["designerId"] === "string" ? source["designerId"] : "", + themeName: stringOr(source["themeName"], DEFAULT_THEME_NAME), + themeMode: stringOr(source["themeMode"], DEFAULT_THEME_MODE), + }; +}; + +// Effectful shells (only place the sandbox touches persistence). +export const loadSettings = async ( + storage: PixsoClientStorage, +): Promise => parseSettings(await storage.getAsync(SETTINGS_KEY)); + +export const saveSettings = async ( + storage: PixsoClientStorage, + settings: StoredSettings, +): Promise => storage.setAsync(SETTINGS_KEY, settings); diff --git a/pixso-move/plugin/src/pixso.d.ts b/pixso-move/plugin/src/pixso.d.ts new file mode 100644 index 00000000000..d24fbf54765 --- /dev/null +++ b/pixso-move/plugin/src/pixso.d.ts @@ -0,0 +1,37 @@ +// Minimal Pixso/Figma plugin API subset — only what code.ts actually uses. +// Offline-safe: we do NOT install @figma/plugin-typings. Pixso mirrors the Figma API. + +interface ExportSettingsImage { + readonly format: "PNG"; + readonly constraint?: { readonly type: "SCALE" | "WIDTH" | "HEIGHT"; readonly value: number }; +} + +interface SceneNode { + readonly id: string; + readonly name: string; + readonly type: string; + readonly children?: ReadonlyArray; + exportAsync(settings: ExportSettingsImage): Promise; + readonly [key: string]: unknown; +} + +interface PixsoClientStorage { + getAsync(key: string): Promise; + setAsync(key: string, value: unknown): Promise; +} + +interface PixsoUi { + postMessage(message: unknown): void; + onmessage: ((message: unknown) => void) | null; +} + +interface PixsoApi { + showUI(html: string, options?: { width?: number; height?: number }): void; + readonly ui: PixsoUi; + on(event: "selectionchange", callback: () => void): void; + readonly currentPage: { readonly selection: ReadonlyArray }; + readonly clientStorage: PixsoClientStorage; +} + +declare const pixso: PixsoApi; +declare const __html__: string; diff --git a/pixso-move/plugin/src/shared/messages.ts b/pixso-move/plugin/src/shared/messages.ts new file mode 100644 index 00000000000..4dc43b5ad34 --- /dev/null +++ b/pixso-move/plugin/src/shared/messages.ts @@ -0,0 +1,39 @@ +// Typed UI <-> code message unions. Imported by BOTH the sandbox (code/) and the iframe (ui/). + +export type SelectionVerdict = + | { readonly ok: true; readonly node: { readonly id: string; readonly name: string } } + | { readonly ok: false; readonly reason: "empty" | "multiple" }; + +export interface StoredSettings { + readonly serverUrl: string; + readonly designerId: string; + // Theme is persisted alongside settings (clientStorage) — the plugin iframe + // can't use localStorage. Kept as strings here; the UI coerces/validates them. + readonly themeName: string; + readonly themeMode: string; +} + +// Messages the sandbox posts to the iframe UI. +export type CodeToUi = + | { readonly type: "selection-state"; readonly verdict: SelectionVerdict } + | { + readonly type: "preview-ready"; + readonly nodeId: string; + readonly preview: string; + readonly rootName: string; + } + | { + readonly type: "collected"; + readonly nodesJson: string; + readonly rootName: string; + readonly preview: string; + readonly nodeCount: number; + } + | { readonly type: "settings-loaded"; readonly settings: StoredSettings } + | { readonly type: "error"; readonly message: string }; + +// Messages the iframe UI posts to the sandbox. +export type UiToCode = + | { readonly type: "request-preview" } + | { readonly type: "collect-and-send-meta" } + | { readonly type: "save-settings"; readonly settings: StoredSettings }; diff --git a/pixso-move/plugin/src/ui/App.tsx b/pixso-move/plugin/src/ui/App.tsx new file mode 100644 index 00000000000..1ee4a22102a --- /dev/null +++ b/pixso-move/plugin/src/ui/App.tsx @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useReducer } from "react"; + +import { sendToServer } from "./api.ts"; +import { postToCode, useBridge } from "./bridge.ts"; +import { showSuccess } from "./components/Toaster.tsx"; +import { MainScreen } from "./screens/MainScreen.tsx"; +import { SettingsScreen } from "./screens/SettingsScreen.tsx"; +import { initialState, reduce } from "./state/reducer.ts"; +import { applyTheme } from "./theme.ts"; +import type { CodeToUi } from "../shared/messages.ts"; +import type { Settings } from "./state/types.ts"; + +export function App() { + const [state, dispatch] = useReducer(reduce, initialState); + const { settings } = state; + + const onMessage = useCallback( + (message: CodeToUi): void => { + dispatch(message); + if (message.type === "settings-loaded") { + applyTheme(message.settings.themeName, message.settings.themeMode); + return; + } + if (message.type !== "collected") return; + void sendToServer(settings, { + designerId: settings.designerId, + rootName: message.rootName, + nodesJson: message.nodesJson, + preview: message.preview, + }).then((result) => { + dispatch({ type: "send-result", result }); + if (result.ok) showSuccess("Дизайн успешно отправлен"); + }); + }, + [settings], + ); + useBridge(onMessage); + + const needsPreview = state.selectionVerdict.ok && state.preview === null; + useEffect(() => { + if (needsPreview) postToCode({ type: "request-preview" }); + }, [needsPreview]); + + const onSend = useCallback((): void => { + if (settings.designerId.length === 0) { + dispatch({ type: "open-settings" }); + return; + } + dispatch({ type: "send-start" }); + postToCode({ type: "collect-and-send-meta" }); + }, [settings.designerId]); + + // Every settings change is real-time: apply the theme live and persist the + // whole blob to clientStorage (via the sandbox). No save button. + const onChange = useCallback((next: Settings): void => { + applyTheme(next.themeName, next.themeMode); + dispatch({ type: "edit-settings", settings: next }); + postToCode({ type: "save-settings", settings: next }); + }, []); + + if (state.screen === "settings") { + return ( + dispatch({ type: "close-settings" })} + /> + ); + } + return ( + dispatch({ type: "open-settings" })} + onSend={onSend} + /> + ); +} diff --git a/pixso-move/plugin/src/ui/api.ts b/pixso-move/plugin/src/ui/api.ts new file mode 100644 index 00000000000..bf745718275 --- /dev/null +++ b/pixso-move/plugin/src/ui/api.ts @@ -0,0 +1,59 @@ +import type { Settings, SendResult } from "./state/types.ts"; + +export const DEFAULT_SERVER_URL = "http://localhost:7787"; + +export interface Collected { + readonly designerId: string; + readonly rootName: string; + readonly nodesJson: string; + readonly preview: string; +} + +export interface IngestRequestPlan { + readonly url: string; + readonly headers: Record; + readonly body: string; +} + +const joinIngestUrl = (serverUrl: string): string => + `${serverUrl.replace(/\/+$/, "")}/ingest`; + +// Pure: turn settings + collected payload into the exact POST /ingest request. +export const buildIngestRequest = (settings: Settings, payload: Collected): IngestRequestPlan => ({ + url: joinIngestUrl(settings.serverUrl), + headers: { + "content-type": "application/json", + "x-designer-id": settings.designerId, + }, + body: JSON.stringify({ + designerId: settings.designerId, + rootName: payload.rootName, + nodesJson: payload.nodesJson, + preview: payload.preview, + }), +}); + +type FetchImpl = typeof fetch; + +// Send the ingest request. Server errors are surfaced verbatim; success returns nodeId. +export const sendToServer = async ( + settings: Settings, + payload: Collected, + fetchImpl: FetchImpl = fetch, +): Promise => { + const plan = buildIngestRequest(settings, payload); + try { + const response = await fetchImpl(plan.url, { + method: "POST", + headers: plan.headers, + body: plan.body, + }); + const text = await response.text(); + if (!response.ok) return { ok: false, message: text || `HTTP ${response.status}` }; + const parsed = JSON.parse(text) as { nodeId?: unknown }; + const nodeId = typeof parsed.nodeId === "string" ? parsed.nodeId : ""; + return { ok: true, nodeId }; + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) }; + } +}; diff --git a/pixso-move/plugin/src/ui/bridge.ts b/pixso-move/plugin/src/ui/bridge.ts new file mode 100644 index 00000000000..2bbfa01a546 --- /dev/null +++ b/pixso-move/plugin/src/ui/bridge.ts @@ -0,0 +1,26 @@ +import { useEffect } from "react"; + +import type { CodeToUi, UiToCode } from "../shared/messages.ts"; + +// True only inside the real plugin iframe (the host is a different window). +// In the local dev server the UI runs top-level, so we must not post to ourselves. +const inPlugin = typeof window !== "undefined" && window.parent !== window; + +// Post a message to the sandbox. Pixso forwards iframe postMessage to code's onmessage. +export const postToCode = (message: UiToCode): void => { + if (inPlugin) parent.postMessage({ pluginMessage: message }, "*"); +}; + +// Subscribe to sandbox -> UI messages (unwrapping the `pluginMessage` envelope) and +// forward each to the dispatcher (the pure reducer turns them into state). +export const useBridge = (onMessage: (message: CodeToUi) => void): void => { + useEffect(() => { + const handler = (event: MessageEvent): void => { + const data = event.data as { pluginMessage?: CodeToUi } | null; + const message = data?.pluginMessage; + if (message) onMessage(message); + }; + window.addEventListener("message", handler); + return () => window.removeEventListener("message", handler); + }, [onMessage]); +}; diff --git a/pixso-move/plugin/src/ui/components/Toaster.tsx b/pixso-move/plugin/src/ui/components/Toaster.tsx new file mode 100644 index 00000000000..f4a8073e254 --- /dev/null +++ b/pixso-move/plugin/src/ui/components/Toaster.tsx @@ -0,0 +1,39 @@ +import { Toast } from "@base-ui/react/toast"; +import { Check } from "lucide-react"; +import type { ReactNode } from "react"; + +// Success toasts built on the ru-code UI kit's Toast primitive (@base-ui/react). +export const toastManager = Toast.createToastManager(); + +export const showSuccess = (message: string): void => { + toastManager.add({ title: message }); +}; + +function ToastList() { + const { toasts } = Toast.useToastManager(); + return ( + + + {toasts.map((toast) => ( + + + + + ))} + + + ); +} + +export function Toaster({ children }: { readonly children: ReactNode }) { + return ( + + {children} + + + ); +} diff --git a/pixso-move/plugin/src/ui/components/settings/settingsLayout.tsx b/pixso-move/plugin/src/ui/components/settings/settingsLayout.tsx new file mode 100644 index 00000000000..bf687a70b4d --- /dev/null +++ b/pixso-move/plugin/src/ui/components/settings/settingsLayout.tsx @@ -0,0 +1,137 @@ +// pixso-move: vendored verbatim from apps/web/src/components/settings/settingsLayout.tsx — keep in sync. +import { Undo2Icon } from "lucide-react"; +import { type ComponentPropsWithoutRef, type ReactNode, useEffect, useState } from "react"; + +import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +/** Re-render every `intervalMs`; return a stable timestamp snapshot for render-time relative labels. */ +export function useRelativeTimeTick(intervalMs = 1_000) { + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setNowMs(Date.now()), intervalMs); + return () => clearInterval(id); + }, [intervalMs]); + return nowMs; +} + +export function SettingsSection({ + title, + icon, + headerAction, + children, + className, + ...sectionProps +}: ComponentPropsWithoutRef<"section"> & { + title: string; + icon?: ReactNode; + headerAction?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+

+ + {icon} + {title} +

+
{headerAction}
+
+
+ {children} +
+
+ ); +} + +export function SettingsRow({ + title, + description, + status, + resetAction, + control, + children, + className, + ...rowProps +}: Omit, "title"> & { + title: ReactNode; + description: ReactNode; + status?: ReactNode; + resetAction?: ReactNode; + control?: ReactNode; + children?: ReactNode; +}) { + return ( +
+
+
+
+

+ {title} +

+ + {resetAction} + +
+

{description}

+ {status ?
{status}
: null} +
+ {control ? ( +
+ {control} +
+ ) : null} +
+ {children} +
+ ); +} + +export function SettingResetButton({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + { + event.stopPropagation(); + onClick(); + }} + > + + + } + /> + Reset to default + + ); +} + +export function SettingsPageContainer({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/pixso-move/plugin/src/ui/components/ui/alert.tsx b/pixso-move/plugin/src/ui/components/ui/alert.tsx new file mode 100644 index 00000000000..a2f0d2b81c6 --- /dev/null +++ b/pixso-move/plugin/src/ui/components/ui/alert.tsx @@ -0,0 +1,73 @@ +// pixso-move: vendored from apps/web/src/components/ui/alert.tsx — keep in sync +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "~/lib/utils"; + +const alertVariants = cva( + "relative grid w-full items-start gap-x-2 gap-y-0.5 rounded-xl border px-3.5 py-3 text-card-foreground text-sm has-[>svg]:has-data-[slot=alert-action]:grid-cols-[calc(var(--spacing)*4)_1fr_auto] has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-data-[slot=alert-action]:grid-cols-[1fr_auto] has-[>svg]:gap-x-2 [&>svg]:h-lh [&>svg]:w-4", + { + defaultVariants: { + variant: "default", + }, + variants: { + variant: { + default: "bg-transparent dark:bg-input/32 [&>svg]:text-muted-foreground", + error: "border-destructive/32 bg-destructive/4 [&>svg]:text-destructive", + info: "border-info/32 bg-info/4 [&>svg]:text-info", + success: "border-success/32 bg-success/4 [&>svg]:text-success", + warning: "border-warning/32 bg-warning/4 [&>svg]:text-warning", + }, + }, + }, +); + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Alert, AlertTitle, AlertDescription, AlertAction }; diff --git a/pixso-move/plugin/src/ui/components/ui/button.tsx b/pixso-move/plugin/src/ui/components/ui/button.tsx new file mode 100644 index 00000000000..93ebd58fc5f --- /dev/null +++ b/pixso-move/plugin/src/ui/components/ui/button.tsx @@ -0,0 +1,75 @@ +// pixso-move: vendored from apps/web/src/components/ui/button.tsx — keep in sync +"use client"; + +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "~/lib/utils"; + +const buttonVariants = cva( + "[&_svg]:-mx-0.5 relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-lg border font-medium text-base outline-none transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 sm:text-sm [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + defaultVariants: { + size: "default", + variant: "default", + }, + variants: { + size: { + default: "h-9 px-[calc(--spacing(3)-1px)] sm:h-8", + icon: "size-9 sm:size-8", + "icon-lg": "size-10 sm:size-9", + "icon-sm": "size-8 sm:size-7", + "icon-xl": + "size-11 sm:size-10 [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5", + "icon-xs": + "size-7 rounded-md before:rounded-[calc(var(--radius-md)-1px)] sm:size-6 not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-4 sm:not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-3.5", + lg: "h-10 px-[calc(--spacing(3.5)-1px)] sm:h-9", + sm: "h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] sm:h-7", + xl: "h-11 px-[calc(--spacing(4)-1px)] text-lg sm:h-10 sm:text-base [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5", + xs: "h-7 gap-1 rounded-md px-[calc(--spacing(2)-1px)] text-sm before:rounded-[calc(var(--radius-md)-1px)] sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5", + }, + variant: { + default: + "not-disabled:inset-shadow-[0_1px_--theme(--color-white/16%)] border-primary bg-primary text-primary-foreground shadow-primary/24 shadow-xs [:active,[data-pressed]]:inset-shadow-[0_1px_--theme(--color-black/8%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-primary/90", + destructive: + "not-disabled:inset-shadow-[0_1px_--theme(--color-white/16%)] border-destructive bg-destructive text-white shadow-destructive/24 shadow-xs [:active,[data-pressed]]:inset-shadow-[0_1px_--theme(--color-black/8%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-destructive/90", + "destructive-outline": + "border-input bg-popover not-dark:bg-clip-padding text-destructive-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:border-destructive/32 [:hover,[data-pressed]]:bg-destructive/4", + ghost: + "border-transparent text-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent", + link: "border-transparent underline-offset-4 [:hover,[data-pressed]]:underline", + outline: + "border-input bg-popover not-dark:bg-clip-padding text-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-accent/50 dark:[:hover,[data-pressed]]:bg-input/64", + secondary: + "border-transparent bg-secondary text-secondary-foreground [:active,[data-pressed]]:bg-secondary/80 [:hover,[data-pressed]]:bg-secondary/90", + }, + }, + }, +); + +interface ButtonProps extends useRender.ComponentProps<"button"> { + variant?: VariantProps["variant"]; + size?: VariantProps["size"]; +} + +function Button({ className, variant, size, render, ...props }: ButtonProps) { + const typeValue: React.ButtonHTMLAttributes["type"] = render + ? undefined + : "button"; + + const defaultProps = { + className: cn(buttonVariants({ className, size, variant })), + "data-slot": "button", + type: typeValue, + }; + + return useRender({ + defaultTagName: "button", + props: mergeProps<"button">(defaultProps, props), + render, + }); +} + +export { Button, buttonVariants }; diff --git a/pixso-move/plugin/src/ui/components/ui/card.tsx b/pixso-move/plugin/src/ui/components/ui/card.tsx new file mode 100644 index 00000000000..c403c6ed0bc --- /dev/null +++ b/pixso-move/plugin/src/ui/components/ui/card.tsx @@ -0,0 +1,197 @@ +// pixso-move: vendored from apps/web/src/components/ui/card.tsx — keep in sync +"use client"; + +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; + +import { cn } from "~/lib/utils"; + +function Card({ className, render, ...props }: useRender.ComponentProps<"div">) { + const defaultProps = { + className: cn( + "relative flex flex-col rounded-2xl border bg-card not-dark:bg-clip-padding text-card-foreground shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]", + className, + ), + "data-slot": "card", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); +} + +function CardFrame({ className, render, ...props }: useRender.ComponentProps<"div">) { + const defaultProps = { + className: cn( + "[--clip-top:-1rem] [--clip-bottom:-1rem] *:data-[slot=card]:first:[--clip-top:1px] *:data-[slot=card]:last:[--clip-bottom:1px] flex flex-col relative rounded-2xl border bg-card before:bg-muted/72 not-dark:bg-clip-padding text-card-foreground shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)] *:data-[slot=card]:-m-px *:not-last:data-[slot=card]:rounded-b-xl *:not-last:data-[slot=card]:before:rounded-b-[calc(var(--radius-xl)-1px)] *:not-first:data-[slot=card]:rounded-t-xl *:not-first:data-[slot=card]:before:rounded-t-[calc(var(--radius-xl)-1px)] *:data-[slot=card]:[clip-path:inset(var(--clip-top)_1px_var(--clip-bottom)_1px_round_calc(var(--radius-2xl)-1px))] *:data-[slot=card]:shadow-none *:data-[slot=card]:before:hidden *:data-[slot=card]:bg-clip-padding", + className, + ), + "data-slot": "card-frame", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); +} + +function CardFrameHeader({ className, render, ...props }: useRender.ComponentProps<"div">) { + const defaultProps = { + className: cn("relative flex flex-col px-6 py-4", className), + "data-slot": "card-frame-header", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); +} + +function CardFrameTitle({ className, render, ...props }: useRender.ComponentProps<"div">) { + const defaultProps = { + className: cn("font-semibold text-sm", className), + "data-slot": "card-frame-title", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); +} + +function CardFrameDescription({ className, render, ...props }: useRender.ComponentProps<"div">) { + const defaultProps = { + className: cn("text-muted-foreground text-sm", className), + "data-slot": "card-frame-description", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); +} + +function CardFrameFooter({ className, render, ...props }: useRender.ComponentProps<"div">) { + const defaultProps = { + className: cn("px-6 py-4", className), + "data-slot": "card-frame-footer", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); +} + +function CardHeader({ className, render, ...props }: useRender.ComponentProps<"div">) { + const defaultProps = { + className: cn( + "grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 p-6 in-[[data-slot=card]:has(>[data-slot=card-panel])]:pb-4 has-data-[slot=card-action]:grid-cols-[1fr_auto]", + className, + ), + "data-slot": "card-header", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); +} + +function CardTitle({ className, render, ...props }: useRender.ComponentProps<"div">) { + const defaultProps = { + className: cn("font-semibold text-lg leading-none", className), + "data-slot": "card-title", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); +} + +function CardDescription({ className, render, ...props }: useRender.ComponentProps<"div">) { + const defaultProps = { + className: cn("text-muted-foreground text-sm", className), + "data-slot": "card-description", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); +} + +function CardAction({ className, render, ...props }: useRender.ComponentProps<"div">) { + const defaultProps = { + className: cn( + "col-start-2 row-span-2 row-start-1 self-start justify-self-end inline-flex", + className, + ), + "data-slot": "card-action", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); +} + +function CardPanel({ className, render, ...props }: useRender.ComponentProps<"div">) { + const defaultProps = { + className: cn( + "flex-1 p-6 in-[[data-slot=card]:has(>[data-slot=card-header]:not(.border-b))]:pt-0 in-[[data-slot=card]:has(>[data-slot=card-footer]:not(.border-t))]:pb-0", + className, + ), + "data-slot": "card-panel", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); +} + +function CardFooter({ className, render, ...props }: useRender.ComponentProps<"div">) { + const defaultProps = { + className: cn( + "flex items-center p-6 in-[[data-slot=card]:has(>[data-slot=card-panel])]:pt-4", + className, + ), + "data-slot": "card-footer", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); +} + +export { + Card, + CardFrame, + CardFrameHeader, + CardFrameTitle, + CardFrameDescription, + CardFrameFooter, + CardAction, + CardDescription, + CardFooter, + CardHeader, + CardPanel, + CardPanel as CardContent, + CardTitle, +}; diff --git a/pixso-move/plugin/src/ui/components/ui/field.tsx b/pixso-move/plugin/src/ui/components/ui/field.tsx new file mode 100644 index 00000000000..c11d02fa1af --- /dev/null +++ b/pixso-move/plugin/src/ui/components/ui/field.tsx @@ -0,0 +1,60 @@ +// pixso-move: vendored from apps/web/src/components/ui/field.tsx — keep in sync +"use client"; + +import { Field as FieldPrimitive } from "@base-ui/react/field"; + +import { cn } from "~/lib/utils"; + +function Field({ className, ...props }: FieldPrimitive.Root.Props) { + return ( + + ); +} + +function FieldLabel({ className, ...props }: FieldPrimitive.Label.Props) { + return ( + + ); +} + +function FieldItem({ className, ...props }: FieldPrimitive.Item.Props) { + return ( + + ); +} + +function FieldDescription({ className, ...props }: FieldPrimitive.Description.Props) { + return ( + + ); +} + +function FieldError({ className, ...props }: FieldPrimitive.Error.Props) { + return ( + + ); +} + +const FieldControl = FieldPrimitive.Control; +const FieldValidity = FieldPrimitive.Validity; + +export { Field, FieldLabel, FieldDescription, FieldError, FieldControl, FieldItem, FieldValidity }; diff --git a/pixso-move/plugin/src/ui/components/ui/input.tsx b/pixso-move/plugin/src/ui/components/ui/input.tsx new file mode 100644 index 00000000000..215b31f05c6 --- /dev/null +++ b/pixso-move/plugin/src/ui/components/ui/input.tsx @@ -0,0 +1,74 @@ +// pixso-move: vendored from apps/web/src/components/ui/input.tsx — keep in sync +"use client"; + +import { Input as InputPrimitive } from "@base-ui/react/input"; +import type * as React from "react"; + +import { cn } from "~/lib/utils"; + +type InputProps = Omit, "size"> & { + size?: "sm" | "default" | "lg" | number; + unstyled?: boolean; + nativeInput?: boolean; +}; + +function Input({ + className, + size = "default", + unstyled = false, + nativeInput = false, + ...props +}: InputProps) { + const inputClassName = cn( + "h-8.5 w-full min-w-0 rounded-[inherit] px-[calc(--spacing(3)-1px)] leading-8.5 outline-none placeholder:text-muted-foreground/72 sm:h-7.5 sm:leading-7.5 [transition:background-color_5000000s_ease-in-out_0s]", + size === "sm" && "h-7.5 px-[calc(--spacing(2.5)-1px)] leading-7.5 sm:h-6.5 sm:leading-6.5", + size === "lg" && "h-9.5 leading-9.5 sm:h-8.5 sm:leading-8.5", + props.type === "search" && + "[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none", + props.type === "file" && + "text-muted-foreground file:me-3 file:bg-transparent file:font-medium file:text-foreground file:text-sm", + ); + let inputElement: React.ReactElement; + + if (nativeInput) { + const { style, onValueChange: _onValueChange, ...nativeInputProps } = props; + const nativeStyle = typeof style === "function" ? undefined : style; + + inputElement = ( + )} + /> + ); + } else { + inputElement = ( + + ); + } + + return ( + + {inputElement} + + ); +} + +export { Input, type InputProps }; diff --git a/pixso-move/plugin/src/ui/components/ui/label.tsx b/pixso-move/plugin/src/ui/components/ui/label.tsx new file mode 100644 index 00000000000..a4457405c7d --- /dev/null +++ b/pixso-move/plugin/src/ui/components/ui/label.tsx @@ -0,0 +1,25 @@ +// pixso-move: vendored from apps/web/src/components/ui/label.tsx — keep in sync +"use client"; + +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; + +import { cn } from "~/lib/utils"; + +function Label({ className, render, ...props }: useRender.ComponentProps<"label">) { + const defaultProps = { + className: cn( + "inline-flex items-center gap-2 text-base/4.5 sm:text-sm/4 font-medium text-foreground", + className, + ), + "data-slot": "label", + }; + + return useRender({ + defaultTagName: "label", + props: mergeProps<"label">(defaultProps, props), + render, + }); +} + +export { Label }; diff --git a/pixso-move/plugin/src/ui/components/ui/select.tsx b/pixso-move/plugin/src/ui/components/ui/select.tsx new file mode 100644 index 00000000000..3649a567f73 --- /dev/null +++ b/pixso-move/plugin/src/ui/components/ui/select.tsx @@ -0,0 +1,258 @@ +"use client"; + +import { mergeProps } from "@base-ui/react/merge-props"; +import { Select as SelectPrimitive } from "@base-ui/react/select"; +import { useRender } from "@base-ui/react/use-render"; +import { cva, type VariantProps } from "class-variance-authority"; +import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from "lucide-react"; +import type * as React from "react"; + +import { cn } from "~/lib/utils"; + +const Select = SelectPrimitive.Root; + +const selectTriggerVariants = cva( + "relative inline-flex cursor-pointer select-none items-center justify-between gap-2 border rounded-lg text-left text-base outline-none transition-[color,box-shadow,background-color] data-disabled:pointer-events-none data-disabled:opacity-64 sm:text-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4", + { + defaultVariants: { + size: "default", + variant: "default", + }, + variants: { + variant: { + default: + "w-full min-w-36 border-input bg-background not-dark:bg-clip-padding text-foreground shadow-xs/5 ring-ring/24 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 focus-visible:border-ring focus-visible:ring-[3px] aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/16 dark:bg-input/32 dark:aria-invalid:ring-destructive/24 dark:not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [&_svg:not([class*='opacity-'])]:opacity-80 [[data-disabled],:focus-visible,[aria-invalid],[data-pressed]]:shadow-none", + ghost: + "border-transparent text-muted-foreground/70 focus-visible:ring-2 focus-visible:ring-ring data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent [:hover,[data-pressed]]:text-foreground/80", + }, + size: { + default: "min-h-9 px-[calc(--spacing(3)-1px)] sm:min-h-8", + lg: "min-h-10 px-[calc(--spacing(3)-1px)] sm:min-h-9", + sm: "min-h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] sm:min-h-7", + xs: "h-7 gap-1 rounded-md px-[calc(--spacing(2)-1px)] text-sm before:rounded-[calc(var(--radius-md)-1px)] sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5", + }, + }, + }, +); + +const selectTriggerIconClassName = "-me-1 size-4.5 opacity-80 sm:size-4"; + +interface SelectButtonProps extends useRender.ComponentProps<"button"> { + size?: VariantProps["size"]; + variant?: VariantProps["variant"]; +} + +function SelectButton({ className, size, variant, render, children, ...props }: SelectButtonProps) { + const typeValue: React.ButtonHTMLAttributes["type"] = render + ? undefined + : "button"; + + const defaultProps = { + children: ( + <> + + {children} + + {variant === "ghost" ? ( + + ) : ( + + )} + + ), + className: cn(selectTriggerVariants({ size, variant }), "min-w-none", className), + "data-slot": "select-button", + type: typeValue, + }; + + return useRender({ + defaultTagName: "button", + props: mergeProps<"button">(defaultProps, props), + render, + }); +} + +function SelectTrigger({ + className, + size = "default", + variant = "default", + children, + ...props +}: SelectPrimitive.Trigger.Props & VariantProps) { + return ( + + {children} + + + + + ); +} + +function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { + return ( + + ); +} + +function SelectPopup({ + className, + popupClassName, + children, + side = "bottom", + sideOffset = 4, + align = "start", + alignOffset = 0, + alignItemWithTrigger = true, + matchTriggerWidth = true, + anchor, + ...props +}: SelectPrimitive.Popup.Props & { + popupClassName?: string; + side?: SelectPrimitive.Positioner.Props["side"]; + sideOffset?: SelectPrimitive.Positioner.Props["sideOffset"]; + align?: SelectPrimitive.Positioner.Props["align"]; + alignOffset?: SelectPrimitive.Positioner.Props["alignOffset"]; + alignItemWithTrigger?: SelectPrimitive.Positioner.Props["alignItemWithTrigger"]; + matchTriggerWidth?: boolean; + anchor?: SelectPrimitive.Positioner.Props["anchor"]; +}) { + return ( + + + + + + +
+ + {children} + +
+ + + +
+
+
+ ); +} + +function SelectItem({ + className, + children, + hideIndicator = false, + ...props +}: SelectPrimitive.Item.Props & { + hideIndicator?: boolean; +}) { + return ( + + {hideIndicator ? null : ( + + + + + + )} + + {children} + + + ); +} + +function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) { + return ( + + ); +} + +function SelectGroup(props: SelectPrimitive.Group.Props) { + return ; +} + +function SelectGroupLabel(props: SelectPrimitive.GroupLabel.Props) { + return ( + + ); +} + +export { + Select, + SelectTrigger, + SelectButton, + selectTriggerVariants, + SelectValue, + SelectPopup, + SelectPopup as SelectContent, + SelectItem, + SelectSeparator, + SelectGroup, + SelectGroupLabel, +}; diff --git a/pixso-move/plugin/src/ui/components/ui/tooltip.tsx b/pixso-move/plugin/src/ui/components/ui/tooltip.tsx new file mode 100644 index 00000000000..2b45de8efb5 --- /dev/null +++ b/pixso-move/plugin/src/ui/components/ui/tooltip.tsx @@ -0,0 +1,60 @@ +// pixso-move: vendored from apps/web/src/components/ui/tooltip.tsx — keep in sync. +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; + +import { cn } from "~/lib/utils"; + +const TooltipCreateHandle = TooltipPrimitive.createHandle; + +const TooltipProvider = TooltipPrimitive.Provider; + +const Tooltip = TooltipPrimitive.Root; + +function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) { + return ; +} + +function TooltipPopup({ + className, + align = "center", + sideOffset = 4, + side = "top", + anchor, + children, + ...props +}: TooltipPrimitive.Popup.Props & { + align?: TooltipPrimitive.Positioner.Props["align"]; + side?: TooltipPrimitive.Positioner.Props["side"]; + sideOffset?: TooltipPrimitive.Positioner.Props["sideOffset"]; + anchor?: TooltipPrimitive.Positioner.Props["anchor"]; +}) { + return ( + + + + + {children} + + + + + ); +} + +export { TooltipCreateHandle, TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup }; diff --git a/pixso-move/plugin/src/ui/index.css b/pixso-move/plugin/src/ui/index.css new file mode 100644 index 00000000000..6bec71a491b --- /dev/null +++ b/pixso-move/plugin/src/ui/index.css @@ -0,0 +1,565 @@ +@import "tailwindcss"; + +/* Themes — picked via data-theme on . Default is "ru-fork" (set in + apps/web/index.html and useTheme.ts). Each theme defines a light variant + in `[data-theme=""]` and a dark variant in `.dark[data-theme=""]`. */ +@import "./themes/ru-fork.css"; +@import "./themes/aurora.css"; +@import "./themes/onyx.css"; +@import "./themes/grayscale.css"; +@import "./themes/pastel-dreams.css"; +@import "./themes/vs-code.css"; +@import "./themes/caffeine.css"; + +@custom-variant dark (&:is(.dark, .dark *)); +/* Window Controls Overlay: active when Electron exposes native titlebar control geometry. */ +@custom-variant wco (&:is(.wco, .wco *)); + +@theme inline { + --animate-skeleton: skeleton 2s -1s infinite linear; + --color-warning-foreground: var(--warning-foreground); + --color-warning: var(--warning); + --color-success-foreground: var(--success-foreground); + --color-success: var(--success); + --color-info-foreground: var(--info-foreground); + --color-info: var(--info); + --color-destructive-foreground: var(--destructive-foreground); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); + @keyframes skeleton { + to { + background-position: -200% 0; + } + } +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + html { + background-color: var(--app-chrome-background); + } + body { + @apply text-foreground relative; + background-color: var(--app-chrome-background); + } +} + +/* Safe-area inset utilities for surfaces that opt into edge-to-edge rendering. + Insets resolve to 0 when the browser already constrains the layout viewport + to the safe area. */ +@utility pt-safe { + padding-top: max(env(safe-area-inset-top), 0px); +} +@utility pb-safe { + padding-bottom: max(env(safe-area-inset-bottom), 0px); +} +@utility pl-safe { + padding-left: max(env(safe-area-inset-left), 0px); +} +@utility pr-safe { + padding-right: max(env(safe-area-inset-right), 0px); +} + +/* Suppress all transitions during theme changes */ +.no-transitions, +.no-transitions *, +.no-transitions *::before, +.no-transitions *::after { + transition-duration: 0s !important; + animation-duration: 0s !important; +} + +/* Cross-theme fallbacks for ru-fork-specific tokens that may not be + defined by every theme (info/success/warning are ru-fork extras; all client + themes inherit these). --app-chrome-background defaults to --background. */ +:root { + --app-chrome-background: var(--background); + --info: var(--color-blue-500); + --info-foreground: var(--color-blue-700); + --success: var(--color-emerald-500); + --success-foreground: var(--color-emerald-700); + --warning: var(--color-amber-500); + --warning-foreground: var(--color-amber-700); + + @variant dark { + --info-foreground: var(--color-blue-400); + --success-foreground: var(--color-emerald-400); + --warning-foreground: var(--color-amber-400); + } +} + +body { + font-family: + "DM Sans", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + system-ui, + sans-serif; + margin: 0; + padding: 0; +} + +html, +body { + min-height: calc(100svh + env(safe-area-inset-top)); + overscroll-behavior: none; +} + +body { + height: 100%; + overflow: hidden; +} + +#root { + height: 100%; + width: 100%; + max-width: 100%; + overflow-x: hidden; + overflow-x: clip; + overflow-y: hidden; + overscroll-behavior-y: none; + padding-top: max(env(safe-area-inset-top), 0px); +} + +body::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + opacity: 0.035; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); + background-repeat: repeat; + background-size: 256px 256px; +} + +pre, +code { + font-family: "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +/* Window drag region (frameless titlebar) */ +.drag-region { + -webkit-app-region: drag; +} + +.drag-region button, +.drag-region input, +.drag-region textarea, +.drag-region select, +.drag-region a { + -webkit-app-region: no-drag; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.15); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.25); +} + +.dark ::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); +} + +.dark ::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.18); +} + +.turn-chip-strip { + scrollbar-width: none; + -ms-overflow-style: none; + overscroll-behavior-x: contain; +} + +.turn-chip-strip::-webkit-scrollbar { + display: none; +} + +/* Terminal drawer scrollbar parity with chat */ +.thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar.vertical { + width: 6px !important; +} + +.thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar > .slider { + border-radius: 3px; +} + +.thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar.vertical > .slider { + width: 6px !important; + left: 0 !important; +} + +/* Reasoning select -- clickable label surface */ +label:has(> select#reasoning-effort) { + position: relative; +} +label:has(> select#reasoning-effort) select { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; + width: 100%; + height: 100%; +} + +/* Chat markdown rendering */ +.chat-markdown { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; +} + +.chat-markdown > :first-child { + margin-top: 0; +} + +.chat-markdown > :last-child { + margin-bottom: 0; +} + +.chat-markdown p, +.chat-markdown ul, +.chat-markdown ol, +.chat-markdown blockquote, +.chat-markdown pre, +.chat-markdown table { + margin: 0.65rem 0; +} + +.chat-markdown ul { + padding-left: 1.25rem; + list-style-type: disc; +} + +.chat-markdown ol { + padding-left: 1.25rem; + list-style-type: decimal; +} + +.chat-markdown ul ul { + list-style-type: circle; +} + +.chat-markdown ul ul ul { + list-style-type: square; +} + +.chat-markdown ol ol { + list-style-type: lower-alpha; +} + +.chat-markdown ol ol ol { + list-style-type: lower-roman; +} + +.chat-markdown li + li { + margin-top: 0.25rem; +} + +.chat-markdown a { + color: var(--info-foreground); + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--info-foreground) 40%, transparent); +} + +.chat-markdown a:hover { + opacity: 0.8; +} + +.chat-markdown blockquote { + border-left: 2px solid var(--border); + padding-left: 0.8rem; + color: var(--muted-foreground); +} + +.chat-markdown :not(pre) > code { + border: 1px solid var(--border); + border-radius: 0.375rem; + background: var(--muted); + padding: 0.1rem 0.35rem; + color: var(--foreground); + font-size: 0.75rem; +} + +.chat-markdown-file-link { + display: inline-flex; + align-items: center; + gap: 0.28rem; + border: 1px solid color-mix(in srgb, var(--border) 92%, transparent); + border-radius: 0.375rem; + background: color-mix(in srgb, var(--muted) 88%, var(--background)); + padding: 0.08rem 0.34rem; + color: var(--foreground); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.75rem; + line-height: 1.15; + vertical-align: text-bottom; + transition: + background-color 120ms ease, + border-color 120ms ease, + color 120ms ease, + opacity 120ms ease; +} + +.chat-markdown-file-link:hover { + opacity: 1; + color: var(--foreground); + border-color: color-mix(in srgb, var(--border) 65%, var(--foreground)); + background: color-mix(in srgb, var(--muted) 72%, var(--background)); +} + +.chat-markdown-file-link:focus-visible { + outline: none; + box-shadow: 0 0 0 1px color-mix(in srgb, var(--ring) 70%, transparent); +} + +.chat-markdown-file-link-icon { + opacity: 0.72; +} + +.chat-markdown-file-link-label { + color: color-mix(in srgb, var(--foreground) 88%, transparent); + line-height: 1.1; +} + +.chat-markdown pre { + max-width: 100%; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 0.75rem; + background: var(--muted); + padding: 0.8rem 0.9rem; +} + +.chat-markdown pre code { + border: none; + background: transparent; + padding: 0; + font-size: 0.75rem; +} + +.markdown-file-link-tooltip-scroll { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent; +} + +.markdown-file-link-tooltip-scroll::-webkit-scrollbar { + height: 6px; +} + +.markdown-file-link-tooltip-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.markdown-file-link-tooltip-scroll::-webkit-scrollbar-thumb { + border-radius: 999px; + background: color-mix(in srgb, var(--border) 78%, transparent); +} + +.chat-markdown .chat-markdown-codeblock { + --chat-markdown-codeblock-copy-button-space: 1.5rem; + position: relative; + margin: 0.65rem 0; +} + +.chat-markdown .chat-markdown-codeblock pre { + margin: 0; + padding-right: calc(0.9rem + var(--chat-markdown-codeblock-copy-button-space)); +} + +.chat-markdown .chat-markdown-copy-button { + position: absolute; + top: 0.5rem; + right: 0.5rem; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + height: 1.5rem; + width: 1.5rem; + border-radius: 0.375rem; + border: 1px solid var(--border); + background: color-mix(in srgb, var(--background) 82%, transparent); + color: var(--muted-foreground); + cursor: pointer; + opacity: 0; + pointer-events: none; + transition: + opacity 120ms ease, + color 120ms ease, + border-color 120ms ease; +} + +.chat-markdown .chat-markdown-codeblock:hover .chat-markdown-copy-button, +.chat-markdown .chat-markdown-codeblock:focus-within .chat-markdown-copy-button { + opacity: 1; + pointer-events: auto; +} + +.chat-markdown .chat-markdown-copy-button:hover { + color: var(--foreground); + border-color: color-mix(in srgb, var(--border) 70%, var(--foreground)); +} + +.chat-markdown .chat-markdown-shiki .shiki { + background: color-mix(in srgb, var(--muted) 78%, var(--background)) !important; +} + +.chat-markdown table { + width: 100%; + border-collapse: collapse; +} + +.chat-markdown th, +.chat-markdown td { + border: 1px solid var(--border); + padding: 0.35rem 0.45rem; + text-align: left; +} + +@keyframes provider-update-pill-countdown { + from { + transform: scaleX(1); + } + to { + transform: scaleX(0); + } +} + +.provider-update-pill-progress { + animation: provider-update-pill-countdown var(--provider-update-pill-dismiss-ms) linear forwards; +} + +/* Diffs theme bridge (match diff surfaces to app palette) */ +.diff-panel-viewport { + background: color-mix(in srgb, var(--background) 94%, var(--card)); +} + +.diff-render-file { + border: 1px solid var(--border); + border-radius: 0.5rem; + overflow: clip; + background: color-mix(in srgb, var(--card) 92%, var(--background)); +} + +@keyframes ultrathink-rainbow { + 0% { + background-position: 0% 50%; + } + 100% { + background-position: 200% 50%; + } +} + +@keyframes ultrathink-chroma-shift { + 0% { + filter: hue-rotate(0deg) saturate(1.2); + } + 100% { + filter: hue-rotate(360deg) saturate(1.2); + } +} + +.ultrathink-frame { + --ultrathink-spectrum: linear-gradient( + 120deg, + #ff6b6b 0%, + #f59e0b 18%, + #22c55e 36%, + #14b8a6 54%, + #3b82f6 72%, + #ec4899 90%, + #ff6b6b 100% + ); + background-image: var(--ultrathink-spectrum); + background-size: 220% 220%; + animation: ultrathink-rainbow 10s linear infinite; +} + +.ultrathink-chroma { + animation: ultrathink-chroma-shift 10s linear infinite; +} + +.ultrathink-pill { + background: + linear-gradient(var(--card), var(--card)) padding-box, + var(--ultrathink-spectrum) border-box; + background-size: + 100% 100%, + 220% 220%; + background-position: + 0 0, + 0% 50%; + animation: ultrathink-rainbow 10s linear infinite; + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--card) 82%, transparent); +} + +.ultrathink-word { + display: inline-block; + color: transparent; + background-image: var(--ultrathink-spectrum); + background-size: 220% 220%; + background-position: 0% 50%; + background-clip: text; + -webkit-background-clip: text; + animation: ultrathink-rainbow 10s linear infinite; +} + +/* Thin scrollbar for model picker */ +.model-picker-list::-webkit-scrollbar { + width: 4px; +} + +.model-picker-list::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.1); + border-radius: 2px; +} + +.model-picker-list::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.2); +} + +.dark .model-picker-list::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.08); +} + +.dark .model-picker-list::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.15); +} diff --git a/pixso-move/plugin/src/ui/index.html b/pixso-move/plugin/src/ui/index.html new file mode 100644 index 00000000000..fcd315942ad --- /dev/null +++ b/pixso-move/plugin/src/ui/index.html @@ -0,0 +1,46 @@ + + + + + + Pixso Move + + + + +
+ + + diff --git a/pixso-move/plugin/src/ui/key.ts b/pixso-move/plugin/src/ui/key.ts new file mode 100644 index 00000000000..fe212983a32 --- /dev/null +++ b/pixso-move/plugin/src/ui/key.ts @@ -0,0 +1,27 @@ +// Generate a fresh designer key. The Pixso plugin iframe is often NOT a secure +// context, so `crypto.randomUUID` may be missing/throw — build a v4 UUID from +// getRandomValues with a Math.random fallback so the button always works. +const randomBytes = (length: number): Uint8Array => { + const bytes = new Uint8Array(length); + const webCrypto = globalThis.crypto; + if (webCrypto && typeof webCrypto.getRandomValues === "function") { + webCrypto.getRandomValues(bytes); + return bytes; + } + for (let index = 0; index < length; index += 1) { + bytes[index] = Math.floor(Math.random() * 256); + } + return bytes; +}; + +const uuidV4 = (): string => { + const bytes = randomBytes(16); + bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40; + bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); + return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex + .slice(6, 8) + .join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`; +}; + +export const generateDesignerId = (): string => `dz_${uuidV4()}`; diff --git a/pixso-move/plugin/src/ui/lib/utils.ts b/pixso-move/plugin/src/ui/lib/utils.ts new file mode 100644 index 00000000000..ce37f31a412 --- /dev/null +++ b/pixso-move/plugin/src/ui/lib/utils.ts @@ -0,0 +1,7 @@ +// pixso-move: vendored from apps/web/src/lib/utils.ts — keep in sync +import { type CxOptions, cx } from "class-variance-authority"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: CxOptions) { + return twMerge(cx(inputs)); +} diff --git a/pixso-move/plugin/src/ui/main.tsx b/pixso-move/plugin/src/ui/main.tsx new file mode 100644 index 00000000000..28013cd0b4a --- /dev/null +++ b/pixso-move/plugin/src/ui/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { App } from "./App.tsx"; +import { Toaster } from "./components/Toaster.tsx"; +import "./index.css"; + +const container = document.getElementById("root"); +if (container) { + createRoot(container).render( + + + + + , + ); +} diff --git a/pixso-move/plugin/src/ui/screens/Body.tsx b/pixso-move/plugin/src/ui/screens/Body.tsx new file mode 100644 index 00000000000..9181d1ae983 --- /dev/null +++ b/pixso-move/plugin/src/ui/screens/Body.tsx @@ -0,0 +1,74 @@ +import { Frame, TriangleAlert } from "lucide-react"; + +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { Card, CardContent } from "~/components/ui/card"; +import type { UiState } from "~/state/types"; + +interface BodyProps { + readonly state: UiState; +} + +function EmptyGuide() { + return ( +
+ +

Выберите фрейм

+

+ Выделите один фрейм на холсте, чтобы отправить его на анализ. +

+
+ ); +} + +export function Body({ state }: BodyProps) { + const { selectionVerdict, preview, sendError } = state; + + if (sendError) { + return ( +
+ + + Не удалось отправить + {sendError} + +
+ ); + } + + if (!selectionVerdict.ok) { + if (selectionVerdict.reason === "multiple") { + return ( + + + Выберите только один фрейм + + Несколько несвязанных элементов из разных фреймов отправить нельзя. + + + ); + } + return ; + } + + return ( + + + {preview ? ( +
+ {selectionVerdict.node.name} +
+ ) : ( +
+ )} +
+

{selectionVerdict.node.name}

+

Готово к отправке

+
+ + + ); +} diff --git a/pixso-move/plugin/src/ui/screens/MainScreen.tsx b/pixso-move/plugin/src/ui/screens/MainScreen.tsx new file mode 100644 index 00000000000..f5b27b31dc7 --- /dev/null +++ b/pixso-move/plugin/src/ui/screens/MainScreen.tsx @@ -0,0 +1,47 @@ +import { Loader2, Send, Settings as SettingsIcon } from "lucide-react"; + +import { Button } from "../components/ui/button.tsx"; +import type { UiState } from "../state/types.ts"; +import { Body } from "./Body.tsx"; + +interface MainScreenProps { + readonly state: UiState; + readonly onOpenSettings: () => void; + readonly onSend: () => void; +} + +export function MainScreen({ state, onOpenSettings, onSend }: MainScreenProps) { + const configured = + state.settings.serverUrl.length > 0 && state.settings.designerId.length > 0; + const ready = state.selectionVerdict.ok && state.preview !== null; + const canSend = ready && configured && !state.sending; + + return ( +
+
+ Pixso Move + +
+ +
+ +
+ +
+ +
+
+ ); +} diff --git a/pixso-move/plugin/src/ui/screens/SettingsHeader.tsx b/pixso-move/plugin/src/ui/screens/SettingsHeader.tsx new file mode 100644 index 00000000000..c9150ce48b9 --- /dev/null +++ b/pixso-move/plugin/src/ui/screens/SettingsHeader.tsx @@ -0,0 +1,30 @@ +import { ChevronLeftIcon, RotateCcwIcon } from "lucide-react"; + +import { Button } from "../components/ui/button.tsx"; + +interface SettingsHeaderProps { + readonly dirty: boolean; + readonly onBack: () => void; + readonly onReset: () => void; +} + +// Mirrors apps/web/src/routes/settings.tsx settings header: title + restore-defaults +// (disabled when nothing changed). The back button replaces the app's sidebar nav. +export function SettingsHeader({ dirty, onBack, onReset }: SettingsHeaderProps) { + return ( +
+
+ +
+ +
+
+
+ ); +} diff --git a/pixso-move/plugin/src/ui/screens/SettingsScreen.tsx b/pixso-move/plugin/src/ui/screens/SettingsScreen.tsx new file mode 100644 index 00000000000..660cfab7728 --- /dev/null +++ b/pixso-move/plugin/src/ui/screens/SettingsScreen.tsx @@ -0,0 +1,120 @@ +import { Copy, RefreshCw } from "lucide-react"; + +import { + SettingResetButton, + SettingsPageContainer, + SettingsRow, + SettingsSection, +} from "../components/settings/settingsLayout.tsx"; +import { showSuccess } from "../components/Toaster.tsx"; +import { Button } from "../components/ui/button.tsx"; +import { Input } from "../components/ui/input.tsx"; +import { generateDesignerId } from "../key.ts"; +import { DEFAULT_SETTINGS } from "../state/reducer.ts"; +import type { Settings } from "../state/types.ts"; +import { SettingsHeader } from "./SettingsHeader.tsx"; +import { ThemeSettings } from "./ThemeSettings.tsx"; + +const APP_VERSION = "0.0.0"; + +interface SettingsScreenProps { + readonly settings: Settings; + readonly onChange: (next: Settings) => void; + readonly onBack: () => void; +} + +export function SettingsScreen({ settings, onChange, onBack }: SettingsScreenProps) { + const set = (patch: Partial): void => onChange({ ...settings, ...patch }); + const dirty = + settings.themeMode !== DEFAULT_SETTINGS.themeMode || + settings.themeName !== DEFAULT_SETTINGS.themeName || + settings.serverUrl !== DEFAULT_SETTINGS.serverUrl || + settings.designerId.length > 0; + const copyKey = (): void => { + void navigator.clipboard?.writeText(settings.designerId); + showSuccess("Ключ скопирован"); + }; + + return ( +
+ onChange(DEFAULT_SETTINGS)} /> + + + set(patch)} + /> + set({ serverUrl: DEFAULT_SETTINGS.serverUrl })} + /> + ) : null + } + control={ + set({ serverUrl: event.target.value })} + placeholder="https://..." + /> + } + /> + 0 ? ( + set({ designerId: "" })} /> + ) : null + } + > +
+ set({ designerId: event.target.value })} + placeholder="—" + /> +
+ + +
+
+
+
+ + + + Версия + {APP_VERSION} + + } + description="Текущая версия приложения." + /> + +
+
+ ); +} diff --git a/pixso-move/plugin/src/ui/screens/ThemeSettings.tsx b/pixso-move/plugin/src/ui/screens/ThemeSettings.tsx new file mode 100644 index 00000000000..ef87ea0cc2e --- /dev/null +++ b/pixso-move/plugin/src/ui/screens/ThemeSettings.tsx @@ -0,0 +1,99 @@ +import { + SettingResetButton, + SettingsRow, +} from "../components/settings/settingsLayout.tsx"; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "../components/ui/select.tsx"; +import { + asThemeMode, + asThemeName, + DEFAULT_THEME_NAME, + THEME_MODE_LABELS, + THEME_MODES, + THEME_NAME_LABELS, + THEME_NAMES, +} from "../theme.ts"; + +const APP_NAME = "Pixso Move"; + +interface ThemeSettingsProps { + readonly themeMode: string; + readonly themeName: string; + readonly onChange: (patch: { readonly themeMode?: string; readonly themeName?: string }) => void; +} + +// Theme + colour-palette rows — identical structure to apps/web GeneralSettingsPanel. +export function ThemeSettings({ themeMode, themeName, onChange }: ThemeSettingsProps) { + return ( + <> + onChange({ themeMode: "system" })} /> + ) : null + } + control={ + + } + /> + onChange({ themeName: DEFAULT_THEME_NAME })} + /> + ) : null + } + control={ + + } + /> + + ); +} diff --git a/pixso-move/plugin/src/ui/state/reducer.ts b/pixso-move/plugin/src/ui/state/reducer.ts new file mode 100644 index 00000000000..bc72a8fd34c --- /dev/null +++ b/pixso-move/plugin/src/ui/state/reducer.ts @@ -0,0 +1,83 @@ +import type { CodeToUi } from "../../shared/messages.ts"; +import { DEFAULT_SERVER_URL } from "../api.ts"; +import { DEFAULT_THEME_MODE, DEFAULT_THEME_NAME } from "../theme.ts"; +import type { SendResult, Settings, UiState } from "./types.ts"; + +// Local UI actions (dispatched by App) plus incoming sandbox messages (CodeToUi). +export type UiAction = + | CodeToUi + | { readonly type: "open-settings" } + | { readonly type: "close-settings" } + | { readonly type: "edit-settings"; readonly settings: Settings } + | { readonly type: "send-start" } + | { readonly type: "send-result"; readonly result: SendResult }; + +export { DEFAULT_SERVER_URL }; + +export const DEFAULT_SETTINGS: Settings = { + serverUrl: DEFAULT_SERVER_URL, + designerId: "", + themeName: DEFAULT_THEME_NAME, + themeMode: DEFAULT_THEME_MODE, +}; + +export const initialState: UiState = { + settings: DEFAULT_SETTINGS, + settingsLoaded: false, + screen: "main", + selectionVerdict: { ok: false, reason: "empty" }, + selectedNodeId: null, + preview: null, + rootName: "", + sending: false, + sendError: null, +}; + +// Pure state machine. Each message/action produces the next UiState. +export const reduce = (state: UiState, action: UiAction): UiState => { + switch (action.type) { + case "settings-loaded": + return { + ...state, + settings: action.settings, + settingsLoaded: true, + // First run (no key yet) opens settings so the designer can set up. + screen: action.settings.designerId.length === 0 ? "settings" : state.screen, + }; + case "selection-state": { + // Reset the preview whenever the selected node changes (or becomes invalid) + // so a fresh request is armed. Keep it when the same node is re-reported. + const nodeId = action.verdict.ok ? action.verdict.node.id : null; + const sameNode = nodeId !== null && nodeId === state.selectedNodeId; + return { + ...state, + selectionVerdict: action.verdict, + selectedNodeId: nodeId, + preview: sameNode ? state.preview : null, + rootName: sameNode ? state.rootName : "", + sendError: null, + }; + } + case "preview-ready": + // Ignore a late preview for a node that is no longer selected. + return action.nodeId === state.selectedNodeId + ? { ...state, preview: action.preview, rootName: action.rootName } + : state; + case "collected": + return { ...state, rootName: action.rootName, preview: action.preview }; + case "error": + return { ...state, sendError: action.message, sending: false }; + case "open-settings": + return { ...state, screen: "settings" }; + case "close-settings": + return { ...state, screen: "main" }; + case "edit-settings": + return { ...state, settings: action.settings }; + case "send-start": + return { ...state, sending: true, sendError: null }; + case "send-result": + return action.result.ok + ? { ...state, sending: false, sendError: null } + : { ...state, sending: false, sendError: action.result.message }; + } +}; diff --git a/pixso-move/plugin/src/ui/state/types.ts b/pixso-move/plugin/src/ui/state/types.ts new file mode 100644 index 00000000000..036f492292b --- /dev/null +++ b/pixso-move/plugin/src/ui/state/types.ts @@ -0,0 +1,27 @@ +import type { SelectionVerdict, StoredSettings } from "../../shared/messages.ts"; + +export type Settings = StoredSettings; + +export type Screen = "main" | "settings"; + +export interface SendSuccess { + readonly ok: true; + readonly nodeId: string; +} +export interface SendFailure { + readonly ok: false; + readonly message: string; +} +export type SendResult = SendSuccess | SendFailure; + +export interface UiState { + readonly settings: Settings; + readonly settingsLoaded: boolean; + readonly screen: Screen; + readonly selectionVerdict: SelectionVerdict; + readonly selectedNodeId: string | null; + readonly preview: string | null; + readonly rootName: string; + readonly sending: boolean; + readonly sendError: string | null; +} diff --git a/pixso-move/plugin/src/ui/theme.ts b/pixso-move/plugin/src/ui/theme.ts new file mode 100644 index 00000000000..139edf4684f --- /dev/null +++ b/pixso-move/plugin/src/ui/theme.ts @@ -0,0 +1,54 @@ +// The ru-code theme set + apply logic. We can't reuse apps/web's useTheme hook +// here because it persists via localStorage, which throws in the sandboxed Pixso +// plugin iframe. Theme is persisted through clientStorage (the settings blob) +// instead; this module only applies the choice to the DOM and validates values. + +export const THEME_NAMES = [ + "ru-fork", + "aurora", + "onyx", + "grayscale", + "pastel-dreams", + "vs-code", + "caffeine", +] as const; +export type ThemeName = (typeof THEME_NAMES)[number]; + +export const THEME_NAME_LABELS: Record = { + "ru-fork": "Ru Code", + aurora: "Aurora", + onyx: "Onyx", + grayscale: "Grayscale", + "pastel-dreams": "Pastel Dreams", + "vs-code": "VS Code", + caffeine: "Caffeine", +}; + +export const THEME_MODES = ["system", "light", "dark"] as const; +export type ThemeMode = (typeof THEME_MODES)[number]; +export const THEME_MODE_LABELS: Record = { + system: "Системная", + light: "Светлая", + dark: "Тёмная", +}; + +export const DEFAULT_THEME_NAME: ThemeName = "pastel-dreams"; +export const DEFAULT_THEME_MODE: ThemeMode = "system"; + +export const asThemeName = (value: string): ThemeName => + (THEME_NAMES as readonly string[]).includes(value) ? (value as ThemeName) : DEFAULT_THEME_NAME; + +export const asThemeMode = (value: string): ThemeMode => + (THEME_MODES as readonly string[]).includes(value) ? (value as ThemeMode) : DEFAULT_THEME_MODE; + +const prefersDark = (): boolean => + typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches; + +// Apply the theme to (data-theme + .dark) — mirrors apps/web applyTheme. +export const applyTheme = (themeName: string, themeMode: string): void => { + if (typeof document === "undefined") return; + const mode = asThemeMode(themeMode); + const dark = mode === "dark" || (mode === "system" && prefersDark()); + document.documentElement.classList.toggle("dark", dark); + document.documentElement.setAttribute("data-theme", asThemeName(themeName)); +}; diff --git a/pixso-move/plugin/src/ui/themes/aurora.css b/pixso-move/plugin/src/ui/themes/aurora.css new file mode 100644 index 00000000000..b0747f6e12e --- /dev/null +++ b/pixso-move/plugin/src/ui/themes/aurora.css @@ -0,0 +1,104 @@ +/* Aurora — northern-lights palette. + * Light: cool whites + ocean teal primary + magenta accents. + * Dark: deep midnight blue-violet + electric cyan + aurora pink. + */ +@layer base { + [data-theme="aurora"] { + --background: oklch(0.985 0.006 230); + --foreground: oklch(0.2 0.04 285); + --card: oklch(1 0 0); + --card-foreground: oklch(0.2 0.04 285); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.2 0.04 285); + --primary: oklch(0.52 0.18 198); + --primary-foreground: oklch(0.985 0.006 230); + --secondary: oklch(0.95 0.025 280); + --secondary-foreground: oklch(0.28 0.06 285); + --muted: oklch(0.95 0.025 280); + --muted-foreground: oklch(0.5 0.05 275); + --accent: oklch(0.94 0.05 325); + --accent-foreground: oklch(0.42 0.2 330); + --destructive: oklch(0.6 0.22 25); + --border: oklch(0.89 0.03 280); + --input: oklch(0.89 0.03 280); + --ring: oklch(0.52 0.18 198); + + --chart-1: oklch(0.6 0.18 198); + --chart-2: oklch(0.65 0.2 330); + --chart-3: oklch(0.7 0.18 145); + --chart-4: oklch(0.72 0.18 60); + --chart-5: oklch(0.55 0.22 280); + + --sidebar: oklch(0.97 0.012 240); + --sidebar-foreground: oklch(0.2 0.04 285); + --sidebar-primary: oklch(0.52 0.18 198); + --sidebar-primary-foreground: oklch(0.985 0.006 230); + --sidebar-accent: oklch(0.93 0.04 320); + --sidebar-accent-foreground: oklch(0.42 0.2 330); + --sidebar-border: oklch(0.89 0.03 280); + --sidebar-ring: oklch(0.52 0.18 198); + + --radius: 0.65rem; + + /* Soft cool-tinted shadows */ + --shadow-2xs: 0 1px 2px 0 oklch(0.3 0.1 280 / 0.06); + --shadow-xs: 0 1px 3px 0 oklch(0.3 0.1 280 / 0.07); + --shadow-sm: 0 1px 3px 0 oklch(0.3 0.1 280 / 0.1), 0 1px 2px -1px oklch(0.3 0.1 280 / 0.1); + --shadow: 0 2px 4px -1px oklch(0.3 0.1 280 / 0.1), 0 1px 2px -1px oklch(0.3 0.1 280 / 0.06); + --shadow-md: 0 4px 6px -1px oklch(0.3 0.1 280 / 0.1), 0 2px 4px -2px oklch(0.3 0.1 280 / 0.08); + --shadow-lg: + 0 10px 15px -3px oklch(0.3 0.1 280 / 0.1), 0 4px 6px -4px oklch(0.3 0.1 280 / 0.08); + --shadow-xl: + 0 20px 25px -5px oklch(0.3 0.1 280 / 0.1), 0 8px 10px -6px oklch(0.3 0.1 280 / 0.06); + --shadow-2xl: 0 25px 50px -12px oklch(0.3 0.1 280 / 0.25); + } + + .dark[data-theme="aurora"] { + --background: oklch(0.16 0.035 280); + --foreground: oklch(0.95 0.012 245); + --card: oklch(0.21 0.045 285); + --card-foreground: oklch(0.95 0.012 245); + --popover: oklch(0.21 0.045 285); + --popover-foreground: oklch(0.95 0.012 245); + --primary: oklch(0.78 0.16 195); + --primary-foreground: oklch(0.16 0.05 230); + --secondary: oklch(0.28 0.05 285); + --secondary-foreground: oklch(0.95 0.012 245); + --muted: oklch(0.26 0.045 285); + --muted-foreground: oklch(0.72 0.04 270); + --accent: oklch(0.42 0.13 325); + --accent-foreground: oklch(0.95 0.04 320); + --destructive: oklch(0.7 0.21 22); + --border: oklch(0.32 0.05 285); + --input: oklch(0.34 0.05 285); + --ring: oklch(0.78 0.16 195); + + --chart-1: oklch(0.78 0.16 195); + --chart-2: oklch(0.72 0.22 330); + --chart-3: oklch(0.78 0.18 145); + --chart-4: oklch(0.82 0.18 75); + --chart-5: oklch(0.7 0.22 290); + + --sidebar: oklch(0.18 0.04 282); + --sidebar-foreground: oklch(0.95 0.012 245); + --sidebar-primary: oklch(0.78 0.16 195); + --sidebar-primary-foreground: oklch(0.16 0.05 230); + --sidebar-accent: oklch(0.32 0.08 325); + --sidebar-accent-foreground: oklch(0.95 0.04 320); + --sidebar-border: oklch(0.3 0.05 285); + --sidebar-ring: oklch(0.78 0.16 195); + + /* Subtle violet glow shadows */ + --shadow-2xs: 0 1px 2px 0 oklch(0.05 0.05 280 / 0.4); + --shadow-xs: 0 1px 3px 0 oklch(0.05 0.05 280 / 0.5); + --shadow-sm: 0 1px 3px 0 oklch(0.05 0.05 280 / 0.6), 0 1px 2px -1px oklch(0.05 0.05 280 / 0.5); + --shadow: 0 2px 4px -1px oklch(0.05 0.05 280 / 0.6), 0 1px 2px -1px oklch(0.05 0.05 280 / 0.4); + --shadow-md: + 0 4px 6px -1px oklch(0.05 0.05 280 / 0.65), 0 0 12px -2px oklch(0.78 0.16 195 / 0.08); + --shadow-lg: + 0 10px 15px -3px oklch(0.05 0.05 280 / 0.7), 0 0 18px -3px oklch(0.78 0.16 195 / 0.1); + --shadow-xl: + 0 20px 25px -5px oklch(0.05 0.05 280 / 0.75), 0 0 24px -4px oklch(0.72 0.22 330 / 0.08); + --shadow-2xl: 0 25px 50px -12px oklch(0.05 0.05 280 / 0.9); + } +} diff --git a/pixso-move/plugin/src/ui/themes/caffeine.css b/pixso-move/plugin/src/ui/themes/caffeine.css new file mode 100644 index 00000000000..d7abb1953fa --- /dev/null +++ b/pixso-move/plugin/src/ui/themes/caffeine.css @@ -0,0 +1,99 @@ +@layer base { + [data-theme="caffeine"] { + --background: oklch(0.98 0 0); + --foreground: oklch(0.24 0 0); + --card: oklch(0.99 0 0); + --card-foreground: oklch(0.24 0 0); + --popover: oklch(0.99 0 0); + --popover-foreground: oklch(0.24 0 0); + --primary: oklch(0.43 0.04 42); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.92 0.07 76.67); + --secondary-foreground: oklch(0.35 0.07 41.41); + --muted: oklch(0.95 0 0); + --muted-foreground: oklch(0.5 0 0); + --accent: oklch(0.93 0 0); + --accent-foreground: oklch(0.24 0 0); + --destructive: oklch(0.63 0.19 33.26); + --border: oklch(0.88 0 0); + --input: oklch(0.88 0 0); + --ring: oklch(0.43 0.04 42); + --chart-1: oklch(0.43 0.04 42); + --chart-2: oklch(0.92 0.07 76.67); + --chart-3: oklch(0.93 0 0); + --chart-4: oklch(0.94 0.05 75.02); + --chart-5: oklch(0.43 0.04 42); + --sidebar: oklch(0.99 0 0); + --sidebar-foreground: oklch(0.26 0 0); + --sidebar-primary: oklch(0.33 0 0); + --sidebar-primary-foreground: oklch(0.99 0 0); + --sidebar-accent: oklch(0.98 0 0); + --sidebar-accent-foreground: oklch(0.33 0 0); + --sidebar-border: oklch(0.94 0 0); + --sidebar-ring: oklch(0.77 0 0); + + --font-sans: + "Geist", "Geist Fallback", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", + "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --font-serif: + "Geist", "Geist Fallback", ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; + --font-mono: + "Geist Mono", "Geist Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; + + --radius: 0.5rem; + + --shadow-2xs: 0 1px 3px 0px oklch(0 0 0 / 0.05); + --shadow-xs: 0 1px 3px 0px oklch(0 0 0 / 0.05); + --shadow-sm: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 1px 2px -1px oklch(0 0 0 / 0.1); + --shadow: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 1px 2px -1px oklch(0 0 0 / 0.1); + --shadow-md: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 2px 4px -1px oklch(0 0 0 / 0.1); + --shadow-lg: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 4px 6px -1px oklch(0 0 0 / 0.1); + --shadow-xl: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 8px 10px -1px oklch(0 0 0 / 0.1); + --shadow-2xl: 0 1px 3px 0px oklch(0 0 0 / 0.25); + } + + .dark[data-theme="caffeine"] { + --background: oklch(0.18 0 0); + --foreground: oklch(0.95 0 0); + --card: oklch(0.21 0 0); + --card-foreground: oklch(0.95 0 0); + --popover: oklch(0.21 0 0); + --popover-foreground: oklch(0.95 0 0); + --primary: oklch(0.92 0.05 67.14); + --primary-foreground: oklch(0.2 0.02 201.14); + --secondary: oklch(0.32 0.02 67); + --secondary-foreground: oklch(0.92 0.05 67.14); + --muted: oklch(0.25 0 0); + --muted-foreground: oklch(0.77 0 0); + --accent: oklch(0.29 0 0); + --accent-foreground: oklch(0.95 0 0); + --destructive: oklch(0.63 0.19 33.26); + --border: oklch(0.24 0.01 88.77); + --input: oklch(0.4 0 0); + --ring: oklch(0.92 0.05 67.14); + --chart-1: oklch(0.92 0.05 67.14); + --chart-2: oklch(0.32 0.02 67); + --chart-3: oklch(0.29 0 0); + --chart-4: oklch(0.35 0.02 67.11); + --chart-5: oklch(0.92 0.05 67.14); + --sidebar: oklch(0.21 0.01 285.56); + --sidebar-foreground: oklch(0.97 0 0); + --sidebar-primary: oklch(0.49 0.22 264.43); + --sidebar-primary-foreground: oklch(1 0 0); + --sidebar-accent: oklch(0.27 0.01 285.81); + --sidebar-accent-foreground: oklch(0.97 0 0); + --sidebar-border: oklch(0.27 0.01 285.81); + --sidebar-ring: oklch(0.87 0.01 286.27); + + --shadow-2xs: 0 1px 3px 0px oklch(0 0 0 / 0.05); + --shadow-xs: 0 1px 3px 0px oklch(0 0 0 / 0.05); + --shadow-sm: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 1px 2px -1px oklch(0 0 0 / 0.1); + --shadow: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 1px 2px -1px oklch(0 0 0 / 0.1); + --shadow-md: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 2px 4px -1px oklch(0 0 0 / 0.1); + --shadow-lg: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 4px 6px -1px oklch(0 0 0 / 0.1); + --shadow-xl: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 8px 10px -1px oklch(0 0 0 / 0.1); + --shadow-2xl: 0 1px 3px 0px oklch(0 0 0 / 0.25); + } +} diff --git a/pixso-move/plugin/src/ui/themes/grayscale.css b/pixso-move/plugin/src/ui/themes/grayscale.css new file mode 100644 index 00000000000..20de9dd391c --- /dev/null +++ b/pixso-move/plugin/src/ui/themes/grayscale.css @@ -0,0 +1,93 @@ +@layer base { + [data-theme="grayscale"] { + --background: oklch(1 0 0); + --foreground: oklch(0.14 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.14 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.14 0 0); + --primary: oklch(0.2 0 0); + --primary-foreground: oklch(0.99 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.2 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.56 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.2 0 0); + --destructive: oklch(0.58 0.24 28.48); + --border: oklch(0.92 0 0); + --input: oklch(0.92 0 0); + --ring: oklch(0.71 0 0); + --chart-1: oklch(0.65 0.22 36.85); + --chart-2: oklch(0.6 0.11 184.15); + --chart-3: oklch(0.4 0.07 227.18); + --chart-4: oklch(0.83 0.17 81.03); + --chart-5: oklch(0.77 0.17 65.36); + --sidebar: oklch(0.99 0 0); + --sidebar-foreground: oklch(0.14 0 0); + --sidebar-primary: oklch(0.2 0 0); + --sidebar-primary-foreground: oklch(0.99 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.2 0 0); + --sidebar-border: oklch(0.92 0 0); + --sidebar-ring: oklch(0.708 0 0); + + --font-sans: "Geist"; + --font-serif: "Geist"; + --font-mono: "Geist Mono"; + + --radius: 0.625rem; + + --shadow-2xs: 0 1px 3px 0px oklch(0 0 0 / 0.05); + --shadow-xs: 0 1px 3px 0px oklch(0 0 0 / 0.05); + --shadow-sm: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 1px 2px -1px oklch(0 0 0 / 0.1); + --shadow: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 1px 2px -1px oklch(0 0 0 / 0.1); + --shadow-md: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 2px 4px -1px oklch(0 0 0 / 0.1); + --shadow-lg: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 4px 6px -1px oklch(0 0 0 / 0.1); + --shadow-xl: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 8px 10px -1px oklch(0 0 0 / 0.1); + --shadow-2xl: 0 1px 3px 0px oklch(0 0 0 / 0.25); + } + + .dark[data-theme="grayscale"] { + --background: oklch(0.14 0 0); + --foreground: oklch(0.99 0 0); + --card: oklch(0.2 0 0); + --card-foreground: oklch(0.99 0 0); + --popover: oklch(0.2 0 0); + --popover-foreground: oklch(0.99 0 0); + --primary: oklch(0.92 0 0); + --primary-foreground: oklch(0.2 0 0); + --secondary: oklch(0.27 0 0); + --secondary-foreground: oklch(0.99 0 0); + --muted: oklch(0.27 0 0); + --muted-foreground: oklch(0.71 0 0); + --accent: oklch(0.27 0 0); + --accent-foreground: oklch(0.99 0 0); + --destructive: oklch(0.7 0.19 22.23); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.56 0 0); + --chart-1: oklch(0.49 0.24 264.4); + --chart-2: oklch(0.7 0.16 160.43); + --chart-3: oklch(0.77 0.17 65.36); + --chart-4: oklch(0.62 0.26 305.32); + --chart-5: oklch(0.64 0.25 16.51); + --sidebar: oklch(0.2 0 0); + --sidebar-foreground: oklch(0.99 0 0); + --sidebar-primary: oklch(0.49 0.24 264.4); + --sidebar-primary-foreground: oklch(0.99 0 0); + --sidebar-accent: oklch(0.27 0 0); + --sidebar-accent-foreground: oklch(0.99 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.56 0 0); + + --shadow-2xs: 0 1px 3px 0px oklch(0 0 0 / 0.05); + --shadow-xs: 0 1px 3px 0px oklch(0 0 0 / 0.05); + --shadow-sm: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 1px 2px -1px oklch(0 0 0 / 0.1); + --shadow: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 1px 2px -1px oklch(0 0 0 / 0.1); + --shadow-md: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 2px 4px -1px oklch(0 0 0 / 0.1); + --shadow-lg: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 4px 6px -1px oklch(0 0 0 / 0.1); + --shadow-xl: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 8px 10px -1px oklch(0 0 0 / 0.1); + --shadow-2xl: 0 1px 3px 0px oklch(0 0 0 / 0.25); + } +} diff --git a/pixso-move/plugin/src/ui/themes/onyx.css b/pixso-move/plugin/src/ui/themes/onyx.css new file mode 100644 index 00000000000..ac247c64c0d --- /dev/null +++ b/pixso-move/plugin/src/ui/themes/onyx.css @@ -0,0 +1,105 @@ +/* Onyx — premium / VIP palette. + * Light: warm cream paper + bronze primary + oxblood accent. + * Dark: rich obsidian + champagne gold primary + wine accent. + * + * Restrained palette, tight 0.5rem radius for a refined fintech / + * luxury-app feel. Shadows are dense and warm-tinted on light, deep + * black on dark. No color glow — depth, not energy. + */ +@layer base { + [data-theme="onyx"] { + --background: oklch(0.97 0.012 88); + --foreground: oklch(0.2 0.008 65); + --card: oklch(0.99 0.006 90); + --card-foreground: oklch(0.2 0.008 65); + --popover: oklch(0.99 0.006 90); + --popover-foreground: oklch(0.2 0.008 65); + --primary: oklch(0.52 0.115 65); + --primary-foreground: oklch(0.97 0.012 88); + --secondary: oklch(0.93 0.015 80); + --secondary-foreground: oklch(0.28 0.01 65); + --muted: oklch(0.93 0.015 80); + --muted-foreground: oklch(0.48 0.012 70); + --accent: oklch(0.92 0.04 25); + --accent-foreground: oklch(0.4 0.13 18); + --destructive: oklch(0.52 0.18 25); + --border: oklch(0.88 0.012 75); + --input: oklch(0.88 0.012 75); + --ring: oklch(0.52 0.115 65); + + --chart-1: oklch(0.55 0.12 65); + --chart-2: oklch(0.4 0.13 18); + --chart-3: oklch(0.45 0.05 65); + --chart-4: oklch(0.65 0.06 75); + --chart-5: oklch(0.3 0.015 70); + + --sidebar: oklch(0.95 0.018 85); + --sidebar-foreground: oklch(0.2 0.008 65); + --sidebar-primary: oklch(0.52 0.115 65); + --sidebar-primary-foreground: oklch(0.97 0.012 88); + --sidebar-accent: oklch(0.91 0.025 80); + --sidebar-accent-foreground: oklch(0.28 0.01 65); + --sidebar-border: oklch(0.88 0.012 75); + --sidebar-ring: oklch(0.52 0.115 65); + + --radius: 0.5rem; + + /* Warm-toned, dense shadows */ + --shadow-2xs: 0 1px 2px 0 oklch(0.2 0.04 60 / 0.08); + --shadow-xs: 0 1px 3px 0 oklch(0.2 0.04 60 / 0.1); + --shadow-sm: 0 1px 2px 0 oklch(0.2 0.04 60 / 0.12), 0 1px 1px 0 oklch(0.2 0.04 60 / 0.08); + --shadow: 0 2px 4px -1px oklch(0.2 0.04 60 / 0.14), 0 1px 2px -1px oklch(0.2 0.04 60 / 0.08); + --shadow-md: 0 4px 8px -2px oklch(0.2 0.04 60 / 0.14), 0 2px 4px -2px oklch(0.2 0.04 60 / 0.1); + --shadow-lg: + 0 12px 20px -4px oklch(0.2 0.04 60 / 0.16), 0 6px 10px -4px oklch(0.2 0.04 60 / 0.1); + --shadow-xl: + 0 24px 32px -8px oklch(0.2 0.04 60 / 0.2), 0 10px 14px -6px oklch(0.2 0.04 60 / 0.12); + --shadow-2xl: 0 32px 48px -12px oklch(0.2 0.04 60 / 0.32); + } + + .dark[data-theme="onyx"] { + --background: oklch(0.14 0.008 65); + --foreground: oklch(0.95 0.012 90); + --card: oklch(0.18 0.01 65); + --card-foreground: oklch(0.95 0.012 90); + --popover: oklch(0.18 0.01 65); + --popover-foreground: oklch(0.95 0.012 90); + --primary: oklch(0.79 0.11 82); + --primary-foreground: oklch(0.14 0.008 65); + --secondary: oklch(0.22 0.012 70); + --secondary-foreground: oklch(0.95 0.012 90); + --muted: oklch(0.22 0.012 70); + --muted-foreground: oklch(0.72 0.012 80); + --accent: oklch(0.32 0.08 22); + --accent-foreground: oklch(0.86 0.06 32); + --destructive: oklch(0.65 0.18 25); + --border: oklch(0.25 0.012 70); + --input: oklch(0.27 0.012 70); + --ring: oklch(0.79 0.11 82); + + --chart-1: oklch(0.79 0.11 82); + --chart-2: oklch(0.55 0.16 22); + --chart-3: oklch(0.6 0.08 75); + --chart-4: oklch(0.85 0.06 88); + --chart-5: oklch(0.45 0.012 75); + + --sidebar: oklch(0.12 0.008 65); + --sidebar-foreground: oklch(0.92 0.012 90); + --sidebar-primary: oklch(0.79 0.11 82); + --sidebar-primary-foreground: oklch(0.14 0.008 65); + --sidebar-accent: oklch(0.2 0.012 70); + --sidebar-accent-foreground: oklch(0.92 0.012 90); + --sidebar-border: oklch(0.2 0.012 70); + --sidebar-ring: oklch(0.79 0.11 82); + + /* Pure deep black shadows — crisp, no tint, no glow */ + --shadow-2xs: 0 1px 2px 0 oklch(0 0 0 / 0.5); + --shadow-xs: 0 1px 3px 0 oklch(0 0 0 / 0.6); + --shadow-sm: 0 1px 3px 0 oklch(0 0 0 / 0.7), 0 1px 2px -1px oklch(0 0 0 / 0.5); + --shadow: 0 2px 4px -1px oklch(0 0 0 / 0.7), 0 1px 2px -1px oklch(0 0 0 / 0.5); + --shadow-md: 0 6px 10px -2px oklch(0 0 0 / 0.7), 0 3px 5px -2px oklch(0 0 0 / 0.5); + --shadow-lg: 0 14px 22px -4px oklch(0 0 0 / 0.75), 0 6px 10px -4px oklch(0 0 0 / 0.55); + --shadow-xl: 0 28px 38px -8px oklch(0 0 0 / 0.85), 0 12px 18px -6px oklch(0 0 0 / 0.6); + --shadow-2xl: 0 36px 54px -12px oklch(0 0 0 / 0.95); + } +} diff --git a/pixso-move/plugin/src/ui/themes/pastel-dreams.css b/pixso-move/plugin/src/ui/themes/pastel-dreams.css new file mode 100644 index 00000000000..794ca7c8010 --- /dev/null +++ b/pixso-move/plugin/src/ui/themes/pastel-dreams.css @@ -0,0 +1,93 @@ +@layer base { + [data-theme="pastel-dreams"] { + --background: oklch(0.97 0.01 316.68); + --foreground: oklch(0.37 0.03 259.73); + --card: oklch(1 0 0); + --card-foreground: oklch(0.37 0.03 259.73); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.37 0.03 259.73); + --primary: oklch(0.71 0.16 293.4); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.91 0.05 306.07); + --secondary-foreground: oklch(0.45 0.03 257.68); + --muted: oklch(0.95 0.03 307.19); + --muted-foreground: oklch(0.55 0.02 264.41); + --accent: oklch(0.94 0.03 322.47); + --accent-foreground: oklch(0.37 0.03 259.73); + --destructive: oklch(0.81 0.1 19.47); + --border: oklch(0.91 0.05 306.07); + --input: oklch(0.91 0.05 306.07); + --ring: oklch(0.71 0.16 293.4); + --chart-1: oklch(0.71 0.16 293.4); + --chart-2: oklch(0.61 0.22 292.63); + --chart-3: oklch(0.54 0.25 293.03); + --chart-4: oklch(0.49 0.24 292.7); + --chart-5: oklch(0.43 0.21 292.63); + --sidebar: oklch(0.91 0.05 306.07); + --sidebar-foreground: oklch(0.37 0.03 259.73); + --sidebar-primary: oklch(0.71 0.16 293.4); + --sidebar-primary-foreground: oklch(1 0 0); + --sidebar-accent: oklch(0.94 0.03 322.47); + --sidebar-accent-foreground: oklch(0.37 0.03 259.73); + --sidebar-border: oklch(0.91 0.05 306.07); + --sidebar-ring: oklch(0.71 0.16 293.4); + + --font-sans: Open Sans, sans-serif; + --font-serif: Source Serif 4, serif; + --font-mono: IBM Plex Mono, monospace; + + --radius: 1.5rem; + + --shadow-2xs: 0px 8px 16px -4px oklch(0 0 0 / 0.04); + --shadow-xs: 0px 8px 16px -4px oklch(0 0 0 / 0.04); + --shadow-sm: 0px 8px 16px -4px oklch(0 0 0 / 0.08), 0px 1px 2px -5px oklch(0 0 0 / 0.08); + --shadow: 0px 8px 16px -4px oklch(0 0 0 / 0.08), 0px 1px 2px -5px oklch(0 0 0 / 0.08); + --shadow-md: 0px 8px 16px -4px oklch(0 0 0 / 0.08), 0px 2px 4px -5px oklch(0 0 0 / 0.08); + --shadow-lg: 0px 8px 16px -4px oklch(0 0 0 / 0.08), 0px 4px 6px -5px oklch(0 0 0 / 0.08); + --shadow-xl: 0px 8px 16px -4px oklch(0 0 0 / 0.08), 0px 8px 10px -5px oklch(0 0 0 / 0.08); + --shadow-2xl: 0px 8px 16px -4px oklch(0 0 0 / 0.2); + } + + .dark[data-theme="pastel-dreams"] { + --background: oklch(0.22 0.01 52.96); + --foreground: oklch(0.93 0.03 273.66); + --card: oklch(0.28 0.03 307.25); + --card-foreground: oklch(0.93 0.03 273.66); + --popover: oklch(0.28 0.03 307.25); + --popover-foreground: oklch(0.93 0.03 273.66); + --primary: oklch(0.79 0.12 295.97); + --primary-foreground: oklch(0.22 0.01 52.96); + --secondary: oklch(0.34 0.04 309.13); + --secondary-foreground: oklch(0.87 0.01 261.81); + --muted: oklch(0.28 0.03 307.25); + --muted-foreground: oklch(0.71 0.02 261.33); + --accent: oklch(0.39 0.05 304.68); + --accent-foreground: oklch(0.87 0.01 261.81); + --destructive: oklch(0.81 0.1 19.47); + --border: oklch(0.34 0.04 309.13); + --input: oklch(0.34 0.04 309.13); + --ring: oklch(0.79 0.12 295.97); + --chart-1: oklch(0.79 0.12 295.97); + --chart-2: oklch(0.71 0.16 293.4); + --chart-3: oklch(0.61 0.22 292.63); + --chart-4: oklch(0.54 0.25 293.03); + --chart-5: oklch(0.49 0.24 292.7); + --sidebar: oklch(0.34 0.04 309.13); + --sidebar-foreground: oklch(0.93 0.03 273.66); + --sidebar-primary: oklch(0.79 0.12 295.97); + --sidebar-primary-foreground: oklch(0.22 0.01 52.96); + --sidebar-accent: oklch(0.39 0.05 304.68); + --sidebar-accent-foreground: oklch(0.87 0.01 261.81); + --sidebar-border: oklch(0.34 0.04 309.13); + --sidebar-ring: oklch(0.79 0.12 295.97); + + --shadow-2xs: 0 1px 3px 0px oklch(0 0 0 / 0.05); + --shadow-xs: 0 1px 3px 0px oklch(0 0 0 / 0.05); + --shadow-sm: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 1px 2px -1px oklch(0 0 0 / 0.1); + --shadow: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 1px 2px -1px oklch(0 0 0 / 0.1); + --shadow-md: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 2px 4px -1px oklch(0 0 0 / 0.1); + --shadow-lg: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 4px 6px -1px oklch(0 0 0 / 0.1); + --shadow-xl: 0 1px 3px 0px oklch(0 0 0 / 0.1), 0 8px 10px -1px oklch(0 0 0 / 0.1); + --shadow-2xl: 0 1px 3px 0px oklch(0 0 0 / 0.25); + } +} diff --git a/pixso-move/plugin/src/ui/themes/ru-fork.css b/pixso-move/plugin/src/ui/themes/ru-fork.css new file mode 100644 index 00000000000..faa839fedf8 --- /dev/null +++ b/pixso-move/plugin/src/ui/themes/ru-fork.css @@ -0,0 +1,64 @@ +/* pixso-move: vendored from apps/web/src/themes/ru-fork.css — keep in sync */ +/* Default theme — preserves the original t3code look. */ +@layer base { + [data-theme="ru-fork"] { + color-scheme: light; + --radius: 0.625rem; + --background: var(--color-white); + --app-chrome-background: var(--background); + --foreground: var(--color-neutral-800); + --card: var(--color-white); + --card-foreground: var(--color-neutral-800); + --popover: var(--color-white); + --popover-foreground: var(--color-neutral-800); + --primary: oklch(0.488 0.217 264); + --primary-foreground: var(--color-white); + --secondary: --alpha(var(--color-black) / 4%); + --secondary-foreground: var(--color-neutral-800); + --muted: --alpha(var(--color-black) / 4%); + --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-black)); + --accent: --alpha(var(--color-black) / 4%); + --accent-foreground: var(--color-neutral-800); + --destructive: var(--color-red-500); + --border: --alpha(var(--color-black) / 8%); + --input: --alpha(var(--color-black) / 10%); + --ring: oklch(0.488 0.217 264); + --destructive-foreground: var(--color-red-700); + --info: var(--color-blue-500); + --info-foreground: var(--color-blue-700); + --success: var(--color-emerald-500); + --success-foreground: var(--color-emerald-700); + --warning: var(--color-amber-500); + --warning-foreground: var(--color-amber-700); + } + + .dark[data-theme="ru-fork"] { + color-scheme: dark; + --background: color-mix(in srgb, var(--color-neutral-950) 95%, var(--color-white)); + --app-chrome-background: var(--background); + --foreground: var(--color-neutral-100); + --card: color-mix(in srgb, var(--background) 98%, var(--color-white)); + --card-foreground: var(--color-neutral-100); + --popover: color-mix(in srgb, var(--background) 98%, var(--color-white)); + --popover-foreground: var(--color-neutral-100); + --primary: oklch(0.588 0.217 264); + --primary-foreground: var(--color-white); + --secondary: --alpha(var(--color-white) / 4%); + --secondary-foreground: var(--color-neutral-100); + --muted: --alpha(var(--color-white) / 4%); + --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white)); + --accent: --alpha(var(--color-white) / 4%); + --accent-foreground: var(--color-neutral-100); + --destructive: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white)); + --border: --alpha(var(--color-white) / 6%); + --input: --alpha(var(--color-white) / 8%); + --ring: oklch(0.588 0.217 264); + --destructive-foreground: var(--color-red-400); + --info: var(--color-blue-500); + --info-foreground: var(--color-blue-400); + --success: var(--color-emerald-500); + --success-foreground: var(--color-emerald-400); + --warning: var(--color-amber-500); + --warning-foreground: var(--color-amber-400); + } +} diff --git a/pixso-move/plugin/src/ui/themes/vs-code.css b/pixso-move/plugin/src/ui/themes/vs-code.css new file mode 100644 index 00000000000..df76ef2f93d --- /dev/null +++ b/pixso-move/plugin/src/ui/themes/vs-code.css @@ -0,0 +1,105 @@ +@layer base { + [data-theme="vs-code"] { + --background: oklch(0.97 0.02 225.66); + --foreground: oklch(0.15 0.02 269.18); + --card: oklch(0.98 0.01 228.79); + --card-foreground: oklch(0.15 0.02 269.18); + --popover: oklch(0.98 0.01 238.45); + --popover-foreground: oklch(0.15 0.02 269.18); + --primary: oklch(0.71 0.15 239.15); + --primary-foreground: oklch(0.94 0.03 232.39); + --secondary: oklch(0.91 0.03 229.2); + --secondary-foreground: oklch(0.15 0.02 269.18); + --muted: oklch(0.89 0.02 225.69); + --muted-foreground: oklch(0.36 0.03 231.55); + --accent: oklch(0.88 0.02 235.72); + --accent-foreground: oklch(0.34 0.05 229.2); + --destructive: oklch(0.61 0.24 20.96); + --border: oklch(0.82 0.02 240.77); + --input: oklch(0.82 0.02 240.77); + --ring: oklch(0.55 0.1 235.72); + --chart-1: oklch(0.57 0.11 228.92); + --chart-2: oklch(0.45 0.1 270.08); + --chart-3: oklch(0.65 0.15 159.03); + --chart-4: oklch(0.75 0.1 100.01); + --chart-5: oklch(0.55 0.15 299.88); + --sidebar: oklch(0.93 0.01 238.46); + --sidebar-foreground: oklch(0.15 0.02 269.18); + --sidebar-primary: oklch(0.57 0.11 228.92); + --sidebar-primary-foreground: oklch(0.99 0.01 203.97); + --sidebar-accent: oklch(0.88 0.02 235.72); + --sidebar-accent-foreground: oklch(0.15 0.02 269.18); + --sidebar-border: oklch(0.82 0.02 240.77); + --sidebar-ring: oklch(0.57 0.11 228.92); + + --font-sans: + "Source Code Pro", "Geist", "Geist Fallback", ui-sans-serif, system-ui, -apple-system, + BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --font-serif: + "Source Serif 4", "Geist", "Geist Fallback", ui-serif, Georgia, Cambria, "Times New Roman", + Times, serif; + --font-mono: + "Source Code Pro", "Geist Mono", "Geist Mono Fallback", ui-monospace, SFMono-Regular, Menlo, + Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + + --radius: 0rem; + + --shadow-2xs: 0px 1px 2.5px 0px oklch(0.49 0.09 235 / 0.03); + --shadow-xs: 0px 1px 2.5px 0px oklch(0.49 0.09 235 / 0.03); + --shadow-sm: + 0px 1px 2.5px 0px oklch(0.49 0.09 235 / 0.06), 0px 1px 2px -1px oklch(0.49 0.09 235 / 0.06); + --shadow: + 0px 1px 2.5px 0px oklch(0.49 0.09 235 / 0.06), 0px 1px 2px -1px oklch(0.49 0.09 235 / 0.06); + --shadow-md: + 0px 1px 2.5px 0px oklch(0.49 0.09 235 / 0.06), 0px 2px 4px -1px oklch(0.49 0.09 235 / 0.06); + --shadow-lg: + 0px 1px 2.5px 0px oklch(0.49 0.09 235 / 0.06), 0px 4px 6px -1px oklch(0.49 0.09 235 / 0.06); + --shadow-xl: + 0px 1px 2.5px 0px oklch(0.49 0.09 235 / 0.06), 0px 8px 10px -1px oklch(0.49 0.09 235 / 0.06); + --shadow-2xl: 0px 1px 2.5px 0px oklch(0.49 0.09 235 / 0.15); + } + + .dark[data-theme="vs-code"] { + --background: oklch(0.18 0.02 271.27); + --foreground: oklch(0.9 0.01 238.47); + --card: oklch(0.22 0.02 271.67); + --card-foreground: oklch(0.9 0.01 238.47); + --popover: oklch(0.22 0.02 271.67); + --popover-foreground: oklch(0.9 0.01 238.47); + --primary: oklch(0.71 0.15 239.15); + --primary-foreground: oklch(0.94 0.03 232.39); + --secondary: oklch(0.28 0.03 270.91); + --secondary-foreground: oklch(0.9 0.01 238.47); + --muted: oklch(0.28 0.03 270.91); + --muted-foreground: oklch(0.6 0.03 269.46); + --accent: oklch(0.28 0.03 270.91); + --accent-foreground: oklch(0.9 0.01 238.47); + --destructive: oklch(0.64 0.25 19.69); + --border: oklch(0.9 0.01 238.47 / 15%); + --input: oklch(0.9 0.01 238.47 / 20%); + --ring: oklch(0.66 0.13 227.7); + --chart-1: oklch(0.66 0.13 227.7); + --chart-2: oklch(0.6 0.1 269.83); + --chart-3: oklch(0.7 0.15 159.83); + --chart-4: oklch(0.8 0.1 100.65); + --chart-5: oklch(0.6 0.15 300.14); + --sidebar: oklch(0.22 0.02 271.67); + --sidebar-foreground: oklch(0.9 0.01 238.47); + --sidebar-primary: oklch(0.66 0.13 227.7); + --sidebar-primary-foreground: oklch(0.18 0.02 271.27); + --sidebar-accent: oklch(0.28 0.03 270.91); + --sidebar-accent-foreground: oklch(0.9 0.01 238.47); + --sidebar-border: oklch(0.9 0.01 238.47 / 15%); + --sidebar-ring: oklch(0.66 0.13 227.7); + + --shadow-2xs: 0px 1px 2px 0px oklch(0 0 0 / 0.01); + --shadow-xs: 0px 1px 2px 0px oklch(0 0 0 / 0.01); + --shadow-sm: 0px 1px 2px 0px oklch(0 0 0 / 0.01), 0px 1px 2px -1px oklch(0 0 0 / 0.01); + --shadow: 0px 1px 2px 0px oklch(0 0 0 / 0.01), 0px 1px 2px -1px oklch(0 0 0 / 0.01); + --shadow-md: 0px 1px 2px 0px oklch(0 0 0 / 0.01), 0px 2px 4px -1px oklch(0 0 0 / 0.01); + --shadow-lg: 0px 1px 2px 0px oklch(0 0 0 / 0.01), 0px 4px 6px -1px oklch(0 0 0 / 0.01); + --shadow-xl: 0px 1px 2px 0px oklch(0 0 0 / 0.01), 0px 8px 10px -1px oklch(0 0 0 / 0.01); + --shadow-2xl: 0px 1px 2px 0px oklch(0 0 0 / 0.03); + } +} diff --git a/pixso-move/plugin/tests/api.test.ts b/pixso-move/plugin/tests/api.test.ts new file mode 100644 index 00000000000..6483cba47bc --- /dev/null +++ b/pixso-move/plugin/tests/api.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { buildIngestRequest, sendToServer } from "../src/ui/api.ts"; +import type { Settings } from "../src/ui/state/types.ts"; + +const settings: Settings = { + serverUrl: "http://localhost:7787/", + designerId: "dz_alice", + themeName: "pastel-dreams", + themeMode: "system", +}; +const payload = { + designerId: "dz_alice", + rootName: "Hero", + nodesJson: "{}", + preview: "AAAA", +}; + +describe("buildIngestRequest", () => { + it("joins the url, sets headers and a matching body", () => { + const plan = buildIngestRequest(settings, payload); + expect(plan.url).toBe("http://localhost:7787/ingest"); + expect(plan.headers["x-designer-id"]).toBe("dz_alice"); + expect(plan.headers["content-type"]).toBe("application/json"); + expect(JSON.parse(plan.body)).toEqual({ + designerId: "dz_alice", + rootName: "Hero", + nodesJson: "{}", + preview: "AAAA", + }); + }); +}); + +const fakeFetch = (status: number, body: string): typeof fetch => + (async () => ({ ok: status >= 200 && status < 300, status, text: async () => body })) as unknown as typeof fetch; + +describe("sendToServer", () => { + it("maps a 200 to success with nodeId", async () => { + const result = await sendToServer(settings, payload, fakeFetch(200, '{"nodeId":"n1"}')); + expect(result).toEqual({ ok: true, nodeId: "n1" }); + }); + + it("surfaces a 4xx body verbatim", async () => { + const result = await sendToServer(settings, payload, fakeFetch(400, "bad request")); + expect(result).toEqual({ ok: false, message: "bad request" }); + }); + + it("maps a network throw to a failure", async () => { + const throwing = (() => Promise.reject(new Error("offline"))) as unknown as typeof fetch; + const result = await sendToServer(settings, payload, throwing); + expect(result).toEqual({ ok: false, message: "offline" }); + }); +}); diff --git a/pixso-move/plugin/tests/base64.test.ts b/pixso-move/plugin/tests/base64.test.ts new file mode 100644 index 00000000000..c317d33c018 --- /dev/null +++ b/pixso-move/plugin/tests/base64.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { bytesToBase64 } from "../src/code/base64.ts"; + +const encode = (text: string): string => + bytesToBase64(Uint8Array.from(text, (character) => character.charCodeAt(0))); + +describe("bytesToBase64", () => { + it("encodes the empty input", () => { + expect(bytesToBase64(new Uint8Array())).toBe(""); + }); + + it("matches known RFC 4648 vectors", () => { + expect(encode("")).toBe(""); + expect(encode("f")).toBe("Zg=="); + expect(encode("fo")).toBe("Zm8="); + expect(encode("foo")).toBe("Zm9v"); + expect(encode("foob")).toBe("Zm9vYg=="); + expect(encode("fooba")).toBe("Zm9vYmE="); + expect(encode("foobar")).toBe("Zm9vYmFy"); + }); + + it("encodes high bytes", () => { + expect(bytesToBase64(Uint8Array.of(0xff, 0xff, 0xff))).toBe("////"); + expect(bytesToBase64(Uint8Array.of(0x00, 0x00, 0x00))).toBe("AAAA"); + }); +}); diff --git a/pixso-move/plugin/tests/key.test.ts b/pixso-move/plugin/tests/key.test.ts new file mode 100644 index 00000000000..9fbf6c22c06 --- /dev/null +++ b/pixso-move/plugin/tests/key.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { generateDesignerId } from "../src/ui/key.ts"; + +describe("generateDesignerId", () => { + it("produces a dz_-prefixed uuid", () => { + const id = generateDesignerId(); + expect(id).toMatch( + /^dz_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + }); + + it("produces distinct ids", () => { + expect(generateDesignerId()).not.toBe(generateDesignerId()); + }); +}); diff --git a/pixso-move/plugin/tests/reducer.test.ts b/pixso-move/plugin/tests/reducer.test.ts new file mode 100644 index 00000000000..281aad02785 --- /dev/null +++ b/pixso-move/plugin/tests/reducer.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; + +import { initialState, reduce } from "../src/ui/state/reducer.ts"; + +describe("reduce", () => { + it("settings-loaded fills settings and stays on main when a key exists", () => { + const next = reduce(initialState, { + type: "settings-loaded", + settings: { serverUrl: "http://x", designerId: "dz_1", themeName: "onyx", themeMode: "dark" }, + }); + expect(next.settings.designerId).toBe("dz_1"); + expect(next.settingsLoaded).toBe(true); + expect(next.screen).toBe("main"); + }); + + it("settings-loaded opens the settings screen when no key is configured", () => { + const next = reduce(initialState, { + type: "settings-loaded", + settings: { serverUrl: "http://x", designerId: "", themeName: "aurora", themeMode: "light" }, + }); + expect(next.screen).toBe("settings"); + }); + + it("selection-state enables a valid verdict and clears stale preview/error when invalid", () => { + const withPreview = { ...initialState, preview: "AAAA", rootName: "Old", sendError: "boom" }; + const invalid = reduce(withPreview, { + type: "selection-state", + verdict: { ok: false, reason: "multiple" }, + }); + expect(invalid.preview).toBeNull(); + expect(invalid.rootName).toBe(""); + expect(invalid.sendError).toBeNull(); + const valid = reduce(initialState, { + type: "selection-state", + verdict: { ok: true, node: { id: "1", name: "Hero" } }, + }); + expect(valid.selectionVerdict.ok).toBe(true); + }); + + it("selection-state resets the preview when the node changes, keeps it for the same node", () => { + const onA = { ...initialState, selectedNodeId: "A", preview: "IMG_A", rootName: "Frame A" }; + const sameA = reduce(onA, { + type: "selection-state", + verdict: { ok: true, node: { id: "A", name: "Frame A" } }, + }); + expect(sameA.preview).toBe("IMG_A"); + const toB = reduce(onA, { + type: "selection-state", + verdict: { ok: true, node: { id: "B", name: "Frame B" } }, + }); + expect(toB.preview).toBeNull(); + expect(toB.selectedNodeId).toBe("B"); + }); + + it("preview-ready sets the image/name when it matches the selected node, ignores stale", () => { + const selected = { ...initialState, selectedNodeId: "n1" }; + const next = reduce(selected, { + type: "preview-ready", + nodeId: "n1", + preview: "PNG", + rootName: "Hero", + }); + expect(next.preview).toBe("PNG"); + expect(next.rootName).toBe("Hero"); + const stale = reduce(selected, { + type: "preview-ready", + nodeId: "other", + preview: "PNG", + rootName: "Hero", + }); + expect(stale.preview).toBeNull(); + }); + + it("collected carries rootName and preview", () => { + const next = reduce(initialState, { + type: "collected", + nodesJson: "{}", + rootName: "Hero", + preview: "PNG", + nodeCount: 2, + }); + expect(next.rootName).toBe("Hero"); + expect(next.preview).toBe("PNG"); + }); + + it("error sets sendError and stops sending", () => { + const sending = { ...initialState, sending: true }; + const next = reduce(sending, { type: "error", message: "boom" }); + expect(next.sendError).toBe("boom"); + expect(next.sending).toBe(false); + }); + + it("send lifecycle: start, success clears error, failure records it", () => { + const started = reduce({ ...initialState, sendError: "old" }, { type: "send-start" }); + expect(started.sending).toBe(true); + expect(started.sendError).toBeNull(); + const success = reduce(started, { type: "send-result", result: { ok: true, nodeId: "n1" } }); + expect(success.sending).toBe(false); + expect(success.sendError).toBeNull(); + const failure = reduce(started, { + type: "send-result", + result: { ok: false, message: "nope" }, + }); + expect(failure.sending).toBe(false); + expect(failure.sendError).toBe("nope"); + }); + + it("open/close settings switches the screen", () => { + const opened = reduce(initialState, { type: "open-settings" }); + expect(opened.screen).toBe("settings"); + const closed = reduce(opened, { type: "close-settings" }); + expect(closed.screen).toBe("main"); + }); + + it("edit-settings updates settings without changing the screen", () => { + const edited = reduce(initialState, { + type: "edit-settings", + settings: { + serverUrl: "http://y", + designerId: "dz_9", + themeName: "vs-code", + themeMode: "dark", + }, + }); + expect(edited.settings.serverUrl).toBe("http://y"); + expect(edited.screen).toBe("main"); + }); +}); diff --git a/pixso-move/plugin/tests/selection.test.ts b/pixso-move/plugin/tests/selection.test.ts new file mode 100644 index 00000000000..943a998dd55 --- /dev/null +++ b/pixso-move/plugin/tests/selection.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import type { SceneNodeLike } from "../src/code/selection.ts"; +import { validateSelection } from "../src/code/selection.ts"; + +const node = (id: string, name: string): SceneNodeLike => ({ id, name, type: "FRAME" }); + +describe("validateSelection", () => { + it("rejects an empty selection", () => { + expect(validateSelection([])).toEqual({ ok: false, reason: "empty" }); + }); + + it("accepts exactly one node", () => { + expect(validateSelection([node("1", "Hero")])).toEqual({ + ok: true, + node: { id: "1", name: "Hero" }, + }); + }); + + it("rejects multiple nodes", () => { + expect(validateSelection([node("1", "A"), node("2", "B")])).toEqual({ + ok: false, + reason: "multiple", + }); + }); +}); diff --git a/pixso-move/plugin/tests/serialize.test.ts b/pixso-move/plugin/tests/serialize.test.ts new file mode 100644 index 00000000000..12c10b92180 --- /dev/null +++ b/pixso-move/plugin/tests/serialize.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import type { SceneNodeLike } from "../src/code/selection.ts"; +import { MAX_DEPTH, serializeNode } from "../src/code/serialize.ts"; + +interface Parsed { + readonly id: string; + readonly type: string; + readonly children?: ReadonlyArray; + readonly truncated?: true; + readonly [key: string]: unknown; +} + +const parse = (node: SceneNodeLike): Parsed => + JSON.parse(serializeNode(node).nodesJson) as Parsed; + +describe("serializeNode", () => { + it("serializes nested children", () => { + const tree: SceneNodeLike = { + id: "root", + name: "Root", + type: "FRAME", + children: [{ id: "child", name: "Child", type: "TEXT" }], + }; + const result = serializeNode(tree); + expect(result.nodeCount).toBe(2); + expect(result.truncated).toBe(false); + expect(parse(tree).children?.[0]?.id).toBe("child"); + }); + + it("keeps the stable prop subset and drops unknown/function props", () => { + const tree = { + id: "1", + name: "Box", + type: "RECTANGLE", + width: 100, + fills: [{ type: "SOLID" }], + secret: "drop me", + exportAsync: () => undefined, + } as unknown as SceneNodeLike; + const parsed = parse(tree); + expect(parsed["width"]).toBe(100); + expect(parsed["fills"]).toEqual([{ type: "SOLID" }]); + expect(parsed["secret"]).toBeUndefined(); + expect(parsed["exportAsync"]).toBeUndefined(); + }); + + it("handles empty children", () => { + const parsed = parse({ id: "1", name: "Leaf", type: "TEXT", children: [] }); + expect(parsed.children).toBeUndefined(); + }); + + it("marks truncated when depth cap is exceeded", () => { + let leaf: SceneNodeLike = { id: "deep", name: "deep", type: "FRAME" }; + for (let depth = 0; depth <= MAX_DEPTH; depth += 1) { + leaf = { id: `n${depth}`, name: "n", type: "FRAME", children: [leaf] }; + } + expect(serializeNode(leaf).truncated).toBe(true); + }); +}); diff --git a/pixso-move/plugin/tests/settings.test.ts b/pixso-move/plugin/tests/settings.test.ts new file mode 100644 index 00000000000..11b52716f66 --- /dev/null +++ b/pixso-move/plugin/tests/settings.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_SERVER_URL, + DEFAULT_THEME_MODE, + DEFAULT_THEME_NAME, + parseSettings, +} from "../src/code/settings.ts"; + +const defaults = { + serverUrl: DEFAULT_SERVER_URL, + designerId: "", + themeName: DEFAULT_THEME_NAME, + themeMode: DEFAULT_THEME_MODE, +}; + +describe("parseSettings", () => { + it("applies defaults for missing values", () => { + expect(parseSettings(undefined)).toEqual(defaults); + }); + + it("keeps stored values when complete", () => { + expect( + parseSettings({ + serverUrl: "https://x.dev", + designerId: "dz_1", + themeName: "onyx", + themeMode: "dark", + }), + ).toEqual({ + serverUrl: "https://x.dev", + designerId: "dz_1", + themeName: "onyx", + themeMode: "dark", + }); + }); + + it("fills only the missing parts of partial input", () => { + expect(parseSettings({ designerId: "dz_2" })).toEqual({ ...defaults, designerId: "dz_2" }); + }); + + it("ignores garbage shapes and non-string fields", () => { + expect(parseSettings(42)).toEqual(defaults); + expect(parseSettings({ serverUrl: 5, designerId: {}, themeName: 1, themeMode: [] })).toEqual( + defaults, + ); + }); +}); diff --git a/pixso-move/plugin/tests/theme.test.ts b/pixso-move/plugin/tests/theme.test.ts new file mode 100644 index 00000000000..7552b872813 --- /dev/null +++ b/pixso-move/plugin/tests/theme.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { + asThemeMode, + asThemeName, + DEFAULT_THEME_MODE, + DEFAULT_THEME_NAME, + THEME_NAMES, +} from "../src/ui/theme.ts"; + +describe("theme coercion", () => { + it("accepts every known theme name", () => { + for (const name of THEME_NAMES) { + expect(asThemeName(name)).toBe(name); + } + }); + + it("falls back to the default for an unknown theme name", () => { + expect(asThemeName("nope")).toBe(DEFAULT_THEME_NAME); + }); + + it("accepts the known modes and falls back otherwise", () => { + expect(asThemeMode("light")).toBe("light"); + expect(asThemeMode("dark")).toBe("dark"); + expect(asThemeMode("system")).toBe("system"); + expect(asThemeMode("bogus")).toBe(DEFAULT_THEME_MODE); + }); +}); diff --git a/pixso-move/plugin/tsconfig.json b/pixso-move/plugin/tsconfig.json new file mode 100644 index 00000000000..5838861feb9 --- /dev/null +++ b/pixso-move/plugin/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["node"], + "plugins": [], + "module": "ESNext", + "moduleResolution": "Bundler", + "paths": { "~/*": ["./src/ui/*"] } + }, + "include": ["src", "tests", "vite.code.config.ts", "vite.ui.config.ts"] +} diff --git a/pixso-move/plugin/vite.code.config.ts b/pixso-move/plugin/vite.code.config.ts new file mode 100644 index 00000000000..22e249873ef --- /dev/null +++ b/pixso-move/plugin/vite.code.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vite"; + +// Sandbox bundle: a single IIFE that runs in the Pixso main thread (node API, no DOM). +export default defineConfig({ + build: { + target: "esnext", + minify: true, + emptyOutDir: false, + outDir: "dist", + lib: { + entry: "src/code/code.ts", + name: "__pixsoPlugin", + formats: ["iife"], + fileName: () => "code.js", + }, + rollupOptions: { output: { inlineDynamicImports: true } }, + }, +}); diff --git a/pixso-move/plugin/vite.ui.config.ts b/pixso-move/plugin/vite.ui.config.ts new file mode 100644 index 00000000000..634d7084b87 --- /dev/null +++ b/pixso-move/plugin/vite.ui.config.ts @@ -0,0 +1,37 @@ +import path from "node:path"; + +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig, type Plugin } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +// vite roots on index.html; rename the single emitted HTML to ui.html (manifest refers to it). +const renameToUiHtml = (): Plugin => ({ + name: "pixso-move:rename-ui-html", + enforce: "post", + generateBundle(_options, bundle) { + for (const [key, asset] of Object.entries(bundle)) { + if (key.endsWith(".html")) asset.fileName = "ui.html"; + } + }, +}); + +// Iframe bundle: React + Tailwind v4, emitted as one self-contained dist/ui.html. +// `build` emits one self-contained dist/ui.html; `dev` (vite serve) runs the UI +// standalone in the browser so it can be inspected without Pixso (the bridge +// no-ops when there is no plugin host). +export default defineConfig(({ command }) => ({ + root: "src/ui", + plugins: [ + react(), + tailwindcss(), + ...(command === "build" ? [viteSingleFile(), renameToUiHtml()] : []), + ], + resolve: { alias: { "~": path.resolve(import.meta.dirname, "src/ui") } }, + build: { + target: "esnext", + outDir: "../../dist", + emptyOutDir: false, + rollupOptions: { input: "src/ui/index.html" }, + }, +})); diff --git a/pixso-move/plugin/vitest.config.ts b/pixso-move/plugin/vitest.config.ts new file mode 100644 index 00000000000..d57f10c4277 --- /dev/null +++ b/pixso-move/plugin/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +// Plugin pure-helper tests run in plain node (no Pixso runtime, no jsdom needed). +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + environment: "node", + }, +}); diff --git a/pixso-move/processor/package.json b/pixso-move/processor/package.json new file mode 100644 index 00000000000..71444d6a26c --- /dev/null +++ b/pixso-move/processor/package.json @@ -0,0 +1,25 @@ +{ + "name": "@pixso-move/processor", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { "types": "./src/index.ts", "import": "./src/index.ts" } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run --coverage", + "test:fast": "vitest run" + }, + "dependencies": { + "@pixso-move/contracts": "workspace:*", + "effect": "catalog:", + "effect-acp": "workspace:*" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "vitest": "catalog:", + "@vitest/coverage-v8": "catalog:" + } +} diff --git a/pixso-move/processor/src/acp/acpRunnerLive.integration.ts b/pixso-move/processor/src/acp/acpRunnerLive.integration.ts new file mode 100644 index 00000000000..0065fced766 --- /dev/null +++ b/pixso-move/processor/src/acp/acpRunnerLive.integration.ts @@ -0,0 +1,85 @@ +import * as AcpClient from "effect-acp/client"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { AcpRunError, type AcpRunnerShape } from "../types.ts"; +import { accumulateDelta } from "./collect.ts"; +import { + authenticateParams, + initializeParams, + mapStopReason, + newSessionParams, + promptBlocks, +} from "./handshake.ts"; +import { AcpRunner } from "./runner.ts"; + +/** + * The real ACP runner — the one un-unit-testable file (real child-process spawn glue), hence + * `*.integration.ts` and coverage-excluded. Every decision it makes lives in a pure, tested + * helper (handshake / collect): this file only assembles them against a live qwen process. + * + * Session-per-job: each `run` spawns `node --acp`, initializes → authenticates → + * creates a session → prompts, accumulating the agent's text deltas, and returns the text + + * stop reason. Any failure or defect collapses to a single {@link AcpRunError}. + */ +export interface AcpRunnerOptions { + readonly cliJs: string; + readonly cwd: string; + readonly authMethodId: string; + readonly cliHome?: string; + readonly noSsl?: boolean; +} + +const buildEnv = (options: AcpRunnerOptions): NodeJS.ProcessEnv => { + const env: NodeJS.ProcessEnv = {}; + if (options.cliHome) env.CLI_HOME = options.cliHome; + if (options.noSsl) env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + return env; +}; + +export const makeAcpRunnerLayer = ( + options: AcpRunnerOptions, +): Layer.Layer => + Layer.effect( + AcpRunner, + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const env = buildEnv(options); + + const run: AcpRunnerShape["run"] = (input) => + Effect.scoped( + Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make(process.execPath, [options.cliJs, "--acp"], { + cwd: options.cwd, + env: { ...process.env, ...env }, + shell: false, + }), + ); + const context = yield* Layer.build(AcpClient.layerChildProcess(child)); + const acp = yield* Effect.service(AcpClient.AcpClient).pipe(Effect.provide(context)); + const buffer = yield* Ref.make(""); + yield* acp.handleSessionUpdate((notification) => + Ref.update(buffer, (current) => accumulateDelta(current, notification)), + ); + yield* acp.agent.initialize(initializeParams()); + yield* acp.agent.authenticate(authenticateParams(options.authMethodId)); + const session = yield* acp.agent.createSession(newSessionParams(options.cwd)); + const response = yield* acp.agent.prompt({ + sessionId: session.sessionId, + prompt: promptBlocks(input.prompt), + }); + return { text: yield* Ref.get(buffer), stopReason: mapStopReason(response) }; + }), + ).pipe( + Effect.catchCause((cause) => + Effect.fail(new AcpRunError({ message: Cause.pretty(cause) })), + ), + ); + + return { run }; + }), + ); diff --git a/pixso-move/processor/src/acp/collect.ts b/pixso-move/processor/src/acp/collect.ts new file mode 100644 index 00000000000..407b5c73308 --- /dev/null +++ b/pixso-move/processor/src/acp/collect.ts @@ -0,0 +1,12 @@ +import type { SessionNotification } from "effect-acp/schema"; + +// Pure delta reducer over ACP `session/update` notifications: append the text of an +// `agent_message_chunk` text block; ignore every other update kind. Folded over the stream +// to build the agent's full message. +export const accumulateDelta = (buffer: string, notification: SessionNotification): string => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") return buffer; + const content = update.content; + if (content.type !== "text") return buffer; + return buffer + content.text; +}; diff --git a/pixso-move/processor/src/acp/handshake.ts b/pixso-move/processor/src/acp/handshake.ts new file mode 100644 index 00000000000..910dfd9c851 --- /dev/null +++ b/pixso-move/processor/src/acp/handshake.ts @@ -0,0 +1,32 @@ +import type { AcpError } from "effect-acp/errors"; +import type { + AuthenticateRequest, + ContentBlock, + InitializeRequest, + NewSessionRequest, + PromptResponse, +} from "effect-acp/schema"; + +import { AcpRunError } from "../types.ts"; + +// Pure builders / mappers for the ACP handshake. Constants verified against +// apps/server AcpSessionRuntime.ts + config.ts (CLI_AUTH_METHOD_ID = "openai"). + +export const initializeParams = (): InitializeRequest => ({ + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }, + clientInfo: { name: "pixso-move", version: "0.0.0" }, +}); + +export const authenticateParams = (methodId: string): AuthenticateRequest => ({ methodId }); + +export const newSessionParams = (cwd: string): NewSessionRequest => ({ cwd, mcpServers: [] }); + +export const promptBlocks = (prompt: string): ReadonlyArray => [ + { type: "text", text: prompt }, +]; + +export const mapStopReason = (response: PromptResponse): string => response.stopReason; + +export const mapAcpError = (error: AcpError): AcpRunError => + new AcpRunError({ message: `${error._tag}: ${JSON.stringify(error)}` }); diff --git a/pixso-move/processor/src/acp/runner.ts b/pixso-move/processor/src/acp/runner.ts new file mode 100644 index 00000000000..aac664a820c --- /dev/null +++ b/pixso-move/processor/src/acp/runner.ts @@ -0,0 +1,10 @@ +import * as Context from "effect/Context"; + +import type { AcpRunnerShape } from "../types.ts"; + +// The ACP runner service tag. Production provides `AcpRunnerLive` (real qwen spawn, see +// acpRunnerLive.integration.ts); tests provide a scripted fake. The embed layer resolves +// this and hands it to the engine as `deps.acp`. +export class AcpRunner extends Context.Service()( + "pixso-move/AcpRunner", +) {} diff --git a/pixso-move/processor/src/config.ts b/pixso-move/processor/src/config.ts new file mode 100644 index 00000000000..8dbc63a8e4a --- /dev/null +++ b/pixso-move/processor/src/config.ts @@ -0,0 +1,16 @@ +import { DesignerId, ResultTag } from "@pixso-move/contracts"; + +import type { ProcessorConfig } from "./types.ts"; + +// The operator-edited processing config. Only the designer id, the prompt, and the result +// tag are hardcoded; the CLI path / home / auth method are resolved from the server config +// and environment (never hardcoded). Add an entry per designer you want enriched. +export const processorConfig: ProcessorConfig = [ + { + designerId: DesignerId.make("dz_c07a93f7-2505-4e60-94af-17a2cc068b79"), + resultTag: ResultTag.make("html-css"), + prompt: + "Создай html/css all in single file for this component. " + + "Используй только один файл, без внешних зависимостей.", + }, +]; diff --git a/pixso-move/processor/src/drain.ts b/pixso-move/processor/src/drain.ts new file mode 100644 index 00000000000..18e6de8dbc7 --- /dev/null +++ b/pixso-move/processor/src/drain.ts @@ -0,0 +1,45 @@ +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; + +import { extractText } from "./extract.ts"; +import { buildPrompt } from "./prompt.ts"; +import type { ClaimedJob, ProcessorDeps } from "./types.ts"; + +// Run one claimed job to completion. This is the single contained unit: ANY failure or +// defect (ACP error, missing node became invalid, store defect) is logged and written as an +// `error` row — it never escapes, so the drain loop and the process keep going. +export const runOneJob = (deps: ProcessorDeps, job: ClaimedJob, promptText: string) => + Effect.gen(function* () { + const node = yield* deps.getForProcessing(job.nodeId); + if (node === undefined) { + yield* deps.fail(job.id, "node missing"); + yield* Effect.logDebug("job skipped: node missing", { + nodeId: job.nodeId, + resultTag: job.resultTag, + }); + return; + } + const prompt = buildPrompt({ + prompt: promptText, + rootName: node.rootName, + nodesJson: node.nodesJson, + }); + const response = yield* deps.acp.run({ prompt }); + yield* deps.complete(job.id, extractText(response.text).text); + yield* Effect.logDebug("job done", { + nodeId: job.nodeId, + resultTag: job.resultTag, + stopReason: response.stopReason, + }); + }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logError("job failed", { + nodeId: job.nodeId, + resultTag: job.resultTag, + cause: Cause.pretty(cause), + }); + yield* deps.fail(job.id, Cause.pretty(cause)); + }), + ), + ); diff --git a/pixso-move/processor/src/engine.ts b/pixso-move/processor/src/engine.ts new file mode 100644 index 00000000000..baf7e907232 --- /dev/null +++ b/pixso-move/processor/src/engine.ts @@ -0,0 +1,101 @@ +import type { DesignerId, NodeId } from "@pixso-move/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; + +import { runOneJob } from "./drain.ts"; +import type { ProcessorShape } from "./processor.ts"; +import { computeReconcileRows, configuredDesignerIds, resolvePrompt } from "./reconcile.ts"; +import type { ProcessorDeps, ProcessorOptions } from "./types.ts"; + +const DEFAULT_POLL_MS = 2000; + +// Serializes ticks: at most one drive loop runs; a request arriving mid-drive flags a re-run. +type DriveState = "idle" | "running" | "rerun"; + +// Build the processor over injected deps. Returns its shape; the embed layer publishes it as +// the `Processor` service. Requires a Scope (the timer + notify forks live in it). +export const makeProcessor = ( + deps: ProcessorDeps, + options: ProcessorOptions, +): Effect.Effect => + Effect.gen(function* () { + const config = options.config; + const pollMs = options.pollIntervalMs ?? DEFAULT_POLL_MS; + const scope = yield* Effect.scope; + const stateRef = yield* Ref.make("idle"); + const timerRef = yield* Ref.make | undefined>(undefined); + + // Ensure a `pending` row exists for every (configured node × tag). + const reconcileAll = Effect.gen(function* () { + const entries: Array]> = []; + for (const designerId of configuredDesignerIds(config)) { + entries.push([designerId, yield* deps.listNodeIds(designerId)]); + } + const inserted = yield* deps.reconcile(computeReconcileRows(config, new Map(entries))); + yield* Effect.logDebug("reconciled", { inserted }); + }); + + // Claim and run pending jobs until the queue is empty. + const drainAll = Effect.gen(function* () { + for (;;) { + const job = yield* deps.claimNextPending; + if (job === undefined) break; + const promptText = resolvePrompt(config, job.designerId, job.resultTag); + if (promptText === undefined) { + yield* deps.fail(job.id, "no configured prompt"); + continue; + } + yield* runOneJob(deps, job, promptText); + } + }); + + // One full tick. Contained: a defect can't kill the timer or the process. + const runTickOnce: Effect.Effect = reconcileAll.pipe( + Effect.flatMap(() => drainAll), + Effect.catchCause((cause) => + Effect.logError("tick failed", { cause: Cause.pretty(cause) }), + ), + ); + + // Run ticks back-to-back while re-runs are requested, then go idle. Atomic via Ref.modify. + const driveLoop: Effect.Effect = Effect.gen(function* () { + yield* runTickOnce; + const again = yield* Ref.modify(stateRef, (state): [boolean, DriveState] => + state === "rerun" ? [true, "running"] : [false, "idle"], + ); + if (again) yield* driveLoop; + }); + + // Non-blocking: start a drive if idle, else flag a re-run. Never fails its caller. + const notify: Effect.Effect = Ref.modify( + stateRef, + (state): [boolean, DriveState] => + state === "idle" ? [true, "running"] : [false, "rerun"], + ).pipe( + Effect.flatMap((shouldStart) => + shouldStart ? Effect.asVoid(Effect.forkIn(driveLoop, scope)) : Effect.void, + ), + ); + + // Recover interrupted work, then arm a fixed-interval poll (re-discovers nodes, retries). + const start = Effect.gen(function* () { + const recovered = yield* deps.recoverInFlight; + yield* Effect.logDebug("recovered", { count: recovered }); + const timer = yield* Effect.forkIn( + Effect.schedule(notify, Schedule.fixed(pollMs)), + scope, + ); + yield* Ref.set(timerRef, timer); + }); + + const stop = Effect.gen(function* () { + const timer = yield* Ref.get(timerRef); + if (timer !== undefined) yield* Fiber.interrupt(timer); + }); + + return { start, notify, stop, runTickOnce } satisfies ProcessorShape; + }); diff --git a/pixso-move/processor/src/extract.ts b/pixso-move/processor/src/extract.ts new file mode 100644 index 00000000000..e814266e1fc --- /dev/null +++ b/pixso-move/processor/src/extract.ts @@ -0,0 +1,14 @@ +// Unwrap a single fenced code block (``` … ```), else return the trimmed whole. The LLM is +// asked for the result only, but often wraps it in a fence — strip it so the stored result +// is the raw artifact. A body that itself contains a fence is not a single block, so it is +// returned whole. Pure. +const SINGLE_FENCE = /^```[^\n]*\n([\s\S]*?)\n?```$/; + +export const extractText = (raw: string): { readonly text: string } => { + const trimmed = raw.trim(); + const body = trimmed.match(SINGLE_FENCE)?.[1]; + if (body !== undefined && !body.includes("```")) { + return { text: body }; + } + return { text: trimmed }; +}; diff --git a/pixso-move/processor/src/index.ts b/pixso-move/processor/src/index.ts new file mode 100644 index 00000000000..26c95fbddae --- /dev/null +++ b/pixso-move/processor/src/index.ts @@ -0,0 +1,13 @@ +export * from "./processor.ts"; +export * from "./noop.ts"; +export * from "./types.ts"; +export * from "./config.ts"; +export * from "./prompt.ts"; +export * from "./extract.ts"; +export * from "./reconcile.ts"; +export * from "./drain.ts"; +export * from "./engine.ts"; +export * from "./acp/collect.ts"; +export * from "./acp/handshake.ts"; +export * from "./acp/runner.ts"; +export { makeAcpRunnerLayer, type AcpRunnerOptions } from "./acp/acpRunnerLive.integration.ts"; diff --git a/pixso-move/processor/src/noop.ts b/pixso-move/processor/src/noop.ts new file mode 100644 index 00000000000..bc2d6f2b72f --- /dev/null +++ b/pixso-move/processor/src/noop.ts @@ -0,0 +1,13 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { Processor } from "./processor.ts"; + +// No-op processor: satisfies the `Processor` seam so the server runs standalone +// before the real engine (Task 4) is wired. Every operation is a harmless void. +export const NoopProcessorLive = Layer.succeed(Processor, { + start: Effect.void, + notify: Effect.void, + stop: Effect.void, + runTickOnce: Effect.void, +}); diff --git a/pixso-move/processor/src/processor.ts b/pixso-move/processor/src/processor.ts new file mode 100644 index 00000000000..75e127a65cf --- /dev/null +++ b/pixso-move/processor/src/processor.ts @@ -0,0 +1,16 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +// The processing engine, as seen by the server. The full implementation lands in +// Task 4; for now the server is wired to a no-op (see ./noop.ts) so it runs +// standalone — `notify` is what the ingest route calls after storing a node. +export interface ProcessorShape { + readonly start: Effect.Effect; + readonly notify: Effect.Effect; + readonly stop: Effect.Effect; + readonly runTickOnce: Effect.Effect; +} + +export class Processor extends Context.Service()( + "pixso-move/Processor", +) {} diff --git a/pixso-move/processor/src/prompt.ts b/pixso-move/processor/src/prompt.ts new file mode 100644 index 00000000000..fe2ac65c455 --- /dev/null +++ b/pixso-move/processor/src/prompt.ts @@ -0,0 +1,20 @@ +export interface PromptInput { + readonly prompt: string; + readonly rootName: string; + readonly nodesJson: string; +} + +// Deterministic prompt builder: the configured instruction, an output rule, then the +// payload (component name + fenced node JSON). Pure — no I/O, no clock, no randomness. +export const buildPrompt = (input: PromptInput): string => + [ + input.prompt.trim(), + "", + "Верни только результат, без пояснений.", + "", + `Компонент: ${input.rootName}`, + "", + "```json", + input.nodesJson, + "```", + ].join("\n"); diff --git a/pixso-move/processor/src/reconcile.ts b/pixso-move/processor/src/reconcile.ts new file mode 100644 index 00000000000..cb88e57482f --- /dev/null +++ b/pixso-move/processor/src/reconcile.ts @@ -0,0 +1,32 @@ +import type { DesignerId, NodeId, ResultTag } from "@pixso-move/contracts"; + +import type { ProcessorConfig, ReconcileRow } from "./types.ts"; + +// Cross each configured designer's nodes with that designer's result tags. Only configured +// designers produce rows. Pure — the engine supplies `nodesByDesigner` via the deps. +export const computeReconcileRows = ( + config: ProcessorConfig, + nodesByDesigner: ReadonlyMap>, +): ReadonlyArray => { + const rows: ReconcileRow[] = []; + for (const entry of config) { + for (const nodeId of nodesByDesigner.get(entry.designerId) ?? []) { + rows.push({ designerId: entry.designerId, nodeId, resultTag: entry.resultTag }); + } + } + return rows; +}; + +// The distinct designer ids referenced by the config (whose nodes the engine fetches). +export const configuredDesignerIds = (config: ProcessorConfig): ReadonlyArray => [ + ...new Set(config.map((entry) => entry.designerId)), +]; + +// The prompt configured for a (designer, tag) pair, or undefined if none matches. +export const resolvePrompt = ( + config: ProcessorConfig, + designerId: DesignerId, + resultTag: ResultTag, +): string | undefined => + config.find((entry) => entry.designerId === designerId && entry.resultTag === resultTag) + ?.prompt; diff --git a/pixso-move/processor/src/types.ts b/pixso-move/processor/src/types.ts new file mode 100644 index 00000000000..d79aa3a4670 --- /dev/null +++ b/pixso-move/processor/src/types.ts @@ -0,0 +1,68 @@ +import type { DesignerId, NodeId, ResultTag } from "@pixso-move/contracts"; +import type * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +// A configured processing rule: a designer whose stored nodes get `prompt` run through +// the LLM, with the output stored under `resultTag`. The designer id / prompt / tag are the +// ONLY hardcoded values (see config.ts); CLI path + auth come from the server config/env. +export interface ConfigEntry { + readonly designerId: DesignerId; + readonly prompt: string; + readonly resultTag: ResultTag; +} +export type ProcessorConfig = ReadonlyArray; + +// A pending job claimed for processing (mirrors the server's ResultStore.ClaimedJob). +export interface ClaimedJob { + readonly id: string; + readonly designerId: DesignerId; + readonly nodeId: NodeId; + readonly resultTag: ResultTag; +} + +// A reconcile row: one (node × configured tag) to ensure a result row exists for. +export interface ReconcileRow { + readonly designerId: DesignerId; + readonly nodeId: NodeId; + readonly resultTag: ResultTag; +} + +// The node payload the processor builds a prompt from. +export interface NodeForProcessing { + readonly nodeId: NodeId; + readonly rootName: string; + readonly nodesJson: string; +} + +// Failure of a single ACP run, mapped from effect-acp's AcpError. Stored as the error text. +export class AcpRunError extends Schema.TaggedErrorClass()("AcpRunError", { + message: Schema.String, +}) {} + +// The narrow ACP seam: run one prompt, get text + stopReason. Isolates all effect-acp detail +// so the engine is testable with a scripted fake. +export interface AcpRunnerShape { + readonly run: (input: { + readonly prompt: string; + }) => Effect.Effect<{ readonly text: string; readonly stopReason: string }, AcpRunError>; +} + +// Everything the engine needs, injected. The server satisfies it at embed time from its +// stores + the ACP runner — there is no SQL and no `@pixso-move/server` import here. +export interface ProcessorDeps { + readonly listNodeIds: (designerId: DesignerId) => Effect.Effect>; + readonly getForProcessing: ( + nodeId: NodeId, + ) => Effect.Effect; + readonly reconcile: (rows: ReadonlyArray) => Effect.Effect; + readonly claimNextPending: Effect.Effect; + readonly complete: (id: string, result: string) => Effect.Effect; + readonly fail: (id: string, error: string) => Effect.Effect; + readonly recoverInFlight: Effect.Effect; + readonly acp: AcpRunnerShape; +} + +export interface ProcessorOptions { + readonly config: ProcessorConfig; + readonly pollIntervalMs?: number; +} diff --git a/pixso-move/processor/tests/collect.test.ts b/pixso-move/processor/tests/collect.test.ts new file mode 100644 index 00000000000..ce3c020a996 --- /dev/null +++ b/pixso-move/processor/tests/collect.test.ts @@ -0,0 +1,34 @@ +import type { SessionNotification } from "effect-acp/schema"; +import { describe, expect, it } from "vitest"; + +import { accumulateDelta } from "../src/acp/collect.ts"; + +const agentText = (text: string): SessionNotification => ({ + sessionId: "s", + update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } }, +}); + +describe("accumulateDelta", () => { + it("appends the text of an agent message chunk", () => { + expect(accumulateDelta("a", agentText("b"))).toBe("ab"); + }); + + it("ignores a non-text agent chunk", () => { + const imageChunk: SessionNotification = { + sessionId: "s", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "image", data: "x", mimeType: "image/png" }, + }, + }; + expect(accumulateDelta("a", imageChunk)).toBe("a"); + }); + + it("ignores a non-agent-message update", () => { + const thought: SessionNotification = { + sessionId: "s", + update: { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "z" } }, + }; + expect(accumulateDelta("a", thought)).toBe("a"); + }); +}); diff --git a/pixso-move/processor/tests/drain.test.ts b/pixso-move/processor/tests/drain.test.ts new file mode 100644 index 00000000000..6ff2d21dd1c --- /dev/null +++ b/pixso-move/processor/tests/drain.test.ts @@ -0,0 +1,70 @@ +import { assert, describe, it } from "@effect/vitest"; +import { DesignerId, NodeId, ResultTag } from "@pixso-move/contracts"; +import * as Effect from "effect/Effect"; + +import { runOneJob } from "../src/drain.ts"; +import type { ClaimedJob } from "../src/types.ts"; +import { addNode, dyingAcp, failingAcp, makeDeps, makeState, scriptedAcp } from "./fakes.ts"; + +const dz = DesignerId.make("dz_a"); +const node = (s: string) => NodeId.make(s); +const tag = ResultTag.make("html"); + +const claim = (depsClaim: Effect.Effect) => + Effect.flatMap(depsClaim, (job) => { + assert.isDefined(job); + return Effect.succeed(job); + }); + +describe("runOneJob", () => { + it.effect("success → done row with the extracted (unwrapped) text", () => + Effect.gen(function* () { + const state = makeState(); + addNode(state, dz, { nodeId: node("n1"), rootName: "Hero", nodesJson: "{}" }); + const deps = makeDeps(state, scriptedAcp("```\n
\n```")); + yield* deps.reconcile([{ designerId: dz, nodeId: node("n1"), resultTag: tag }]); + const job = yield* claim(deps.claimNextPending); + yield* runOneJob(deps, job, "Make HTML"); + assert.equal(state.rows[0]!.status, "done"); + assert.equal(state.rows[0]!.result, "
"); + }), + ); + + it.effect("ACP failure → error row, loop can continue", () => + Effect.gen(function* () { + const state = makeState(); + addNode(state, dz, { nodeId: node("n1"), rootName: "Hero", nodesJson: "{}" }); + const deps = makeDeps(state, failingAcp("model exploded")); + yield* deps.reconcile([{ designerId: dz, nodeId: node("n1"), resultTag: tag }]); + const job = yield* claim(deps.claimNextPending); + yield* runOneJob(deps, job, "Make HTML"); + assert.equal(state.rows[0]!.status, "error"); + assert.match(state.rows[0]!.error ?? "", /model exploded/); + }), + ); + + it.effect("a defect is caught → error row (never escapes)", () => + Effect.gen(function* () { + const state = makeState(); + addNode(state, dz, { nodeId: node("n1"), rootName: "Hero", nodesJson: "{}" }); + const deps = makeDeps(state, dyingAcp("boom")); + yield* deps.reconcile([{ designerId: dz, nodeId: node("n1"), resultTag: tag }]); + const job = yield* claim(deps.claimNextPending); + yield* runOneJob(deps, job, "Make HTML"); + assert.equal(state.rows[0]!.status, "error"); + }), + ); + + it.effect("missing node → fail('node missing')", () => + Effect.gen(function* () { + const state = makeState(); + // Row exists but the node was never registered (getForProcessing → undefined). + const deps = makeDeps(state, scriptedAcp("x")); + yield* deps.reconcile([{ designerId: dz, nodeId: node("ghost"), resultTag: tag }]); + const job = yield* claim(deps.claimNextPending); + yield* runOneJob(deps, job, "Make HTML"); + assert.equal(state.rows[0]!.status, "error"); + assert.equal(state.rows[0]!.error, "node missing"); + }), + ); +}); diff --git a/pixso-move/processor/tests/engine.test.ts b/pixso-move/processor/tests/engine.test.ts new file mode 100644 index 00000000000..1f5dcfcd103 --- /dev/null +++ b/pixso-move/processor/tests/engine.test.ts @@ -0,0 +1,207 @@ +import { assert, describe, it } from "@effect/vitest"; +import { DesignerId, NodeId, ResultTag } from "@pixso-move/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; + +import { makeProcessor } from "../src/engine.ts"; +import type { AcpRunnerShape, ProcessorConfig } from "../src/types.ts"; +import { addNode, makeDeps, makeState, scriptedAcp, type FakeState } from "./fakes.ts"; + +const dz = DesignerId.make("dz_a"); +const html = ResultTag.make("html"); +const summary = ResultTag.make("summary"); +const config: ProcessorConfig = [{ designerId: dz, prompt: "P", resultTag: html }]; +const node = (id: string) => ({ nodeId: NodeId.make(id), rootName: "N", nodesJson: "{}" }); + +const settle = (predicate: () => boolean) => + Effect.gen(function* () { + for (let i = 0; i < 200; i += 1) { + if (predicate()) return; + yield* Effect.yieldNow; + } + }); + +const allDone = (state: FakeState, count: number) => + state.rows.length === count && state.rows.every((r) => r.status === "done"); + +describe("makeProcessor — runTickOnce", () => { + it.effect("reconciles only configured designers and drains them to done", () => + Effect.scoped( + Effect.gen(function* () { + const state = makeState(); + addNode(state, dz, node("n1")); + addNode(state, DesignerId.make("dz_other"), node("nX")); + const processor = yield* makeProcessor(makeDeps(state, scriptedAcp("OUT")), { config }); + yield* processor.runTickOnce; + assert.equal(state.rows.length, 1); + assert.equal(state.rows[0]!.nodeId, "n1"); + assert.equal(state.rows[0]!.status, "done"); + assert.equal(state.rows[0]!.result, "OUT"); + }), + ), + ); + + it.effect("creates one row per configured tag", () => + Effect.scoped( + Effect.gen(function* () { + const state = makeState(); + addNode(state, dz, node("n1")); + const twoTags: ProcessorConfig = [ + { designerId: dz, prompt: "P1", resultTag: html }, + { designerId: dz, prompt: "P2", resultTag: summary }, + ]; + const processor = yield* makeProcessor(makeDeps(state, scriptedAcp("OUT")), { + config: twoTags, + }); + yield* processor.runTickOnce; + assert.isTrue(allDone(state, 2)); + }), + ), + ); + + it.effect("is idempotent across ticks (no duplicate rows)", () => + Effect.scoped( + Effect.gen(function* () { + const state = makeState(); + addNode(state, dz, node("n1")); + const processor = yield* makeProcessor(makeDeps(state, scriptedAcp("OUT")), { config }); + yield* processor.runTickOnce; + yield* processor.runTickOnce; + assert.equal(state.rows.length, 1); + }), + ), + ); + + it.effect("backfills nodes added after an earlier tick", () => + Effect.scoped( + Effect.gen(function* () { + const state = makeState(); + addNode(state, dz, node("n1")); + const processor = yield* makeProcessor(makeDeps(state, scriptedAcp("OUT")), { config }); + yield* processor.runTickOnce; + addNode(state, dz, node("n2")); + yield* processor.runTickOnce; + assert.isTrue(allDone(state, 2)); + }), + ), + ); + + it.effect("fails a pending row that has no configured prompt", () => + Effect.scoped( + Effect.gen(function* () { + const state = makeState(); + state.rows.push({ + id: "r0", + designerId: dz, + nodeId: NodeId.make("n1"), + resultTag: ResultTag.make("other"), + status: "pending", + attempts: 0, + result: null, + error: null, + }); + const processor = yield* makeProcessor(makeDeps(state, scriptedAcp("OUT")), { config }); + yield* processor.runTickOnce; + assert.equal(state.rows[0]!.status, "error"); + assert.equal(state.rows[0]!.error, "no configured prompt"); + }), + ), + ); + + it.effect("contains a tick defect — never throws", () => + Effect.scoped( + Effect.gen(function* () { + const state = makeState(); + const deps = { + ...makeDeps(state, scriptedAcp("OUT")), + listNodeIds: () => Effect.die("kaboom"), + }; + const processor = yield* makeProcessor(deps, { config }); + const proof: Effect.Effect = processor.runTickOnce; + yield* proof; + assert.ok(true); + }), + ), + ); +}); + +describe("makeProcessor — lifecycle", () => { + it.effect("notify drives a tick that processes pending work", () => + Effect.scoped( + Effect.gen(function* () { + const state = makeState(); + addNode(state, dz, node("n1")); + const processor = yield* makeProcessor(makeDeps(state, scriptedAcp("OUT")), { config }); + yield* processor.notify; + yield* settle(() => allDone(state, 1)); + assert.isTrue(allDone(state, 1)); + }), + ), + ); + + it.effect("start recovers interrupted work (processing→pending, attempts kept)", () => + Effect.scoped( + Effect.gen(function* () { + const state = makeState(); + state.rows.push({ + id: "r0", + designerId: dz, + nodeId: NodeId.make("n1"), + resultTag: html, + status: "processing", + attempts: 1, + result: null, + error: null, + }); + const processor = yield* makeProcessor(makeDeps(state, scriptedAcp("OUT")), { config }); + yield* processor.start; + assert.equal(state.rows[0]!.status, "pending"); + assert.equal(state.rows[0]!.attempts, 1); + }), + ), + ); + + it.effect("stop is safe before start and interrupts the timer after", () => + Effect.scoped( + Effect.gen(function* () { + const state = makeState(); + const processor = yield* makeProcessor(makeDeps(state, scriptedAcp("OUT")), { config }); + yield* processor.stop; + yield* processor.start; + yield* processor.stop; + assert.ok(true); + }), + ), + ); + + it.effect("serializes ticks: a re-run picks up work added mid-tick", () => + Effect.scoped( + Effect.gen(function* () { + const state = makeState(); + addNode(state, dz, node("n1")); + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + let first = true; + const acp: AcpRunnerShape = { + run: () => + Effect.gen(function* () { + if (first) { + first = false; + yield* Deferred.succeed(entered, undefined); + yield* Deferred.await(release); + } + return { text: "OUT", stopReason: "end_turn" }; + }), + }; + const processor = yield* makeProcessor(makeDeps(state, acp), { config }); + yield* processor.notify; + yield* Deferred.await(entered); + yield* processor.notify; + addNode(state, dz, node("n2")); + yield* Deferred.succeed(release, undefined); + yield* settle(() => allDone(state, 2)); + assert.isTrue(allDone(state, 2)); + }), + ), + ); +}); diff --git a/pixso-move/processor/tests/extract.test.ts b/pixso-move/processor/tests/extract.test.ts new file mode 100644 index 00000000000..0ce616f6aac --- /dev/null +++ b/pixso-move/processor/tests/extract.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { extractText } from "../src/extract.ts"; + +describe("extractText", () => { + it("unwraps a single fenced block", () => { + expect(extractText("```\n
\n```").text).toBe("
"); + }); + + it("unwraps a fenced block with a language tag", () => { + expect(extractText("```html\n

hi

\n```").text).toBe("

hi

"); + }); + + it("unwraps a fence with no trailing newline before the closing fence", () => { + expect(extractText("```html\n

hi

```").text).toBe("

hi

"); + }); + + it("returns the trimmed whole when there is no single fence", () => { + expect(extractText(" just text ").text).toBe("just text"); + }); + + it("returns the whole when there are multiple fences (not a single block)", () => { + const raw = "```\na\n```\n```\nb\n```"; + expect(extractText(raw).text).toBe(raw); + }); +}); diff --git a/pixso-move/processor/tests/fakes.ts b/pixso-move/processor/tests/fakes.ts new file mode 100644 index 00000000000..6ad24ff8cc3 --- /dev/null +++ b/pixso-move/processor/tests/fakes.ts @@ -0,0 +1,129 @@ +import type { DesignerId, NodeId, ResultTag } from "@pixso-move/contracts"; +import * as Effect from "effect/Effect"; + +import { + AcpRunError, + type AcpRunnerShape, + type ClaimedJob, + type NodeForProcessing, + type ProcessorDeps, + type ReconcileRow, +} from "../src/types.ts"; + +export interface FakeResultRow { + id: string; + designerId: DesignerId; + nodeId: NodeId; + resultTag: ResultTag; + status: "pending" | "processing" | "done" | "error"; + attempts: number; + result: string | null; + error: string | null; +} + +export interface FakeState { + readonly nodeIdsByDesigner: Map; + readonly nodes: Map; + readonly rows: FakeResultRow[]; + seq: number; +} + +export const makeState = (): FakeState => ({ + nodeIdsByDesigner: new Map(), + nodes: new Map(), + rows: [], + seq: 0, +}); + +export const addNode = ( + state: FakeState, + designerId: DesignerId, + node: NodeForProcessing, +): void => { + const list = state.nodeIdsByDesigner.get(designerId) ?? []; + list.push(node.nodeId); + state.nodeIdsByDesigner.set(designerId, list); + state.nodes.set(node.nodeId, node); +}; + +// In-memory ProcessorDeps mirroring the server stores' observable behaviour (claim bumps +// attempts; reconcile is idempotent on node+tag; recovery flips processing→pending). +export const makeDeps = (state: FakeState, acp: AcpRunnerShape): ProcessorDeps => ({ + listNodeIds: (designerId) => + Effect.sync(() => state.nodeIdsByDesigner.get(designerId) ?? []), + getForProcessing: (nodeId) => Effect.sync(() => state.nodes.get(nodeId)), + reconcile: (rows: ReadonlyArray) => + Effect.sync(() => { + let inserted = 0; + for (const row of rows) { + const exists = state.rows.some( + (r) => r.nodeId === row.nodeId && r.resultTag === row.resultTag, + ); + if (exists) continue; + state.rows.push({ + id: `r${state.seq++}`, + designerId: row.designerId, + nodeId: row.nodeId, + resultTag: row.resultTag, + status: "pending", + attempts: 0, + result: null, + error: null, + }); + inserted += 1; + } + return inserted; + }), + claimNextPending: Effect.sync(() => { + const row = state.rows.find((r) => r.status === "pending"); + if (row === undefined) return undefined; + row.status = "processing"; + row.attempts += 1; + return { + id: row.id, + designerId: row.designerId, + nodeId: row.nodeId, + resultTag: row.resultTag, + } satisfies ClaimedJob; + }), + complete: (id, result) => + Effect.sync(() => { + const row = state.rows.find((r) => r.id === id); + if (row) { + row.status = "done"; + row.result = result; + row.error = null; + } + }), + fail: (id, error) => + Effect.sync(() => { + const row = state.rows.find((r) => r.id === id); + if (row) { + row.status = "error"; + row.error = error; + } + }), + recoverInFlight: Effect.sync(() => { + let count = 0; + for (const row of state.rows) { + if (row.status === "processing") { + row.status = "pending"; + count += 1; + } + } + return count; + }), + acp, +}); + +export const scriptedAcp = (text: string, stopReason = "end_turn"): AcpRunnerShape => ({ + run: () => Effect.succeed({ text, stopReason }), +}); + +export const failingAcp = (message: string): AcpRunnerShape => ({ + run: () => Effect.fail(new AcpRunError({ message })), +}); + +export const dyingAcp = (defect: string): AcpRunnerShape => ({ + run: () => Effect.die(defect), +}); diff --git a/pixso-move/processor/tests/handshake.test.ts b/pixso-move/processor/tests/handshake.test.ts new file mode 100644 index 00000000000..938bc498aba --- /dev/null +++ b/pixso-move/processor/tests/handshake.test.ts @@ -0,0 +1,50 @@ +import { AcpProtocolParseError } from "effect-acp/errors"; +import type { PromptResponse } from "effect-acp/schema"; +import { describe, expect, it } from "vitest"; + +import { + authenticateParams, + initializeParams, + mapAcpError, + mapStopReason, + newSessionParams, + promptBlocks, +} from "../src/acp/handshake.ts"; +import { AcpRunError } from "../src/types.ts"; + +describe("handshake builders", () => { + it("initializeParams advertises no fs/terminal and protocol v1", () => { + const params = initializeParams(); + expect(params.protocolVersion).toBe(1); + expect(params.clientCapabilities).toEqual({ + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }); + expect(params.clientInfo?.name).toBe("pixso-move"); + }); + + it("authenticateParams carries the method id", () => { + expect(authenticateParams("openai")).toEqual({ methodId: "openai" }); + }); + + it("newSessionParams carries cwd and no MCP servers", () => { + expect(newSessionParams("/work")).toEqual({ cwd: "/work", mcpServers: [] }); + }); + + it("promptBlocks wraps the prompt in a single text block", () => { + expect(promptBlocks("hi")).toEqual([{ type: "text", text: "hi" }]); + }); +}); + +describe("response mappers", () => { + it("mapStopReason returns the stop reason", () => { + const response = { stopReason: "end_turn" } as PromptResponse; + expect(mapStopReason(response)).toBe("end_turn"); + }); + + it("mapAcpError builds an AcpRunError mentioning the tag", () => { + const mapped = mapAcpError(new AcpProtocolParseError({ detail: "bad" })); + expect(mapped).toBeInstanceOf(AcpRunError); + expect(mapped.message).toContain("AcpProtocolParseError"); + }); +}); diff --git a/pixso-move/processor/tests/noop.test.ts b/pixso-move/processor/tests/noop.test.ts new file mode 100644 index 00000000000..3054f7cd22b --- /dev/null +++ b/pixso-move/processor/tests/noop.test.ts @@ -0,0 +1,15 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { NoopProcessorLive, Processor } from "../src/index.ts"; + +it.effect("no-op processor runs every operation without effect", () => + Effect.gen(function* () { + const processor = yield* Processor; + yield* processor.start; + yield* processor.notify; + yield* processor.runTickOnce; + yield* processor.stop; + assert.ok(true); + }).pipe(Effect.provide(NoopProcessorLive)), +); diff --git a/pixso-move/processor/tests/prompt.test.ts b/pixso-move/processor/tests/prompt.test.ts new file mode 100644 index 00000000000..fac97139ebf --- /dev/null +++ b/pixso-move/processor/tests/prompt.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { buildPrompt } from "../src/prompt.ts"; + +describe("buildPrompt", () => { + it("includes the trimmed instruction, the output rule, the name, and fenced JSON", () => { + const out = buildPrompt({ + prompt: " Make HTML ", + rootName: "Hero", + nodesJson: '{"id":"1"}', + }); + expect(out).toContain("Make HTML"); + expect(out).not.toContain(" Make HTML "); + expect(out).toContain("Верни только результат, без пояснений."); + expect(out).toContain("Компонент: Hero"); + expect(out).toContain("```json\n{\"id\":\"1\"}\n```"); + }); + + it("is deterministic", () => { + const input = { prompt: "p", rootName: "n", nodesJson: "{}" }; + expect(buildPrompt(input)).toBe(buildPrompt(input)); + }); +}); diff --git a/pixso-move/processor/tests/reconcile.test.ts b/pixso-move/processor/tests/reconcile.test.ts new file mode 100644 index 00000000000..64b805380f0 --- /dev/null +++ b/pixso-move/processor/tests/reconcile.test.ts @@ -0,0 +1,55 @@ +import { DesignerId, NodeId, ResultTag } from "@pixso-move/contracts"; +import { describe, expect, it } from "vitest"; + +import { + computeReconcileRows, + configuredDesignerIds, + resolvePrompt, +} from "../src/reconcile.ts"; +import type { ProcessorConfig } from "../src/types.ts"; + +const dz = (s: string) => DesignerId.make(s); +const node = (s: string) => NodeId.make(s); +const tag = (s: string) => ResultTag.make(s); + +const config: ProcessorConfig = [ + { designerId: dz("dz_a"), prompt: "P1", resultTag: tag("html") }, + { designerId: dz("dz_a"), prompt: "P2", resultTag: tag("summary") }, + { designerId: dz("dz_b"), prompt: "P3", resultTag: tag("html") }, +]; + +describe("computeReconcileRows", () => { + it("crosses each configured designer's nodes with that designer's tags", () => { + const rows = computeReconcileRows( + config, + new Map([ + [dz("dz_a"), [node("n1"), node("n2")]], + [dz("dz_b"), [node("n3")]], + ]), + ); + // dz_a: 2 nodes × 2 tags = 4; dz_b: 1 node × 1 tag = 1. + expect(rows).toHaveLength(5); + expect(rows.filter((r) => r.resultTag === "summary")).toHaveLength(2); + expect(rows.every((r) => r.designerId === "dz_a" || r.designerId === "dz_b")).toBe(true); + }); + + it("produces no rows for a configured designer with no nodes", () => { + expect(computeReconcileRows(config, new Map())).toHaveLength(0); + }); +}); + +describe("configuredDesignerIds", () => { + it("returns distinct designer ids", () => { + expect(configuredDesignerIds(config)).toEqual([dz("dz_a"), dz("dz_b")]); + }); +}); + +describe("resolvePrompt", () => { + it("finds the prompt for a designer+tag pair", () => { + expect(resolvePrompt(config, dz("dz_a"), tag("summary"))).toBe("P2"); + }); + + it("returns undefined when nothing matches", () => { + expect(resolvePrompt(config, dz("dz_a"), tag("missing"))).toBeUndefined(); + }); +}); diff --git a/pixso-move/processor/tsconfig.json b/pixso-move/processor/tsconfig.json new file mode 100644 index 00000000000..35d9475d0f6 --- /dev/null +++ b/pixso-move/processor/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["src", "tests"] +} diff --git a/pixso-move/processor/vitest.config.ts b/pixso-move/processor/vitest.config.ts new file mode 100644 index 00000000000..857222dfdd7 --- /dev/null +++ b/pixso-move/processor/vitest.config.ts @@ -0,0 +1,3 @@ +import { makeVitestConfig } from "../vitest.base.ts"; + +export default makeVitestConfig(import.meta.dirname); diff --git a/pixso-move/server/package.json b/pixso-move/server/package.json new file mode 100644 index 00000000000..baf09fddd99 --- /dev/null +++ b/pixso-move/server/package.json @@ -0,0 +1,28 @@ +{ + "name": "@pixso-move/server", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { "pixso-move-server": "./dist/bin.mjs" }, + "scripts": { + "dev": "node --watch src/bin.ts start", + "build": "tsdown", + "start": "node dist/bin.mjs start", + "typecheck": "tsc --noEmit", + "test": "vitest run --coverage", + "test:fast": "vitest run" + }, + "dependencies": { + "@pixso-move/contracts": "workspace:*", + "@pixso-move/processor": "workspace:*", + "effect": "catalog:", + "@effect/platform-node": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "vitest": "catalog:", + "@vitest/coverage-v8": "catalog:", + "tsdown": "catalog:" + } +} diff --git a/pixso-move/server/src/bin.ts b/pixso-move/server/src/bin.ts new file mode 100644 index 00000000000..24a0e0007a7 --- /dev/null +++ b/pixso-move/server/src/bin.ts @@ -0,0 +1,44 @@ +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { makeAcpRunnerLayer } from "@pixso-move/processor"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { resolveServerConfig, ServerConfig } from "./config.ts"; +import { runServer } from "./server.ts"; + +const flag = (name: string): string | undefined => { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +}; + +const host = flag("--host"); +const port = flag("--port"); +const db = flag("--db"); +const cliJs = flag("--cli-js"); +const cliHome = flag("--cli-home"); +const cwd = flag("--cwd"); + +const config = resolveServerConfig({ + ...(host ? { host } : {}), + ...(port ? { port: Number(port) } : {}), + ...(db ? { dbPath: db } : {}), + ...(cliJs ? { cliJs } : {}), + ...(cliHome ? { cliHome } : {}), + ...(cwd ? { acpCwd: cwd } : {}), + ...(process.argv.includes("--no-ssl") ? { acpNoSsl: true } : {}), +}); + +// The real qwen ACP runner, built from the resolved CLI config and given a Node spawner. +const acpRunnerLive = makeAcpRunnerLayer({ + cliJs: config.cliJs, + cwd: config.acpCwd, + authMethodId: config.authMethodId, + noSsl: config.acpNoSsl, + ...(config.cliHome ? { cliHome: config.cliHome } : {}), +}).pipe(Layer.provide(NodeServices.layer)); + +runServer.pipe( + Effect.provide(Layer.mergeAll(ServerConfig.layer(config), acpRunnerLive)), + NodeRuntime.runMain, +); diff --git a/pixso-move/server/src/config.ts b/pixso-move/server/src/config.ts new file mode 100644 index 00000000000..76fc2389967 --- /dev/null +++ b/pixso-move/server/src/config.ts @@ -0,0 +1,55 @@ +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; +import * as LogLevel from "effect/LogLevel"; + +export interface ServerConfigShape { + readonly host: string; + readonly port: number; + readonly dbPath: string; + readonly logLevel: LogLevel.LogLevel; + // ACP / qwen wiring for the processor. The CLI path, home, and auth method are resolved + // here (from CLI flags / env), never hardcoded in the processor. `cliJs === ""` means no + // qwen is configured: jobs still run but fail gracefully into `error` rows. + readonly cliJs: string; + readonly cliHome: string | undefined; + readonly authMethodId: string; + readonly acpCwd: string; + readonly acpNoSsl: boolean; +} + +export const DEFAULT_HOST = "127.0.0.1"; +export const DEFAULT_PORT = 7787; +export const DEFAULT_DB_PATH = "./.data/pixso.sqlite"; +export const DEFAULT_AUTH_METHOD_ID = "openai"; + +const defaults: ServerConfigShape = { + host: DEFAULT_HOST, + port: DEFAULT_PORT, + dbPath: DEFAULT_DB_PATH, + logLevel: "Debug", + cliJs: "", + cliHome: undefined, + authMethodId: DEFAULT_AUTH_METHOD_ID, + acpCwd: ".", + acpNoSsl: false, +}; + +export class ServerConfig extends Context.Service()( + "pixso-move/ServerConfig", +) { + // Production layer built from resolved options. + static readonly layer = (shape: ServerConfigShape): Layer.Layer => + Layer.succeed(ServerConfig, shape); + + // Test layer: defaults with an in-memory DB, overridable per test. + static readonly layerTest = ( + overrides: Partial = {}, + ): Layer.Layer => + Layer.succeed(ServerConfig, { ...defaults, dbPath: ":memory:", ...overrides }); +} + +// Merge CLI/env options over defaults (used by bin.ts). +export const resolveServerConfig = (overrides: Partial): ServerConfigShape => ({ + ...defaults, + ...overrides, +}); diff --git a/pixso-move/server/src/http/auth.ts b/pixso-move/server/src/http/auth.ts new file mode 100644 index 00000000000..1cbfaefc2a7 --- /dev/null +++ b/pixso-move/server/src/http/auth.ts @@ -0,0 +1,15 @@ +import { AuthError, DesignerId } from "@pixso-move/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpServerRequest } from "effect/unstable/http"; + +const decodeDesignerId = Schema.decodeUnknownEffect(DesignerId); + +// Read and validate the `x-designer-id` header. Missing/blank/invalid → 401. +export const requireDesignerId = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const raw = request.headers["x-designer-id"]; + return yield* decodeDesignerId(raw).pipe( + Effect.mapError(() => new AuthError({ message: "Missing or invalid designer key.", status: 401 })), + ); +}); diff --git a/pixso-move/server/src/http/cors.ts b/pixso-move/server/src/http/cors.ts new file mode 100644 index 00000000000..23f2636d005 --- /dev/null +++ b/pixso-move/server/src/http/cors.ts @@ -0,0 +1,12 @@ +import { HttpRouter } from "effect/unstable/http"; + +// Open CORS so the cross-origin Pixso plugin can post; `x-designer-id` is the +// auth header. The middleware also answers OPTIONS preflight automatically. +export const corsAllowedMethods = ["GET", "POST", "OPTIONS"] as const; +export const corsAllowedHeaders = ["content-type", "x-designer-id"] as const; + +export const corsLayer = HttpRouter.cors({ + allowedMethods: [...corsAllowedMethods], + allowedHeaders: [...corsAllowedHeaders], + maxAge: 600, +}); diff --git a/pixso-move/server/src/http/ingest.ts b/pixso-move/server/src/http/ingest.ts new file mode 100644 index 00000000000..52c4a581178 --- /dev/null +++ b/pixso-move/server/src/http/ingest.ts @@ -0,0 +1,44 @@ +import { AuthError, IngestError, IngestRequest } from "@pixso-move/contracts"; +import { Processor } from "@pixso-move/processor"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http"; + +import { NodeStore } from "../services/nodeStore.ts"; +import { requireDesignerId } from "./auth.ts"; +import { respondJson } from "./respond.ts"; +import { route } from "./route.ts"; + +// POST /ingest — validate, store the node, nudge the processor, return { nodeId }. +export const ingestRoute = route( + "POST", + "/ingest", + Effect.gen(function* () { + const designerId = yield* requireDesignerId; + const body = yield* HttpServerRequest.schemaBodyJson(IngestRequest).pipe( + Effect.mapError(() => new IngestError({ message: "Invalid ingest payload.", status: 400 })), + ); + if (body.designerId !== designerId) { + return yield* new AuthError({ + message: "Designer key does not match the payload.", + status: 401, + }); + } + const nodes = yield* NodeStore; + const { nodeId } = yield* nodes.insert({ + designerId, + rootName: body.rootName, + nodesJson: body.nodesJson, + preview: body.preview, + }); + // Fire-and-forget pickup. `notify` is non-blocking; even so, contain any failure here so + // a processor hiccup can never turn a stored node into a failed HTTP response. + yield* (yield* Processor).notify.pipe( + Effect.catchCause((cause) => + Effect.logError("ingest notify failed", { nodeId, cause: Cause.pretty(cause) }), + ), + ); + yield* Effect.logDebug("ingest stored", { nodeId, designerId }); + return respondJson({ nodeId }, 200); + }), +); diff --git a/pixso-move/server/src/http/nodes.ts b/pixso-move/server/src/http/nodes.ts new file mode 100644 index 00000000000..53774237033 --- /dev/null +++ b/pixso-move/server/src/http/nodes.ts @@ -0,0 +1,41 @@ +import { IngestError, NodeId, NodeNotFoundError } from "@pixso-move/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpServerRequest } from "effect/unstable/http"; + +import { NodeStore } from "../services/nodeStore.ts"; +import { requireDesignerId } from "./auth.ts"; +import { queryParam } from "./query.ts"; +import { respondJson } from "./respond.ts"; +import { route } from "./route.ts"; + +const decodeNodeId = Schema.decodeUnknownEffect(NodeId); + +// GET /nodes — lightweight summaries for the authenticated designer. +export const listNodesRoute = route( + "GET", + "/nodes", + Effect.gen(function* () { + const designerId = yield* requireDesignerId; + const summaries = yield* (yield* NodeStore).listSummaries(designerId); + return respondJson(summaries, 200); + }), +); + +// GET /node?id=… — full record, scoped to the designer's key (404 otherwise). +export const getNodeRoute = route( + "GET", + "/node", + Effect.gen(function* () { + const designerId = yield* requireDesignerId; + const request = yield* HttpServerRequest.HttpServerRequest; + const nodeId = yield* decodeNodeId(queryParam(request, "id")).pipe( + Effect.mapError(() => new IngestError({ message: "Missing or invalid id.", status: 400 })), + ); + const record = yield* (yield* NodeStore).getById(designerId, nodeId); + if (record === undefined) { + return yield* new NodeNotFoundError({ message: "Node not found.", status: 404 }); + } + return respondJson(record, 200); + }), +); diff --git a/pixso-move/server/src/http/processing.ts b/pixso-move/server/src/http/processing.ts new file mode 100644 index 00000000000..e97b8657859 --- /dev/null +++ b/pixso-move/server/src/http/processing.ts @@ -0,0 +1,34 @@ +import { IngestError, NodeId, NodeNotFoundError } from "@pixso-move/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpServerRequest } from "effect/unstable/http"; + +import { NodeStore } from "../services/nodeStore.ts"; +import { ResultStore } from "../services/resultStore.ts"; +import { requireDesignerId } from "./auth.ts"; +import { queryParam } from "./query.ts"; +import { respondJson } from "./respond.ts"; +import { route } from "./route.ts"; + +const decodeNodeId = Schema.decodeUnknownEffect(NodeId); + +// GET /processing-data?nodeId=… — all result rows (every tag/status) for a node. +export const processingDataRoute = route( + "GET", + "/processing-data", + Effect.gen(function* () { + const designerId = yield* requireDesignerId; + const request = yield* HttpServerRequest.HttpServerRequest; + const nodeId = yield* decodeNodeId(queryParam(request, "nodeId")).pipe( + Effect.mapError( + () => new IngestError({ message: "Missing or invalid nodeId.", status: 400 }), + ), + ); + const record = yield* (yield* NodeStore).getById(designerId, nodeId); + if (record === undefined) { + return yield* new NodeNotFoundError({ message: "Node not found.", status: 404 }); + } + const results = yield* (yield* ResultStore).listByNode(designerId, nodeId); + return respondJson(results, 200); + }), +); diff --git a/pixso-move/server/src/http/query.ts b/pixso-move/server/src/http/query.ts new file mode 100644 index 00000000000..afd4269d0db --- /dev/null +++ b/pixso-move/server/src/http/query.ts @@ -0,0 +1,13 @@ +import type { HttpServerRequest } from "effect/unstable/http"; + +// Read a query-string parameter from the request URL. Returns null when absent. +// (Parses request.url directly — no Option dance — so both branches are testable.) +export const queryParam = ( + request: HttpServerRequest.HttpServerRequest, + key: string, +): string | null => { + const url = request.url; + const queryStart = url.indexOf("?"); + const queryString = queryStart >= 0 ? url.slice(queryStart + 1) : ""; + return new URLSearchParams(queryString).get(key); +}; diff --git a/pixso-move/server/src/http/respond.ts b/pixso-move/server/src/http/respond.ts new file mode 100644 index 00000000000..1cb9c43c64c --- /dev/null +++ b/pixso-move/server/src/http/respond.ts @@ -0,0 +1,12 @@ +import type { AuthError, IngestError, NodeNotFoundError } from "@pixso-move/contracts"; +import { HttpServerResponse } from "effect/unstable/http"; + +export type KnownError = AuthError | IngestError | NodeNotFoundError; + +// The single JSON response + error-mapping helpers. CORS headers are added by +// the cors middleware (see ./cors.ts), so responses only set status + body. +export const respondJson = (body: unknown, status: number): HttpServerResponse.HttpServerResponse => + HttpServerResponse.jsonUnsafe(body, { status }); + +export const respondError = (error: KnownError): HttpServerResponse.HttpServerResponse => + respondJson({ error: error.message }, error.status); diff --git a/pixso-move/server/src/http/route.ts b/pixso-move/server/src/http/route.ts new file mode 100644 index 00000000000..d17084dc565 --- /dev/null +++ b/pixso-move/server/src/http/route.ts @@ -0,0 +1,30 @@ +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import { HttpRouter, type HttpServerResponse } from "effect/unstable/http"; + +import { respondError, respondJson, type KnownError } from "./respond.ts"; + +type Method = "GET" | "POST"; + +const onKnown = (error: KnownError) => Effect.succeed(respondError(error)); + +// The single route wrapper: known errors → their JSON+status; any remaining +// defect → logged + 500. No handler can throw into the server (never crashes). +export const route = ( + method: Method, + path: HttpRouter.PathInput, + handler: Effect.Effect, +) => + HttpRouter.add( + method, + path, + handler.pipe( + Effect.catchTags({ AuthError: onKnown, IngestError: onKnown, NodeNotFoundError: onKnown }), + Effect.catchCause((cause) => + Effect.as( + Effect.logError("route defect", { path, cause: Cause.pretty(cause) }), + respondJson({ error: "Internal error." }, 500), + ), + ), + ), + ); diff --git a/pixso-move/server/src/http/routes.ts b/pixso-move/server/src/http/routes.ts new file mode 100644 index 00000000000..b965c9fce3a --- /dev/null +++ b/pixso-move/server/src/http/routes.ts @@ -0,0 +1,13 @@ +import * as Layer from "effect/Layer"; + +import { ingestRoute } from "./ingest.ts"; +import { getNodeRoute, listNodesRoute } from "./nodes.ts"; +import { processingDataRoute } from "./processing.ts"; + +// All routes merged. CORS middleware is provided alongside in server.ts. +export const routesLayer = Layer.mergeAll( + ingestRoute, + listNodesRoute, + getNodeRoute, + processingDataRoute, +); diff --git a/pixso-move/server/src/httpServer.ts b/pixso-move/server/src/httpServer.ts new file mode 100644 index 00000000000..9d8b8afca12 --- /dev/null +++ b/pixso-move/server/src/httpServer.ts @@ -0,0 +1,17 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { ServerConfig } from "./config.ts"; + +// The Node HTTP listener, bound to the configured host/port. Mirrors +// apps/server/src/server.ts:100-112 (dynamic import to keep it node-only). +export const HttpServerLive = Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig; + const [NodeHttpServer, NodeHttp] = yield* Effect.all([ + Effect.promise(() => import("@effect/platform-node/NodeHttpServer")), + Effect.promise(() => import("node:http")), + ]); + return NodeHttpServer.layer(NodeHttp.createServer, { host: config.host, port: config.port }); + }), +); diff --git a/pixso-move/server/src/persistence/migrate.ts b/pixso-move/server/src/persistence/migrate.ts new file mode 100644 index 00000000000..752b2b7a969 --- /dev/null +++ b/pixso-move/server/src/persistence/migrate.ts @@ -0,0 +1,13 @@ +import * as Effect from "effect/Effect"; + +import m001 from "./migrations/001_nodes.ts"; +import m002 from "./migrations/002_results.ts"; + +// Migrations are idempotent DDL (CREATE … IF NOT EXISTS), so we run the ordered +// list every startup — no Migrator registry needed for two tables. +export const runMigrations = Effect.gen(function* () { + for (const step of [m001, m002]) { + yield* step; + } + yield* Effect.logDebug("migrations applied", { count: 2 }); +}); diff --git a/pixso-move/server/src/persistence/migrations/001_nodes.ts b/pixso-move/server/src/persistence/migrations/001_nodes.ts new file mode 100644 index 00000000000..8728f280aa9 --- /dev/null +++ b/pixso-move/server/src/persistence/migrations/001_nodes.ts @@ -0,0 +1,18 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +// `nodes` — immutable ingestion records. Append-only; never mutated. +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS nodes ( + id TEXT PRIMARY KEY, + designer_id TEXT NOT NULL, + root_name TEXT NOT NULL, + nodes_json TEXT NOT NULL, + preview TEXT NOT NULL, + added_at TEXT NOT NULL + ) + `; + yield* sql`CREATE INDEX IF NOT EXISTS idx_nodes_designer ON nodes(designer_id, added_at)`; +}); diff --git a/pixso-move/server/src/persistence/migrations/002_results.ts b/pixso-move/server/src/persistence/migrations/002_results.ts new file mode 100644 index 00000000000..0985638ae54 --- /dev/null +++ b/pixso-move/server/src/persistence/migrations/002_results.ts @@ -0,0 +1,29 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +// `processing_results` — the job ledger AND the output. One row per (node × tag). +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS processing_results ( + id TEXT PRIMARY KEY, + designer_id TEXT NOT NULL, + node_id TEXT NOT NULL, + result_tag TEXT NOT NULL, + status TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + result TEXT, + error TEXT, + created_at TEXT NOT NULL, + started_at TEXT, + finished_at TEXT, + UNIQUE (node_id, result_tag), + FOREIGN KEY (node_id) REFERENCES nodes(id) + ) + `; + yield* sql`CREATE INDEX IF NOT EXISTS idx_results_status ON processing_results(status)`; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_results_designer_node + ON processing_results(designer_id, node_id) + `; +}); diff --git a/pixso-move/server/src/persistence/sqlite.ts b/pixso-move/server/src/persistence/sqlite.ts new file mode 100644 index 00000000000..8bb228b3b1a --- /dev/null +++ b/pixso-move/server/src/persistence/sqlite.ts @@ -0,0 +1,37 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { ServerConfig } from "../config.ts"; +import * as NodeSqliteClient from "../vendor/NodeSqliteClient.ts"; +import { runMigrations } from "./migrate.ts"; + +// PRAGMA + migrations, applied on top of the raw SqlClient layer. +const setup = Layer.effectDiscard( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`PRAGMA journal_mode = WAL;`; + yield* sql`PRAGMA foreign_keys = ON;`; + yield* runMigrations; + }), +); + +// In-memory persistence (tests). +export const SqlitePersistenceMemory = Layer.provideMerge(setup, NodeSqliteClient.layerMemory()); + +// File-backed persistence (production); creates the parent dir. +export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")(function* ( + dbPath: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true }); + return Layer.provideMerge(setup, NodeSqliteClient.layer({ filename: dbPath })); +}, Layer.unwrap); + +// Production persistence resolved from ServerConfig.dbPath. +export const persistenceLive = Layer.unwrap( + Effect.map(Effect.service(ServerConfig), (config) => makeSqlitePersistenceLive(config.dbPath)), +); diff --git a/pixso-move/server/src/server.ts b/pixso-move/server/src/server.ts new file mode 100644 index 00000000000..4971e6883de --- /dev/null +++ b/pixso-move/server/src/server.ts @@ -0,0 +1,37 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; +import { HttpRouter } from "effect/unstable/http"; + +import { corsLayer } from "./http/cors.ts"; +import { routesLayer } from "./http/routes.ts"; +import { HttpServerLive } from "./httpServer.ts"; +import { persistenceLive } from "./persistence/sqlite.ts"; +import { ServerLoggerLive } from "./serverLogger.ts"; +import { NodeStoreLive } from "./services/nodeStoreLive.ts"; +import { ProcessorLive } from "./services/processorLive.ts"; +import { ResultStoreLive } from "./services/resultStoreLive.ts"; + +// Routes + CORS middleware. +const appLayer = routesLayer.pipe(Layer.provide(corsLayer)); + +// Stores over the shared sqlite connection. +const storesLive = Layer.mergeAll(NodeStoreLive, ResultStoreLive).pipe( + Layer.provideMerge(persistenceLive), +); + +// The processor, built on the stores (provideMerge keeps NodeStore/ResultStore in the output +// context so the route handlers resolve them too). The AcpRunner it needs is provided from +// outside — the real qwen runner in bin.ts, a fake in tests. +const servicesLive = ProcessorLive.pipe(Layer.provideMerge(storesLive)); + +// The full server: routes served over the HTTP listener, with persistence, stores, the +// embedded processor, and the logger wired in. Still requires ServerConfig (bin.ts / tests) +// and AcpRunner (bin.ts real / tests fake). +export const makeServerLayer = HttpRouter.serve(appLayer).pipe( + Layer.provideMerge(servicesLive), + Layer.provide(HttpServerLive), + Layer.provide(ServerLoggerLive), + Layer.provide(NodeServices.layer), +); + +export const runServer = Layer.launch(makeServerLayer); diff --git a/pixso-move/server/src/serverLogger.ts b/pixso-move/server/src/serverLogger.ts new file mode 100644 index 00000000000..44da36496f2 --- /dev/null +++ b/pixso-move/server/src/serverLogger.ts @@ -0,0 +1,15 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as References from "effect/References"; + +import { ServerConfig } from "./config.ts"; + +// pixso-move: ported from apps/server/src/serverLogger.ts. Pretty console logger +// with the configured minimum level. Convention: logError / logDebug only. +export const ServerLoggerLive = Effect.gen(function* () { + const config = yield* ServerConfig; + const minimumLogLevelLayer = Layer.succeed(References.MinimumLogLevel, config.logLevel); + const loggerLayer = Logger.layer([Logger.consolePretty()], { mergeWithExisting: false }); + return Layer.mergeAll(loggerLayer, minimumLogLevelLayer); +}).pipe(Layer.unwrap); diff --git a/pixso-move/server/src/services/nodeStore.ts b/pixso-move/server/src/services/nodeStore.ts new file mode 100644 index 00000000000..aa7c7875dd9 --- /dev/null +++ b/pixso-move/server/src/services/nodeStore.ts @@ -0,0 +1,33 @@ +import type { DesignerId, NodeId, NodeRecord, NodeSummary } from "@pixso-move/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface NodeInsert { + readonly designerId: DesignerId; + readonly rootName: string; + readonly nodesJson: string; + readonly preview: string; +} + +export interface NodeForProcessing { + readonly nodeId: NodeId; + readonly rootName: string; + readonly nodesJson: string; +} + +// All methods orDie on DB errors (a SQL failure is a defect → HTTP 500), so the +// shape stays clean (no error/requirement channels leak to callers). +export interface NodeStoreShape { + readonly insert: (input: NodeInsert) => Effect.Effect<{ readonly nodeId: NodeId }>; + readonly listSummaries: (designerId: DesignerId) => Effect.Effect>; + readonly getById: ( + designerId: DesignerId, + nodeId: NodeId, + ) => Effect.Effect; + readonly listNodeIds: (designerId: DesignerId) => Effect.Effect>; + readonly getForProcessing: (nodeId: NodeId) => Effect.Effect; +} + +export class NodeStore extends Context.Service()( + "pixso-move/NodeStore", +) {} diff --git a/pixso-move/server/src/services/nodeStoreLive.ts b/pixso-move/server/src/services/nodeStoreLive.ts new file mode 100644 index 00000000000..4bad0bcb7a2 --- /dev/null +++ b/pixso-move/server/src/services/nodeStoreLive.ts @@ -0,0 +1,94 @@ +import type { DesignerId, NodeRecord, NodeSummary } from "@pixso-move/contracts"; +import { NodeId } from "@pixso-move/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { nowIso } from "../time.ts"; +import { NodeStore, type NodeForProcessing, type NodeInsert } from "./nodeStore.ts"; + +interface SummaryRow { + readonly id: string; + readonly root_name: string; + readonly preview: string; + readonly added_at: string; +} +interface RecordRow extends SummaryRow { + readonly designer_id: string; + readonly nodes_json: string; +} + +const toSummary = (r: SummaryRow): NodeSummary => ({ + nodeId: NodeId.make(r.id), + rootName: r.root_name, + addedAt: r.added_at, + preview: r.preview, +}); + +export const NodeStoreLive = Layer.effect( + NodeStore, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return { + insert: (input: NodeInsert) => + Effect.gen(function* () { + const nodeId = NodeId.make(crypto.randomUUID()); + const addedAt = yield* nowIso; + yield* sql` + INSERT INTO nodes (id, designer_id, root_name, nodes_json, preview, added_at) + VALUES (${nodeId}, ${input.designerId}, ${input.rootName}, ${input.nodesJson}, ${input.preview}, ${addedAt}) + `; + return { nodeId }; + }).pipe(Effect.orDie), + + listSummaries: (designerId: DesignerId) => + sql` + SELECT id, root_name, preview, added_at FROM nodes + WHERE designer_id = ${designerId} ORDER BY added_at DESC, rowid DESC + `.pipe( + Effect.map((rows) => rows.map(toSummary)), + Effect.orDie, + ), + + getById: (designerId: DesignerId, nodeId: NodeId) => + sql` + SELECT id, designer_id, root_name, nodes_json, preview, added_at FROM nodes + WHERE id = ${nodeId} AND designer_id = ${designerId} + `.pipe( + Effect.map((rows): NodeRecord | undefined => { + const r = rows[0]; + return r === undefined + ? undefined + : { + nodeId: NodeId.make(r.id), + designerId, + rootName: r.root_name, + nodesJson: r.nodes_json, + preview: r.preview, + addedAt: r.added_at, + }; + }), + Effect.orDie, + ), + + listNodeIds: (designerId: DesignerId) => + sql<{ readonly id: string }>`SELECT id FROM nodes WHERE designer_id = ${designerId}`.pipe( + Effect.map((rows) => rows.map((r) => NodeId.make(r.id))), + Effect.orDie, + ), + + getForProcessing: (nodeId: NodeId) => + sql<{ readonly id: string; readonly root_name: string; readonly nodes_json: string }>` + SELECT id, root_name, nodes_json FROM nodes WHERE id = ${nodeId} + `.pipe( + Effect.map((rows): NodeForProcessing | undefined => { + const r = rows[0]; + return r === undefined + ? undefined + : { nodeId: NodeId.make(r.id), rootName: r.root_name, nodesJson: r.nodes_json }; + }), + Effect.orDie, + ), + }; + }), +); diff --git a/pixso-move/server/src/services/processorLive.ts b/pixso-move/server/src/services/processorLive.ts new file mode 100644 index 00000000000..9fda8dd88aa --- /dev/null +++ b/pixso-move/server/src/services/processorLive.ts @@ -0,0 +1,35 @@ +import { AcpRunner, makeProcessor, Processor, processorConfig } from "@pixso-move/processor"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { NodeStore } from "./nodeStore.ts"; +import { ResultStore } from "./resultStore.ts"; + +// Compose the processor into the server runtime. Its `ProcessorDeps` are built from the +// task-3 stores plus the ACP runner; SQL stays solely in the stores. The processor is +// started (recover + arm the poll timer) when the layer is acquired and stopped when it is +// released (scoped), so it shares one process, one sqlite connection, and one logger. +export const ProcessorLive = Layer.effect( + Processor, + Effect.gen(function* () { + const nodeStore = yield* NodeStore; + const resultStore = yield* ResultStore; + const acp = yield* AcpRunner; + const processor = yield* makeProcessor( + { + listNodeIds: nodeStore.listNodeIds, + getForProcessing: nodeStore.getForProcessing, + reconcile: resultStore.reconcile, + claimNextPending: resultStore.claimNextPending, + complete: resultStore.complete, + fail: resultStore.fail, + recoverInFlight: resultStore.recoverInFlight, + acp, + }, + { config: processorConfig }, + ); + yield* processor.start; + yield* Effect.addFinalizer(() => processor.stop); + return processor; + }), +); diff --git a/pixso-move/server/src/services/resultStore.ts b/pixso-move/server/src/services/resultStore.ts new file mode 100644 index 00000000000..9760f79857a --- /dev/null +++ b/pixso-move/server/src/services/resultStore.ts @@ -0,0 +1,34 @@ +import type { DesignerId, NodeId, ProcessingResult, ResultTag } from "@pixso-move/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface ReconcileRow { + readonly designerId: DesignerId; + readonly nodeId: NodeId; + readonly resultTag: ResultTag; +} + +export interface ClaimedJob { + readonly id: string; + readonly designerId: DesignerId; + readonly nodeId: NodeId; + readonly resultTag: ResultTag; +} + +// All methods orDie on DB errors (SQL failure = defect → HTTP 500). +export interface ResultStoreShape { + readonly reconcile: (rows: ReadonlyArray) => Effect.Effect; + readonly claimNextPending: Effect.Effect; + readonly complete: (id: string, result: string) => Effect.Effect; + readonly fail: (id: string, error: string) => Effect.Effect; + readonly recoverInFlight: Effect.Effect; + readonly listByNode: ( + designerId: DesignerId, + nodeId: NodeId, + ) => Effect.Effect>; + readonly countPending: Effect.Effect; +} + +export class ResultStore extends Context.Service()( + "pixso-move/ResultStore", +) {} diff --git a/pixso-move/server/src/services/resultStoreLive.ts b/pixso-move/server/src/services/resultStoreLive.ts new file mode 100644 index 00000000000..784159d8496 --- /dev/null +++ b/pixso-move/server/src/services/resultStoreLive.ts @@ -0,0 +1,129 @@ +import type { ProcessingResult, ProcessingStatus } from "@pixso-move/contracts"; +import { DesignerId, NodeId, ResultTag } from "@pixso-move/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { nowIso } from "../time.ts"; +import { ResultStore, type ClaimedJob, type ReconcileRow } from "./resultStore.ts"; + +interface ResultRow { + readonly node_id: string; + readonly result_tag: string; + readonly status: string; + readonly attempts: number; + readonly result: string | null; + readonly error: string | null; + readonly created_at: string; + readonly started_at: string | null; + readonly finished_at: string | null; +} + +const toResult = (r: ResultRow): ProcessingResult => ({ + nodeId: NodeId.make(r.node_id), + resultTag: ResultTag.make(r.result_tag), + status: r.status as ProcessingStatus, + attempts: r.attempts, + result: r.result, + error: r.error, + createdAt: r.created_at, + startedAt: r.started_at, + finishedAt: r.finished_at, +}); + +export const ResultStoreLive = Layer.effect( + ResultStore, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return { + reconcile: (rows: ReadonlyArray) => + Effect.gen(function* () { + let inserted = 0; + for (const row of rows) { + const createdAt = yield* nowIso; + const out = yield* sql<{ readonly id: string }>` + INSERT INTO processing_results + (id, designer_id, node_id, result_tag, status, attempts, created_at) + VALUES (${crypto.randomUUID()}, ${row.designerId}, ${row.nodeId}, ${row.resultTag}, 'pending', 0, ${createdAt}) + ON CONFLICT (node_id, result_tag) DO NOTHING + RETURNING id + `; + inserted += out.length; + } + return inserted; + }).pipe(Effect.orDie), + + claimNextPending: Effect.gen(function* () { + const startedAt = yield* nowIso; + const rows = yield* sql<{ + readonly id: string; + readonly designer_id: string; + readonly node_id: string; + readonly result_tag: string; + }>` + UPDATE processing_results + SET status = 'processing', started_at = ${startedAt}, attempts = attempts + 1 + WHERE id = ( + SELECT id FROM processing_results WHERE status = 'pending' + ORDER BY created_at LIMIT 1 + ) AND status = 'pending' + RETURNING id, designer_id, node_id, result_tag + `; + const r = rows[0]; + return r === undefined + ? undefined + : ({ + id: r.id, + designerId: DesignerId.make(r.designer_id), + nodeId: NodeId.make(r.node_id), + resultTag: ResultTag.make(r.result_tag), + } satisfies ClaimedJob); + }).pipe(Effect.orDie), + + complete: (id: string, result: string) => + Effect.gen(function* () { + const finishedAt = yield* nowIso; + yield* sql` + UPDATE processing_results + SET status = 'done', result = ${result}, error = NULL, finished_at = ${finishedAt} + WHERE id = ${id} + `; + }).pipe(Effect.orDie), + + fail: (id: string, error: string) => + Effect.gen(function* () { + const finishedAt = yield* nowIso; + yield* sql` + UPDATE processing_results + SET status = 'error', error = ${error}, finished_at = ${finishedAt} + WHERE id = ${id} + `; + }).pipe(Effect.orDie), + + recoverInFlight: sql<{ readonly id: string }>` + UPDATE processing_results SET status = 'pending' + WHERE status = 'processing' RETURNING id + `.pipe( + Effect.map((rows) => rows.length), + Effect.orDie, + ), + + listByNode: (designerId, nodeId) => + sql` + SELECT node_id, result_tag, status, attempts, result, error, created_at, started_at, finished_at + FROM processing_results WHERE designer_id = ${designerId} AND node_id = ${nodeId} + ORDER BY result_tag + `.pipe( + Effect.map((rows) => rows.map(toResult)), + Effect.orDie, + ), + + countPending: sql<{ readonly c: number }>` + SELECT COUNT(*) AS c FROM processing_results WHERE status = 'pending' + `.pipe( + Effect.map((rows) => rows.reduce((total, row) => total + row.c, 0)), + Effect.orDie, + ), + }; + }), +); diff --git a/pixso-move/server/src/time.ts b/pixso-move/server/src/time.ts new file mode 100644 index 00000000000..acddfacd9f4 --- /dev/null +++ b/pixso-move/server/src/time.ts @@ -0,0 +1,6 @@ +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; + +// The single timestamp source (ISO-8601), read from the Effect clock so tests +// can pin time. Matches ru-fork ws.ts:124. +export const nowIso: Effect.Effect = Effect.map(DateTime.now, DateTime.formatIso); diff --git a/pixso-move/server/src/vendor/NodeSqliteClient.ts b/pixso-move/server/src/vendor/NodeSqliteClient.ts new file mode 100644 index 00000000000..e5008feea4f --- /dev/null +++ b/pixso-move/server/src/vendor/NodeSqliteClient.ts @@ -0,0 +1,281 @@ +/** + * pixso-move: vendored verbatim from apps/server/src/persistence/NodeSqliteClient.ts + * (ru-fork @ b3a4207d). Keep in sync; do not edit. Exempt from the 150-LOC cap + * and from coverage (upstream infra). See specs/conventions.md §2.3. + * + * Port of `@effect/sql-sqlite-node` that uses the native `node:sqlite` + * bindings instead of `better-sqlite3`. + * + * @module SqliteClient + */ +import { DatabaseSync, type StatementSync } from "node:sqlite"; + +import * as Cache from "effect/Cache"; +import * as Config from "effect/Config"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import { identity } from "effect/Function"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Context from "effect/Context"; +import * as Stream from "effect/Stream"; +import * as Reactivity from "effect/unstable/reactivity/Reactivity"; +import * as Client from "effect/unstable/sql/SqlClient"; +import type { Connection } from "effect/unstable/sql/SqlConnection"; +import { SqlError, classifySqliteError } from "effect/unstable/sql/SqlError"; +import * as Statement from "effect/unstable/sql/Statement"; + +const ATTR_DB_SYSTEM_NAME = "db.system.name"; + +export const TypeId: TypeId = "~local/sqlite-node/SqliteClient"; + +export type TypeId = "~local/sqlite-node/SqliteClient"; + +/** + * SqliteClient - Effect service tag for the sqlite SQL client. + */ +export const SqliteClient = Context.Service("t3/persistence/NodeSqliteClient"); + +export interface SqliteClientConfig { + readonly filename: string; + readonly readonly?: boolean | undefined; + readonly allowExtension?: boolean | undefined; + readonly prepareCacheSize?: number | undefined; + readonly prepareCacheTTL?: Duration.Input | undefined; + readonly spanAttributes?: Record | undefined; + readonly transformResultNames?: ((str: string) => string) | undefined; + readonly transformQueryNames?: ((str: string) => string) | undefined; +} + +export interface SqliteMemoryClientConfig extends Omit< + SqliteClientConfig, + "filename" | "readonly" +> {} + +/** + * Verify that the current Node.js version includes the `node:sqlite` APIs + * used by `NodeSqliteClient` — specifically `StatementSync.columns()` (added + * in Node 22.16.0 / 23.11.0). + * + * @see https://github.com/nodejs/node/pull/57490 + */ +const checkNodeSqliteCompat = () => { + const parts = process.versions.node.split(".").map(Number); + const major = parts[0] ?? 0; + const minor = parts[1] ?? 0; + const supported = (major === 22 && minor >= 16) || (major === 23 && minor >= 11) || major >= 24; + + if (!supported) { + return Effect.die( + `Node.js ${process.versions.node} is missing required node:sqlite APIs ` + + `(StatementSync.columns). Upgrade to Node.js >=22.16, >=23.11, or >=24.`, + ); + } + return Effect.void; +}; + +const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( + options: SqliteClientConfig, + openDatabase: () => DatabaseSync, +): Effect.fn.Return { + yield* checkNodeSqliteCompat(); + + const compiler = Statement.makeCompilerSqlite(options.transformQueryNames); + const transformRows = options.transformResultNames + ? Statement.defaultTransforms(options.transformResultNames).array + : undefined; + + const makeConnection = Effect.gen(function* () { + const scope = yield* Effect.scope; + const db = openDatabase(); + yield* Scope.addFinalizer( + scope, + Effect.sync(() => db.close()), + ); + + const statementReaderCache = new WeakMap(); + const hasRows = (statement: StatementSync): boolean => { + const cached = statementReaderCache.get(statement); + if (cached !== undefined) { + return cached; + } + const value = statement.columns().length > 0; + statementReaderCache.set(statement, value); + return value; + }; + + const prepareCache = yield* Cache.make({ + capacity: options.prepareCacheSize ?? 200, + timeToLive: options.prepareCacheTTL ?? Duration.minutes(10), + lookup: (sql: string) => + Effect.try({ + try: () => db.prepare(sql), + catch: (cause) => + new SqlError({ + reason: classifySqliteError(cause, { + message: "Failed to prepare statement", + operation: "prepare", + }), + }), + }), + }); + + const runStatement = (statement: StatementSync, params: ReadonlyArray, raw: boolean) => + Effect.withFiber, SqlError>((fiber) => { + statement.setReadBigInts(Boolean(Context.get(fiber.context, Client.SafeIntegers))); + try { + if (hasRows(statement)) { + return Effect.succeed(statement.all(...(params as any))); + } + const result = statement.run(...(params as any)); + return Effect.succeed(raw ? (result as unknown as ReadonlyArray) : []); + } catch (cause) { + return Effect.fail( + new SqlError({ + reason: classifySqliteError(cause, { + message: "Failed to execute statement", + operation: "execute", + }), + }), + ); + } + }); + + const run = (sql: string, params: ReadonlyArray, raw = false) => + Effect.flatMap(Cache.get(prepareCache, sql), (s) => runStatement(s, params, raw)); + + const runValues = (sql: string, params: ReadonlyArray) => + Effect.acquireUseRelease( + Cache.get(prepareCache, sql), + (statement) => + Effect.try({ + try: () => { + if (hasRows(statement)) { + statement.setReturnArrays(true); + // Safe to cast to array after we've setReturnArrays(true) + return statement.all(...(params as any)) as unknown as ReadonlyArray< + ReadonlyArray + >; + } + statement.run(...(params as any)); + return []; + }, + catch: (cause) => + new SqlError({ + reason: classifySqliteError(cause, { + message: "Failed to execute statement", + operation: "execute", + }), + }), + }), + (statement) => + Effect.sync(() => { + if (hasRows(statement)) { + statement.setReturnArrays(false); + } + }), + ); + + return identity({ + execute(sql, params, rowTransform) { + return rowTransform ? Effect.map(run(sql, params), rowTransform) : run(sql, params); + }, + executeRaw(sql, params) { + return run(sql, params, true); + }, + executeValues(sql, params) { + return runValues(sql, params); + }, + executeUnprepared(sql, params, rowTransform) { + const effect = runStatement(db.prepare(sql), params ?? [], false); + return rowTransform ? Effect.map(effect, rowTransform) : effect; + }, + executeStream(_sql, _params) { + return Stream.die("executeStream not implemented"); + }, + }); + }); + + const semaphore = yield* Semaphore.make(1); + const connection = yield* makeConnection; + + const acquirer = semaphore.withPermits(1)(Effect.succeed(connection)); + const transactionAcquirer = Effect.uninterruptibleMask((restore) => { + const fiber = Fiber.getCurrent()!; + const scope = Context.getUnsafe(fiber.context, Scope.Scope); + return Effect.as( + Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))), + connection, + ); + }); + + return yield* Client.make({ + acquirer, + compiler, + transactionAcquirer, + spanAttributes: [ + ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []), + [ATTR_DB_SYSTEM_NAME, "sqlite"], + ], + transformRows, + }); +}); + +const make = ( + options: SqliteClientConfig, +): Effect.Effect => + makeWithDatabase( + options, + () => + new DatabaseSync(options.filename, { + readOnly: options.readonly ?? false, + allowExtension: options.allowExtension ?? false, + }), + ); + +const makeMemory = ( + config: SqliteMemoryClientConfig = {}, +): Effect.Effect => + makeWithDatabase( + { + ...config, + filename: ":memory:", + readonly: false, + }, + () => { + const database = new DatabaseSync(":memory:", { + allowExtension: config.allowExtension ?? false, + }); + return database; + }, + ); + +export const layerConfig = ( + config: Config.Wrap, +): Layer.Layer => + Layer.effectContext( + Config.unwrap(config) + .asEffect() + .pipe( + Effect.flatMap(make), + Effect.map((client) => + Context.make(SqliteClient, client).pipe(Context.add(Client.SqlClient, client)), + ), + ), + ).pipe(Layer.provide(Reactivity.layer)); + +export const layer = (config: SqliteClientConfig): Layer.Layer => + Layer.effectContext( + Effect.map(make(config), (client) => + Context.make(SqliteClient, client).pipe(Context.add(Client.SqlClient, client)), + ), + ).pipe(Layer.provide(Reactivity.layer)); + +export const layerMemory = (config: SqliteMemoryClientConfig = {}): Layer.Layer => + Layer.effectContext( + Effect.map(makeMemory(config), (client) => + Context.make(SqliteClient, client).pipe(Context.add(Client.SqlClient, client)), + ), + ).pipe(Layer.provide(Reactivity.layer)); diff --git a/pixso-move/server/tests/config.test.ts b/pixso-move/server/tests/config.test.ts new file mode 100644 index 00000000000..5b8fc7820d4 --- /dev/null +++ b/pixso-move/server/tests/config.test.ts @@ -0,0 +1,25 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { DEFAULT_PORT, resolveServerConfig, ServerConfig } from "../src/config.ts"; + +it.effect("layer provides the resolved config", () => + Effect.gen(function* () { + const config = yield* ServerConfig; + assert.equal(config.port, 9999); + assert.equal(config.host, "0.0.0.0"); + }).pipe(Effect.provide(ServerConfig.layer(resolveServerConfig({ port: 9999, host: "0.0.0.0" })))), +); + +it.effect("layerTest defaults to an in-memory db and applies overrides", () => + Effect.gen(function* () { + const config = yield* ServerConfig; + assert.equal(config.dbPath, ":memory:"); + assert.equal(config.host, "1.2.3.4"); + }).pipe(Effect.provide(ServerConfig.layerTest({ host: "1.2.3.4" }))), +); + +it("resolveServerConfig merges over the defaults", () => { + assert.equal(resolveServerConfig({}).port, DEFAULT_PORT); + assert.equal(resolveServerConfig({ port: 1 }).port, 1); +}); diff --git a/pixso-move/server/tests/embed.test.ts b/pixso-move/server/tests/embed.test.ts new file mode 100644 index 00000000000..196511fb0d7 --- /dev/null +++ b/pixso-move/server/tests/embed.test.ts @@ -0,0 +1,95 @@ +import { assert, describe, it } from "@effect/vitest"; +import { DesignerId } from "@pixso-move/contracts"; +import { Processor } from "@pixso-move/processor"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpRouter } from "effect/unstable/http"; + +import { corsLayer } from "../src/http/cors.ts"; +import { routesLayer } from "../src/http/routes.ts"; +import { SqlitePersistenceMemory } from "../src/persistence/sqlite.ts"; +import { NodeStore } from "../src/services/nodeStore.ts"; +import { NodeStoreLive } from "../src/services/nodeStoreLive.ts"; +import { ProcessorLive } from "../src/services/processorLive.ts"; +import { ResultStore } from "../src/services/resultStore.ts"; +import { ResultStoreLive } from "../src/services/resultStoreLive.ts"; +import { FAKE_ACP_TEXT, FakeAcpRunnerLive } from "./fakeAcpRunner.ts"; +import { jsonRequest } from "./http/harness.ts"; + +// The configured designer (see @pixso-move/processor config.ts). +const CONFIGURED = DesignerId.make("dz_c07a93f7-2505-4e60-94af-17a2cc068b79"); + +// Stores + embedded processor (fake runner) over a fresh in-memory DB — one runtime. +const storesLive = Layer.mergeAll(NodeStoreLive, ResultStoreLive).pipe( + Layer.provideMerge(SqlitePersistenceMemory), +); +const appServices = ProcessorLive.pipe( + Layer.provideMerge(storesLive), + Layer.provide(FakeAcpRunnerLive), +); + +describe("processor embed", () => { + it.effect("processes a configured designer's node to done with the runner's text", () => + Effect.gen(function* () { + const nodeStore = yield* NodeStore; + const resultStore = yield* ResultStore; + const processor = yield* Processor; + const { nodeId } = yield* nodeStore.insert({ + designerId: CONFIGURED, + rootName: "Card", + nodesJson: "{}", + preview: "x", + }); + yield* processor.runTickOnce; + const results = yield* resultStore.listByNode(CONFIGURED, nodeId); + assert.equal(results.length, 1); + assert.equal(results[0]!.status, "done"); + assert.equal(results[0]!.resultTag, "html-css"); + assert.equal(results[0]!.result, FAKE_ACP_TEXT); + }).pipe(Effect.provide(appServices)), + ); + + it.effect("stores an unconfigured designer's node but reconciles nothing", () => + Effect.gen(function* () { + const nodeStore = yield* NodeStore; + const resultStore = yield* ResultStore; + const processor = yield* Processor; + const designerId = DesignerId.make("dz_unknown"); + const { nodeId } = yield* nodeStore.insert({ + designerId, + rootName: "X", + nodesJson: "{}", + preview: "x", + }); + yield* processor.runTickOnce; + assert.equal((yield* resultStore.listByNode(designerId, nodeId)).length, 0); + }).pipe(Effect.provide(appServices)), + ); + + it.effect("a failing notify never breaks ingest (still 200)", () => + Effect.gen(function* () { + const failingProcessor = Layer.succeed(Processor, { + start: Effect.void, + notify: Effect.die("notify boom"), + stop: Effect.void, + runTickOnce: Effect.void, + }); + const app = Layer.mergeAll(routesLayer, corsLayer).pipe( + Layer.provideMerge(storesLive), + Layer.provideMerge(failingProcessor), + ); + const { handler } = HttpRouter.toWebHandler(app); + const response = yield* Effect.promise(() => + handler( + jsonRequest("POST", "/ingest", "dz_z", { + designerId: "dz_z", + rootName: "C", + nodesJson: "{}", + preview: "x", + }), + ), + ); + assert.equal(response.status, 200); + }), + ); +}); diff --git a/pixso-move/server/tests/fakeAcpRunner.ts b/pixso-move/server/tests/fakeAcpRunner.ts new file mode 100644 index 00000000000..7870aff6391 --- /dev/null +++ b/pixso-move/server/tests/fakeAcpRunner.ts @@ -0,0 +1,10 @@ +import { AcpRunner } from "@pixso-move/processor"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export const FAKE_ACP_TEXT = ""; + +// A scripted ACP runner for tests — returns fixed text instead of spawning qwen. +export const FakeAcpRunnerLive = Layer.succeed(AcpRunner, { + run: () => Effect.succeed({ text: FAKE_ACP_TEXT, stopReason: "end_turn" }), +}); diff --git a/pixso-move/server/tests/http/harness.ts b/pixso-move/server/tests/http/harness.ts new file mode 100644 index 00000000000..ab666932a73 --- /dev/null +++ b/pixso-move/server/tests/http/harness.ts @@ -0,0 +1,50 @@ +import { Processor } from "@pixso-move/processor"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpRouter } from "effect/unstable/http"; +import type * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { corsLayer } from "../../src/http/cors.ts"; +import { routesLayer } from "../../src/http/routes.ts"; +import { SqlitePersistenceMemory } from "../../src/persistence/sqlite.ts"; +import { NodeStore } from "../../src/services/nodeStore.ts"; +import { NodeStoreLive } from "../../src/services/nodeStoreLive.ts"; +import { ResultStoreLive } from "../../src/services/resultStoreLive.ts"; + +const noopProcessor = Layer.succeed(Processor, { + start: Effect.void, + notify: Effect.void, + stop: Effect.void, + runTickOnce: Effect.void, +}); + +// A NodeStore whose reads die — used to exercise the route catch-all (500). +export const brokenNodeStore = Layer.succeed(NodeStore, { + insert: () => Effect.die("boom"), + listSummaries: () => Effect.die("boom"), + getById: () => Effect.die("boom"), + listNodeIds: () => Effect.die("boom"), + getForProcessing: () => Effect.die("boom"), +}); + +// Build an in-process web handler over a fresh in-memory DB. Services are +// provideMerge'd so they remain in the runtime context the handlers resolve from. +export const makeHandler = ( + nodeStoreLayer: Layer.Layer = NodeStoreLive, +) => { + const services = Layer.mergeAll(nodeStoreLayer, ResultStoreLive, noopProcessor).pipe( + Layer.provideMerge(SqlitePersistenceMemory), + ); + const app = Layer.mergeAll(routesLayer, corsLayer).pipe(Layer.provideMerge(services)); + return HttpRouter.toWebHandler(app); +}; + +export const jsonRequest = (method: string, path: string, key?: string, body?: unknown): Request => + new Request(`http://test${path}`, { + method, + headers: { + "content-type": "application/json", + ...(key === undefined ? {} : { "x-designer-id": key }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); diff --git a/pixso-move/server/tests/http/routes.test.ts b/pixso-move/server/tests/http/routes.test.ts new file mode 100644 index 00000000000..97e76c6b413 --- /dev/null +++ b/pixso-move/server/tests/http/routes.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { brokenNodeStore, jsonRequest, makeHandler } from "./harness.ts"; + +const key = "dz_alice"; +const body = { designerId: key, rootName: "Card", nodesJson: '{"id":"1"}', preview: "iVBOR" }; + +let handler: (request: Request) => Promise; +let dispose: () => Promise; + +const setup = (broken = false) => { + const built = broken ? makeHandler(brokenNodeStore) : makeHandler(); + handler = built.handler; + dispose = built.dispose; +}; + +afterEach(() => dispose()); + +describe("POST /ingest", () => { + beforeEach(() => setup()); + + it("stores a node and returns its id", async () => { + const res = await handler(jsonRequest("POST", "/ingest", key, body)); + expect(res.status).toBe(200); + const json = (await res.json()) as { nodeId: string }; + expect(typeof json.nodeId).toBe("string"); + }); + + it("rejects a missing key with 401", async () => { + const res = await handler(jsonRequest("POST", "/ingest", undefined, body)); + expect(res.status).toBe(401); + }); + + it("rejects an invalid body with 400", async () => { + const res = await handler(jsonRequest("POST", "/ingest", key, { designerId: key })); + expect(res.status).toBe(400); + }); + + it("rejects a key/body mismatch with 401", async () => { + const res = await handler(jsonRequest("POST", "/ingest", key, { ...body, designerId: "dz_x" })); + expect(res.status).toBe(401); + }); +}); + +describe("GET /nodes and /node", () => { + beforeEach(() => setup()); + + it("lists summaries and fetches one by id", async () => { + const created = (await (await handler(jsonRequest("POST", "/ingest", key, body))).json()) as { + nodeId: string; + }; + const list = await handler(jsonRequest("GET", "/nodes", key)); + expect(list.status).toBe(200); + expect(((await list.json()) as unknown[]).length).toBe(1); + + const one = await handler(jsonRequest("GET", `/node?id=${created.nodeId}`, key)); + expect(one.status).toBe(200); + expect(((await one.json()) as { rootName: string }).rootName).toBe("Card"); + }); + + it("401 without a key, 400 without an id, 404 for an unknown id", async () => { + expect((await handler(jsonRequest("GET", "/nodes", undefined))).status).toBe(401); + expect((await handler(jsonRequest("GET", "/node", key))).status).toBe(400); + expect((await handler(jsonRequest("GET", "/node?id=missing", key))).status).toBe(404); + }); +}); + +describe("GET /processing-data", () => { + beforeEach(() => setup()); + + it("returns an empty array for a valid node with no results", async () => { + const created = (await (await handler(jsonRequest("POST", "/ingest", key, body))).json()) as { + nodeId: string; + }; + const res = await handler(jsonRequest("GET", `/processing-data?nodeId=${created.nodeId}`, key)); + expect(res.status).toBe(200); + expect(await res.json()).toEqual([]); + }); + + it("400 for a missing nodeId, 404 for an unknown node", async () => { + expect((await handler(jsonRequest("GET", "/processing-data", key))).status).toBe(400); + expect((await handler(jsonRequest("GET", "/processing-data?nodeId=missing", key))).status).toBe( + 404, + ); + }); +}); + +describe("resilience and CORS", () => { + it("returns 500 (not a crash) when a store defects", async () => { + setup(true); + const res = await handler(jsonRequest("GET", "/nodes", key)); + expect(res.status).toBe(500); + expect((await res.json()) as { error: string }).toHaveProperty("error"); + }); + + it("answers CORS preflight with the allowed headers", async () => { + setup(); + const res = await handler( + new Request("http://test/ingest", { + method: "OPTIONS", + headers: { origin: "http://x", "access-control-request-method": "POST" }, + }), + ); + expect(res.headers.get("access-control-allow-methods")).toContain("POST"); + }); +}); diff --git a/pixso-move/server/tests/nodeStore.test.ts b/pixso-move/server/tests/nodeStore.test.ts new file mode 100644 index 00000000000..5b20e5977ad --- /dev/null +++ b/pixso-move/server/tests/nodeStore.test.ts @@ -0,0 +1,64 @@ +import { DesignerId, NodeId } from "@pixso-move/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { SqlitePersistenceMemory } from "../src/persistence/sqlite.ts"; +import { NodeStore } from "../src/services/nodeStore.ts"; +import { NodeStoreLive } from "../src/services/nodeStoreLive.ts"; + +const layer = NodeStoreLive.pipe(Layer.provide(SqlitePersistenceMemory)); +const alice = DesignerId.make("dz_alice"); +const bob = DesignerId.make("dz_bob"); +const seed = { rootName: "Card", nodesJson: '{"id":"1"}', preview: "iVBOR" }; + +it.effect("insert persists a record and getById returns it", () => + Effect.gen(function* () { + const store = yield* NodeStore; + const { nodeId } = yield* store.insert({ designerId: alice, ...seed }); + assert.isString(nodeId); + const record = yield* store.getById(alice, nodeId); + assert.isDefined(record); + assert.equal(record?.designerId, alice); + assert.equal(record?.nodesJson, '{"id":"1"}'); + assert.isString(record?.addedAt); + }).pipe(Effect.provide(layer)), +); + +it.effect("listSummaries omits nodes_json, newest first, isolated by designer", () => + Effect.gen(function* () { + const store = yield* NodeStore; + yield* store.insert({ designerId: alice, ...seed, rootName: "First" }); + yield* store.insert({ designerId: alice, ...seed, rootName: "Second" }); + yield* store.insert({ designerId: bob, ...seed, rootName: "BobOnly" }); + const summaries = yield* store.listSummaries(alice); + assert.equal(summaries.length, 2); + assert.equal(summaries[0]?.rootName, "Second"); + assert.deepEqual( + summaries.map((s) => s.rootName), + ["Second", "First"], + ); + assert.notInclude(Object.keys(summaries[0] ?? {}), "nodesJson"); + }).pipe(Effect.provide(layer)), +); + +it.effect("getById returns undefined for a wrong key or missing id", () => + Effect.gen(function* () { + const store = yield* NodeStore; + const { nodeId } = yield* store.insert({ designerId: alice, ...seed }); + assert.isUndefined(yield* store.getById(bob, nodeId)); + assert.isUndefined(yield* store.getById(alice, NodeId.make("nope"))); + }).pipe(Effect.provide(layer)), +); + +it.effect("listNodeIds and getForProcessing project the right fields", () => + Effect.gen(function* () { + const store = yield* NodeStore; + const { nodeId } = yield* store.insert({ designerId: alice, ...seed }); + assert.deepEqual(yield* store.listNodeIds(alice), [nodeId]); + const forProcessing = yield* store.getForProcessing(nodeId); + assert.equal(forProcessing?.rootName, "Card"); + assert.equal(forProcessing?.nodesJson, '{"id":"1"}'); + assert.isUndefined(yield* store.getForProcessing(NodeId.make("missing"))); + }).pipe(Effect.provide(layer)), +); diff --git a/pixso-move/server/tests/persistence.test.ts b/pixso-move/server/tests/persistence.test.ts new file mode 100644 index 00000000000..1d98eca9677 --- /dev/null +++ b/pixso-move/server/tests/persistence.test.ts @@ -0,0 +1,29 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { resolveServerConfig, ServerConfig } from "../src/config.ts"; +import { persistenceLive } from "../src/persistence/sqlite.ts"; + +const tmpDb = `./.data/test-${crypto.randomUUID()}.sqlite`; + +it.effect("file-backed persistence creates the parent dir, runs migrations, persists", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const tables = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name + `; + const names = tables.map((t) => t.name); + assert.include(names, "nodes"); + assert.include(names, "processing_results"); + }).pipe( + Effect.provide( + persistenceLive.pipe( + Layer.provide(ServerConfig.layer(resolveServerConfig({ dbPath: tmpDb }))), + Layer.provide(NodeServices.layer), + ), + ), + ), +); diff --git a/pixso-move/server/tests/resultStore.test.ts b/pixso-move/server/tests/resultStore.test.ts new file mode 100644 index 00000000000..0782891c776 --- /dev/null +++ b/pixso-move/server/tests/resultStore.test.ts @@ -0,0 +1,96 @@ +import { DesignerId, ResultTag } from "@pixso-move/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { SqlitePersistenceMemory } from "../src/persistence/sqlite.ts"; +import { NodeStore } from "../src/services/nodeStore.ts"; +import { NodeStoreLive } from "../src/services/nodeStoreLive.ts"; +import { ResultStore } from "../src/services/resultStore.ts"; +import { ResultStoreLive } from "../src/services/resultStoreLive.ts"; + +const layer = Layer.mergeAll(NodeStoreLive, ResultStoreLive).pipe( + Layer.provide(SqlitePersistenceMemory), +); +const alice = DesignerId.make("dz_alice"); +const react = ResultTag.make("react"); +const summary = ResultTag.make("summary"); +const seed = { rootName: "Card", nodesJson: "{}", preview: "iVBOR" }; + +const seedNode = Effect.gen(function* () { + const nodes = yield* NodeStore; + const { nodeId } = yield* nodes.insert({ designerId: alice, ...seed }); + return nodeId; +}); + +it.effect("reconcile inserts missing pairs and is idempotent", () => + Effect.gen(function* () { + const store = yield* ResultStore; + const nodeId = yield* seedNode; + const rows = [ + { designerId: alice, nodeId, resultTag: react }, + { designerId: alice, nodeId, resultTag: summary }, + ]; + assert.equal(yield* store.reconcile(rows), 2); + assert.equal(yield* store.reconcile(rows), 0); + assert.equal(yield* store.countPending, 2); + }).pipe(Effect.provide(layer)), +); + +it.effect("claimNextPending atomically takes one job; loser gets undefined", () => + Effect.gen(function* () { + const store = yield* ResultStore; + const nodeId = yield* seedNode; + yield* store.reconcile([{ designerId: alice, nodeId, resultTag: react }]); + const job = yield* store.claimNextPending; + assert.isDefined(job); + assert.equal(job?.resultTag, react); + assert.equal(yield* store.countPending, 0); + assert.isUndefined(yield* store.claimNextPending); + }).pipe(Effect.provide(layer)), +); + +it.effect("complete and fail set terminal status and listByNode reflects it", () => + Effect.gen(function* () { + const store = yield* ResultStore; + const nodeId = yield* seedNode; + yield* store.reconcile([ + { designerId: alice, nodeId, resultTag: react }, + { designerId: alice, nodeId, resultTag: summary }, + ]); + const first = yield* store.claimNextPending; + const second = yield* store.claimNextPending; + yield* store.complete(first?.id ?? "", "generated code"); + yield* store.fail(second?.id ?? "", "boom"); + const results = yield* store.listByNode(alice, nodeId); + assert.equal(results.length, 2); + const done = results.find((r) => r.status === "done"); + const errored = results.find((r) => r.status === "error"); + assert.equal(done?.result, "generated code"); + assert.equal(errored?.error, "boom"); + assert.isString(done?.finishedAt); + }).pipe(Effect.provide(layer)), +); + +it.effect("recoverInFlight flips processing back to pending without bumping attempts", () => + Effect.gen(function* () { + const store = yield* ResultStore; + const nodeId = yield* seedNode; + yield* store.reconcile([{ designerId: alice, nodeId, resultTag: react }]); + yield* store.claimNextPending; // → processing, attempts = 1 + assert.equal(yield* store.recoverInFlight, 1); + const [row] = yield* store.listByNode(alice, nodeId); + assert.equal(row?.status, "pending"); + assert.equal(row?.attempts, 1); + }).pipe(Effect.provide(layer)), +); + +it.effect("listByNode is scoped by designer", () => + Effect.gen(function* () { + const store = yield* ResultStore; + const nodeId = yield* seedNode; + yield* store.reconcile([{ designerId: alice, nodeId, resultTag: react }]); + assert.equal((yield* store.listByNode(alice, nodeId)).length, 1); + assert.equal((yield* store.listByNode(DesignerId.make("dz_other"), nodeId)).length, 0); + }).pipe(Effect.provide(layer)), +); diff --git a/pixso-move/server/tests/server.test.ts b/pixso-move/server/tests/server.test.ts new file mode 100644 index 00000000000..5c9e5fdef73 --- /dev/null +++ b/pixso-move/server/tests/server.test.ts @@ -0,0 +1,44 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { resolveServerConfig, ServerConfig } from "../src/config.ts"; +import { makeServerLayer } from "../src/server.ts"; +import { FakeAcpRunnerLive } from "./fakeAcpRunner.ts"; + +const PORT = 17789; +const key = "dz_alice"; +const body = { designerId: key, rootName: "Card", nodesJson: '{"id":"1"}', preview: "iVBOR" }; + +// Integration: builds the real server layer (HTTP listener + logger + stores + +// persistence), binds a port, and round-trips a request over a real socket. +it.effect("real server ingests and lists over HTTP", () => + Effect.scoped( + Effect.gen(function* () { + yield* Layer.build(makeServerLayer); + const base = `http://127.0.0.1:${PORT}`; + + const ingest = yield* Effect.promise(() => + fetch(`${base}/ingest`, { + method: "POST", + headers: { "content-type": "application/json", "x-designer-id": key }, + body: JSON.stringify(body), + }), + ); + assert.equal(ingest.status, 200); + + const list = yield* Effect.promise(() => + fetch(`${base}/nodes`, { headers: { "x-designer-id": key } }), + ); + assert.equal(list.status, 200); + assert.equal(((yield* Effect.promise(() => list.json())) as unknown[]).length, 1); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + ServerConfig.layer(resolveServerConfig({ port: PORT, dbPath: ":memory:" })), + FakeAcpRunnerLive, + ), + ), + ), +); diff --git a/pixso-move/server/tsconfig.json b/pixso-move/server/tsconfig.json new file mode 100644 index 00000000000..5d226858063 --- /dev/null +++ b/pixso-move/server/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { "lib": ["ESNext", "esnext.disposable"] }, + "include": ["src", "tests"] +} diff --git a/pixso-move/server/tsdown.config.ts b/pixso-move/server/tsdown.config.ts new file mode 100644 index 00000000000..cd90abe4a33 --- /dev/null +++ b/pixso-move/server/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/bin.ts"], + format: ["esm"], + outDir: "dist", + sourcemap: false, + clean: true, + // Bundle every dep so the output is self-contained; keep node:sqlite native. + noExternal: () => true, + inlineOnly: false, + banner: { js: "#!/usr/bin/env node\n" }, +}); diff --git a/pixso-move/server/vitest.config.ts b/pixso-move/server/vitest.config.ts new file mode 100644 index 00000000000..857222dfdd7 --- /dev/null +++ b/pixso-move/server/vitest.config.ts @@ -0,0 +1,3 @@ +import { makeVitestConfig } from "../vitest.base.ts"; + +export default makeVitestConfig(import.meta.dirname); diff --git a/pixso-move/specs/00-overview.md b/pixso-move/specs/00-overview.md new file mode 100644 index 00000000000..26d70a01849 --- /dev/null +++ b/pixso-move/specs/00-overview.md @@ -0,0 +1,164 @@ +# 00 — Overview: data model, contracts, endpoints + +The single source of truth for the **shapes** every package agrees on. Tasks 2–5 implement this; +tasks 6–8 produce/consume it from the plugin. + +--- + +## Glossary + +| Term | Meaning | +|---|---| +| **designerId** | The designer's key. An opaque non-empty string the designer generates once in the plugin and saves. Used everywhere as both identity and access token. Called `authorKey` in the original sketch — we use **`designerId`** everywhere. | +| **node record** | One immutable submission: the serialized Pixso node subtree + a preview image + the designerId + when it arrived. Identified by a server-generated **`nodeId`**. | +| **processing result** | The outcome of running one configured prompt against one node record. Status-tracked. Identified by `(nodeId, resultTag)`. | +| **resultTag** | A short label naming what a prompt produces (e.g. `react`, `summary`, `a11y`). Lets multiple prompts run against the same node, each producing a distinct result. | + +--- + +## Data model (sqlite, 2 tables) + +### `nodes` — immutable ingestion records +| column | type | notes | +|---|---|---| +| `id` | TEXT PRIMARY KEY | the **nodeId**, server-generated UUID | +| `designer_id` | TEXT NOT NULL | indexed | +| `root_name` | TEXT NOT NULL | the selected node's name (for the developer list) | +| `nodes_json` | TEXT NOT NULL | serialized node subtree (JSON string) | +| `preview` | TEXT NOT NULL | base64 PNG (1×), no `data:` prefix | +| `added_at` | TEXT NOT NULL | ISO-8601 (`DateTime.formatIso`) | + +Index: `idx_nodes_designer ON nodes(designer_id, added_at)`. +This table is **append-only** — never mutated after insert. + +### `processing_results` — the job ledger AND the output +One row per `(node × configured prompt)`. Carries both lifecycle status and the produced result. +| column | type | notes | +|---|---|---| +| `id` | TEXT PRIMARY KEY | server-generated UUID | +| `designer_id` | TEXT NOT NULL | denormalized from the node (for key-gated reads), indexed | +| `node_id` | TEXT NOT NULL | FK → `nodes(id)` | +| `result_tag` | TEXT NOT NULL | from processor config | +| `status` | TEXT NOT NULL | `pending` \| `processing` \| `done` \| `error` | +| `attempts` | INTEGER NOT NULL DEFAULT 0 | incremented each run attempt | +| `result` | TEXT | the LLM output text; NULL until `done` | +| `error` | TEXT | failure message; NULL unless `error` (or last failed attempt) | +| `created_at` | TEXT NOT NULL | when the job row was created | +| `started_at` | TEXT | when last claimed (→ `processing`) | +| `finished_at` | TEXT | when it reached `done`/`error` | + +Constraints / indexes: +- `UNIQUE(node_id, result_tag)` → reconciliation is idempotent (`INSERT … ON CONFLICT DO NOTHING`). +- `idx_results_status ON processing_results(status)` → fast claim scan. +- `idx_results_designer_node ON processing_results(designer_id, node_id)` → fast reads. + +> **Why this design (status off the `nodes` table):** the product owner requires full +> observability and control — monitor status, mark errors, retry later, finish unfinished work +> after a crash. We get all of that from the ledger while keeping `nodes` immutable. Adding a +> **new** prompt to config later auto-creates `pending` rows for existing nodes (reconciliation +> via the UNIQUE key), so new prompts backfill historical nodes. + +--- + +## Processing lifecycle (state machine) + +``` + reconcile (INSERT new (node,tag)) + │ + ▼ + ┌────────────┐ claim ┌─────────────┐ ACP ok ┌────────┐ + │ pending │──────▶│ processing │───────▶│ done │ + └────────────┘ └─────────────┘ └────────┘ + ▲ ▲ │ ACP fail + │ │ ▼ + │ │ ┌──────────┐ + │ └────────────│ error │ (terminal; manual/future retry resets to pending) + │ crash recovery└──────────┘ + │ (processing→pending on startup, unbounded; attempts NOT incremented by recovery) +``` + +- **claim** is atomic: `UPDATE … SET status='processing', started_at=…, attempts=attempts+1 + WHERE id=? AND status='pending'` — the row is only ours if `changes === 1`. +- **crash recovery** on startup: `UPDATE … SET status='pending' WHERE status='processing'` (work + interrupted mid-flight resumes). Recovery does **not** bump `attempts`. +- **retry** (future): a config flag may auto-reset `error` rows with `attempts < maxAttempts` back + to `pending`. The columns already support it; the loop hook is specified in + [04-processor.md](./04-processor.md) but defaults OFF. + +--- + +## HTTP API (plain HTTP + effect Schema; `effect/unstable/http`) + +Auth: every endpoint requires the header **`x-designer-id: `**. A request whose body +or query `designerId` disagrees with the header is rejected. Reads are scoped to that designerId — +you can only read what your key owns. CORS is open (`access-control-allow-origin: *`) because the +plugin posts cross-origin; `x-designer-id` is added to the allowed headers. + +| Method | Path | Auth | Request | Response (200) | Errors | +|---|---|---|---|---|---| +| `POST` | `/ingest` | header | `IngestRequest` (body) | `IngestResponse` `{ nodeId }` | 400 invalid, 401 missing key | +| `GET` | `/nodes` | header | — | `NodeSummary[]` (this designer, newest first) | 401 | +| `GET` | `/nodes/:id` | header | — | `NodeRecord` (full, key-scoped) | 401, 404 | +| `GET` | `/processing-data` | header | `?nodeId=…` | `ProcessingResult[]` for that node | 400, 401, 404 | +| `OPTIONS` | `*` | — | CORS preflight | 204 | — | + +> `GET /nodes` returns **summaries** (no `nodes_json`, to keep the list light) — id, rootName, +> addedAt, preview. `GET /nodes/:id` returns the full record including `nodes_json`. +> `GET /processing-data` returns all result rows (every `resultTag`, every status) for one node so +> the developer sees progress and errors, not just finished output. + +### Contract shapes (effect Schema — defined in `@pixso-move/contracts`, see [02](./02-contracts.md)) + +```ts +DesignerId = TrimmedNonEmptyString.pipe(Schema.brand("DesignerId")) // ≤ 200 chars +NodeId = TrimmedNonEmptyString.pipe(Schema.brand("NodeId")) +ResultTag = TrimmedNonEmptyString.pipe(Schema.brand("ResultTag")) // ≤ 64 chars +Base64Png = TrimmedNonEmptyString // ≤ ~8 MB guard + +IngestRequest = { designerId, rootName, nodesJson: string, preview: Base64Png } +IngestResponse = { nodeId } +NodeSummary = { nodeId, rootName, addedAt, preview } +NodeRecord = { nodeId, designerId, rootName, nodesJson, preview, addedAt } +ProcessingStatus = "pending" | "processing" | "done" | "error" +ProcessingResult = { nodeId, resultTag, status, attempts, result: string|null, + error: string|null, createdAt, startedAt: string|null, + finishedAt: string|null } + +// tagged errors: +IngestError ("IngestError", { message, status }) // 400/413 +AuthError ("AuthError", { message, status }) // 401 +NodeNotFoundError ("NodeNotFound", { message, status }) // 404 +``` + +`nodesJson` is carried as an opaque validated **string** (already-serialized JSON from the plugin). +The server stores it verbatim; it does not re-parse the node tree. (Keeps the contract simple and +the server agnostic to Pixso's node schema, which can change.) + +--- + +## Processor config (`@pixso-move/processor`) + +A plain TS file — `src/config.ts` — the operator edits: +```ts +export const processorConfig: ProcessorConfig = { + entries: [ + { designerId: "dz_alice", prompt: "Generate a React component for this design.", resultTag: "react" }, + { designerId: "dz_alice", prompt: "Summarize this screen for a PM.", resultTag: "summary" }, + { designerId: "dz_bob", prompt: "List accessibility issues.", resultTag: "a11y" }, + ], +}; +``` +- The processor only acts on nodes whose `designerId` matches a config entry. Nodes from unknown + designers are stored by the server but **never processed**. +- Multiple entries for one designer → multiple result rows per node (distinct `resultTag`). +- Adding/removing/editing entries is picked up on next process start (and reconciliation backfills + new `(node, resultTag)` pairs for existing nodes). + +--- + +## Deployment (decided) + +- The **processor runs embedded in the server process** (one Effect runtime). Ingest calls the + processor's `notify()` for low-latency pickup; a poll timer is the backstop. Single sqlite + writer → no cross-process `SQLITE_BUSY`. See [05-embed.md](./05-embed.md). +- The DB file path comes from server config (default `./.data/pixso.sqlite`; `:memory:` in tests). diff --git a/pixso-move/specs/01-scaffold.md b/pixso-move/specs/01-scaffold.md new file mode 100644 index 00000000000..f29fc1f6673 --- /dev/null +++ b/pixso-move/specs/01-scaffold.md @@ -0,0 +1,209 @@ +# Task 1 — Scaffold + +Stand up the four packages, wire them into the monorepo, and confirm the `effect-acp` reuse path. +**No business logic** — just buildable, typecheckable, lintable empty packages. + +## Deliverables + +### Shared config (DRY — created once at `pixso-move/`) +``` +pixso-move/ + tsconfig.base.json extends ../tsconfig.base.json; common compilerOptions for all packages + vitest.base.ts export makeVitestConfig(dir) (see conventions §5) +``` +`pixso-move/tsconfig.base.json`: +```jsonc +{ "extends": "../tsconfig.base.json", + "compilerOptions": { "composite": true, "types": ["node"], "lib": ["ESNext"] } } +``` +Every package's `tsconfig.json` then reduces to: +```jsonc +{ "extends": "../tsconfig.base.json", "include": ["src", "tests"] } +``` +(Server adds `"lib": ["ESNext","esnext.disposable"]`; plugin overrides `lib`/`jsx`/`types`/`paths` +— see [06](./06-plugin-build.md).) Every package's `vitest.config.ts` is the 3-line call from +conventions §5. **No coverage/compiler options are duplicated per package.** + +### Packages +``` +pixso-move/ + contracts/ + package.json @pixso-move/contracts + tsconfig.json + vitest.config.ts + src/index.ts (re-exports; empty for now) + tests/.gitkeep + server/ + package.json @pixso-move/server + tsconfig.json + vitest.config.ts + tsdown.config.ts + src/bin.ts (entrypoint stub) + tests/.gitkeep + processor/ + package.json @pixso-move/processor + tsconfig.json + vitest.config.ts + src/index.ts + tests/.gitkeep + plugin/ + package.json @pixso-move/plugin + tsconfig.json + manifest.json (filled in task 6) + src/.gitkeep +``` + +## package.json templates + +Use the monorepo's `catalog:`/`workspace:*` conventions (verified against +`apps/server/package.json`, `packages/contracts/package.json`). + +**contracts** +```jsonc +{ + "name": "@pixso-move/contracts", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { ".": { "types": "./src/index.ts", "import": "./src/index.ts" } }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run --coverage", + "test:fast": "vitest run" + }, + "dependencies": { "effect": "catalog:" }, + "devDependencies": { + "@effect/vitest": "catalog:", "vitest": "catalog:", "@vitest/coverage-v8": "catalog:" + } +} +``` + +**server** (depends on contracts + processor + effect-acp; node:sqlite is built-in) +```jsonc +{ + "name": "@pixso-move/server", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { "pixso-move-server": "./dist/bin.mjs" }, + "scripts": { + "dev": "node --watch src/bin.ts start", + "build": "tsdown", + "start": "node dist/bin.mjs start", + "typecheck": "tsc --noEmit", + "test": "vitest run --coverage", + "test:fast": "vitest run" + }, + "dependencies": { + "@pixso-move/contracts": "workspace:*", + "@pixso-move/processor": "workspace:*", + "effect": "catalog:", + "@effect/platform-node": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", "vitest": "catalog:", "@vitest/coverage-v8": "catalog:", + "tsdown": "catalog:" + } +} +``` +> If a needed dep is not yet in the root `catalog:` (e.g. `@effect/platform-node`), add it to the +> catalog in `pnpm-workspace.yaml` rather than pinning an ad-hoc version — match `apps/server`. + +**processor** (depends on contracts + effect-acp; effect-acp is unscoped `effect-acp`) +```jsonc +{ + "name": "@pixso-move/processor", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { ".": { "types": "./src/index.ts", "import": "./src/index.ts" } }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run --coverage", + "test:fast": "vitest run" + }, + "dependencies": { + "@pixso-move/contracts": "workspace:*", + "effect": "catalog:", + "effect-acp": "workspace:*", + "@effect/platform-node": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", "vitest": "catalog:", "@vitest/coverage-v8": "catalog:" + } +} +``` + +**plugin** (versions are EXACT — must match `apps/web` so the vendored UI is identical) +```jsonc +{ + "name": "@pixso-move/plugin", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "pnpm build:code && pnpm build:ui", + "build:code": "vite build --config vite.code.config.ts", + "build:ui": "vite build --config vite.ui.config.ts", + "dev:code": "vite build --config vite.code.config.ts --watch", + "dev:ui": "vite build --config vite.ui.config.ts --watch", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "react": "19.2.5", "react-dom": "19.2.5", "@base-ui/react": "1.4.1", + "class-variance-authority": "0.7.1", "tailwind-merge": "3.4.0", "lucide-react": "0.564.0" + }, + "devDependencies": { + "vite": "8.0.10", "@vitejs/plugin-react": "catalog:", + "tailwindcss": "4.2.4", "@tailwindcss/vite": "4.2.4", + "vite-plugin-singlefile": "catalog-or-pin", "typescript": "catalog:", + "@figma/plugin-typings": "pin-latest" + } +} +``` +> The plugin is **not** part of the 100%-coverage/test gate (needs Pixso runtime). It must still +> `typecheck` and `oxlint` clean. `@figma/plugin-typings` gives the Pixso/Figma `figma`/`pixso` +> global types (Pixso mirrors the Figma plugin API). + +## tsconfig.json (each package) — extends the shared base above +```jsonc +{ "extends": "../tsconfig.base.json", "include": ["src", "tests"] } +``` +- `pixso-move/tsconfig.base.json` is **one** level up from each package (`pixso-move//` → + `../tsconfig.base.json`); it in turn extends the worktree-root `../tsconfig.base.json`. ✓ +- Server's `tsconfig.json` adds `"compilerOptions": { "lib": ["ESNext","esnext.disposable"] }`. +- Plugin's overrides `lib`/`jsx`/`types`/`paths` (see [06](./06-plugin-build.md)). + +## Workspace wiring +- Edit `pnpm-workspace.yaml`: add `"pixso-move/*"` to the `packages:` list (alongside `apps/*`, + `packages/*`). +- Add any missing catalog entries used above (`@effect/platform-node`, `@vitejs/plugin-react`, + `vite-plugin-singlefile`, `tsdown` if not present) under `catalog:`. +- Run `pnpm install` so workspace links resolve. +- Optional: a `turbo.json` already globs all workspaces; confirm `typecheck`/`test` tasks pick up + the new packages (no change expected). + +## Confirm effect-acp reuse (blocking gate for task 4) +Verified facts to encode now (so task 4 has no surprises): +- Package name: **`effect-acp`** (unscoped, `private`), `package.json` exports `./client`, + `./schema`, `./errors`, `./rpc`, `./protocol`, `./agent`, `./terminal`. +- Import as `import * as AcpClient from "effect-acp/client"` → `AcpClient.AcpClient` (Context + service), `AcpClient.layerChildProcess(handle, options?)`. +- Depend via `"effect-acp": "workspace:*"`. +- **Action:** add `effect-acp` to processor deps; write a throwaway `tests/imports.test.ts` that + imports `AcpClient`, `AcpSchema`, `AcpErrors` and asserts the symbols exist — proves resolution + before any real wiring. (Delete or fold into real tests in task 4.) + +## TDD / tests +Scaffold has no logic, but the test harness must run: +- Each server-side package gets a trivial `tests/smoke.test.ts` (`it.effect("boots", () => + Effect.succeed(…))`) so `vitest run --coverage` exits 0 with the config in place. +- `vitest.config.ts` per package per [conventions §5](./conventions.md). The 100% thresholds are + active from day one (trivially met while `src` is near-empty; keeps us honest as code lands). + +## Acceptance +- [ ] `pnpm install` resolves; all four packages linked. +- [ ] `turbo run typecheck` — 0 errors across new packages. +- [ ] `pnpm -w lint` — 0 errors. +- [ ] `turbo run test` — green; server-side packages report 100% (trivially). +- [ ] processor `imports.test.ts` proves `effect-acp` resolves. diff --git a/pixso-move/specs/02-contracts.md b/pixso-move/specs/02-contracts.md new file mode 100644 index 00000000000..a08f8b1bc97 --- /dev/null +++ b/pixso-move/specs/02-contracts.md @@ -0,0 +1,104 @@ +# Task 2 — Contracts (`@pixso-move/contracts`) + +All cross-package shapes as **effect Schema**. Server decodes/validates with them; processor reuses +row types; plugin imports request types. TDD: decode/encode tests first. Uses the **confirmed** +Schema API (conventions §4) — no hedging. + +## File budget (all ≤ 150 LOC, single-responsibility) +| Path | Responsibility | est. LOC | +|---|---|---| +| `src/base.ts` | shared primitives copied from ru-fork `baseSchemas.ts` (`TrimmedString`, `TrimmedNonEmptyString`, `NonNegativeInt`) | ~16 | +| `src/ids.ts` | `DesignerId`, `NodeId`, `ResultTag` (branded) | ~18 | +| `src/ingest.ts` | `Base64Png`, `IngestRequest`, `IngestResponse` | ~22 | +| `src/node.ts` | `NodeSummary`, `NodeRecord` | ~18 | +| `src/processing.ts` | `ProcessingStatus`, `ProcessingResult` | ~20 | +| `src/errors.ts` | `AuthError`, `IngestError`, `NodeNotFoundError` | ~18 | +| `src/index.ts` | re-export all | ~8 | +| `tests/*.test.ts` | one per src module | — | + +## `src/base.ts` (copied utility — DRY primitive source) +```ts +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; + +export const TrimmedString = Schema.String.pipe( + Schema.decodeTo(Schema.String, SchemaTransformation.transformOrFail({ + decode: (v) => Effect.succeed(v.trim()), encode: (v) => Effect.succeed(v.trim()), + })), +); +export const TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty()); +export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); +``` +> Verbatim from `packages/contracts/src/baseSchemas.ts:5-15`. Header-mark it `// pixso-move: copied +> from ru-fork baseSchemas.ts`. (Small enough to own; keeps `@pixso-move/contracts` standalone.) + +## `src/ids.ts` +```ts +import * as Schema from "effect/Schema"; +import { TrimmedNonEmptyString } from "./base.ts"; +const makeId = (b: B) => TrimmedNonEmptyString.pipe(Schema.brand(b)); + +export const DesignerId = makeId("DesignerId").check(Schema.isMaxLength(200)); +export const NodeId = makeId("NodeId"); +export const ResultTag = makeId("ResultTag").check(Schema.isMaxLength(64)); +export type DesignerId = typeof DesignerId.Type; +export type NodeId = typeof NodeId.Type; +export type ResultTag = typeof ResultTag.Type; +``` + +## `src/ingest.ts` +```ts +export const Base64Png = Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(8 * 1024 * 1024)); +export const IngestRequest = Schema.Struct({ + designerId: DesignerId, + rootName: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(512)), + nodesJson: Schema.String.check(Schema.isMinLength(2)), // opaque JSON string, stored verbatim + preview: Base64Png, +}); +export const IngestResponse = Schema.Struct({ nodeId: NodeId }); +``` + +## `src/node.ts` +```ts +export const NodeSummary = Schema.Struct({ + nodeId: NodeId, rootName: Schema.String, addedAt: Schema.String, preview: Base64Png }); +export const NodeRecord = Schema.Struct({ + nodeId: NodeId, designerId: DesignerId, rootName: Schema.String, + nodesJson: Schema.String, preview: Base64Png, addedAt: Schema.String }); +``` + +## `src/processing.ts` +```ts +export const ProcessingStatus = Schema.Literals(["pending", "processing", "done", "error"]); +export const ProcessingResult = Schema.Struct({ + nodeId: NodeId, resultTag: ResultTag, status: ProcessingStatus, attempts: NonNegativeInt, + result: Schema.NullOr(Schema.String), error: Schema.NullOr(Schema.String), + createdAt: Schema.String, startedAt: Schema.NullOr(Schema.String), + finishedAt: Schema.NullOr(Schema.String) }); +``` + +## `src/errors.ts` +```ts +export class AuthError extends Schema.TaggedErrorClass()("AuthError", + { message: Schema.String, status: Schema.Int }) {} // 401 +export class IngestError extends Schema.TaggedErrorClass()("IngestError", + { message: Schema.String, status: Schema.Int }) {} // 400 | 413 +export class NodeNotFoundError extends Schema.TaggedErrorClass()( + "NodeNotFoundError", { message: Schema.String, status: Schema.Int }) {} // 404 +``` + +## TDD — tests first (100%) +Use `Schema.decodeUnknownExit(schema)(input)` and assert `Exit.isSuccess`/`Exit.isFailure` +(idiom from `packages/shared/src/schemaJson.ts`). + +- **happy decode** per schema → branded/typed value; `TrimmedNonEmptyString` trims. +- **reject** per refinement: empty string, over-max (`DesignerId>200`, `ResultTag>64`, + `rootName>512`, `preview>8MB`), `nodesJson` length <2, wrong `status` literal, missing field. + Each `.check` branch is exercised (→ 100% branches). +- **errors**: each `TaggedError` carries `_tag`/`message`/`status`. + +## Acceptance +- [ ] Confirmed Schema API only; all valid inputs decode, all invalid rejected (tested). +- [ ] Each file ≤150 LOC, single-purpose; primitives sourced once from `base.ts`. +- [ ] 100% coverage; `tsc`/oxlint clean; shapes exactly match [00-overview.md](./00-overview.md). diff --git a/pixso-move/specs/03-server.md b/pixso-move/specs/03-server.md new file mode 100644 index 00000000000..d83ca068836 --- /dev/null +++ b/pixso-move/specs/03-server.md @@ -0,0 +1,214 @@ +# Task 3 — Server (`@pixso-move/server`) + +Effect + effect-platform HTTP server, `node:sqlite` persistence, 2-table schema, four endpoints, +key-gating, structured logging, launchable bootstrap. **Never crashes.** TDD, 100% (minus +`vendor/**`, `Migrations`, `bin.ts`). Every authored file ≤150 LOC. + +## File budget +| Path | Responsibility | LOC | +|---|---|---| +| `src/vendor/NodeSqliteClient.ts` | **vendored** node:sqlite client (verbatim from ru-fork) | 277\* | +| `src/persistence/sqlite.ts` | `SqlitePersistenceMemory` + `makeSqlitePersistenceLive(dbPath)` + PRAGMA setup | ~45 | +| `src/persistence/migrate.ts` | `runMigrations` = run ordered migration list | ~20 | +| `src/persistence/migrations/001_nodes.ts` | `nodes` DDL | ~22\* | +| `src/persistence/migrations/002_results.ts` | `processing_results` DDL | ~28\* | +| `src/services/nodeStore.ts` | `NodeStore` tag + shape + `NodeRow` schema + decoders | ~45 | +| `src/services/nodeStoreLive.ts` | `NodeStoreLive` layer (insert/list/get/reads) | ~95 | +| `src/services/resultStore.ts` | `ResultStore` tag + shape + `ResultRow` schema + decoders | ~45 | +| `src/services/resultStoreLive.ts` | `ResultStoreLive` layer (reconcile/claim/complete/fail/recover/reads) | ~120 | +| `src/http/cors.ts` | cors headers + `corsLayer` (adds `x-designer-id`) | ~16 | +| `src/http/respond.ts` | `respondJson` + `respondError` (single error→response mapper) | ~30 | +| `src/http/route.ts` | `route(method, path, handler)` wrapper (catch + cors + never-throw) | ~30 | +| `src/http/auth.ts` | `requireDesignerId` | ~25 | +| `src/http/ingest.ts` | `POST /ingest` | ~35 | +| `src/http/nodes.ts` | `GET /nodes`, `GET /nodes/:id` | ~40 | +| `src/http/processing.ts` | `GET /processing-data` | ~30 | +| `src/http/routes.ts` | merge route layers + `corsLayer` | ~15 | +| `src/config.ts` | `ServerConfig` service + `layerTest` + defaults | ~50 | +| `src/serverLogger.ts` | logger layer (vendored-tiny, ported) | ~16 | +| `src/time.ts` | `nowIso` (shared) | ~5 | +| `src/httpServer.ts` | `HttpServerLive` (NodeHttpServer.layer) | ~20 | +| `src/server.ts` | `makeServerLayer` + `runServer` compose | ~70 | +| `src/bin.ts` | flag parse → provide config + AcpRunnerLive → runServer | ~30\* | + +`*` = coverage-excluded (`vendor/**`, `migrations/**`, `bin.ts`). `tests/` mirrors `src/`. + +> **Vendoring (conventions §2.3):** `vendor/NodeSqliteClient.ts` is copied verbatim from +> `apps/server/src/persistence/NodeSqliteClient.ts` (277 LOC, uses `node:sqlite`). Header-marked, +> not rewritten, not split, excluded from coverage. It exports the SqlClient layer constructors our +> `persistence/sqlite.ts` consumes. + +## Persistence + +### `persistence/sqlite.ts` +Mirror `apps/server/src/persistence/Layers/Sqlite.ts:18-76` but trimmed: +```ts +const setup = Layer.effectDiscard(Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`PRAGMA journal_mode = WAL;`; + yield* sql`PRAGMA foreign_keys = ON;`; + yield* runMigrations; +})); +export const SqlitePersistenceMemory = Layer.provideMerge(setup, NodeSqliteClient.layerMemory()); +export const makeSqlitePersistenceLive = (dbPath: string) => /* mkdir parent, then */ + Layer.provideMerge(setup, NodeSqliteClient.layer({ filename: dbPath })); +export const persistenceLive = Layer.unwrap( + Effect.map(Effect.service(ServerConfig), (c) => makeSqlitePersistenceLive(c.dbPath))); +// idiom from apps/server/src/persistence/Layers/Sqlite.ts:74 +``` + +### `persistence/migrate.ts` — no Migrator dependency +Migrations are idempotent DDL (`CREATE TABLE IF NOT EXISTS …`), so just run them in order each +startup: +```ts +import m001 from "./migrations/001_nodes.ts"; +import m002 from "./migrations/002_results.ts"; +export const runMigrations = Effect.gen(function* () { + for (const step of [m001, m002]) yield* step; // ordered, idempotent + yield* Effect.logDebug("migrations applied", { count: 2 }); +}); +``` +> Simpler than the ru-fork `Migrator.fromRecord` registry (we have 2 tables) — fewer moving parts, +> fully covered by the migration test. DDL exactly per [00-overview.md](./00-overview.md): all +> columns, `UNIQUE(node_id, result_tag)`, the 3 indexes. + +## Stores (all SQL lives here; interface ⟂ impl) + +### `services/nodeStore.ts` — interface + row codec +```ts +export interface NodeStoreShape { + readonly insert: (i: { designerId: DesignerId; rootName: string; nodesJson: string; preview: string }) + => Effect.Effect<{ nodeId: NodeId }>; + readonly listSummaries: (d: DesignerId) => Effect.Effect>; + readonly getById: (d: DesignerId, n: NodeId) => Effect.Effect; + readonly listNodeIds: (d: DesignerId) => Effect.Effect>; // processor + readonly getForProcessing: (n: NodeId) + => Effect.Effect<{ nodeId: NodeId; rootName: string; nodesJson: string } | undefined>; +} +export class NodeStore extends Context.Service()("pixso-move/NodeStore") {} +// NodeRow = Schema.Struct({ id, designer_id, root_name, nodes_json, preview, added_at }); +// rowToSummary / rowToRecord via Schema.decode. +``` + +### `services/nodeStoreLive.ts` — `NodeStoreLive` layer +- `insert`: `nodeId = NodeId.make(crypto.randomUUID())`, `addedAt = yield* nowIso`, one `INSERT`. +- `listSummaries`: `SELECT id, root_name, preview, added_at … WHERE designer_id=? ORDER BY added_at + DESC` → decode `NodeSummary[]` (no `nodes_json`). +- `getById`: `WHERE id=? AND designer_id=?` (key isolation in SQL) → `NodeRecord | undefined`. +- `listNodeIds`/`getForProcessing`: minimal projections for the processor. + +### `services/resultStore.ts` — interface + row codec +```ts +export interface ResultStoreShape { + readonly reconcile: (rows: ReadonlyArray<{ designerId; nodeId; resultTag }>) => Effect.Effect; + readonly claimNextPending: Effect.Effect; + readonly complete: (id: string, result: string) => Effect.Effect; + readonly fail: (id: string, error: string) => Effect.Effect; + readonly recoverInFlight: Effect.Effect; + readonly listByNode: (d: DesignerId, n: NodeId) => Effect.Effect>; + readonly countPending: Effect.Effect; +} +export type ClaimedJob = { id: string; designerId: DesignerId; nodeId: NodeId; resultTag: ResultTag }; +export class ResultStore extends Context.Service()("pixso-move/ResultStore") {} +``` + +### `services/resultStoreLive.ts` — `ResultStoreLive` layer +- `reconcile`: `INSERT INTO processing_results (…) VALUES … ON CONFLICT(node_id, result_tag) DO + NOTHING`; id = `crypto.randomUUID()`, `created_at = yield* nowIso`, `status='pending'`. Returns + inserted count. +- `claimNextPending`: `SELECT … WHERE status='pending' ORDER BY created_at LIMIT 1`, then + `UPDATE … SET status='processing', started_at=?, attempts=attempts+1 WHERE id=? AND + status='pending'`; return the job **only if `changes === 1`** (else `undefined` — lost race). +- `complete`/`fail`: set terminal `status` + `finished_at` + `result`/`error`. +- `recoverInFlight`: `UPDATE … SET status='pending' WHERE status='processing'` (no attempts bump). +- `listByNode`: `WHERE designer_id=? AND node_id=?` → `ProcessingResult[]`. `countPending` for tests. + +## HTTP (effect/unstable/http, `HttpRouter.add` — no base-path helper) + +### `http/cors.ts` +```ts +export const corsAllowedMethods = ["GET", "POST", "OPTIONS"] as const; +export const corsAllowedHeaders = ["content-type", "x-designer-id"] as const; +export const corsHeaders = { "access-control-allow-origin": "*", + "access-control-allow-methods": corsAllowedMethods.join(", "), + "access-control-allow-headers": corsAllowedHeaders.join(", ") } as const; +export const corsLayer = HttpRouter.cors({ allowedMethods: [...corsAllowedMethods], + allowedHeaders: [...corsAllowedHeaders], maxAge: 600 }); +``` + +### `http/respond.ts` — the single mapper (DRY) +```ts +export const respondJson = (body: unknown, status: number) => + HttpServerResponse.jsonUnsafe(body, { status, headers: corsHeaders }); +export const respondError = (e: AuthError | IngestError | NodeNotFoundError) => + respondJson({ error: e.message }, e.status); +``` + +### `http/route.ts` — the single wrapper (DRY, never-throws) +```ts +export const route = (method, path, handler: Effect.Effect) => + HttpRouter.add(method, path, handler.pipe( + Effect.catchTags({ AuthError: respondError, IngestError: respondError, NodeNotFoundError: respondError }), + Effect.catchAllCause((cause) => // defects: log + 500, process survives + Effect.zipRight(Effect.logError("route defect", { path, cause: Cause.pretty(cause) }), + respondJson({ error: "Internal error." }, 500))), + )); +``` +Every route is defined via `route(...)`; **no route re-implements error handling or cors.** + +### `http/auth.ts` +`requireDesignerId: Effect` — read `x-designer-id`, decode +through `DesignerId`; missing/blank/invalid → `new AuthError({ message:"Missing or invalid designer +key.", status:401 })`. + +### Route handlers (each tiny, identical shape) +| File | Route | Body | +|---|---|---| +| `http/ingest.ts` | `POST /ingest` | auth → `schemaBodyJson(IngestRequest)` (fail→`IngestError(400)`) → assert `body.designerId === key` (else `AuthError(401)`) → preview-size guard (`IngestError(413)`) → `NodeStore.insert` → `Processor.notify` (task 5) → `respondJson({ nodeId }, 200)`; `logDebug("ingest stored", { nodeId })` | +| `http/nodes.ts` | `GET /nodes` | auth → `NodeStore.listSummaries` → 200 | +| `http/nodes.ts` | `GET /nodes/:id` | auth → `NodeStore.getById` → 200 or `NodeNotFoundError(404)` | +| `http/processing.ts` | `GET /processing-data` | auth → decode `?nodeId` (`IngestError(400)`) → ensure node owned (`NodeNotFoundError(404)`) → `ResultStore.listByNode` → 200 | + +### `http/routes.ts` +Merge the four `route(...)` layers; provide `corsLayer`. (`OPTIONS` preflight handled by `corsLayer`.) + +## Config / logger / bootstrap +- `config.ts`: `ServerConfig = Context.Service<…>("pixso-move/ServerConfig")` with `{ host, port, + dbPath, logLevel, cliJs, cliHome? }`; `ServerConfig.layerTest(overrides)`; defaults `host + 127.0.0.1`, `port 7787`, `dbPath ./.data/pixso.sqlite`, `logLevel Debug`. +- `serverLogger.ts`: port `apps/server/src/serverLogger.ts:8-16` (consolePretty + MinimumLogLevel). +- `httpServer.ts`: `HttpServerLive = NodeHttpServer.layer(NodeHttp.createServer, { host, port })` + (dynamic import, mirror `server.ts:100-112`). +- `server.ts`: `makeServerLayer = Layer.mergeAll(HttpRouter.serve(routes), httpListening?)` provided + with `persistenceLive`, `ServerLoggerLive`, `NodeStoreLive`, `ResultStoreLive`, `ProcessorLive` + (task 5), over `PlatformServicesLive`/`NodeServices`. `runServer = Layer.launch(makeServerLayer)`. +- `bin.ts`: parse `--port/--host/--db/--cli-js` → provide `ServerConfig` + `AcpRunnerLive` → + `runServer` (coverage-excluded). + +## TDD — tests first (100%) +**migrations** (`tests/persistence`): run `SqlitePersistenceMemory`; assert both tables + 3 indexes + +the unique constraint exist (query `sqlite_master`); idempotent (build layer twice). + +**nodeStore**: insert returns fresh id + persists all columns, `added_at` ISO (pin clock); +`listSummaries` excludes `nodes_json`, newest-first, **isolated by designerId**; `getById` undefined +for wrong key and missing id; `listNodeIds`/`getForProcessing` projections correct. + +**resultStore**: `reconcile` inserts only missing pairs, idempotent (2nd call → 0); `claimNextPending` +flips one row to `processing` (attempts=1) and returns it; pre-claimed row → loser gets `undefined`; +`complete`/`fail` set terminal status + `finished_at` + result/error; `recoverInFlight` flips +`processing→pending` without bumping attempts; `listByNode` scoped + all tags; `countPending`. + +**http** (`tests/http`): exercise each route Effect with a constructed `HttpServerRequest` over a +layer = routes + `SqlitePersistenceMemory` + stores + test logger + a **no-op `Processor`** + +`ServerConfig.layerTest`. Cover: missing/blank/invalid key→401; ingest body/header mismatch→401; +invalid body→400; oversize preview→413; happy→200 + body + `corsHeaders`; `/nodes/:id` unknown→404, +wrong-key→404; `processing-data` missing/invalid `nodeId`→400, unknown node→404, happy→array. +**Catch-all**: inject a store layer whose method dies → handler returns 500 `{error}` and `logError` +fired (proves never-crash). `respond.ts`/`route.ts`/`auth.ts`/`cors.ts`/`config.ts`/`time.ts` each +fully covered. + +## Acceptance +- [ ] Both tables + indexes + unique constraint; key-gating enforced in SQL and at the edge. +- [ ] Every route via `route()`; one `respondError`; no duplicated handling; defects → 500+log. +- [ ] `logError`/`logDebug` only; ingest steps logged; server cannot crash. +- [ ] Every authored file ≤150 LOC; `vendor/` isolated; `tsc`/oxlint clean; coverage 100% (excl.). diff --git a/pixso-move/specs/04-processor.md b/pixso-move/specs/04-processor.md new file mode 100644 index 00000000000..0f230ec5f1a --- /dev/null +++ b/pixso-move/specs/04-processor.md @@ -0,0 +1,144 @@ +# Task 4 — Processor (`@pixso-move/processor`) + +Watches new node records from **configured** designers, runs each configured prompt through qwen +over ACP (via `effect-acp`), writes status-tracked rows. **Never crashes.** TDD, 100% (minus the one +`*.integration.ts` spawn-glue file). Every authored file ≤150 LOC. + +## File budget +| Path | Responsibility | LOC | +|---|---|---| +| `src/types.ts` | `ProcessorConfig`, `ConfigEntry`, `ProcessorDeps`, `AcpRunner`, `AcpRunError`, `ClaimedJob`, `ProcessorOptions` | ~50 | +| `src/config.ts` | the operator-edited `processorConfig` value | ~20 | +| `src/prompt.ts` | `buildPrompt(input)` — pure | ~25 | +| `src/extract.ts` | `extractText(raw)` — pure (code-fence unwrap) | ~25 | +| `src/reconcile.ts` | `computeReconcileRows(config, nodesByDesigner)` — pure | ~30 | +| `src/drain.ts` | `runOneJob(deps, job)` — the single contained unit | ~45 | +| `src/engine.ts` | `makeProcessor(deps, options)` — loop, notify, recover, timer | ~95 | +| `src/processor.ts` | `Processor` service tag + `Processor` shape | ~20 | +| `src/acp/collect.ts` | `accumulateDelta(buf, notification)` — pure delta reducer | ~25 | +| `src/acp/handshake.ts` | initialize/authenticate/createSession param builders + stopReason/error mapping — pure | ~30 | +| `src/acp/acpRunnerLive.integration.ts` | spawn qwen + `layerChildProcess` + run → `AcpRunner` | ~80\* | +| `src/index.ts` | public exports | ~12 | + +`*` = coverage-excluded (only the real-process spawn glue). `tests/` mirrors the pure modules. + +> **No dependency on `@pixso-move/server`.** The processor owns the `ProcessorDeps` interface; the +> server satisfies it at embed time (task 5). This avoids a cycle and keeps SQL solely in the +> server's stores. + +## Injected dependencies (`types.ts`) +```ts +export interface ProcessorDeps { + readonly listNodeIds: (d: DesignerId) => Effect.Effect>; + readonly getForProcessing: (n: NodeId) + => Effect.Effect<{ nodeId: NodeId; rootName: string; nodesJson: string } | undefined>; + readonly reconcile: (rows: ReadonlyArray<{ designerId; nodeId; resultTag }>) => Effect.Effect; + readonly claimNextPending: Effect.Effect; + readonly complete: (id: string, result: string) => Effect.Effect; + readonly fail: (id: string, error: string) => Effect.Effect; + readonly recoverInFlight: Effect.Effect; + readonly acp: AcpRunner; +} +export interface AcpRunner { + readonly run: (input: { prompt: string }) => + Effect.Effect<{ text: string; stopReason: string }, AcpRunError>; +} +``` +The tiny `AcpRunner` seam is what makes the engine testable and isolates all `effect-acp` detail. + +## Pure helpers (fully tested) + +### `prompt.ts` +`buildPrompt({ prompt, rootName, nodesJson }): string` — configured prompt + output rule ("return +the result only") + payload (`rootName` then ```json fenced `nodesJson`). Deterministic. Borrowed +*shape* from the reference's prompt-builder; reimplemented. + +### `extract.ts` +`extractText(raw): { text: string }` — if the result is a single fenced block, return its body; +else the trimmed whole. Borrowed *idea* (code-fence extraction) from the reference. + +### `reconcile.ts` +`computeReconcileRows(config, nodesByDesigner): Array<{ designerId; nodeId; resultTag }>` — cross +each configured designer's `nodeIds` with that designer's `resultTag`s. Pure; the engine fetches +`nodesByDesigner` via `deps.listNodeIds` and passes `deps.reconcile` the result. + +### `acp/collect.ts` +`accumulateDelta(buf: string, n: SessionNotification): string` — appends `n.update.content.text` +when `n.update.sessionUpdate === "agent_message_chunk"` && `content.type === "text"`; ignores other +update kinds. Pure reducer over the `effect-acp/schema` union. + +### `acp/handshake.ts` +Pure builders: `initializeParams()`, `authenticateParams()` (`{ methodId: "openai" }`), +`newSessionParams(cwd)`, and `mapAcpError(e: AcpError): AcpRunError`, +`mapStopReason(res): string`. (Constants verified against `AcpSessionRuntime.ts` + `config.ts:52`.) + +## `drain.ts` — the contained unit +```ts +export const runOneJob = (deps: ProcessorDeps, job: ClaimedJob) => Effect.gen(function* () { + const node = yield* deps.getForProcessing(job.nodeId); + if (!node) { yield* deps.fail(job.id, "node missing"); return; } + const prompt = buildPrompt({ prompt: /*from config via engine*/, rootName: node.rootName, nodesJson: node.nodesJson }); + const res = yield* deps.acp.run({ prompt }); + yield* deps.complete(job.id, extractText(res.text).text); + yield* Effect.logDebug("job done", { nodeId: job.nodeId, resultTag: job.resultTag, stopReason: res.stopReason }); +}).pipe(Effect.catchAllCause((cause) => Effect.zipRight( + Effect.logError("job failed", { nodeId: job.nodeId, resultTag: job.resultTag, cause: Cause.pretty(cause) }), + deps.fail(job.id, Cause.pretty(cause))))); // ANY failure/defect → error row, never escapes +``` +> The job's prompt text comes from the config entry matching `job.resultTag`+`designerId`; the +> engine resolves it and passes it in (so `drain` stays pure-of-config-lookup). Adjust signature to +> `runOneJob(deps, job, promptText)`. + +## `engine.ts` — the loop +```ts +export const makeProcessor = (deps, options): Effect.Effect +``` +`ProcessorOptions = { config: ProcessorConfig; pollIntervalMs?: number /*2000*/ }`. +- **start**: `recoverInFlight` (`logDebug("recovered", { count })`) → fork a `Schedule.fixed` + timer calling `notify`, interruptible on scope close. +- **runTickOnce**: (1) for each configured designer, `listNodeIds` → `computeReconcileRows` → + `deps.reconcile`; `logDebug("reconciled", { inserted })`. (2) drain: loop `claimNextPending`; + resolve `promptText` from config by `resultTag`; `runOneJob(deps, job, promptText)`; until + `undefined`. +- **notify**: if a tick is in-flight (`Ref`), set a "re-run" flag and return; else run a + tick, then re-run once if flagged. Serializes ticks (reference's poll+notify guard). +- **never-crash**: `runTickOnce` is wrapped `Effect.catchAllCause(logError)` so a tick can't kill + the timer; `runOneJob` already contains per-job failure; `notify` is self-contained. +- **stop**: interrupt timer, await in-flight. + +`processor.ts` exports `Processor = Context.Service<…>("pixso-move/Processor")` with +`{ start; notify; stop; runTickOnce }`. + +## Real ACP impl — `acp/acpRunnerLive.integration.ts` (excluded) +The only un-unit-testable file. Assembles the pure helpers into a real runner: +1. spawn `process.execPath [cliJs, "--acp"]` via `ChildProcessSpawner` (`shell:false`, env per + `CliAcpSupport.ts`: `CLI_HOME`, `NODE_TLS_REJECT_UNAUTHORIZED="0"`). +2. `AcpClient.layerChildProcess(handle)` → `AcpClient.AcpClient`. +3. register `client.handleSessionUpdate(n => Ref.update(buf, b => accumulateDelta(b, n)))`. +4. `initialize` → `authenticate` → `createSession` (using `handshake.ts` builders) → `prompt`. +5. read `buf`; return `{ text, stopReason: mapStopReason(res) }`; map `AcpError` via `mapAcpError`. +Exposed as `AcpRunnerLive` layer. Session-per-job (simple, isolated); child reused across jobs with +idle reap is an allowed later optimization. Because every pure part lives in `collect.ts`/ +`handshake.ts`/`extract.ts` (100% tested), this file is thin glue and justifiably excluded. + +## TDD — tests first (100% of non-excluded) +Inject a **FakeAcpRunner** (scripted `{text,stopReason}` or fail-on-demand) + the **real stores** +over `SqlitePersistenceMemory` (exercises real claim atomicity), or hand fakes implementing +`ProcessorDeps`. +- **prompt/extract/collect/handshake/reconcile**: enumerated pure cases (fence variants; delta kinds + appended vs ignored; reconcile crosses only configured designers × their tags; error/stopReason + mappings). +- **drain**: success→`done`+text; FakeAcpRunner failure→`error` row + `logError`; a thrown defect→ + caught→`error`+loop continues (assert a second queued job still completes); missing node→ + `fail("node missing")`. +- **engine**: reconcile creates rows only for configured designers; multiple tags→multiple rows; + idempotent across ticks; adding a config entry + re-tick backfills existing nodes; crash recovery + (`processing→pending` on start, attempts not bumped); notify serialization (no concurrent ticks, + re-run after); timer fires (Effect **TestClock**, no real sleep); the loop type is `Effect<…, + never>` (never-fails proof). + +## Acceptance +- [ ] Only configured designers processed; multiple tags supported; full lifecycle observable. +- [ ] Crash recovery resumes interrupted work; no job (even a defect) breaks the loop/process. +- [ ] Every step logged (`logDebug` progress / `logError` failures). +- [ ] Each authored file ≤150 LOC; only `*.integration.ts` excluded; `tsc`/oxlint clean; 100%. diff --git a/pixso-move/specs/05-embed.md b/pixso-move/specs/05-embed.md new file mode 100644 index 00000000000..708a1a6b872 --- /dev/null +++ b/pixso-move/specs/05-embed.md @@ -0,0 +1,90 @@ +# Task 5 — Embed the processor into the server runtime + +Compose the processor (task 4) into the server's Effect runtime (task 3) so they share one process, +one sqlite connection, and one logger. Wire `POST /ingest` to `notify()` the processor for +low-latency pickup. TDD, 100% coverage for the wiring. + +## What gets wired + +``` +makeServerLayer + ├─ persistence (SqlitePersistenceMemory | layerConfig) ← single sqlite, single writer + ├─ ServerLoggerLive + ├─ NodeStoreLive, ResultStoreLive (task 3 services) + ├─ ProcessorLive (NEW — builds makeProcessor) + │ deps = ProcessorDeps built from NodeStore + ResultStore + │ started on layer acquisition; stopped on release (scoped) + └─ HttpRouter.serve(routes) ← ingest handler resolves Processor and calls notify +``` + +## File budget +| Path | Responsibility | LOC | +|---|---|---| +| `server/src/services/processorLive.ts` | build `ProcessorDeps` from stores + `AcpRunner`; `ProcessorLive` scoped layer | ~45 | + +(`Processor` tag and `AcpRunnerLive` come from `@pixso-move/processor`; `NodeStore.listNodeIds`/ +`getForProcessing` already exist from task 3 — no new store methods needed.) + +## ProcessorLive layer (`server/src/services/processorLive.ts`) +```ts +export const ProcessorLive = Layer.scoped(Processor, Effect.gen(function* () { + const nodeStore = yield* NodeStore; + const resultStore = yield* ResultStore; + const acp = yield* AcpRunner; // AcpRunnerLive in prod; provided fake in tests + const deps: ProcessorDeps = { + listNodeIds: nodeStore.listNodeIds, + getForProcessing: nodeStore.getForProcessing, + reconcile: resultStore.reconcile, + claimNextPending: resultStore.claimNextPending, + complete: resultStore.complete, + fail: resultStore.fail, + recoverInFlight: resultStore.recoverInFlight, + acp, + }; + const processor = yield* makeProcessor(deps, { config: processorConfig }); + yield* processor.start; // recover + arm timer + yield* Effect.addFinalizer(() => processor.stop); + return processor; +})); +``` +- `Processor` is the `Context.Service` tag from `@pixso-move/processor`. The ingest handler resolves + it to call `notify`. SQL stays solely in task-3 stores. + +## Ingest → notify +In `http/ingest.ts` (task 3), after a successful `NodeStore.insert`: +```ts +const processor = yield* Processor; +yield* processor.notify; // fire-and-forget pickup; never fails the request +``` +- `notify` is non-blocking and self-contained (its own catch). If the designer is unconfigured, + the tick reconciles nothing for them — harmless. The HTTP response returns `{ nodeId }` + regardless of processing outcome. + +## AcpRunner provisioning +- **Production**: `AcpRunnerLive` (task 4) provided into `makeServerLayer`, reading `cliJs`/`cwd`/ + env from `ServerConfig` (extend `ServerConfig` with `cliJs`, `cliHome?` fields; default `cliJs` + to a config flag `--cli-js`). +- **Tests**: provide a `FakeAcpRunner` layer instead → end-to-end-in-memory without qwen. + +## TDD — tests first (100%) +Build a full in-memory app layer: `SqlitePersistenceMemory` + stores + `ProcessorLive` (with +**FakeAcpRunner**) + routes + test logger + `ServerConfig.layerTest`. + +- **embed smoke**: layer builds, processor `start` ran (recovery executed), timer armed; layer + release stops the processor (no leaked fiber — assert finalizer ran). +- **ingest triggers processing**: `POST /ingest` for a **configured** designer → 200 `{ nodeId }`; + after allowing the tick to run (drive `runTickOnce` or advance TestClock), `GET /processing-data? + nodeId=…` shows the configured tags transitioning to `done` with FakeAcpRunner's text. +- **unconfigured designer**: `POST /ingest` for an unknown designer → stored, but + `processing-data` stays empty (no rows reconciled). Server fine. +- **notify never breaks ingest**: inject a processor whose `notify` would error → ingest still + returns 200 (notify is contained). Assert `logError` captured, response unaffected. +- **shared writer**: ingest + processing both write the same in-memory DB without error + (single-writer guarantee holds because one runtime). + +## Acceptance +- [ ] One process, one sqlite, one logger; processor starts/stops with the server layer. +- [ ] Ingest notifies the processor; pickup is near-immediate for configured designers. +- [ ] A failing `notify` never affects the HTTP response. +- [ ] End-to-end in-memory test (ingest → processed → readable) passes with a fake ACP runner. +- [ ] `tsc`/oxlint clean; wiring covered 100%. diff --git a/pixso-move/specs/06-plugin-build.md b/pixso-move/specs/06-plugin-build.md new file mode 100644 index 00000000000..8e364e5d7e5 --- /dev/null +++ b/pixso-move/specs/06-plugin-build.md @@ -0,0 +1,110 @@ +# Task 6 — Plugin build & vendored UI kit (`@pixso-move/plugin`) + +Set up the two-bundle Pixso plugin build and vendor ru-fork web's UI kit so the iframe UI looks +**identical** to ru-fork. No app logic yet (tasks 7–8) — this task produces a buildable shell with +the design system in place. + +## File budget (authored; vendored UI files exempt per conventions §2.3) +| Path | Responsibility | LOC | +|---|---|---| +| `vite.code.config.ts` | sandbox IIFE build | ~25 | +| `vite.ui.config.ts` | iframe React+Tailwind single-file build | ~20 | +| `manifest.json` | Pixso plugin manifest | ~8 | +| `src/ui/index.html` | iframe host (`data-theme`/`dark`) | ~20 | +| `src/ui/main.tsx` | React mount | ~10 | +| `src/ui/lib/utils.ts` | vendored `cn` (stripped) | ~6 | +| `src/ui/components/ui/*.tsx` | **vendored** (button/input/label/field/card) | exempt | +| `src/ui/index.css`, `src/ui/themes/ru-fork.css` | **vendored** css/theme | exempt | + +All **authored** plugin files obey the 150-LOC cap. App/screen/code decomposition is in +[07](./07-plugin-code.md) / [08](./08-plugin-ui.md). + +## Pixso plugin model (two bundles) +A Pixso plugin (Figma-compatible API) ships two artifacts declared in `manifest.json`: +- **`main`** → `dist/code.js` — runs in the **sandbox** (the `pixso`/`figma` main thread; has the + node API; **no** DOM). Built as a single IIFE. +- **`ui`** → `dist/ui.html` — runs in an **iframe** (full DOM/React; **no** node API). Built as one + self-contained HTML (JS+CSS inlined via `vite-plugin-singlefile`). +They communicate only via `postMessage` (task 7). + +## manifest.json +```json +{ + "name": "PixsoMove", + "id": "REPLACE_WITH_PIXSO_PLUGIN_ID", + "api": "2.0.0", + "editorType": ["pixso"], + "main": "dist/code.js", + "ui": "dist/ui.html" +} +``` +(Format verified against the reference `pixso-plugin/manifest.json`.) + +## Vite configs (two) +**`vite.code.config.ts`** — sandbox bundle (IIFE, no externals, minified): +```ts +export default defineConfig({ + build: { + target: "esnext", minify: true, emptyOutDir: false, outDir: "dist", + lib: { entry: "src/code/code.ts", name: "__pixsoPlugin", formats: ["iife"], fileName: () => "code.js" }, + rollupOptions: { output: { inlineDynamicImports: true } }, + }, +}); +``` +**`vite.ui.config.ts`** — iframe bundle (React + Tailwind v4 + single-file): +```ts +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; +export default defineConfig({ + root: "src/ui", + plugins: [react(), tailwindcss(), viteSingleFile()], + resolve: { alias: { "~": path.resolve(import.meta.dirname, "src/ui") } }, // mirror ru-fork "~/" + build: { target: "esnext", outDir: "../../dist", emptyOutDir: false, + rollupOptions: { input: "src/ui/index.html" } }, +}); +``` +> Two configs because the two bundles have **opposite** constraints (no-DOM IIFE vs React SPA). +> Verified against the reference plugin's `vite.code.config.ts`/`vite.ui.config.ts`. + +## Vendored UI kit (copied from `apps/web`, must stay identical) +Copy these into `src/ui/` and rewrite imports (`~/lib/utils` works via the `~` alias): + +| Source (apps/web) | Dest (plugin) | Notes | +|---|---|---| +| `src/lib/utils.ts` → just `cn()` | `src/ui/lib/utils.ts` | **strip** the `@t3tools/contracts` import + the id helpers; keep only `cn` (cx + twMerge) | +| `src/components/ui/button.tsx` | `src/ui/components/ui/button.tsx` | verbatim | +| `src/components/ui/input.tsx` | `src/ui/components/ui/input.tsx` | verbatim | +| `src/components/ui/label.tsx` | `src/ui/components/ui/label.tsx` | verbatim | +| `src/components/ui/field.tsx` | `src/ui/components/ui/field.tsx` | verbatim | +| `src/components/ui/card.tsx` | `src/ui/components/ui/card.tsx` | verbatim | +| `src/index.css` | `src/ui/index.css` | keep `@import "tailwindcss"`, the `@theme inline` token map, the `@layer base`, and the cross-theme fallbacks. **Drop** imports of themes we don't ship (keep only `./themes/ru-fork.css`). | +| `src/themes/ru-fork.css` | `src/ui/themes/ru-fork.css` | verbatim (light + dark blocks) | + +Exact dep versions (must match `apps/web` so classes/tokens render identically): +`react@19.2.5`, `react-dom@19.2.5`, `@base-ui/react@1.4.1`, `class-variance-authority@0.7.1`, +`tailwind-merge@3.4.0`, `lucide-react@0.564.0`, `tailwindcss@4.2.4`, `@tailwindcss/vite@4.2.4`, +`vite@8.0.10`. + +> **Why copy, not import:** the plugin is a separate vite build that can't pull from the `apps/web` +> app (router/store/effect deps). Per the project rule, we vendor the **presentational** components +> (they only need `@base-ui/react` + `cn`). Mark each copied file with a header comment +> `// pixso-move: vendored from apps/web/src/components/ui/.tsx — keep in sync`. + +## index.html (iframe host) +`src/ui/index.html` sets `data-theme="ru-fork"` + `class="dark"` on `` (so tokens resolve), +imports `index.css`, mounts `#root`, and loads `main.tsx`. Body uses the same font stack as +ru-fork (DM Sans) for parity; bundle the font or fall back to system if offline (corporate). + +## TDD / validation +Plugin is exempt from the 100% rule (no Pixso runtime here), **but**: +- `pnpm --filter @pixso-move/plugin typecheck` → 0 errors (the vendored components must typecheck + against `@base-ui/react@1.4.1`). +- `oxlint` → 0 errors on `src/`. +- `pnpm --filter @pixso-move/plugin build` produces `dist/code.js` + `dist/ui.html`. +- A pure-logic unit test target *is* added in task 7 for the sandbox helpers. + +## Acceptance +- [ ] `build` emits `dist/code.js` (IIFE) + `dist/ui.html` (single file). +- [ ] Vendored components typecheck & lint clean; tokens/classes render (manual eyeball in task 9). +- [ ] Only the `ru-fork` theme is shipped; UI matches ru-fork visually. diff --git a/pixso-move/specs/07-plugin-code.md b/pixso-move/specs/07-plugin-code.md new file mode 100644 index 00000000000..dc74baaa600 --- /dev/null +++ b/pixso-move/specs/07-plugin-code.md @@ -0,0 +1,123 @@ +# Task 7 — Plugin sandbox (`code.ts`) + +The sandbox side: read the selection, **validate it's one frame**, serialize the node subtree, +export a 1× preview, and bridge to the UI over `postMessage`. Pure helpers are unit-tested. + +## File budget (all authored ≤150 LOC, single-responsibility) +| Path | Responsibility | LOC | tested | +|---|---|---|---| +| `src/code/code.ts` | entry: `showUI`, selection listener, message routing (glue) | ~70 | no (needs Pixso) | +| `src/code/selection.ts` | `validateSelection` — pure | ~25 | yes | +| `src/code/serialize.ts` | `serializeNode` — recursive collect | ~95 | yes | +| `src/code/nodeProps.ts` | per-node property extraction (split out of serialize) | ~60 | yes | +| `src/code/base64.ts` | `bytesToBase64` — pure encoder | ~25 | yes | +| `src/code/settings.ts` | `clientStorage` read/save + `parseSettings` (pure) | ~40 | pure part yes | +| `src/shared/messages.ts` | typed UI↔code message unions (shared with UI) | ~35 | n/a | +| `tests/*` | one per pure module | — | — | + +> If `serialize.ts` would exceed 150 LOC, the per-node property extraction lives in `nodeProps.ts` +> and `serialize.ts` only does the recursion + caps. `messages.ts` lives under `src/shared/` so both +> the sandbox and the iframe import the same unions (DRY). + +## Plugin lifecycle (`code.ts`) +```ts +pixso.showUI(__html__, { width: 380, height: 560 }); +pixso.on("selectionchange", () => postSelectionState()); +pixso.ui.onmessage = (msg: UiToCode) => { /* route: "request-preview" | "collect-and-send-meta" */ }; +``` +- On load and on every `selectionchange`, compute `validateSelection(pixso.currentPage.selection)` + and post the result to the UI (so the UI can enable/disable Send and show the "not allowed" + message live). + +## Selection validation (`selection.ts`) — PURE, the core rule +> Rule: support a **regular selection of exactly one node** (its whole subtree comes with it). +> Reject ctrl/shift multi-select of unrelated items. + +```ts +type SelectionVerdict = + | { ok: true; node: { id: string; name: string } } + | { ok: false; reason: "empty" | "multiple" }; + +export const validateSelection = (selection: ReadonlyArray): SelectionVerdict; +``` +- `selection.length === 0` → `{ ok:false, reason:"empty" }` (UI: "Select a frame to analyze"). +- `selection.length > 1` → `{ ok:false, reason:"multiple" }` (UI: "Select a single frame — multiple + unrelated items aren't supported"). +- `selection.length === 1` → `{ ok:true, node:{ id, name } }`. The one node's children come for + free via serialization; no further parent/page checks needed because a single selected node is by + definition one subtree. + +`SceneNodeLike` is a minimal structural type (`{ id: string; name: string; type: string; … }`) so +the function is testable without the Pixso runtime. + +## Node serialization (`serialize.ts`) +```ts +export const serializeNode = (node: SceneNodeLike): string; // returns JSON string +``` +- Recursively walk `node` + `node.children`, capturing a useful, **stable** subset of properties + (id, name, type, geometry, layout/auto-layout, fills/strokes/effects, text + style, + componentId/variant info). Borrow the property list from the reference's `nodes-full` collector + (`packages/pixso-plugin/.../nodes-full.ts`) as a **starting point** — it's a utility we can adapt. +- Guard depth/size: cap recursion depth and total node count (configurable consts) to avoid + pathological trees; if capped, include a `truncated: true` marker. Log nothing here (sandbox has + no server logger); surface a count to the UI. +- Output is `JSON.stringify` of the collected tree → becomes `IngestRequest.nodesJson`. +- Tested with hand-built fake node trees: nested children serialized; selected subset of props + present; depth/count cap triggers `truncated`; empty children handled. + +## Preview export (1×) — in `code.ts` (not pure; thin) +```ts +const bytes = await node.exportAsync({ format: "PNG", constraint: { type: "SCALE", value: 1 } }); +const base64 = bytesToBase64(bytes); // bytesToBase64 is PURE + tested +``` +- `bytesToBase64(Uint8Array): string` is a pure helper (chunked `String.fromCharCode` + `btoa`, or + a manual base64 encoder since the sandbox may lack `btoa` — verify; provide a pure encoder and + test it against known vectors). +- 1× scale per the product decision ("so we can use it later in pixel-perfect"). +- The base64 (no `data:` prefix) → `IngestRequest.preview`. + +## Message bridge (`messages.ts`) — typed both directions +```ts +type CodeToUi = + | { type: "selection-state"; verdict: SelectionVerdict } + | { type: "preview-ready"; preview: string; rootName: string } // base64 + | { type: "collected"; nodesJson: string; rootName: string; preview: string; nodeCount: number } + | { type: "error"; message: string }; +type UiToCode = + | { type: "request-preview" } // user selected → show preview in UI + | { type: "collect-and-send-meta" }; // user pressed Send → gather everything for the UI to POST +``` +- The **UI** owns the `fetch` to the server (it has the saved settings + designerId in + `clientStorage` is read on the code side? — see note). Decision: **settings live UI-side** (the + UI renders/edits them and does the POST); the **code side** only produces nodesJson + preview and + hands them to the UI via `postMessage`. This keeps network out of the sandbox and matches the + reference (UI did the fetch). +- On `collect-and-send-meta`: validate selection again (guard), serialize, export preview, post + `{ type:"collected", nodesJson, rootName, preview, nodeCount }`. On any thrown error, post + `{ type:"error", message }` (UI shows it) — the sandbox never throws unhandled. + +> **clientStorage note:** Pixso `figma.clientStorage` is available in the **sandbox**, not the +> iframe. Two options: (a) UI sends settings to code to persist, code echoes them back on load; or +> (b) UI persists via a `clientStorage` round-trip through messages. Spec choice: the **code side** +> owns `clientStorage` (it's the only side with access); on load it reads settings and posts +> `{ type:"settings-loaded", settings }` to the UI; the UI sends `{ type:"save-settings", settings }` +> which code persists. Add these two message variants. (This is the one place the sandbox touches +> persistence; keep it tiny and tested via a fake clientStorage.) + +## TDD — tests first +Pure helpers are fully tested (this is the plugin's tested surface): +- `validateSelection`: empty / single / multiple → correct verdicts. +- `serializeNode`: structure, prop subset, depth/count cap + `truncated`, empty children. +- `bytesToBase64`: known byte→base64 vectors; empty input. +- (if code-side settings) a `parseSettings`/`applyDefaults` pure helper for stored settings → + tested for missing/partial/garbage stored values. + +The thin glue in `code.ts` (showUI, exportAsync, message routing) is not unit-tested (needs the +Pixso runtime) — kept minimal; validated manually in task 9. + +## Acceptance +- [ ] Selecting one frame → `{ ok:true }`; zero or multiple → the right rejection reason. +- [ ] Serialization produces stable JSON of the subtree with caps. +- [ ] 1× preview exported as base64. +- [ ] Sandbox never throws unhandled; errors are posted to the UI. +- [ ] Pure helpers tested; `typecheck`/oxlint clean. diff --git a/pixso-move/specs/08-plugin-ui.md b/pixso-move/specs/08-plugin-ui.md new file mode 100644 index 00000000000..d821dffb3bf --- /dev/null +++ b/pixso-move/specs/08-plugin-ui.md @@ -0,0 +1,103 @@ +# Task 8 — Plugin UI (iframe React app) + +The designer-facing UI, built from the vendored ru-fork kit (task 6) and bridged to the sandbox +(task 7). Three screens: **Settings**, **Select/Preview**, **Send**. The UI owns settings state and +the `fetch` to the server. + +## File budget (all authored ≤150 LOC, single-responsibility) +| Path | Responsibility | LOC | tested | +|---|---|---|---| +| `src/ui/App.tsx` | wire `reducer` + `useBridge` + switch screen (thin) | ~70 | manual | +| `src/ui/state/reducer.ts` | `reduce(state, msg)` — **pure** state machine | ~70 | yes | +| `src/ui/state/types.ts` | `Settings`, `UiState`, `Screen` types | ~25 | n/a | +| `src/ui/bridge.ts` | `postToCode` + `useBridge(dispatch)` hook (Pixso envelope) | ~40 | hook glue | +| `src/ui/api.ts` | `buildIngestRequest` (pure) + `sendToServer(_, _, fetchImpl)` | ~45 | yes | +| `src/ui/key.ts` | `generateDesignerId()` (`dz_${uuid}`) — pure | ~10 | yes | +| `src/ui/screens/SettingsScreen.tsx` | settings form (vendored Card/Field/Input/Button) | ~95 | manual | +| `src/ui/screens/SelectScreen.tsx` | placeholder + preview + Send | ~95 | manual | +| `src/ui/components/ui/*`, `lib/utils.ts` | **vendored** (task 6) | exempt | + +> `App.tsx` stays thin: it holds `useReducer(reduce, initial)`, calls `useBridge(dispatch)`, and +> renders a screen by `state.screen`. **All UI logic is in the pure `reduce` + `api` + `key` +> modules** (100% unit-tested), so no screen file needs logic beyond rendering + dispatch. + +## State model (local React state; no store needed) +```ts +type Settings = { serverUrl: string; designerId: string }; +type Screen = "select" | "settings"; +// plus: selectionVerdict, preview (base64|null), rootName, isSending, sendResult, errorMessage +``` +- Settings come from the sandbox on load (`settings-loaded` message) and are saved back via + `save-settings`. The UI never persists directly (sandbox owns `clientStorage`). + +## Screen 1 — Settings (`SettingsScreen`) +Built with vendored `Card`, `Field`/`FieldLabel`/`FieldDescription`, `Input`, `Button`, `Label`. +- **Server URL** field — `Input`, default `http://localhost:7787`. Basic validation (non-empty, + parses as URL) shown via `FieldError`. +- **Designer key (`designerId`)** field — `Input` showing the current key + two buttons: + - **Generate** → `dz_${uuid}` (a `generateDesignerId()` pure helper; uuid via `crypto.randomUUID` + in the iframe — DOM context, available). Fills the field. + - **Save** → posts `save-settings` to the sandbox; shows a saved confirmation. + - Copy-to-clipboard affordance (designer "writes it on paper"/shares it). +- Reachable via a gear button in the header from the Select screen, and on first run (no saved key) + the UI opens Settings by default. + +## Screen 2 — Select / Preview / Send (`SelectScreen`) +Driven by `selection-state` messages from the sandbox: +- **No/var selection** → placeholder card: "Select a frame to analyze" (verdict `empty`) or + "Select a single frame — multiple unrelated items aren't supported" (verdict `multiple`). + Send disabled. +- **Valid single selection** → request a preview (`request-preview` → `preview-ready`), show the + **preview image** (the base64 PNG) in a `Card`, the frame name, and an enabled **Send to server** + `Button`. +- **Send** flow: + 1. Guard: settings present (else route to Settings with a hint). + 2. Ask the sandbox to collect (`collect-and-send-meta` → `collected { nodesJson, rootName, + preview, nodeCount }`). + 3. `api.sendToServer(settings, { designerId, rootName, nodesJson, preview })` → `POST {serverUrl}/ingest` + with header `x-designer-id: ` and `IngestRequest` body. + 4. On 200 → success state (show returned `nodeId`); on non-2xx or network error → `errorMessage` + from the response/`catch` (the UI surfaces server errors verbatim). + +## api.ts (testable) +```ts +export const buildIngestRequest = (s: Settings, p: Collected): { url; headers; body }; // PURE +export const sendToServer = (s: Settings, p: Collected, fetchImpl = fetch): Promise; +``` +- `buildIngestRequest` is pure (URL join, headers incl. `x-designer-id`, JSON body matching + `IngestRequest`) → unit-tested. +- `sendToServer` takes an injectable `fetchImpl` → unit-tested with a fake fetch for 200 / 400 / + 413 / network-throw, asserting it maps to `SendResult`. + +## Bridge (`bridge.ts`) +Thin `postToCode(msg: UiToCode)` and a `useBridge(dispatch)` hook wrapping `window.onmessage` → +`event.data.pluginMessage` (Pixso/Figma envelope), typed by the shared `src/shared/messages.ts` +unions (same file the sandbox imports). The hook only forwards incoming messages to `dispatch`; the +pure `reduce(state, msg)` (in `state/reducer.ts`) turns them into state — and is unit-tested. + +## Look & feel +- Identical to ru-fork: same tokens (`bg-card`, `text-foreground`, `border-border`, `rounded-2xl`, + button variants). Use `variant="default"` for primary Send, `variant="outline"`/`"ghost"` for + secondary, `Alert` (if vendored) or `FieldError` for errors. +- Compact, plugin-sized (≈380×560). Header with title + gear (settings) button. + +## TDD / validation +Plugin UI is exempt from the 100% gate, but the **pure** units are tested (vitest + jsdom or pure): +- `buildIngestRequest` → correct url/headers/body for given settings+payload. +- `sendToServer` → 200/4xx/throw mapped to `SendResult` via fake fetch. +- `generateDesignerId` → `dz_` prefix + uuid shape. +- `reduce(state, msg)` → each `CodeToUi` message produces the right state transition + (selection-state enables/disables Send; preview-ready sets the image; error sets errorMessage; + collected triggers send; settings-loaded fills settings). +- React rendering is validated manually in task 9 (Pixso). Optionally add React Testing Library + smoke tests for the two screens if jsdom is wired — not required for the gate. + +`typecheck` + `oxlint` must be clean. + +## Acceptance +- [ ] Settings screen sets/generates/saves server URL + designerId; persisted via sandbox. +- [ ] Placeholder + the exact "not allowed" copy for multi-select; Send disabled when invalid. +- [ ] Valid selection shows the 1× preview + enabled Send. +- [ ] Send POSTs the correct `IngestRequest` with `x-designer-id`; success shows `nodeId`, errors + surfaced verbatim. +- [ ] Looks identical to ru-fork; pure logic tested; `typecheck`/oxlint clean. diff --git a/pixso-move/specs/09-end-to-end.md b/pixso-move/specs/09-end-to-end.md new file mode 100644 index 00000000000..373eb7f7013 --- /dev/null +++ b/pixso-move/specs/09-end-to-end.md @@ -0,0 +1,59 @@ +# Task 9 — End-to-end: gates + manual smoke + +Final wiring, the green-gate verification, and the manual smoke test (which **you** run, since this +box has no Pixso and no qwen). + +## Automated gates (must all pass) +Run from the worktree root: +```bash +pnpm install +pnpm -w lint # oxlint — 0 errors across pixso-move/* +turbo run typecheck --filter='@pixso-move/*' # tsc --noEmit — 0 errors +turbo run test --filter='@pixso-move/*' # vitest --coverage +``` +- **Server-side coverage = 100%** for `@pixso-move/contracts`, `@pixso-move/server`, + `@pixso-move/processor` (thresholds enforced in each `vitest.config.ts`; only `Migrations/**`, + `bin.ts`, and the justified `acpRunnerLive.ts` spawn glue are excluded). +- **Plugin** typechecks, lints, and `build`s (`dist/code.js` + `dist/ui.html`). + +> Note on this environment: per project memory, native-binding-dependent test runs can fail in this +> box. `node:sqlite` is a built-in (Node 22, `--experimental-sqlite`) and the ACP path is faked in +> tests, so the server-side suites are expected to run headless. If any suite truly can't execute +> here, that is flagged explicitly (not silently skipped) and you run it on a real machine. + +## Manual smoke (you run it) +Because Pixso and qwen live on your machine: + +1. **Server**: `pnpm --filter @pixso-move/server start --port 7787 --db ./.data/pixso.sqlite + --cli-js `. Confirm it logs startup (debug) and listens. +2. **Processor config**: edit `pixso-move/processor/src/config.ts` to add an entry for the + `designerId` you'll use in the plugin, with a real prompt + `resultTag`. +3. **Plugin**: `pnpm --filter @pixso-move/plugin build`; import `pixso-move/plugin/manifest.json` + into Pixso (Plugins → Development → Import). Open the plugin. +4. In the plugin: open **Settings**, set Server URL `http://localhost:7787`, **Generate** a key, + **Save** (use that same `designerId` in the processor config). +5. Select **one frame** → see the preview + enabled Send. (Try selecting two unrelated items → the + "not allowed" message; Send disabled.) +6. **Send** → expect 200 + a `nodeId`. +7. Verify storage + enrichment: + ```bash + curl -H "x-designer-id: " http://localhost:7787/nodes + curl -H "x-designer-id: " http://localhost:7787/nodes/ + curl -H "x-designer-id: " "http://localhost:7787/processing-data?nodeId=" + ``` + `processing-data` should show rows transitioning `pending → processing → done` (or `error` with + a message), one per configured `resultTag`, with the LLM `result` text on `done`. +8. **Robustness checks**: stop the server mid-processing and restart → `processing` rows recover to + `pending` and finish. Point a config entry's prompt at something that makes qwen fail → the row + goes `error` with a message; the server stays up; other jobs still process. + +## What to capture +- Server logs for an ingest + a full processing cycle (`logDebug` steps + any `logError`). +- The three `curl` outputs. +- Confirmation that multi-select is rejected and single-select works. + +## Acceptance (project-level) +- [ ] All automated gates green; server-side 100%. +- [ ] Manual smoke: ingest → stored → processed → readable, end to end, against real qwen. +- [ ] Crash recovery and error-marking observed working. +- [ ] Server never crashed throughout. diff --git a/pixso-move/specs/README.md b/pixso-move/specs/README.md new file mode 100644 index 00000000000..03f9fc5cf9a --- /dev/null +++ b/pixso-move/specs/README.md @@ -0,0 +1,85 @@ +# pixso-move — specs + +> **Implementation status:** see [`../STATUS.md`](../STATUS.md). Tasks 1, 2, 3, 6–8 are **done and +> green** (server-side 100% coverage); Tasks 4–5 (real processor + embed) are **deferred**. STATUS.md +> also records where the implementation deviates from these specs (e.g. `GET /node?id=`, theme via +> `pixso.clientStorage`, capped display preview). + + +Design-handoff pipeline for **Pixso** (a Figma-style design tool) where the corporate +environment **blocks all Pixso APIs**. The only way to extract design data is a **plugin** +running inside Pixso. Raw node JSON is useful on its own, but we **enrich it with an LLM** +(qwen, driven over ACP) to produce more usable, developer-facing output. + +Three actors, one data spine: + +``` + ┌──────────┐ POST /ingest ┌──────────┐ reconcile+claim ┌────────────┐ + │ PLUGIN │ ──{designerId,preview, │ SERVER │ ───────────────────▶ │ PROCESSOR │ + │ (Pixso) │ nodes}────────────▶ │ (HTTP + │ (embedded in │ (effect-acp│ + │ │ │ sqlite) │ server runtime) │ → qwen) │ + └──────────┘ ◀── GET /nodes … └──────────┘ ◀── writes results ──└────────────┘ + designer developer reads sqlite (nodes, processing_results) +``` + +- **Plugin** (designer): selects one frame, sends `{designerId, preview, nodes}` to the server. +- **Server** (storage + read API): validates, persists to sqlite, exposes key-gated GET endpoints. +- **Processor** (enrichment): for *configured* designers, runs configured prompts through qwen and + writes status-tracked results that developers query. + +## Why this design + +- Pixso APIs are blocked → a **plugin is the only extraction path**. +- Raw nodes are useful, but **LLM enrichment** (component specs, code, summaries — whatever a + prompt asks) makes them more usable. +- A designer carries a **key (`designerId`)** — generates/saves it once, shares it with + developers so they can read that designer's nodes and enriched results. + +## This is built FRESH on ru-fork architecture + +The reference product at `…/experements/pixso-move` is **someone else's**. We borrow *ideas and +utility helpers only* (node serialization, poll+notify loop, atomic claim, code-fence extraction). +Everything else — server, plugin UI, processor — is built from scratch on **ru-fork best +practices**: Effect + effect-platform server, ru-fork web components/styles for the plugin UI, +and ru-fork's own `effect-acp` package for the CLI/ACP integration. + +## Layout + +``` +pixso-move/ + contracts/ @pixso-move/contracts — effect Schema: requests/responses/rows + tagged errors + server/ @pixso-move/server — Effect HTTP + node:sqlite; embeds the processor + processor/ @pixso-move/processor — drives effect-acp; reconcile→claim→ACP→store loop + plugin/ @pixso-move/plugin — Pixso plugin: code.ts (sandbox) + iframe UI (vendored ru-fork kit) + specs/ these documents +``` + +## Spec index + +| # | Spec | Covers | +|---|------|--------| +| — | [conventions.md](./conventions.md) | Lint, typecheck, test, TDD/coverage policy, import style, Effect idioms — **read first, applies to every task** | +| — | [00-overview.md](./00-overview.md) | Vision, full data model, contracts, endpoint table, glossary | +| 1 | [01-scaffold.md](./01-scaffold.md) | 4 packages, package.json, tsconfigs, workspace wiring, effect-acp confirmation | +| 2 | [02-contracts.md](./02-contracts.md) | effect Schema for all requests/responses/rows + tagged errors | +| 3 | [03-server.md](./03-server.md) | sqlite layer + migrations (2 tables), HTTP routes, logger, config, bootstrap | +| 4 | [04-processor.md](./04-processor.md) | effect-acp client wrapper, reconcile+claim repo, run loop, crash recovery, config | +| 5 | [05-embed.md](./05-embed.md) | compose processor into server runtime, `notify()` on ingest | +| 6 | [06-plugin-build.md](./06-plugin-build.md) | two vite configs, manifest, vendor UI kit + theme + `cn` | +| 7 | [07-plugin-code.md](./07-plugin-code.md) | selection validation, node serialize, `exportAsync` 1× preview, postMessage bridge | +| 8 | [08-plugin-ui.md](./08-plugin-ui.md) | settings / preview / send screens, clientStorage, fetch to server | +| 9 | [09-end-to-end.md](./09-end-to-end.md) | typecheck/lint/coverage gates; manual smoke (user runs Pixso + qwen) | + +## Hard rules (non-negotiable, from the product owner) + +1. **All server-side code is covered by tests — 100% coverage — written TDD (tests first).** +2. **Lint + typecheck identical to ru-fork web/server. Zero lint errors, zero typecheck errors.** +3. **Server logs everything — `logError` / `logDebug` only** (no info/warn). Especially the + processing steps, so we can see what failed and what worked. **The server must never crash.** +4. Processing has **full status tracking** (pending/processing/done/error, attempts, error text, + timestamps) for observability, retry, and crash recovery. +5. **Production-grade, senior-level, DRY.** Every authored source file is **≤150 LOC**, single + responsibility, well-decomposed. Cross-cutting logic is defined **once** and reused (shared + tsconfig/vitest base, one error→response mapper, one route wrapper, one timestamp source). + Proven upstream infra (e.g. the `node:sqlite` client) is **vendored verbatim** under `vendor/`, + isolated and exempt — never rewritten. See [conventions.md](./conventions.md) §2. diff --git a/pixso-move/specs/conventions.md b/pixso-move/specs/conventions.md new file mode 100644 index 00000000000..2aa33d0b534 --- /dev/null +++ b/pixso-move/specs/conventions.md @@ -0,0 +1,200 @@ +# Conventions — applies to every task + +Derived from the ru-fork monorepo (verified by reading `apps/server`, `apps/web`, `packages/*`, +root configs in this worktree). Every `pixso-move/` package obeys these. **This is the backbone — +read it before any task.** + +--- + +## 1. Toolchain (identical to ru-fork) + +| Concern | Tool / version | Source | +|---|---|---| +| Language | TypeScript 5.9.3, ESM, `module`/`moduleResolution`: `NodeNext` | `tsconfig.base.json` | +| Effect | `effect` (catalog) — **namespace** imports | throughout | +| Lint | **oxlint** 1.63.0 (root `.oxlintrc.json`) | root `package.json:19` | +| Format | **oxfmt** | root `package.json` | +| Typecheck | `tsc --noEmit` per package | `apps/server/package.json:23` | +| Tests | **vitest 3.2.4** + `@effect/vitest` 4.0.0-beta.59 | `pnpm-workspace.yaml` | +| Coverage | `@vitest/coverage-v8` 3.2.4, provider `v8` | `apps/server/vitest.config.ts` | +| Server bundler | tsdown 0.20.3 | `apps/server/tsdown.config.ts` | +| Plugin bundler | vite 8 (two configs) | [06](./06-plugin-build.md) | + +Root scripts run with `NODE_OPTIONS='--experimental-strip-types --experimental-sqlite'`. +`node:sqlite` is **built-in** (Node 22+); there is **no `better-sqlite3`**. + +### Gate commands (every package must pass, zero issues) +```bash +pnpm -w lint # oxlint, 0 errors +turbo run typecheck --filter='@pixso-move/*' # tsc --noEmit, 0 errors +turbo run test --filter='@pixso-move/*' # vitest --coverage; server-side = 100% +``` + +--- + +## 2. Senior-engineering rules (non-negotiable) + +### 2.1 File & module budget — **hard cap 150 LOC per file** +- **No source file exceeds 150 lines of code** (blank lines and comment-only lines don't count; + everything else does). If a module approaches the cap, **decompose by responsibility** before + adding more. +- **One responsibility per file.** A file is either: a service *interface* (tag + shape), a service + *implementation*, a *pure helper module*, a *route*, a *migration*, a *schema group*, or a + *layer-composition* module — never a mix. +- Every task spec carries a **file-budget table** (path · responsibility · est. LOC). Implementation + must match it; deviations require splitting, not inflating. +- **Functions:** keep them small and single-purpose; prefer pure functions extracted from effectful + shells so logic is unit-testable without I/O. + +### 2.2 DRY — shared infrastructure, defined once +Cross-cutting concerns live in exactly one place and are imported, never re-pasted: +- `pixso-move/tsconfig.base.json` — shared TS config (extends the repo `../tsconfig.base.json`); + every package `extends` it. (Avoids per-package compiler-option drift.) +- `pixso-move/vitest.base.ts` — shared vitest/coverage config factory; each package's + `vitest.config.ts` is a 3-line call into it (see §5). +- `@pixso-move/contracts` `src/base.ts` — the shared schema primitives (`TrimmedNonEmptyString` + etc.), copied once from ru-fork `baseSchemas.ts`. +- `@pixso-move/server` `src/http/respond.ts` + `src/http/route.ts` — the single error→response + mapper and the single route wrapper (auth + catch + cors). Every route reuses them; no route + re-implements error handling. +- `@pixso-move/server` `src/time.ts` — `const nowIso = Effect.map(DateTime.now, DateTime.formatIso)` + (the one timestamp source; matches `ws.ts:124`). + +### 2.3 Vendoring policy (ported upstream code) +Some ru-fork infrastructure is reused verbatim and is **too large to rewrite or re-test** (e.g. +`NodeSqliteClient.ts` is 277 LOC). Such files: +- live under a `vendor/` directory in the consuming package (e.g. `server/src/vendor/`); +- are copied **verbatim** with a header `// pixso-move: vendored from @ — + keep in sync; do not edit`; +- are **exempt** from the 150-LOC cap, from 100% coverage, and from our authored-code lint + expectations (they are upstream code); +- are excluded in `vitest.config.ts` coverage and may be `oxlint`-ignored if upstream style + diverges. +This keeps *our* authored surface small, DRY, and fully owned, while reusing proven infra. + +--- + +## 3. TypeScript rules (from `tsconfig.base.json`) + +All ON; zero typecheck errors: +- `strict`, `noUncheckedIndexedAccess` (array access is `T | undefined` — guard it), + `exactOptionalPropertyTypes` (spread conditional optionals: `{ ...(env ? { env } : {}) }`), + `verbatimModuleSyntax` (`import type` for types), `noImplicitOverride`, `useDefineForClassFields`. +- `allowImportingTsExtensions` + `rewriteRelativeImportExtensions` → **relative imports carry `.ts`**: + `import { x } from "./x.ts"`. Bare package specifiers don't. + +The `@effect/language-service` "error" diagnostics in `tsconfig.base.json` are **editor-only** — they +do not affect `tsc --noEmit`/oxlint, so `crypto.randomUUID()` and friends compile. We still prefer +the testable idioms in §4 where they help. + +### Import style +- Effect: `import * as Effect from "effect/Effect"`, `* as Layer`, `* as Schema`, `* as DateTime + from "effect/DateTime"`, `* as SqlClient from "effect/unstable/sql/SqlClient"`. **Never** barrel + `"effect"`. +- HTTP: `import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"`. +- ACP: `import * as AcpClient from "effect-acp/client"`, `* as AcpErrors from "effect-acp/errors"`, + `* as AcpSchema from "effect-acp/schema"`. + +--- + +## 4. Effect idioms (server + processor) + +### Confirmed Schema API (from `packages/contracts/src/baseSchemas.ts` + usage) +```ts +Schema.String, Schema.Int, Schema.Number, Schema.Struct({...}), Schema.NullOr(x), +Schema.Literal("a"), Schema.Literals(["a","b","c"]), Schema.Array(x), Schema.brand("Brand") +// refinements via .check(...): +.check(Schema.isNonEmpty()) // non-empty string/array +.check(Schema.isMaxLength(n)) / .check(Schema.isMinLength(n)) +.check(Schema.isBetween({ minimum, maximum })) +.check(Schema.isGreaterThanOrEqualTo(n)) / .check(Schema.isLessThanOrEqualTo(n)) +// reuse, don't redefine: +TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty()) +makeEntityId(brand) = TrimmedNonEmptyString.pipe(Schema.brand(brand)) +// decode at the edge: +Schema.decodeUnknownExit(schema)(input) → Exit ; HttpServerRequest.schemaBodyJson(schema) +``` + +### Timestamps — never `new Date()` +`import * as DateTime from "effect/DateTime"`; `nowIso = Effect.map(DateTime.now, DateTime.formatIso)` +(one shared module, §2.2). Tests pin the clock. + +### IDs +`NodeId.make(crypto.randomUUID())` (ru-fork pattern, `serverRuntimeStartup.ts:160`). Designer keys +are generated client-side in the plugin (`dz_${uuid}`). + +### Logging — `logError` / `logDebug` ONLY +`Effect.logError("msg", { …annotations })` for failures/defects we must see; `Effect.logDebug` for +traces, expected failures, and processing progress. **No** `logInfo`/`logWarning`. Annotate with +structured objects. + +### Never crash +Every fallible unit (HTTP handler, processor job, ingest→notify) is wrapped so a failure is +**logged and contained**: handlers → typed-error JSON or a 500+`logError`; processor jobs → +`error` row + `logError` + loop continues. Mirror the three-way cause split in `ws.ts:57-81` +(defect→error, interrupt→debug, typed→debug). + +### Errors as data +`Schema.TaggedErrorClass()("Name", { …fields })` (`git.ts:320`). Handlers map them via the +shared `respond.ts` (§2.2), never inline. + +--- + +## 5. Testing & coverage (server-side: TDD, 100%) + +**Server-side** = `@pixso-move/contracts`, `@pixso-move/server`, `@pixso-move/processor` → **100%** +(lines/branches/functions/statements), minus the documented exclusions (`Migrations/**`, `bin.ts`, +`vendor/**`, justified spawn glue). **Plugin** is exempt from 100% (needs Pixso runtime); its *pure* +helpers are still unit-tested, and it must typecheck + lint clean. + +### TDD per unit: red → green → refactor. Tests live in `tests/**/*.test.ts`, mirroring `src/`. + +### Shared vitest base (`pixso-move/vitest.base.ts`) — DRY +```ts +import * as path from "node:path"; +import { defineConfig } from "vitest/config"; +export const makeVitestConfig = (dir: string) => defineConfig({ + resolve: { alias: [{ find: /^@pixso-move\/contracts$/, + replacement: path.resolve(dir, "../contracts/src/index.ts") }] }, + test: { + include: ["tests/**/*.test.ts"], testTimeout: 60_000, hookTimeout: 60_000, + coverage: { + provider: "v8", reporter: ["text", "json-summary", "lcov"], reportsDirectory: "./coverage", + all: true, include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts", "src/**/testUtils/**", "src/persistence/migrations/**", + "src/vendor/**", "src/bin.ts", + "src/**/*.integration.ts"], // *.integration.ts = real-process spawn glue only, justified per file + thresholds: { lines: 100, branches: 100, functions: 100, statements: 100 }, + }, + }, +}); +``` +Each package `vitest.config.ts`: +```ts +import { makeVitestConfig } from "../vitest.base.ts"; +export default makeVitestConfig(import.meta.dirname); +``` + +### Idioms (from ru-fork) +```ts +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +it.effect("does X", () => Effect.gen(function* () { /* … */ })); +const layer = it.layer(SqlitePersistenceMemory); // migrations run in :memory: +layer("repo", (it) => it.effect("inserts", () => Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; /* … */ +}))); +``` +No real network, no real child processes, no real `Date.now()` in any test. The processor's ACP +dependency is injected behind `AcpRunner` and faked. SQL stores run against `:memory:`. + +--- + +## 6. Definition of done (every task) +- [ ] Tests written first; green; server-side coverage 100%. +- [ ] Every source file ≤ 150 LOC, single-responsibility (vendor/ exempt). +- [ ] No duplicated cross-cutting logic — shared infra (§2.2) reused. +- [ ] `tsc --noEmit` + `oxlint` clean (0 errors). +- [ ] `logError`/`logDebug` only; nothing can crash the process. +- [ ] Matches the data model + contracts in [00-overview.md](./00-overview.md). diff --git a/pixso-move/tsconfig.base.json b/pixso-move/tsconfig.base.json new file mode 100644 index 00000000000..f1c0919f19e --- /dev/null +++ b/pixso-move/tsconfig.base.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "types": ["node"], + "lib": ["ESNext"] + } +} diff --git a/pixso-move/vitest.base.ts b/pixso-move/vitest.base.ts new file mode 100644 index 00000000000..13b2a114abf --- /dev/null +++ b/pixso-move/vitest.base.ts @@ -0,0 +1,37 @@ +import * as path from "node:path"; +import { defineConfig } from "vitest/config"; + +// pixso-move: shared vitest/coverage config. Each package's vitest.config.ts is +// `export default makeVitestConfig(import.meta.dirname)`. See specs/conventions.md §5. +export const makeVitestConfig = (dir: string) => + defineConfig({ + resolve: { + alias: [ + { + find: /^@pixso-move\/contracts$/, + replacement: path.resolve(dir, "../contracts/src/index.ts"), + }, + ], + }, + test: { + include: ["tests/**/*.test.ts"], + testTimeout: 60_000, + hookTimeout: 60_000, + coverage: { + provider: "v8", + reporter: ["text", "json-summary", "lcov"], + reportsDirectory: "./coverage", + all: true, + include: ["src/**/*.ts"], + exclude: [ + "src/**/*.test.ts", + "src/**/testUtils/**", + "src/persistence/migrations/**", + "src/vendor/**", + "src/bin.ts", + "src/**/*.integration.ts", + ], + thresholds: { lines: 100, branches: 100, functions: 100, statements: 100 }, + }, + }, + }); diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 66b245f9c7b..31e4efadd4a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - "oxlint-plugin-t3code" - "scripts" - "mcp-probe" + - "pixso-move/*" catalog: # MCP client SDK — used by the probe now and the server-side MCP supervisor