proto-plugin 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,7 +56,17 @@ export default page.default;
56
56
  export const generateStaticParams = page.generateStaticParams;
57
57
  ```
58
58
 
59
- 4. **Next.js config** — transpile the package (use a relative import so CSS is not loaded during config evaluation):
59
+ 4. **Component library** — `src/app/component-library/page.tsx` (created automatically on `pnpm install` if missing; or run `pnpm --dir node_modules/proto-plugin setup-host`):
60
+
61
+ ```tsx
62
+ import { createPrototypeComponentLibraryPage } from "proto-plugin";
63
+
64
+ export default createPrototypeComponentLibraryPage();
65
+ ```
66
+
67
+ Pass `children` once you've synced UI from source. Until then, the page shows an empty state with a copyable agent prompt.
68
+
69
+ 5. **Next.js config** — transpile the package (use a relative import so CSS is not loaded during config evaluation):
60
70
 
61
71
  ```ts
62
72
  import { withPrototype } from "./packages/prototype/src/config/with-prototype";
@@ -64,7 +74,7 @@ import { withPrototype } from "./packages/prototype/src/config/with-prototype";
64
74
  export default withPrototype({ nextConfig: { /* … */ } });
65
75
  ```
66
76
 
67
- 5. **Tailwind** — scan prototype package components from the host `globals.css`. Register semantic `--color-*` tokens as self-referential vars so utilities like `text-muted-foreground` resolve from `[data-prototype-root]` at runtime — **do not** map `--color-foreground` / `--color-background` to product `:root` tokens (that breaks gallery contrast):
77
+ 6. **Tailwind** — scan prototype package components from the host `globals.css`. Register semantic `--color-*` tokens as self-referential vars so utilities like `text-muted-foreground` resolve from `[data-prototype-root]` at runtime — **do not** map `--color-foreground` / `--color-background` to product `:root` tokens (that breaks gallery contrast):
68
78
 
69
79
  ```css
70
80
  @import "tailwindcss";
@@ -79,7 +89,7 @@ export default withPrototype({ nextConfig: { /* … */ } });
79
89
 
80
90
  Product tokens use separate names (e.g. `--product-background`) scoped to `[data-prototype-screenshot]`.
81
91
 
82
- 6. **Root layout + tool themes** — wrap routes with `createPrototypeSiteLayout()` and import package chrome CSS (dark/light themes live here, not in host `globals.css`):
92
+ 7. **Root layout + tool themes** — wrap routes with `createPrototypeSiteLayout()` and import package chrome CSS (dark/light themes live here, not in host `globals.css`):
83
93
 
84
94
  ```tsx
85
95
  import { createPrototypeSiteLayout } from "proto-plugin";
@@ -89,7 +99,7 @@ import "./globals.css";
89
99
  const PrototypeSiteLayout = createPrototypeSiteLayout();
90
100
  ```
91
101
 
92
- 7. **tsconfig paths** (optional, for tool internal `@prototype/*` alias when developing the package):
102
+ 8. **tsconfig paths** (optional, for tool internal `@prototype/*` alias when developing the package):
93
103
 
94
104
  ```json
95
105
  {
@@ -102,7 +112,7 @@ const PrototypeSiteLayout = createPrototypeSiteLayout();
102
112
  }
103
113
  ```
104
114
 
105
- 8. **Dev server port** — run the host on port **1985** so it stays separate from a typical source app on 3000. In the host `package.json`:
115
+ 9. **Dev server port** — run the host on port **1985** so it stays separate from a typical source app on 3000. In the host `package.json`:
106
116
 
