carouselbot 0.3.4 → 0.3.6

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
@@ -100,3 +100,5 @@ Any number of agents can share one connected browser tab, including editing the
100
100
  Browser writes use revision-checked IndexedDB transactions and cross-tab synchronization. A stale tab cannot replace a newer project snapshot; it reloads the canonical copy and returns `STALE_PROJECT` so the agent can inspect and retry.
101
101
 
102
102
  Paths support exactly two folder levels: `/Client/Account` appears as Home → Client → Account. Use this when the user asks to organize multiple accounts for a client; do not reorganize projects without being asked. Create or move projects with `folderPath: "/Client/Account"`; the parent and subfolder appear automatically. `/Client/Account/Campaign` is rejected. `inspect_editor` includes parent folders and `parentPath`; folder project counts include descendants. Renaming a parent in the UI preserves its subfolders.
103
+
104
+ `duplicate_project` copies a complete carousel into an optional destination `folderPath` in one local operation. Images, fonts, slides and editable layers are preserved with independent IDs. It returns `createdProjectId`; the source `projectId` and edit session remain unchanged. Batch two variants together with `apply_operations`, then start sessions on their new IDs to restyle them.
@@ -23,7 +23,7 @@ Read this before creating or editing slides. Use it as a compact quality bar, th
23
23
  - Use rotation sparingly. Small intentional angles can add energy; arbitrary angles make carousels feel inconsistent.
24
24
  - Reuse a small palette and consistent type scale across the project.
25
25
  - For an installed Mac font, select an exact face with `list_local_fonts`, import its opaque ID with `import_font`, and apply only the returned project `fontId`. Reuse project faces through `list_project_fonts`; never guess family strings.
26
- - Use `fontWeight` (or the matching `wght` axis) for variable weight. Do not newly apply `wdth`, `opsz`, `slnt`, or custom axes until exported-canvas parity is available.
26
+ - Use numeric `fontWeight`, including 550 or 725, for variable weight. Built-in TikTok Sans supports 100–900; imported variable faces use their wght range. Static fonts select an exact matching imported family face or report FONT_FACE_MISMATCH; they cannot interpolate arbitrary weights. `inspect_editor` reports effectiveFontWeight and supportedWeights for each text layer. Check every title and body layer separately. Do not newly apply `wdth`, `opsz`, `slnt`, or custom axes until exported-canvas parity is available.
27
27
  - Keep ordinary typography as editable text layers, including typography that uses an imported font. Never generate or import a text-only PNG/SVG and place it with `add_image` just to imitate a font. If the requested face cannot be imported, keep the copy editable in an available face and report the substitution instead of baking the words into pixels.
28
28
 
29
29
  ## Working method
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carouselbot",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Local-first MCP companion for the hosted CarouselBot editor",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -57,7 +57,7 @@ When the user asks for a font installed on their Mac, use the deterministic two-
57
57
 
58
58
  If listing returns `FONT_PERMISSION_REQUIRED`, ask the user to open the real CarouselBot tab and choose **Allow local fonts** from the text font control, then retry. Do not bypass this with browser automation. Font paths and bytes are intentionally unavailable to agents. If rendering reports `FONT_UNAVAILABLE`, preserve the editable text, report the missing face, and ask the user to replace or re-import it rather than accepting fallback pixels.
59
59
 
60
- Use `fontWeight` or `fontVariationSettings.wght` when varying an imported face. Preserve existing non-weight variable settings, but do not newly apply `wdth`, `opsz`, `slnt`, or custom axes until CarouselBot advertises exact exported-canvas parity for them.
60
+ Use numeric `fontWeight` or `fontVariationSettings.wght` when varying a face, including intermediate values such as 550 and 725. Built-in TikTok Sans supports 100–900; imported variable fonts use their wght range. For static fonts the editor automatically selects a matching imported family face, otherwise returns FONT_FACE_MISMATCH. Inspect effectiveFontWeight and supportedWeights; do not infer a visual change from the requested number alone. Titles and body copy are independent layers: inspect and update all requested layers. Preserve existing non-weight variable settings, but do not newly apply `wdth`, `opsz`, `slnt`, or custom axes until CarouselBot advertises exact exported-canvas parity for them.
61
61
 
62
62
  All ordinary words must remain CarouselBot text layers, including words styled with a local font. Never generate or import a text-only PNG/SVG and place it as an image merely to imitate a font. If an exact face cannot be listed, imported, or rendered, keep the copy editable in an available face and tell the user what was substituted. Use image layers only for photographs, illustrations, logos, screenshots, and deliberate artwork that CarouselBot cannot represent as editable layers.
63
63
 
@@ -67,6 +67,10 @@ When `add_slide` uses `backgroundPath` without `aspectRatio`, CarouselBot derive
67
67
 