107
117
  ```json
108
118
  {
@@ -120,6 +130,7 @@ Open [http://localhost:1985](http://localhost:1985) when developing.
120
130
  | --- | --- |
121
131
  | `definePrototypeConfig` | Register prototypes (sets global config as a side effect) |
122
132
  | `createPrototypePage` | Factory for `/prototypes/[slug]` page + `generateStaticParams` |
133
+ | `createPrototypeComponentLibraryPage` | Factory for `/component-library` (empty state until host passes `children`) |
123
134
  | `createPrototypeRegistry` | Server helpers: `getAllPrototypes`, `getPrototype`, … |
124
135
  | `PrototypeComponent`, `PrototypeControl` | Comment-anchorable UI wrappers |
125
136
  | `PrototypeShell` | Review chrome (comments sidebar, screenshot capture) |
@@ -261,16 +272,17 @@ Follow the package README (`node_modules/proto-plugin/README.md`) and implement
261
272
  1. **`prototype.config.ts`** at the repo root — start with an empty `prototypes: []` array using `definePrototypeConfig` from `proto-plugin`.
262
273
  2. **Gallery home** — `src/app/page.tsx` via `createPrototypeGalleryPage(prototypeConfig)`.
263
274
  3. **Prototype route** — `src/app/prototypes/[slug]/page.tsx` via `createPrototypePage(prototypeConfig)` (export `default` + `generateStaticParams`).
264
- 4. **Root layout** — wrap children with `createPrototypeSiteLayout()` from `proto-plugin` in `src/app/layout.tsx`, and import the tool chrome theme:
275
+ 4. **Component library** — `src/app/component-library/page.tsx` via `createPrototypeComponentLibraryPage()` (auto-scaffolded on `pnpm install` if missing; shows empty state + copyable populate prompt until synced UI is wired as `children`).
276
+ 5. **Root layout** — wrap children with `createPrototypeSiteLayout()` from `proto-plugin` in `src/app/layout.tsx`, and import the tool chrome theme:
265
277
 
266
278
  ```tsx
267
279
  import { createPrototypeSiteLayout } from "proto-plugin";
268
280
  import "proto-plugin/styles/globals.css";
269
281
  import "./globals.css";
270
282
  ```
271
- 5. **API catch-all** — `src/app/api/[...path]/route.ts` using `createPrototypeApiRoute(prototypeConfig)` from `proto-plugin/server`; export `GET`, `POST`, and `PUT` handlers that call `dispatch`.
272
- 6. **`next.config.ts`** — wrap with `withPrototype` from `proto-plugin/with-prototype` and ensure `proto-plugin` is transpiled.
273
- 7. **`src/app/globals.css`** — Tailwind v4 + product tokens synced from source. **Do not** put tool chrome dark/light mode here (no `prefers-color-scheme`, no hardcoded gallery colors). Scan package components:
283
+ 6. **API catch-all** — `src/app/api/[...path]/route.ts` using `createPrototypeApiRoute(prototypeConfig)` from `proto-plugin/server`; export `GET`, `POST`, and `PUT` handlers that call `dispatch`.
284
+ 7. **`next.config.ts`** — wrap with `withPrototype` from `proto-plugin/with-prototype` and ensure `proto-plugin` is transpiled.
285
+ 8. **`src/app/globals.css`** — Tailwind v4 + product tokens synced from source. **Do not** put tool chrome dark/light mode here (no `prefers-color-scheme`, no hardcoded gallery colors). Scan package components:
274
286
 
275
287
  ```css
276
288
  @import "tailwindcss";
@@ -279,7 +291,7 @@ Follow the package README (`node_modules/proto-plugin/README.md`) and implement
279
291
 
280
292
  Tool themes live in `proto-plugin/styles/globals.css` (import in layout). The package toggles dark/light on `[data-prototype-root]` via `data-prototype-comment-theme`.
281
293
 
282
- 8. **`package.json` scripts** — set dev to port 1985 and forward CLI commands to the installed package:
294
+ 9. **`package.json` scripts** — set dev to port 1985 and forward CLI commands to the installed package:
283
295
 
284
296
  ```json
285
297
  {
@@ -292,9 +304,9 @@ Follow the package README (`node_modules/proto-plugin/README.md`) and implement
292
304
  }
293
305
  ```
294
306
 
295
- 9. **`.env.example` and `.env.local`** — document and set `SOURCE_PATH` to the source app path I provided. Add comment/screenshot storage vars only if I asked for them in step 5 — the app should run without them, but comments won't persist.
307
+ 10. **`.env.example` and `.env.local`** — document and set `SOURCE_PATH` to the source app path I provided. Add comment/screenshot storage vars only if I asked for them in step 5 — the app should run without them, but comments won't persist.
296
308
 