68
68
  Dashboard folders are implicit and use exact canonical slash paths such as `/campaigns`. Set `folderPath` when calling `create_project` to create the project inside a folder. Call `move_project` with another slash path to move it between folders, or with `folderPath: null` to move it back to the dashboard root. Paths support exactly two folder levels: `/Client/Account` appears as Home → Client → Account. Use this when the user asks to organize multiple accounts for a client; do not reorganize projects without being asked. Create or move projects with `folderPath: "/Client/Account"`; the parent and subfolder appear automatically. `/Client/Account/Campaign` is rejected. `inspect_editor` includes parent folders and `parentPath`; folder project counts include descendants. Renaming a parent in the UI preserves its subfolders. Moving the final project out of a folder removes that empty folder automatically. Use `inspect_editor` to read each project's current `folderPath`.
69
69
 
70
+ To duplicate whole carousels into another folder, use `duplicate_project({ projectId, folderPath: "/Client/Account", name, editSessionId })`. This copies all slides, assets, fonts, and editable layers locally in one call; do not reconstruct layers or transfer images through the agent. The source is unchanged. Omit `name` for a “copy” suffix or supply the original name for parallel variants in separate folders. The response's `createdProjectId` identifies the copy; `projectId` and the session stay bound to the source. Begin a new session on the copy to edit it. Two duplicates of one source can share one `apply_operations` call and source-bound session.
71
+
72
+ For restyling many carousels, inspect one project at a time with `includeAllProjects: false`, use explicit slide IDs, and batch up to 100 edits per `apply_operations` call within its project session. `update_text` accepts up to 100 `{ id, ...stylePatch }` entries in its `updates` array for one slide. `render_slide` returns actual pixels (540px wide by default); it does not require an editor screenshot or navigation. Inspect representative renders, but check every affected slide when font or geometry changes could cause clipping. There is no automatic project-wide style selector: first inspect the affected layers, then apply targeted patches.
73
+
70
74
  Use readable role-based type ranges: title `92–124`, subtitle `68–84`, body `54–68`, caption `44–52`. Do not solve dense copy by dropping below the body range; shorten it or split it across slides. `add_text` and `update_text` automatically preserve width and fit height around every wrapped line with safe padding. For highlighted text, prefer `style: "boxed"` with `backgroundShape: "lines"`. Use `backgroundShape: "full"` only for a deliberate card. Call `fit_text_boxes` with `mode: "both"` only when you intentionally want the width to shrink too.
71
75
 
72
76
  After each meaningful composition or after a short batch, call `render_slide` and inspect the returned image. Fix clipping, spacing, contrast, unsafe overlay placement, and weak hierarchy before claiming the slide is finished. Use `export_slide` or `export_project` only when local files are requested; do not overwrite existing files unless authorized.