297
- Run `pnpm dev` and confirm [http://localhost:1985](http://localhost:1985) shows the empty gallery and the app builds cleanly before continuing.
309
+ Run `pnpm dev` and confirm [http://localhost:1985](http://localhost:1985) shows the empty gallery, `/component-library` renders (empty state is fine), and the app builds cleanly before continuing.
298
310
 
299
311
  ## 3. Link the source app
300
312
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proto-plugin",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org"
@@ -31,6 +31,8 @@
31
31
  "./styles/globals.css": "./src/styles/globals.css"
32
32
  },
33
33
  "scripts": {
34
+ "postinstall": "node scripts/ensure-component-library-page.mjs",
35
+ "setup-host": "node scripts/ensure-component-library-page.mjs",
34
36
  "verify:prototype-ids": "node scripts/verify-prototype-component-ids.mjs",
35
37
  "verify:prototype-preview-states": "node scripts/verify-prototype-preview-states.mjs",
36
38
  "link-source": "./scripts/link-source.sh",
@@ -0,0 +1,89 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { resolveHostRoot } from "./lib/resolve-host-root.mjs";
6
+
7
+ const PAGE_CONTENT = `import { createPrototypeComponentLibraryPage } from "proto-plugin";
8
+
9
+ // Scaffolded by proto-plugin — pass \`children\` once you've synced UI from source.
10
+ export default createPrototypeComponentLibraryPage();
11
+ `;
12
+
13
+ function resolveAppDir(hostRoot) {
14
+ const srcApp = path.join(hostRoot, "src", "app");
15
+ if (fs.existsSync(srcApp)) {
16
+ return srcApp;
17
+ }
18
+
19
+ const app = path.join(hostRoot, "app");
20
+ if (fs.existsSync(app)) {
21
+ return app;
22
+ }
23
+
24
+ return null;
25
+ }
26
+
27
+ function isPrototypeHost(hostRoot) {
28
+ if (fs.existsSync(path.join(hostRoot, "prototype.config.ts"))) {
29
+ return true;
30
+ }
31
+
32
+ const packageJsonPath = path.join(hostRoot, "package.json");
33
+ if (!fs.existsSync(packageJsonPath)) {
34
+ return false;
35
+ }
36
+
37
+ try {
38
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
39
+ const deps = {
40
+ ...packageJson.dependencies,
41
+ ...packageJson.devDependencies,
42
+ };
43
+ return Boolean(deps["proto-plugin"]);
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ export function ensureComponentLibraryPage(hostRoot) {
50
+ if (!isPrototypeHost(hostRoot)) {
51
+ return { created: false, reason: "not-a-prototype-host" };
52
+ }
53
+
54
+ const appDir = resolveAppDir(hostRoot);
55
+ if (!appDir) {
56
+ return { created: false, reason: "missing-app-dir" };
57
+ }
58
+
59
+ const pageDir = path.join(appDir, "component-library");
60
+ const pagePath = path.join(pageDir, "page.tsx");
61
+
62
+ if (fs.existsSync(pagePath)) {
63
+ return { created: false, reason: "already-exists", pagePath };
64
+ }
65
+
66
+ fs.mkdirSync(pageDir, { recursive: true });
67
+ fs.writeFileSync(pagePath, PAGE_CONTENT, "utf8");
68
+
69
+ return { created: true, pagePath };
70
+ }
71
+
72
+ function main() {
73
+ const toolRoot = path.resolve(
74
+ path.dirname(fileURLToPath(import.meta.url)),
75
+ "..",
76
+ );
77
+ const hostRoot = resolveHostRoot(toolRoot);
78
+ const result = ensureComponentLibraryPage(hostRoot);
79
+
80
+ if (result.created) {
81
+ console.log(
82
+ `proto-plugin: created component library route at ${path.relative(hostRoot, result.pagePath)}`,
83
+ );
84
+ }
85
+ }
86
+
87
+ if (import.meta.url === new URL(process.argv[1], "file:").href) {
88
+ main();
89
+ }
@@ -38,3 +38,5 @@ fi
38
38
 
39
39
  ln -s "$TARGET" "$LINK"
40
40
  echo "Linked source/ -> $TARGET"
41
+
42
+ node "$TOOL_ROOT/scripts/ensure-component-library-page.mjs"
package/src/app.ts CHANGED
@@ -2,3 +2,4 @@ import "server-only";
2
2
 
3
3
  export { createPrototypeGalleryPage } from "./config/create-prototype-gallery-page";
4
4
  export { createPrototypeCatchAllPage } from "./config/create-prototype-host-app";
5
+ export { createPrototypeComponentLibraryPage } from "./config/create-prototype-component-library-page";
@@ -3,72 +3,84 @@
3
3
  import { Button } from "@prototype/components/ui/button";
4
4
  import { buildUpgradeProtoPluginCopyText } from "@prototype/lib/prototypes/upgrade-proto-plugin-prompt";
5
5
  import { useProtoPluginVersionCheck } from "@prototype/lib/prototypes/use-proto-plugin-version-check";
6
+ import { useActivePrototypeToolTheme } from "@prototype/lib/prototypes/use-prototype-tool-theme";
6
7
  import useCopyToClipboard from "@prototype/lib/use-copy-to-clipboard";
7
8
  import { cn } from "@prototype/lib/utils";
8
9
  import { X } from "lucide-react";
10
+ import { useEffect, useState } from "react";
11
+ import { createPortal } from "react-dom";
9
12
  import { toast } from "sonner";
10
13
 
11
14
  export function PrototypePluginUpdatePopup() {
12
15
  const { status, shouldShowPopup, dismiss } = useProtoPluginVersionCheck();
13
16
  const { copy, icon, isCopied } = useCopyToClipboard();
17
+ const theme = useActivePrototypeToolTheme();
18
+ const [mounted, setMounted] = useState(false);
14
19
 
15
- if (!shouldShowPopup || !status?.installed || !status.latest) {
20
+ useEffect(() => {
21
+ setMounted(true);
22
+ }, []);
23
+
24
+ if (!shouldShowPopup || !status?.installed || !status.latest || !mounted) {
16
25
  return null;
17
26
  }
18
27
 
19
- const handleCopy = async () => {
28
+ const handleCopyPrompt = async () => {
20
29
  try {
21
30
  await copy(
22
31
  buildUpgradeProtoPluginCopyText({
23
- installed: status.installed!,
24
- latest: status.latest!,
32
+ installed: status.installed,
33
+ latest: status.latest,
25
34
  }),
26
35
  );
27
36
  } catch {
28
37
  toast.error("Copy error", {
29
- description: "Failed to copy upgrade prompt",
38
+ description: "Failed to copy update prompt",
30
39
  });
31
40
  }
32
41
  };
33
42
 
34
- return (
43
+ return createPortal(
35
44
  <div
36
- role="status"
37
- aria-live="polite"
38
- className={cn(
39
- "border-border bg-card ring-border pointer-events-auto fixed bottom-4 left-4 z-[1050] flex w-[min(20rem,calc(100vw-2rem))] flex-col gap-3 rounded-xl border p-4 shadow-lg ring-[0.5px]",
40
- )}
45
+ data-prototype-root
46
+ data-prototype-comment-theme={theme}
47
+ className="pointer-events-none fixed bottom-4 right-4 z-[1050] !bg-transparent"
41
48
  >
42
- <div className="flex items-start justify-between gap-2">
43
- <div className="min-w-0 flex-1">
44
- <p className="text-sm font-medium text-foreground">Update proto-plugin</p>
45
- <p className="mt-1 text-sm text-muted-foreground">
46
- v{status.latest} is available. You&apos;re on v{status.installed}.
47
- </p>
48
- </div>
49
+ <div
50
+ role="status"
51
+ aria-live="polite"
52
+ className={cn(
53
+ "pointer-events-auto relative flex w-[15rem] flex-col gap-3 rounded-lg bg-[var(--tool-chrome-surface)] p-4 shadow-md ring-[0.5px] ring-[var(--tool-chrome-border)]",
54
+ )}
55
+ >
49
56
  <button
50
57
  type="button"
51
58
  aria-label="Dismiss update notification"
52
- className="text-muted-foreground hover:text-foreground shrink-0 rounded p-1 transition-colors duration-200 ease"
59
+ className="absolute right-2 top-2 rounded p-1 text-[var(--tool-chrome-text-muted)] transition-colors duration-200 ease hover:text-[var(--tool-chrome-text-heading)]"
53
60
  onClick={dismiss}
54
61
  >
55
- <X size={14} />
62
+ <X size={12} />
56
63
  </button>
57
- </div>
58
64
 
59
- <Button
60
- type="button"
61
- variant="outline"
62
- size="sm"
63
- className="h-8 w-fit cursor-pointer gap-1.5"
64
- onClick={() => {
65
- void handleCopy();
66
- }}
67
- aria-label="Copy upgrade prompt"
68
- >
69
- {isCopied ? "Copied prompt" : "Copy prompt"}
70
- {icon}
71
- </Button>
72
- </div>
65
+ <p className="pr-4 text-xs leading-relaxed text-[var(--tool-chrome-text-muted)]">
66
+ A new version of Proto is available, copy the prompt below into your agent to update
67
+ </p>
68
+
69
+ <Button
70
+ type="button"
71
+ variant="chrome"
72
+ size="sm"
73
+ className="h-7 w-fit cursor-pointer gap-1 px-2 text-[11px]"
74
+ onClick={() => {
75
+ void handleCopyPrompt();
76
+ }}
77
+ aria-label="Copy update prompt"
78
+ >
79
+ {isCopied ? "Copied update prompt" : "Copy update prompt"}
80
+ {icon}
81
+ </Button>
82
+ </div>
83
+ </div>,
84
+ document.body,
73
85
  );
74
86
  }
@@ -10,9 +10,12 @@ import type {
10
10
  PrototypeExtraRoute,
11
11
  } from "@prototype/lib/prototypes/prototype-config-types";
12
12
 
13
+ import { createPrototypeComponentLibraryPage } from "./create-prototype-component-library-page";
13
14
  import { createPrototypePage } from "./create-prototype-page";
14
15
  import { createPrototypeStateMapPage } from "./create-prototype-state-map-page";
15
16
 
17
+ const ComponentLibraryPage = createPrototypeComponentLibraryPage();
18
+
16
19
  function matchExtraRoute(
17
20
  path: string[],
18
21
  pattern: string[],
@@ -61,7 +64,11 @@ export function createPrototypeCatchAllPage(config: PrototypeConfig) {
61
64
  path: ["prototypes", slug, "states"],
62
65
  }));
63
66
 
64
- return [...prototypePaths, ...stateMapPaths];
67
+ return [
68
+ { path: ["component-library"] },
69
+ ...prototypePaths,
70
+ ...stateMapPaths,
71
+ ];
65
72
  }
66
73
 
67
74
  async function CatchAllPage({
@@ -71,6 +78,10 @@ export function createPrototypeCatchAllPage(config: PrototypeConfig) {
71
78
  }) {
72
79
  const { path = [] } = await params;
73
80
 
81
+ if (path.length === 1 && path[0] === "component-library") {
82
+ return <ComponentLibraryPage />;
83
+ }
84
+
74
85
  if (path.length === 2 && path[0] === "prototypes") {
75
86
  const slug = path[1]!;
76
87
  return prototypePage.default({ params: Promise.resolve({ slug }) });
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { PrototypeProvider } from "@prototype/components/prototype-provider";
3
4
  import { PrototypePluginUpdatePopup } from "@prototype/components/shell/prototype-plugin-update-popup";
4
5
  import { usePathname } from "next/navigation";
5
6
  import type { ComponentType, ReactNode } from "react";
@@ -29,7 +30,7 @@ export function PrototypeSiteLayoutClient({
29
30
 
30
31
  if (isGalleryRoute) {
31
32
  return (
32
- <div className="bg-[var(--bg-main)] flex h-svh min-h-svh w-full flex-col overflow-hidden">
33
+ <PrototypeProvider className="bg-[var(--bg-main)] flex h-svh min-h-svh w-full flex-col overflow-hidden">
33
34
  <GalleryShell
34
35
  sourceDirectoryName={sourceDirectoryName}
35
36
  sourcePath={sourcePath}
@@ -38,15 +39,15 @@ export function PrototypeSiteLayoutClient({
38
39
  </GalleryShell>
39
40
  <PrototypePluginUpdatePopup />
40
41
  {toaster}
41
- </div>
42
+ </PrototypeProvider>
42
43
  );
43
44
  }
44
45
 
45
46
  return (
46
- <div className="bg-[var(--bg-ground)] flex h-svh overflow-hidden">
47
+ <PrototypeProvider className="bg-[var(--bg-ground)] flex h-svh overflow-hidden">
47
48
  {children}
48
49
  <PrototypePluginUpdatePopup />
49
50
  {toaster}
50
- </div>
51
+ </PrototypeProvider>
51
52
  );
52
53
  }
@@ -0,0 +1,18 @@
1
+ export function isNewerProtoPluginVersion(latest: string, installed: string): boolean {
2
+ const latestParts = parseSemver(latest);
3
+ const installedParts = parseSemver(installed);
4
+ if (!latestParts || !installedParts) return false;
5
+
6
+ for (let index = 0; index < 3; index += 1) {
7
+ if (latestParts[index]! > installedParts[index]!) return true;
8
+ if (latestParts[index]! < installedParts[index]!) return false;
9
+ }
10
+
11
+ return false;
12
+ }
13
+
14
+ function parseSemver(version: string): [number, number, number] | null {
15
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
16
+ if (!match) return null;
17
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
18
+ }
@@ -1,3 +1,5 @@
1
+ import { isNewerProtoPluginVersion } from "@prototype/lib/prototypes/proto-plugin-semver";
2
+
1
3
  export type ProtoPluginVersionStatus = {
2
4
  installed: string | null;
3
5
  latest: string | null;
@@ -8,12 +10,73 @@ export type ProtoPluginVersionStatus = {
8
10
 
9
11
  export const PROTOTYPE_PLUGIN_VERSION_PATH = "/api/proto-plugin/version";
10
12
 
13
+ const NPM_LATEST_URL = "https://registry.npmjs.org/proto-plugin/latest";
14
+
15
+ type NpmLatestResponse = {
16
+ version?: string;
17
+ repository?: { url?: string };
18
+ };
19
+
20
+ function normalizeRepositoryUrl(url: string | undefined): string | undefined {
21
+ return url?.replace(/^git\+/, "").replace(/\.git$/, "");
22
+ }
23
+
24
+ async function fetchLatestFromNpmRegistry(): Promise<{
25
+ latest: string | null;
26
+ repositoryUrl?: string;
27
+ }> {
28
+ try {
29
+ const response = await fetch(NPM_LATEST_URL, { cache: "no-store" });
30
+ if (!response.ok) {
31
+ return { latest: null };
32
+ }
33
+
34
+ const data = (await response.json()) as NpmLatestResponse;
35
+ return {
36
+ latest: data.version ?? null,
37
+ repositoryUrl: normalizeRepositoryUrl(data.repository?.url),
38
+ };
39
+ } catch {
40
+ return { latest: null };
41
+ }
42
+ }
43
+
44
+ /** Client-side npm check when the server route is stale (older plugin releases cached npm for 1h). */
45
+ async function reconcileWithNpmRegistry(
46
+ status: ProtoPluginVersionStatus,
47
+ ): Promise<ProtoPluginVersionStatus> {
48
+ if (status.isWorkspaceLink || !status.installed) {
49
+ return status;
50
+ }
51
+
52
+ if (
53
+ status.updateAvailable &&
54
+ status.latest &&
55
+ isNewerProtoPluginVersion(status.latest, status.installed)
56
+ ) {
57
+ return status;
58
+ }
59
+
60
+ const { latest, repositoryUrl } = await fetchLatestFromNpmRegistry();
61
+ if (!latest || !isNewerProtoPluginVersion(latest, status.installed)) {
62
+ return status;
63
+ }
64
+
65
+ return {
66
+ ...status,
67
+ latest,
68
+ updateAvailable: true,
69
+ repositoryUrl: repositoryUrl ?? status.repositoryUrl,
70
+ };
71
+ }
72
+
11
73
  export async function fetchProtoPluginVersionStatus(): Promise<ProtoPluginVersionStatus> {
12
- const response = await fetch(PROTOTYPE_PLUGIN_VERSION_PATH);
74
+ const response = await fetch(PROTOTYPE_PLUGIN_VERSION_PATH, { cache: "no-store" });
13
75
 
14
76
  if (!response.ok) {
15
77
  throw new Error("Failed to check proto-plugin version.");
16
78
  }
17
79
 
18
- return (await response.json()) as ProtoPluginVersionStatus;
80
+ const status = (await response.json()) as ProtoPluginVersionStatus;
81
+ return reconcileWithNpmRegistry(status);
19
82
  }
@@ -38,3 +38,9 @@ export function buildUpgradeProtoPluginCopyText(
38
38
  ): string {
39
39
  return buildUpgradeProtoPluginPrompt(options);
40
40
  }
41
+
42
+ export function buildUpgradeProtoPluginCommand({
43
+ latest,
44
+ }: Pick<UpgradeProtoPluginPromptOptions, "latest">): string {
45
+ return `pnpm add proto-plugin@${latest}`;
46
+ }
@@ -8,12 +8,20 @@ import { usePersistedLocalString } from "@prototype/lib/prototypes/use-persisted
8
8
  import { useCallback, useEffect, useMemo, useState } from "react";
9
9
 
10
10
  const DISMISSED_VERSION_KEY = "prototype-review:global:dismissed-plugin-version";
11
+ const PREVIEW_PLUGIN_UPDATE_PARAM = "previewPluginUpdate";
12
+
13
+ function readPreviewPluginUpdateParam(): boolean {
14
+ if (process.env.NODE_ENV !== "development") return false;
15
+ if (typeof window === "undefined") return false;
16
+ return new URLSearchParams(window.location.search).has(PREVIEW_PLUGIN_UPDATE_PARAM);
17
+ }
11
18
 
12
19
  export function useProtoPluginVersionCheck() {
13
20
  const [status, setStatus] = useState<ProtoPluginVersionStatus | null>(null);
14
21
  const [isLoading, setIsLoading] = useState(true);
15
22
  const { value: dismissedVersion, updateValue: setDismissedVersion } =
16
23
  usePersistedLocalString(DISMISSED_VERSION_KEY, "");
24
+ const forcePreview = readPreviewPluginUpdateParam();
17
25
 
18
26
  useEffect(() => {
19
27
  let cancelled = false;
@@ -46,7 +54,35 @@ export function useProtoPluginVersionCheck() {
46
54
  }
47
55
  }, [setDismissedVersion, status?.latest]);
48
56
 
57
+ const previewStatus = useMemo<ProtoPluginVersionStatus | null>(() => {
58
+ if (!forcePreview) return null;
59
+
60
+ const installed = status?.installed ?? "0.1.7";
61
+ let latest = status?.latest ?? "0.1.9";
62
+
63
+ if (latest === installed) {
64
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(installed);
65
+ latest = match
66
+ ? `${match[1]}.${match[2]}.${Number(match[3]) + 1}`
67
+ : "0.1.9";
68
+ }
69
+
70
+ return {
71
+ installed,
72
+ latest,
73
+ updateAvailable: true,
74
+ isWorkspaceLink: false,
75
+ repositoryUrl: status?.repositoryUrl,
76
+ };
77
+ }, [forcePreview, status]);
78
+
79
+ const effectiveStatus = previewStatus ?? status;
80
+
49
81
  const shouldShowPopup = useMemo(() => {
82
+ if (forcePreview) {
83
+ return Boolean(effectiveStatus?.installed && effectiveStatus.latest);
84
+ }
85
+
50
86
  if (isLoading || !status?.updateAvailable || !status.latest || !status.installed) {
51
87
  return false;
52
88
  }
@@ -56,10 +92,10 @@ export function useProtoPluginVersionCheck() {
56
92
  }
57
93
 
58
94
  return dismissedVersion !== status.latest;
59
- }, [dismissedVersion, isLoading, status]);
95
+ }, [dismissedVersion, effectiveStatus, forcePreview, isLoading, status]);
60
96
 
61
97
  return {
62
- status,
98
+ status: effectiveStatus,
63
99
  shouldShowPopup,
64
100
  dismiss,
65
101
  isLoading,
@@ -7,7 +7,9 @@ import {
7
7
  createContext,
8
8
  useCallback,
9
9
  useContext,
10
+ useEffect,
10
11
  useMemo,
12
+ useState,
11
13
  type ReactNode,
12
14
  } from "react";
13
15
 
@@ -113,3 +115,37 @@ export function usePrototypeToolTheme(): PrototypeToolThemeContextValue {
113
115
 
114
116
  return context;
115
117
  }
118
+
119
+ function readActivePrototypeToolTheme(): PrototypeToolTheme {
120
+ if (typeof document === "undefined") return DEFAULT_THEME;
121
+
122
+ const roots = document.querySelectorAll<HTMLElement>("[data-prototype-root]");
123
+ const activeRoot = roots[roots.length - 1] ?? roots[0];
124
+ return normalizeTheme(activeRoot?.getAttribute("data-prototype-comment-theme") ?? DEFAULT_THEME);
125
+ }
126
+
127
+ /** Syncs with the innermost tool theme root (e.g. portaled UI outside nested providers). */
128
+ export function useActivePrototypeToolTheme(): PrototypeToolTheme {
129
+ const { theme: contextTheme } = usePrototypeToolTheme();
130
+ const [theme, setTheme] = useState<PrototypeToolTheme>(contextTheme);
131
+
132
+ useEffect(() => {
133
+ const syncTheme = () => {
134
+ setTheme(readActivePrototypeToolTheme());
135
+ };
136
+
137
+ syncTheme();
138
+
139
+ const observer = new MutationObserver(syncTheme);
140
+ for (const root of document.querySelectorAll("[data-prototype-root]")) {
141
+ observer.observe(root, {
142
+ attributes: true,
143
+ attributeFilter: ["data-prototype-comment-theme"],
144
+ });
145
+ }
146
+
147
+ return () => observer.disconnect();
148
+ }, []);
149
+
150
+ return theme;
151
+ }
@@ -1,11 +1,11 @@
1
1
  import { readFileSync } from "fs";
2
2
  import { join } from "path";
3
3
 
4
+ import { isNewerProtoPluginVersion } from "@prototype/lib/prototypes/proto-plugin-semver";
4
5
  import { NextResponse } from "next/server";
5
6
 
6
7
  const PACKAGE_NAME = "proto-plugin";
7
8
  const NPM_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
8
- const CACHE_TTL_MS = 60 * 60 * 1000;
9
9
 
10
10
  export type ProtoPluginVersionStatus = {
11
11
  installed: string | null;
@@ -20,28 +20,6 @@ type NpmLatestResponse = {
20
20
  repository?: { url?: string };
21
21
  };
22
22
 
23
- let cachedLatest: { version: string; repositoryUrl?: string; fetchedAt: number } | null =
24
- null;
25
-
26
- function parseSemver(version: string): [number, number, number] | null {
27
- const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
28
- if (!match) return null;
29
- return [Number(match[1]), Number(match[2]), Number(match[3])];
30
- }
31
-
32
- export function isNewerVersion(latest: string, installed: string): boolean {
33
- const latestParts = parseSemver(latest);
34
- const installedParts = parseSemver(installed);
35
- if (!latestParts || !installedParts) return false;
36
-
37
- for (let index = 0; index < 3; index += 1) {
38
- if (latestParts[index]! > installedParts[index]!) return true;
39
- if (latestParts[index]! < installedParts[index]!) return false;
40
- }
41
-
42
- return false;
43
- }
44
-
45
23
  function readIsWorkspaceDependency(cwd: string): boolean {
46
24
  try {
47
25
  const hostPkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")) as {
@@ -76,18 +54,9 @@ async function fetchLatestFromNpm(): Promise<{
76
54
  version: string | null;
77
55
  repositoryUrl?: string;
78
56
  }> {
79
- const now = Date.now();
80
-
81
- if (cachedLatest && now - cachedLatest.fetchedAt < CACHE_TTL_MS) {
82
- return {
83
- version: cachedLatest.version,
84
- repositoryUrl: cachedLatest.repositoryUrl,
85
- };
86
- }
87
-
88
57
  try {
89
58
  const response = await fetch(NPM_LATEST_URL, {
90
- next: { revalidate: 3600 },
59
+ cache: "no-store",
91
60
  });
92
61
 
93
62
  if (!response.ok) {
@@ -98,10 +67,6 @@ async function fetchLatestFromNpm(): Promise<{
98
67
  const version = data.version ?? null;
99
68
  const repositoryUrl = data.repository?.url?.replace(/^git\+/, "").replace(/\.git$/, "");
100
69
 
101
- if (version) {
102
- cachedLatest = { version, repositoryUrl, fetchedAt: now };
103
- }
104
-
105
70
  return { version, repositoryUrl };
106
71
  } catch {
107
72
  return { version: null };
@@ -119,13 +84,17 @@ export async function GET() {
119
84
  updateAvailable: false,
120
85
  isWorkspaceLink: true,
121
86
  };
122
- return NextResponse.json(status);
87
+ return NextResponse.json(status, {
88
+ headers: { "Cache-Control": "no-store, max-age=0" },
89
+ });
123
90
  }
124
91
 
125
92
  const { version: latest, repositoryUrl } = await fetchLatestFromNpm();
126
93
 
127
94
  const updateAvailable =
128
- installed != null && latest != null && isNewerVersion(latest, installed);
95
+ installed != null &&
96
+ latest != null &&
97
+ isNewerProtoPluginVersion(latest, installed);
129
98
 
130
99
  const status: ProtoPluginVersionStatus = {
131
100
  installed,
@@ -135,5 +104,7 @@ export async function GET() {
135
104
  repositoryUrl,
136
105
  };
137
106
 
138
- return NextResponse.json(status);
107
+ return NextResponse.json(status, {
108
+ headers: { "Cache-Control": "no-store, max-age=0" },
109
+ });
139
110
  }