@@ -41,7 +41,7 @@ const textFields = {
41
41
  color: color.optional(), background: z.enum(["white", "black"]).optional(),
42
42
  backgroundShape: z.enum(["lines", "full"]).optional(), align: z.enum(["left", "center", "right"]).optional(),
43
43
  fontId,
44
- fontWeight: z.number().int().min(1).max(1000).optional(),
44
+ fontWeight: z.number().int().min(1).max(1000).optional().describe("Numeric weight, including intermediate values such as 550 or 725. Variable fonts clamp to their wght range; built-in TikTok Sans supports 100–900. Static fonts automatically select a matching imported family face; unavailable weights return FONT_FACE_MISMATCH. Changing body text does not change separate title layers."),
45
45
  fontStyle: z.enum(["normal", "italic"]).optional(),
46
46
  fontVariationSettings: z.record(z.string().regex(/^[A-Za-z0-9]{4}$/), z.number()).optional()
47
47
  .describe("Variable-font axis settings. Only wght currently has guaranteed DOM, fitting, and exported-canvas parity; preserve but do not newly apply other axes."),
@@ -68,7 +68,7 @@ function textResult(value, summary = value) {
68
68
  }
69
69
 
70
70
  function compactMutation(value) {
71
- const keys = ["id", "editSessionId", "editorId", "projectId", "aspectRatio", "canvasWidth", "canvasHeight", "folderPath", "slideId", "revision", "leaseExpiresAt", "purpose", "released", "opened", "createdSlideId", "createdTextId", "fittedTextBox", "createdImageId", "createdLayers", "assetId", "fontId", "localFontId", "existing", "repaired", "deletedAssetId", "deletedProjectId", "deletedSlideId", "deletedLayerIds", "updatedTextIds", "fittedTextBoxes", "updatedImageIds", "applied", "path", "bytes"];
71
+ const keys = ["id", "editSessionId", "editorId", "projectId", "aspectRatio", "canvasWidth", "canvasHeight", "folderPath", "slideId", "revision", "leaseExpiresAt", "purpose", "released", "opened", "createdProjectId", "createdSlideId", "createdTextId", "fittedTextBox", "createdImageId", "createdLayers", "assetId", "fontId", "localFontId", "existing", "repaired", "deletedAssetId", "deletedProjectId", "deletedSlideId", "deletedLayerIds", "updatedTextIds", "fittedTextBoxes", "updatedImageIds", "applied", "path", "bytes"];
72
72
  return Object.fromEntries(keys.flatMap((key) => {
73
73
  if (key === "folderPath" && Object.hasOwn(value || {}, key)) return [[key, value[key] ?? null]];
74
74
  return value?.[key] == null ? [] : [[key, value[key]]];
@@ -89,7 +89,7 @@ async function pathExists(value) {
89
89
 
90
90
  function operationLabel(toolName) {
91
91
  return ({
92
- create_project: "Creating a project…", update_project: "Updating the project…", move_project: "Moving the project…", delete_project: "Deleting a project…",
92
+ create_project: "Creating a project…", duplicate_project: "Duplicating a project…", update_project: "Updating the project…", move_project: "Moving the project…", delete_project: "Deleting a project…",
93
93
  open_project: "Opening a project…", add_slide: "Adding a slide…", update_slide: "Updating a slide…",
94
94
  duplicate_slide: "Duplicating a slide…", reorder_slides: "Reordering slides…", delete_slide: "Deleting a slide…",
95
95
  add_text: "Adding text…", update_text: "Updating text…", fit_text_boxes: "Fitting text boxes…", import_font: "Adding a local font…", import_asset: "Importing a local image…",
@@ -125,7 +125,7 @@ async function prepareOperation(companion, toolName, args, editSessionId = null)
125
125
  operation.fontMediaId = prepared.fontMediaId;
126
126
  }
127
127
  const type = ({
128
- create_project: "project.create", open_project: "project.open", update_project: "project.update", move_project: "project.move", delete_project: "project.delete",
128
+ create_project: "project.create", duplicate_project: "project.duplicate", open_project: "project.open", update_project: "project.update", move_project: "project.move", delete_project: "project.delete",
129
129
  add_slide: "slide.add", update_slide: "slide.update", duplicate_slide: "slide.duplicate", reorder_slides: "slide.reorder", delete_slide: "slide.delete",
130
130
  add_text: "text.add", update_text: "text.update", fit_text_boxes: "text.fit", import_font: "font.import", list_project_fonts: "font.list", import_asset: "asset.import", update_asset: "asset.update", delete_asset: "asset.delete",
131
131
  add_image: "image.add", update_image: "image.update", delete_layers: "layer.delete", duplicate_layers: "layer.duplicate", reorder_layers: "layer.reorder",
@@ -199,6 +199,7 @@ export async function createCarouselBotMcpServer(companion) {
199
199
  register("create_project", "Create an empty project without changing the user's current browser view. Choose an optional aspect ratio: use a documented preset or a positive integer W:H value; legacy/default projects use 9:16. Pass a canonical folderPath such as /Client or /Client/Account to create it inside that folder (maximum two levels); omit it or use null for the dashboard root.", z.object({ editSessionId, name: z.string().min(1).max(160), aspectRatio: aspectRatio.optional(), folderPath: folderPath.nullable().optional() }).strict(), (args) => browserOperation(companion, "create_project", args), { destructiveHint: false });
200
200
  register("open_project", "Explicitly navigate the browser to a project and optionally a specific slide without changing content. Use only when the user asks to show it.", z.object({ editSessionId, projectId: id, slideId: optionalId }).strict(), (args) => browserOperation(companion, "open_project", args), { destructiveHint: false, idempotentHint: true });
201
201
  register("update_project", "Rename a project.", z.object({ ...targetProject, name: z.string().min(1).max(160) }).strict(), (args) => browserOperation(companion, "update_project", args), { destructiveHint: true });
202
+ register("duplicate_project", "Duplicate a complete carousel, including all slides, image assets, fonts and editable layers, without changing the source or navigating the browser. Omit folderPath to keep the source folder, pass /Client/Account for another folder, or null for Home. Returns createdProjectId for the independent copy; projectId and the edit session remain bound to the source. Use a new session to edit the copy.", z.object({ ...targetProject, projectId: id, name: z.string().min(1).max(160).optional(), folderPath: folderPath.nullable().optional() }).strict(), (args) => browserOperation(companion, "duplicate_project", args), { destructiveHint: false });
202
203
  register("move_project", "Move a project into a folder by canonical slash path (/Client or /Client/Account, maximum two levels), move it between folders, or move it back to the dashboard root with folderPath=null. Folder cards are derived from project membership, so empty folders disappear.", z.object({ ...targetProject, projectId: id, folderPath: folderPath.nullable() }).strict(), (args) => browserOperation(companion, "move_project", args), { destructiveHint: true });
203
204
  register("delete_project", "Delete a project from browser storage.", z.object({ ...targetProject, projectId: id }).strict(), (args) => browserOperation(companion, "delete_project", args), { destructiveHint: true });
204
205