Skip to content

12 v10 icon library generator#19

Merged
anand-testcompare merged 23 commits intomainfrom
12-v10-icon-library-generator
Jan 27, 2026
Merged

12 v10 icon library generator#19
anand-testcompare merged 23 commits intomainfrom
12-v10-icon-library-generator

Conversation

@anand-testcompare
Copy link
Collaborator

@anand-testcompare anand-testcompare commented Jan 27, 2026

  • refactor(library-editor): full-width layout with sidebar
  • refactor(style-controls): vertical layout for sidebar
  • feat(icon-library): add core components (export, grid, card, uploader)
  • feat(icon-grid): bulk selection, style preview, responsive columns
  • fix: Button asChild prop error on homepage
  • feat(icon-library): add backend schema, mutations, and seed script
  • test: add e2e tests for icon library generator
  • chore: update config and dependencies for icon library feature
  • feat(web): add svg2roughjs dependency for sketchy icon preview
  • feat(web): add SketchyIconPreview component using svg2roughjs
  • refactor(web): use SketchyIconPreview in IconGrid
  • refactor(web): simplify StyleSettings interface
  • refactor(web): use range sliders for roughness and bowing
  • refactor(web): add bowing, randomize, pencilFilter to preview

Summary by CodeRabbit

  • New Features

    • Full Icon Library Generator: create, edit, upload (bulk), reorder, delete, and export libraries (.excalidrawlib, ZIP).
    • Sketchy SVG previews and style controls; client-side SVG uploader with validation and per-file error handling.
    • Export UI for library downloads.
  • Backend

    • New library management APIs, upload handling, and a seed script to populate icons.
  • UX Improvements

    • Simplified header/home link and single-click theme toggle; improved slider thumb styling and remote image support.
  • Tests

    • End-to-end tests for happy path and invalid SVG handling.
  • Chores

    • Added SVG tooling dependencies and type definitions.

✏️ Tip: You can customize this high-level summary in your review settings.

@anand-testcompare anand-testcompare linked an issue Jan 27, 2026 that may be closed by this pull request
15 tasks
@coderabbitai
Copy link

coderabbitai bot commented Jan 27, 2026

Walkthrough

Adds an Icon Library Generator: backend Convex schema, queries/mutations/actions with server-side SVG validation and upload handling; frontend listing/editor routes and components for upload, sketchy previews, reordering, deletion, and export; SVG conversion/validation libs; seeding and test scripts; E2E scenarios and minor config/dependency updates.

Changes

Cohort / File(s) Summary
Config & deps
apps/web/next.config.ts, apps/web/package.json, package.json, packages/backend/package.json
Add Next.js images.remotePatterns; add jszip, svg2roughjs, @types/jszip; add seed:palantir-icons scripts.
Backend schema
packages/backend/convex/schema.ts
Add iconLibraries and iconItems tables and shared styleSettings value object and indices.
Backend API: libraries
packages/backend/convex/iconLibraries.ts
New Convex queries/mutations: list, getBySlug, get, create, update, generateUploadUrl, internal addIconRecord, deleteIcon, reorderIcons; slug uniqueness, counts, preview URL resolution.
Backend action: upload
packages/backend/convex/iconLibrariesActions.ts
Add addIcon action: validate storage object and size (256KB), validate SVG content, compute content hash, sanitize filenames, persist icon record, and delete invalid uploads.
Frontend routes
apps/web/src/app/library-generator/page.tsx, apps/web/src/app/library-generator/[id]/page.tsx, apps/web/src/app/page.tsx
Add library listing/creation page and library editor page (upload, save, style controls, reorder, delete, export); update home page to link to generator.
Frontend components
apps/web/src/components/icon-library/*, apps/web/src/components/header.tsx, apps/web/src/components/mode-toggle.tsx
Add LibraryCard, SvgUploader, IconGrid, StyleControls, SketchyIconPreview, ExportButton; simplify header and theme toggle UI.
Frontend utilities
apps/web/src/lib/icon-library/svg-validate.ts, apps/web/src/lib/icon-library/svg-to-excalidraw.ts
Add SVG validation (root svg, ban scripts/foreignObject, no external refs) and conversion to Excalidraw freedraw elements with sampling and style settings.
Client styles & layout
apps/web/src/index.css, apps/web/src/app/layout.tsx
Add slider thumb styling for light/dark modes; add Caveat font and integrate into RootLayout.
Export UI
apps/web/src/components/icon-library/export-button.tsx
New ExportButton component supporting .excalidrawlib and ZIP (SVG) exports with client-side fetch/pack/download.
Seeding & scripts
packages/backend/scripts/seed-palantir-icons.ts, scripts/test-svg-conversion.ts
Add seeding script to populate palantir-icons and a test script to validate SVG→Excalidraw conversion.
E2E tests
tests/e2e/src/scenarios/unauthenticated/*
Add happy-path and invalid-SVG scenarios for Icon Library Generator.
Backend libs & minor changes
packages/backend/lib/*, packages/backend/convex/arrowOptimization.test.ts
Import/export reorganizations and test formatting tweaks; no behavioral changes except added re-exports.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant Frontend as Frontend UI
    participant Convex as Convex Backend
    participant Storage as File Storage

    User->>Frontend: Select SVG files & click "Upload"
    Frontend->>Frontend: Validate files (size/type)
    Frontend->>Convex: Call generateUploadUrl()
    Convex-->>Frontend: Return upload URL
    Frontend->>Storage: Upload SVG to URL
    Storage-->>Frontend: Return storageId
    Frontend->>Convex: Call addIcon(libraryId, storageId, originalName)
    Convex->>Convex: Read storage object, validate SVG, compute hash, create icon record
    Convex-->>Frontend: Return fileName
    Frontend->>Frontend: Refresh icon grid / show toast
Loading
sequenceDiagram
    participant User as User
    participant Frontend as Frontend UI
    participant Storage as File Storage
    participant Browser as Browser Download

    User->>Frontend: Click "Export .excalidrawlib"
    Frontend->>Storage: Fetch each icon SVG (via resolved URLs)
    Storage-->>Frontend: Return SVG text
    Frontend->>Frontend: Convert SVGs -> Excalidraw elements (svgToExcalidraw + styleSettings)
    Frontend->>Frontend: Build library JSON, create Blob
    Frontend->>Browser: Trigger download (anchor click)
    Browser-->>User: Save .excalidrawlib file
Loading

Possibly related PRs

  • anand-testcompare/nyrra#3 — modifies apps/web/next.config.ts; overlaps Next.js config changes (remote image patterns / config export).
  • anand-testcompare/nyrra#5 — edits apps/web/src/app/layout.tsx; overlaps font/layout adjustments.
  • 3 experiment 04 migrate auto layout to deterministic module #15 — touches packages/backend/lib/diagram-layout*; overlaps import/export reorganization of layout utilities.

Poem

🐰 I nibble SVGs beneath the moonlight,

I roughen strokes and make them bright,
Libraries sprout in tidy rows,
Exports hop out with jaunty bows,
A little carrot for each icon's flight.

🚥 Pre-merge checks | ✅ 1 | ❌ 1
❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Title check ❓ Inconclusive The title '12 v10 icon library generator' is vague and uses internal ticket/version identifiers that lack clear meaning without external context. Consider revising to a more descriptive title like 'Add icon library generator feature with SVG upload, styling, and export' to clearly communicate the main change.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch 12-v10-icon-library-generator

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🤖 Fix all issues with AI agents
In `@apps/web/src/app/library-generator/`[id]/page.tsx:
- Around line 79-106: The handleSave function is missing bowing, randomize, and
pencilFilter when building the styleSettings payload for updateLibrary; update
the payload in handleSave (the object passed to updateLibrary in handleSave) to
include styleSettings.bowing, styleSettings.randomize, and
styleSettings.pencilFilter (pulling them from the local styleSettings state) and
remove/adjust the forced unknown cast so the payload matches the backend
StyleSettings shape or explicitly type the object to Parameters<typeof
updateLibrary>[0]["styleSettings"] to avoid dropping these properties.
- Around line 51-67: The effect in useEffect reads dbSettings but overwrites
bowing, randomize, and pencilFilter with hardcoded values; update the
setStyleSettings call to pull bowing, randomize, and pencilFilter from
dbSettings (casting/transforming as needed) and only fall back to the current
defaults when those keys are undefined, so setStyleSettings uses
dbSettings.fillStyle, dbSettings.roughness, dbSettings.bowing,
dbSettings.randomize, and dbSettings.pencilFilter (with appropriate type
coercion to match StyleSettings) instead of fixed values.

In `@apps/web/src/components/icon-library/library-card.tsx`:
- Around line 38-49: The preview images in LibraryCard are rendered with an
empty alt which is inaccessible; update the previewUrls.map callback to include
the index (e.g., previewUrls.map((url, idx) => ...)) and set the Image alt prop
to a meaningful string such as `${libraryName} preview ${idx + 1}` (use the
component prop or variable that holds the library name) so each Image has
descriptive alt text; modify the JSX in library-card.tsx where Image is used to
pass that computed alt.

In `@apps/web/src/lib/icon-library/svg-to-excalidraw.ts`:
- Around line 140-241: The function svgToExcalidrawElements creates a DOMParser
unconditionally which will throw in SSR; add an explicit guard at the top of
svgToExcalidrawElements that checks typeof window !== "undefined" (or typeof
document !== "undefined") before instantiating DOMParser and, if not present,
throw a clear error like "svgToExcalidrawElements must be called in a browser
environment"; this ensures the DOMParser creation and subsequent
parser.parseFromString calls are only attempted on the client and fail fast with
a clear message when run server-side.

In `@packages/backend/convex/iconLibraries.ts`:
- Around line 232-243: The deleteIcon mutation currently deletes the DB record
then calls ctx.storage.delete which can orphan storage if the storage delete
fails; update deleteIcon (use the existing handler, ctx.db.get, ctx.db.delete,
ctx.storage.delete identifiers) to delete the storage first or wrap the sequence
in try/catch so that you only call ctx.db.delete after ctx.storage.delete
succeeds, and if you must delete the DB first add rollback logic to re-create or
restore the DB record on storage failure and log the error; ensure errors from
ctx.storage.delete are propagated or handled so orphaned storage cannot occur.
- Around line 6-14: DEFAULT_STYLE_SETTINGS in iconLibraries.ts currently defines
strokeColor, backgroundColor, strokeWidth, strokeStyle, fillStyle, roughness,
and opacity which does not match the frontend StyleSettings in
svg-to-excalidraw.ts (which expects fillStyle, roughness, bowing, randomize,
pencilFilter). Fix by updating the backend schema and DEFAULT_STYLE_SETTINGS to
include the frontend fields (add bowing, randomize, pencilFilter with sensible
defaults and keep fillStyle/roughness) or implement a clear transformation
layer: create a converter function (e.g., mapDbStyleToFrontendStyle and
mapFrontendStyleToDbStyle) used wherever library settings are read/written to
convert between DEFAULT_STYLE_SETTINGS and the frontend StyleSettings, and add a
migration to persist missing fields if needed; reference DEFAULT_STYLE_SETTINGS
and the frontend StyleSettings type in svg-to-excalidraw.ts when making the
change.

In `@packages/backend/convex/iconLibrariesActions.ts`:
- Around line 52-81: The current flow creates and validates the SVG then calls
ctx.runMutation(internal.iconLibraries.addIconRecord) but if that mutation
throws the uploaded blob (storageId) is left orphaned; wrap the call to
ctx.runMutation(internal.iconLibraries.addIconRecord, {...}) in a try/catch, and
in the catch await ctx.storage.delete(storageId) before rethrowing the error so
any failure (e.g., invalid libraryId) cleans up the uploaded file; keep all
preceding logic (ctx.storage.get, validateSvgText, hashText, sanitizeFileStem)
intact and ensure the deletion happens only on mutation failure.

In `@packages/backend/convex/schema.ts`:
- Around line 4-21: The styleSettings schema is missing three fields the UI
sends; add bowing: v.number(), randomize: v.boolean(), and pencilFilter:
v.string() to the styleSettings object in schema.ts (alongside strokeColor,
backgroundColor, strokeWidth, strokeStyle, fillStyle, roughness, opacity), and
mirror these same fields and types in the validator used by the update mutation
in iconLibraries.ts so incoming updates are accepted and not dropped.

In `@scripts/test-svg-conversion.ts`:
- Around line 25-33: The test's styleSettings object doesn't match the
StyleSettings interface used by svgToExcalidrawElements; update the test to
construct a StyleSettings-typed object (use StyleSettings as the type for the
mock) and include the missing required properties (add bowing, randomize,
pencilFilter with appropriate values) and remove or replace any non-interface
fields (e.g., replace opacity with the correct alpha/opacity field if defined in
StyleSettings); ensure the variable referenced by the mock uses the
StyleSettings type so the compiler enforces the shape when calling
svgToExcalidrawElements.
🧹 Nitpick comments (15)
packages/backend/convex/iconLibrariesActions.ts (1)

6-40: Consider reusing the shared SVG validation utilities to avoid drift.
If packages/backend/lib/icon-library/svg-validate.ts is the canonical validator, importing MAX_SVG_BYTES and validateSvgText here will keep backend validation consistent.

scripts/test-svg-conversion.ts (1)

74-83: Hardcoded Playwright path is fragile.

The path assumes Playwright is installed in packages/backend/node_modules. This may break if:

  • Dependencies are hoisted to the root node_modules
  • The package manager changes (e.g., pnpm with different linking)
  • Running from a different working directory

Consider using require.resolve('playwright') or importing directly if Playwright is a dev dependency of the script's package.

♻️ Suggested fix
-const playwrightPath = join(
-  repoRoot,
-  "packages",
-  "backend",
-  "node_modules",
-  "playwright",
-  "index.js"
-);
-const playwright = await import(pathToFileURL(playwrightPath).href);
-const { chromium } = playwright as typeof import("playwright");
+// If playwright is a direct dependency:
+import { chromium } from "playwright";
+
+// Or if dynamic resolution is needed:
+// const { chromium } = await import("playwright");
packages/backend/convex/iconLibraries.ts (2)

24-39: Unbounded loop in getUniqueSlug.

The while (true) loop could theoretically run indefinitely if slug collisions persist (e.g., due to a bug or malicious input). Consider adding a maximum attempt limit.

♻️ Suggested fix
 const getUniqueSlug = async (ctx: { db: DatabaseReader }, base: string) => {
   let slug = base;
   let attempt = 1;
+  const maxAttempts = 100;
 
-  while (true) {
+  while (attempt <= maxAttempts) {
     const existing = await ctx.db
       .query("iconLibraries")
       .withIndex("by_slug", (q) => q.eq("slug", slug))
       .unique();
     if (!existing) {
       return slug;
     }
     attempt += 1;
     slug = `${base}-${attempt}`;
   }
+  throw new Error("Unable to generate unique slug after maximum attempts.");
 };

41-71: N+1 query pattern in list().

For each library, the handler queries all icons and then fetches storage URLs for up to 9 icons. With many libraries, this creates O(n) database queries + O(n*9) storage calls.

For now this is acceptable if the library count is small, but consider pagination or denormalizing iconCount onto the library document if performance becomes an issue.

apps/web/src/app/library-generator/page.tsx (1)

76-93: Consider adding Enter key support for library creation.

Users may expect to press Enter in the input field to create a library. Currently, only the button click triggers creation.

♻️ Suggested enhancement
-        <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
+        <form
+          className="flex flex-col gap-3 sm:flex-row sm:items-center"
+          onSubmit={(e) => {
+            e.preventDefault();
+            handleCreate();
+          }}
+        >
           <Input
             onChange={(event) => setLibraryName(event.target.value)}
             placeholder="Library name"
             value={libraryName}
           />
           <Button
             disabled={isCreating}
-            onClick={handleCreate}
             size="sm"
-            type="button"
+            type="submit"
           >
             {isCreating ? "Creating…" : "Create"}
           </Button>
-        </div>
+        </form>
apps/web/src/components/icon-library/style-controls.tsx (2)

31-40: Consider adding ARIA attributes for slider accessibility.

Native range inputs benefit from ARIA attributes to improve screen reader experience.

♻️ Suggested enhancement
         <input
+          aria-valuemax={10}
+          aria-valuemin={0}
+          aria-valuenow={value.roughness}
           className="h-2 w-full cursor-pointer appearance-none rounded-lg bg-muted"
           id="roughness"
           max="10"
           min="0"
           onChange={(e) => update("roughness", Number(e.target.value))}
           step="0.5"
           type="range"
           value={value.roughness}
         />

64-80: Native select may not match design system.

The select uses inline styles while other form elements use shadcn/ui components. Consider using a consistent Select component from the UI library if one exists, or document the intentional choice.

apps/web/src/components/icon-library/icon-grid.tsx (1)

117-141: Consider improving accessibility for the selection button.

The selection button uses a custom checkbox pattern. While the aria-label is good, consider adding aria-pressed to indicate the toggle state for better screen reader support.

♻️ Suggested improvement
                   <button
                     aria-label={isSelected ? "Deselect icon" : "Select icon"}
+                    aria-pressed={isSelected}
                     className="absolute top-1 left-1 flex h-5 w-5 items-center justify-center rounded border bg-background/80 transition-colors hover:bg-background"
                     onClick={() => toggleSelect(icon.id)}
                     type="button"
                   >
apps/web/src/components/icon-library/svg-uploader.tsx (2)

16-26: Add error handling for the async upload callback.

The handleChange function is async and calls onUpload without a try-catch. If onUpload throws, the input won't be reset. Per coding guidelines, async code should handle errors appropriately.

♻️ Suggested improvement
   const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
     if (!event.target.files || event.target.files.length === 0) {
       return;
     }

-    await onUpload(event.target.files);
-
-    if (inputRef.current) {
-      inputRef.current.value = "";
+    try {
+      await onUpload(event.target.files);
+    } finally {
+      if (inputRef.current) {
+        inputRef.current.value = "";
+      }
     }
   };

43-50: Add accessible label to the hidden file input.

The hidden file input lacks an accessible name. While the button triggers it, screen readers may still announce the input without context. Add aria-label or associate it with a label. Per coding guidelines, add labels for form inputs.

♻️ Suggested improvement
       <input
         accept="image/svg+xml"
+        aria-label="Upload SVG files"
         className="hidden"
         multiple
         onChange={handleChange}
         ref={inputRef}
         type="file"
       />
apps/web/src/components/icon-library/sketchy-icon-preview.tsx (2)

48-82: Consider using async/await for cleaner fetch logic.

The coding guidelines prefer async/await syntax over promise chains for better readability. However, the current implementation with the cancelled flag is functionally correct.

♻️ Suggested refactor using async/await
   useEffect(() => {
     if (!svgUrl) {
       setSvgText(null);
       setError(null);
       return;
     }

     let cancelled = false;
-    setIsLoading(true);
-    setError(null);
-
-    fetch(svgUrl)
-      .then((res) => {
-        if (!res.ok) {
-          throw new Error(`Failed to fetch SVG: ${res.status}`);
-        }
-        return res.text();
-      })
-      .then((text) => {
-        if (!cancelled) {
-          setSvgText(text);
-          setIsLoading(false);
-        }
-      })
-      .catch((err) => {
-        if (!cancelled) {
-          setError(err instanceof Error ? err.message : "Failed to fetch SVG");
-          setIsLoading(false);
-        }
-      });
+
+    const fetchSvg = async () => {
+      setIsLoading(true);
+      setError(null);
+
+      try {
+        const res = await fetch(svgUrl);
+        if (!res.ok) {
+          throw new Error(`Failed to fetch SVG: ${res.status}`);
+        }
+        const text = await res.text();
+        if (!cancelled) {
+          setSvgText(text);
+        }
+      } catch (err) {
+        if (!cancelled) {
+          setError(err instanceof Error ? err.message : "Failed to fetch SVG");
+        }
+      } finally {
+        if (!cancelled) {
+          setIsLoading(false);
+        }
+      }
+    };
+
+    fetchSvg();

     return () => {
       cancelled = true;
     };
   }, [svgUrl]);

136-153: Loading and error indicators may overlap with the rendered sketch.

The loading/error spans and the container div are siblings, so when the sketch renders successfully, previous loading text could briefly appear alongside it. Consider conditionally hiding the container or clearing states more explicitly.

♻️ Suggested improvement
   return (
     <div className="relative flex aspect-square items-center justify-center rounded border bg-muted/30">
       {isLoading && (
         <span className="text-[10px] text-muted-foreground">Loading...</span>
       )}
-      {error && (
+      {error && !isLoading && (
         <span className="text-[10px] text-destructive" title={error}>
           Error
         </span>
       )}
       <div
         aria-label={`Sketchy preview of ${name}`}
-        className="flex h-full w-full items-center justify-center [&>svg]:max-h-full [&>svg]:max-w-full"
+        className={`flex h-full w-full items-center justify-center [&>svg]:max-h-full [&>svg]:max-w-full ${isLoading || error ? "hidden" : ""}`}
         ref={containerRef}
         role="img"
       />
     </div>
   );
apps/web/src/app/library-generator/[id]/page.tsx (2)

144-160: Sequential uploads may be slow for multiple files.

Files are uploaded one at a time using for...of with await. For many files, consider using Promise.allSettled for parallel uploads with individual error handling, or limit concurrency with a batching approach.

♻️ Example parallel upload with error collection
   const handleUpload = async (files: FileList) => {
     if (isUploading) {
       return;
     }
     setIsUploading(true);

     try {
-      for (const file of Array.from(files)) {
-        await uploadSvgFile(file);
-      }
+      const results = await Promise.allSettled(
+        Array.from(files).map((file) => uploadSvgFile(file))
+      );
+      const failures = results.filter((r) => r.status === "rejected");
+      if (failures.length > 0) {
+        toast.error(`${failures.length} file(s) failed to upload.`);
+      }
     } catch (error) {
       const message = error instanceof Error ? error.message : "Upload failed.";
       toast.error(message);
     } finally {
       setIsUploading(false);
     }
   };

Note: This requires uploadSvgFile to throw on failure rather than just showing a toast. You may need to refactor error handling accordingly.


162-175: Sequential icon deletion may be slow for bulk operations.

Similar to uploads, icons are deleted sequentially. Consider parallel deletion for better UX when deleting many icons.

apps/web/src/components/icon-library/export-button.tsx (1)

59-77: Consider parallel fetching for better export performance.

Icons are fetched sequentially, which can be slow for large libraries. Parallel fetching would improve UX.

♻️ Suggested parallel approach
-      for (const icon of icons) {
-        if (!icon.url) {
-          throw new Error(`Missing icon URL for ${icon.name}.`);
-        }
-        const response = await fetch(icon.url);
-        if (!response.ok) {
-          throw new Error(`Failed to load ${icon.name}.`);
-        }
-        const svgText = await response.text();
-        const elements = svgToExcalidrawElements(svgText, styleSettings);
-
-        libraryItems.push({
-          id: randomId(),
-          status: "published",
-          created: Date.now(),
-          name: icon.name,
-          elements,
-        });
-      }
+      const results = await Promise.all(
+        icons.map(async (icon) => {
+          if (!icon.url) {
+            throw new Error(`Missing icon URL for ${icon.name}.`);
+          }
+          const response = await fetch(icon.url);
+          if (!response.ok) {
+            throw new Error(`Failed to load ${icon.name}.`);
+          }
+          const svgText = await response.text();
+          const elements = svgToExcalidrawElements(svgText, styleSettings);
+
+          return {
+            id: randomId(),
+            status: "published" as const,
+            created: Date.now(),
+            name: icon.name,
+            elements,
+          };
+        })
+      );
+      libraryItems.push(...results);

Comment on lines +51 to +67
useEffect(() => {
if (data?.library) {
setLibraryName(data.library.name);
const dbSettings = data.library.styleSettings as unknown as Record<
string,
unknown
>;
setStyleSettings({
fillStyle: (dbSettings.fillStyle ||
"hachure") as StyleSettings["fillStyle"],
roughness: (dbSettings.roughness as number) ?? 1,
bowing: 1,
randomize: true,
pencilFilter: false,
});
}
}, [data?.library]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Database style settings are partially ignored.

The effect hardcodes bowing: 1, randomize: true, and pencilFilter: false instead of reading them from dbSettings. This means user-saved settings for these properties will be lost on page load.

🐛 Proposed fix
     useEffect(() => {
       if (data?.library) {
         setLibraryName(data.library.name);
         const dbSettings = data.library.styleSettings as unknown as Record<
           string,
           unknown
         >;
         setStyleSettings({
           fillStyle: (dbSettings.fillStyle ||
             "hachure") as StyleSettings["fillStyle"],
           roughness: (dbSettings.roughness as number) ?? 1,
-          bowing: 1,
-          randomize: true,
-          pencilFilter: false,
+          bowing: (dbSettings.bowing as number) ?? 1,
+          randomize: (dbSettings.randomize as boolean) ?? true,
+          pencilFilter: (dbSettings.pencilFilter as boolean) ?? false,
         });
       }
     }, [data?.library]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
if (data?.library) {
setLibraryName(data.library.name);
const dbSettings = data.library.styleSettings as unknown as Record<
string,
unknown
>;
setStyleSettings({
fillStyle: (dbSettings.fillStyle ||
"hachure") as StyleSettings["fillStyle"],
roughness: (dbSettings.roughness as number) ?? 1,
bowing: 1,
randomize: true,
pencilFilter: false,
});
}
}, [data?.library]);
useEffect(() => {
if (data?.library) {
setLibraryName(data.library.name);
const dbSettings = data.library.styleSettings as unknown as Record<
string,
unknown
>;
setStyleSettings({
fillStyle: (dbSettings.fillStyle ||
"hachure") as StyleSettings["fillStyle"],
roughness: (dbSettings.roughness as number) ?? 1,
bowing: (dbSettings.bowing as number) ?? 1,
randomize: (dbSettings.randomize as boolean) ?? true,
pencilFilter: (dbSettings.pencilFilter as boolean) ?? false,
});
}
}, [data?.library]);
🤖 Prompt for AI Agents
In `@apps/web/src/app/library-generator/`[id]/page.tsx around lines 51 - 67, The
effect in useEffect reads dbSettings but overwrites bowing, randomize, and
pencilFilter with hardcoded values; update the setStyleSettings call to pull
bowing, randomize, and pencilFilter from dbSettings (casting/transforming as
needed) and only fall back to the current defaults when those keys are
undefined, so setStyleSettings uses dbSettings.fillStyle, dbSettings.roughness,
dbSettings.bowing, dbSettings.randomize, and dbSettings.pencilFilter (with
appropriate type coercion to match StyleSettings) instead of fixed values.

Comment on lines +79 to +106
const handleSave = async () => {
if (isSaving) {
return;
}
setIsSaving(true);
try {
await updateLibrary({
id: libraryId,
name: libraryName.trim() || "Untitled Library",
styleSettings: {
strokeColor: "#000000",
backgroundColor: "transparent",
strokeWidth: 2,
strokeStyle: "solid" as const,
fillStyle: styleSettings.fillStyle,
roughness: styleSettings.roughness,
opacity: 100,
} as unknown as Parameters<typeof updateLibrary>[0]["styleSettings"],
});
toast.success("Library updated.");
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to save library.";
toast.error(message);
} finally {
setIsSaving(false);
}
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Style settings bowing, randomize, and pencilFilter are not persisted.

The handleSave function sends style settings to updateLibrary, but the payload doesn't include bowing, randomize, or pencilFilter. These settings will be lost on save and reload.

🐛 Ensure all style settings are persisted

The payload in handleSave (lines 88-96) appears to map to a different schema format. Verify that the backend schema supports the full StyleSettings interface or update accordingly. The current implementation drops bowing, randomize, and pencilFilter.

🤖 Prompt for AI Agents
In `@apps/web/src/app/library-generator/`[id]/page.tsx around lines 79 - 106, The
handleSave function is missing bowing, randomize, and pencilFilter when building
the styleSettings payload for updateLibrary; update the payload in handleSave
(the object passed to updateLibrary in handleSave) to include
styleSettings.bowing, styleSettings.randomize, and styleSettings.pencilFilter
(pulling them from the local styleSettings state) and remove/adjust the forced
unknown cast so the payload matches the backend StyleSettings shape or
explicitly type the object to Parameters<typeof
updateLibrary>[0]["styleSettings"] to avoid dropping these properties.

Comment on lines +38 to +49
: previewUrls.map((url) => (
<div
className="relative flex aspect-square items-center justify-center rounded border bg-muted/30"
key={url}
>
<Image
alt=""
className="object-contain"
fill
sizes="96px"
src={url}
unoptimized
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n apps/web/src/components/icon-library/library-card.tsx | head -60

Repository: anand-testcompare/sketchi

Length of output: 2110


Provide meaningful alt text for preview images.

These preview images convey information about the library's icons; empty alt text makes them inaccessible to screen readers. Use the library name and index to create descriptive alt text.

♿ Suggested fix
-            : previewUrls.map((url) => (
+            : previewUrls.map((url, index) => (
                 <div
                   className="relative flex aspect-square items-center justify-center rounded border bg-muted/30"
                   key={url}
                 >
                   <Image
-                    alt=""
+                    alt={`${name} icon preview ${index + 1}`}
                     className="object-contain"
                     fill
                     sizes="96px"
                     src={url}
                     unoptimized
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
: previewUrls.map((url) => (
<div
className="relative flex aspect-square items-center justify-center rounded border bg-muted/30"
key={url}
>
<Image
alt=""
className="object-contain"
fill
sizes="96px"
src={url}
unoptimized
: previewUrls.map((url, index) => (
<div
className="relative flex aspect-square items-center justify-center rounded border bg-muted/30"
key={url}
>
<Image
alt={`${name} icon preview ${index + 1}`}
className="object-contain"
fill
sizes="96px"
src={url}
unoptimized
/>
</div>
))
🤖 Prompt for AI Agents
In `@apps/web/src/components/icon-library/library-card.tsx` around lines 38 - 49,
The preview images in LibraryCard are rendered with an empty alt which is
inaccessible; update the previewUrls.map callback to include the index (e.g.,
previewUrls.map((url, idx) => ...)) and set the Image alt prop to a meaningful
string such as `${libraryName} preview ${idx + 1}` (use the component prop or
variable that holds the library name) so each Image has descriptive alt text;
modify the JSX in library-card.tsx where Image is used to pass that computed
alt.

Comment on lines +140 to +241
export const svgToExcalidrawElements = (
svgText: string,
styleSettings: StyleSettings
) => {
const parser = new DOMParser();
const doc = parser.parseFromString(svgText, "image/svg+xml");
const parsedSvg = doc.querySelector("svg") as SVGSVGElement | null;
const parseError = doc.querySelector("parsererror");

if (!parsedSvg || parseError) {
throw new Error("Unable to parse SVG.");
}

let container: HTMLDivElement | null = null;
let svg = parsedSvg;

if (typeof document !== "undefined" && document.body) {
container = document.createElement("div");
container.style.cssText =
"position:absolute;left:-99999px;top:-99999px;width:0;height:0;overflow:hidden;visibility:hidden;";
container.appendChild(document.importNode(parsedSvg, true));
document.body.appendChild(container);
const attached = container.querySelector("svg") as SVGSVGElement | null;
if (attached) {
svg = attached;
}
}

try {
const geometryElements = Array.from(
svg.querySelectorAll("path,rect,circle,ellipse,line,polyline,polygon")
).filter((element) => "getTotalLength" in element) as SVGGeometryElement[];

const pathPoints = geometryElements
.map((element) => sampleGeometryPoints(element, svg))
.filter((points) => points.length > 1);

if (pathPoints.length === 0) {
throw new Error("SVG has no usable geometry.");
}

const flattened = pathPoints.flat();
const bounds = computeBounds(flattened);
const width = bounds.maxX - bounds.minX;
const height = bounds.maxY - bounds.minY;
const scale =
width > 0 || height > 0 ? TARGET_SIZE / Math.max(width, height) : 1;

const groupId = randomId();
const updated = Date.now();

return pathPoints.map((points) => {
const scaledPoints = points.map((point) => ({
x: (point.x - bounds.minX) * scale,
y: (point.y - bounds.minY) * scale,
}));

const localBounds = computeBounds(scaledPoints);
const elementX = localBounds.minX;
const elementY = localBounds.minY;
const elementPoints = scaledPoints.map(
(point) =>
[point.x - localBounds.minX, point.y - localBounds.minY] as [
number,
number,
]
);

return {
type: "freedraw",
version: 1,
versionNonce: randomInt(),
isDeleted: false,
id: randomId(),
fillStyle: styleSettings.fillStyle,
strokeWidth: 2,
strokeStyle: "solid",
roughness: styleSettings.roughness,
opacity: 100,
angle: 0,
x: elementX,
y: elementY,
strokeColor: "#000000",
backgroundColor: "transparent",
width: localBounds.maxX - localBounds.minX,
height: localBounds.maxY - localBounds.minY,
seed: randomInt(),
groupIds: [groupId],
strokeSharpness: "sharp",
boundElements: [],
updated,
link: null,
points: elementPoints,
lastCommittedPoint: null,
simulatePressure: true,
pressures: [],
} satisfies ExcalidrawElement;
});
} finally {
container?.remove();
}
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n "svgToExcalidrawElements" --type ts --type tsx -B 3 -A 3

Repository: anand-testcompare/sketchi

Length of output: 96


🏁 Script executed:

rg -n "svgToExcalidrawElements" -t ts -B 3 -A 3

Repository: anand-testcompare/sketchi

Length of output: 3362


🏁 Script executed:

head -n 80 apps/web/src/components/icon-library/export-button.tsx

Repository: anand-testcompare/sketchi

Length of output: 2046


🏁 Script executed:

head -n 1 apps/web/src/components/icon-library/export-button.tsx

Repository: anand-testcompare/sketchi

Length of output: 102


🏁 Script executed:

cat apps/web/package.json | grep -A 5 '"next"'

Repository: anand-testcompare/sketchi

Length of output: 220


🏁 Script executed:

rg -n "from.*svg-to-excalidraw" --type ts -B 2

Repository: anand-testcompare/sketchi

Length of output: 1630


🏁 Script executed:

head -n 20 apps/web/src/app/library-generator/[id]/page.tsx

Repository: anand-testcompare/sketchi

Length of output: 897


🏁 Script executed:

head -n 20 scripts/test-svg-conversion.ts

Repository: anand-testcompare/sketchi

Length of output: 522


Add explicit DOMParser check for SSR safety.
All current call sites are client-side only (export-button.tsx uses React hooks, [id]/page.tsx has "use client"), but the function itself lacks a defensive guard. It checks typeof document !== "undefined" but instantiates DOMParser without verification. Adding an explicit guard ensures the API fails fast with a clear message if called in an SSR context.

 export const svgToExcalidrawElements = (
   svgText: string,
   styleSettings: StyleSettings
 ) => {
+  if (typeof DOMParser === "undefined") {
+    throw new Error("svgToExcalidrawElements requires a browser environment.");
+  }
   const parser = new DOMParser();
🤖 Prompt for AI Agents
In `@apps/web/src/lib/icon-library/svg-to-excalidraw.ts` around lines 140 - 241,
The function svgToExcalidrawElements creates a DOMParser unconditionally which
will throw in SSR; add an explicit guard at the top of svgToExcalidrawElements
that checks typeof window !== "undefined" (or typeof document !== "undefined")
before instantiating DOMParser and, if not present, throw a clear error like
"svgToExcalidrawElements must be called in a browser environment"; this ensures
the DOMParser creation and subsequent parser.parseFromString calls are only
attempted on the client and fail fast with a clear message when run server-side.

Comment on lines +6 to +14
const DEFAULT_STYLE_SETTINGS = {
strokeColor: "#1f2937",
backgroundColor: "transparent",
strokeWidth: 1,
strokeStyle: "solid",
fillStyle: "solid",
roughness: 0,
opacity: 100,
} as const;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check backend DEFAULT_STYLE_SETTINGS
echo "=== Backend DEFAULT_STYLE_SETTINGS ==="
cat -n packages/backend/convex/iconLibraries.ts | head -20

echo -e "\n=== Backend schema definition (full iconLibraries.ts) ==="
wc -l packages/backend/convex/iconLibraries.ts

Repository: anand-testcompare/sketchi

Length of output: 829


🏁 Script executed:

#!/bin/bash
# Search for StyleSettings in frontend
echo "=== Frontend StyleSettings interface ==="
fd svg-to-excalidraw packages/apps/web/src --type f
rg "interface StyleSettings|type StyleSettings" packages/apps/web/src

Repository: anand-testcompare/sketchi

Length of output: 297


🏁 Script executed:

#!/bin/bash
# More comprehensive search for StyleSettings definitions
echo "=== All StyleSettings definitions in codebase ==="
rg "StyleSettings\s*[:=]\s*{|interface\s+StyleSettings|type\s+StyleSettings" --type ts --type tsx

Repository: anand-testcompare/sketchi

Length of output: 313


🏁 Script executed:

#!/bin/bash
# Find the correct structure
echo "=== Repository structure ==="
git ls-files | head -30

echo -e "\n=== Looking for web app ==="
fd "svg-to-excalidraw" --type f

Repository: anand-testcompare/sketchi

Length of output: 1003


🏁 Script executed:

#!/bin/bash
# Search for StyleSettings definitions with corrected regex
echo "=== Frontend StyleSettings ==="
rg "StyleSettings" --type ts --type tsx -A 8

Repository: anand-testcompare/sketchi

Length of output: 144


🏁 Script executed:

#!/bin/bash
# Read the svg-to-excalidraw.ts file to find StyleSettings
echo "=== Frontend StyleSettings in svg-to-excalidraw.ts ==="
cat -n apps/web/src/lib/icon-library/svg-to-excalidraw.ts | grep -A 20 "StyleSettings"

Repository: anand-testcompare/sketchi

Length of output: 1425


🏁 Script executed:

#!/bin/bash
# Search for StyleSettings in all files
echo "=== All StyleSettings references ==="
rg "StyleSettings" -A 8 -B 2

Repository: anand-testcompare/sketchi

Length of output: 14633


Schema mismatch: Backend and frontend StyleSettings are incompatible.

The backend DEFAULT_STYLE_SETTINGS stores strokeColor, backgroundColor, strokeWidth, strokeStyle, fillStyle, roughness, and opacity. The frontend StyleSettings interface (in svg-to-excalidraw.ts) expects fillStyle, roughness, bowing, randomize, and pencilFilter.

Only fillStyle and roughness overlap. When the frontend loads library settings from the database, it extracts only these two properties and hardcodes the rest (bowing: 1, randomize: true, pencilFilter: false), meaning user preferences for bowing, randomize, and pencilFilter are never persisted. Conversely, the backend stores stroke properties that the frontend never uses.

Align the backend schema with the frontend's actual needs, or provide a migration/transformation layer to handle the schema difference.

🤖 Prompt for AI Agents
In `@packages/backend/convex/iconLibraries.ts` around lines 6 - 14,
DEFAULT_STYLE_SETTINGS in iconLibraries.ts currently defines strokeColor,
backgroundColor, strokeWidth, strokeStyle, fillStyle, roughness, and opacity
which does not match the frontend StyleSettings in svg-to-excalidraw.ts (which
expects fillStyle, roughness, bowing, randomize, pencilFilter). Fix by updating
the backend schema and DEFAULT_STYLE_SETTINGS to include the frontend fields
(add bowing, randomize, pencilFilter with sensible defaults and keep
fillStyle/roughness) or implement a clear transformation layer: create a
converter function (e.g., mapDbStyleToFrontendStyle and
mapFrontendStyleToDbStyle) used wherever library settings are read/written to
convert between DEFAULT_STYLE_SETTINGS and the frontend StyleSettings, and add a
migration to persist missing fields if needed; reference DEFAULT_STYLE_SETTINGS
and the frontend StyleSettings type in svg-to-excalidraw.ts when making the
change.

Comment on lines +232 to +243
export const deleteIcon = mutation({
args: { iconId: v.id("iconItems") },
handler: async (ctx, { iconId }) => {
const icon = await ctx.db.get(iconId);
if (!icon) {
throw new Error("Icon not found.");
}

await ctx.db.delete(iconId);
await ctx.storage.delete(icon.storageId);
},
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Storage deletion order may leave orphaned storage objects.

If ctx.storage.delete(icon.storageId) fails after the database record is deleted, the storage object becomes orphaned. Consider deleting storage first, or wrapping in a try-catch with cleanup logic.

♻️ Suggested reorder
 handler: async (ctx, { iconId }) => {
     const icon = await ctx.db.get(iconId);
     if (!icon) {
       throw new Error("Icon not found.");
     }
 
-    await ctx.db.delete(iconId);
     await ctx.storage.delete(icon.storageId);
+    await ctx.db.delete(iconId);
   },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const deleteIcon = mutation({
args: { iconId: v.id("iconItems") },
handler: async (ctx, { iconId }) => {
const icon = await ctx.db.get(iconId);
if (!icon) {
throw new Error("Icon not found.");
}
await ctx.db.delete(iconId);
await ctx.storage.delete(icon.storageId);
},
});
export const deleteIcon = mutation({
args: { iconId: v.id("iconItems") },
handler: async (ctx, { iconId }) => {
const icon = await ctx.db.get(iconId);
if (!icon) {
throw new Error("Icon not found.");
}
await ctx.storage.delete(icon.storageId);
await ctx.db.delete(iconId);
},
});
🤖 Prompt for AI Agents
In `@packages/backend/convex/iconLibraries.ts` around lines 232 - 243, The
deleteIcon mutation currently deletes the DB record then calls
ctx.storage.delete which can orphan storage if the storage delete fails; update
deleteIcon (use the existing handler, ctx.db.get, ctx.db.delete,
ctx.storage.delete identifiers) to delete the storage first or wrap the sequence
in try/catch so that you only call ctx.db.delete after ctx.storage.delete
succeeds, and if you must delete the DB first add rollback logic to re-create or
restore the DB record on storage failure and log the error; ensure errors from
ctx.storage.delete are propagated or handled so orphaned storage cannot occur.

Comment on lines +52 to +81
const file = await ctx.storage.get(storageId);
if (!file) {
throw new Error("Uploaded SVG not found.");
}

if (file.size > MAX_SVG_BYTES) {
await ctx.storage.delete(storageId);
throw new Error("SVG exceeds 256KB limit.");
}

const svgText = await file.text();
try {
validateSvgText(svgText);
} catch (error) {
await ctx.storage.delete(storageId);
throw error;
}

const contentHash = (await hashText(svgText)).slice(0, 12);
const fileStem = sanitizeFileStem(originalName);
const fileName = `${libraryId}-${fileStem}-${contentHash}.svg`;

return ctx.runMutation(internal.iconLibraries.addIconRecord, {
libraryId,
storageId,
originalName,
contentHash,
fileName,
byteSize: file.size,
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Clean up uploaded storage if addIconRecord fails.
If ctx.runMutation throws (e.g., invalid libraryId), the uploaded blob is left orphaned. Wrap the mutation in a try/catch and delete on failure to avoid storage leaks.

🧹 Proposed fix
-    return ctx.runMutation(internal.iconLibraries.addIconRecord, {
-      libraryId,
-      storageId,
-      originalName,
-      contentHash,
-      fileName,
-      byteSize: file.size,
-    });
+    try {
+      return await ctx.runMutation(internal.iconLibraries.addIconRecord, {
+        libraryId,
+        storageId,
+        originalName,
+        contentHash,
+        fileName,
+        byteSize: file.size,
+      });
+    } catch (error) {
+      await ctx.storage.delete(storageId);
+      throw error;
+    }
🤖 Prompt for AI Agents
In `@packages/backend/convex/iconLibrariesActions.ts` around lines 52 - 81, The
current flow creates and validates the SVG then calls
ctx.runMutation(internal.iconLibraries.addIconRecord) but if that mutation
throws the uploaded blob (storageId) is left orphaned; wrap the call to
ctx.runMutation(internal.iconLibraries.addIconRecord, {...}) in a try/catch, and
in the catch await ctx.storage.delete(storageId) before rethrowing the error so
any failure (e.g., invalid libraryId) cleans up the uploaded file; keep all
preceding logic (ctx.storage.get, validateSvgText, hashText, sanitizeFileStem)
intact and ensure the deletion happens only on mutation failure.

Comment on lines +4 to +21
const styleSettings = v.object({
strokeColor: v.string(),
backgroundColor: v.string(),
strokeWidth: v.number(),
strokeStyle: v.union(
v.literal("solid"),
v.literal("dashed"),
v.literal("dotted")
),
fillStyle: v.union(
v.literal("solid"),
v.literal("hachure"),
v.literal("cross-hatch"),
v.literal("zigzag")
),
roughness: v.number(),
opacity: v.number(),
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd packages/backend && find . -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" \) | head -20

Repository: anand-testcompare/sketchi

Length of output: 685


🏁 Script executed:

rg -n "styleSettings" --type ts --type tsx -B 2 -A 5

Repository: anand-testcompare/sketchi

Length of output: 96


🏁 Script executed:

rg -n "bowing|randomize|pencilFilter" --type ts --type tsx --type js --type jsx -B 2 -A 2

Repository: anand-testcompare/sketchi

Length of output: 96


🏁 Script executed:

# Search for where styleSettings is created or populated in UI code
rg -n "strokeColor|backgroundColor|strokeWidth|fillStyle|roughness|opacity" --type ts --type tsx -l | head -20

Repository: anand-testcompare/sketchi

Length of output: 96


🏁 Script executed:

rg -n "styleSettings" --type-add ts:*.ts --type-add tsx:*.tsx -B 2 -A 5 | head -100

Repository: anand-testcompare/sketchi

Length of output: 7355


🏁 Script executed:

rg "bowing|randomize|pencilFilter" | head -50

Repository: anand-testcompare/sketchi

Length of output: 2450


🏁 Script executed:

# Find the schema.ts file and examine its context
find . -name "schema.ts" -type f

Repository: anand-testcompare/sketchi

Length of output: 104


🏁 Script executed:

# Search for where style objects are created in the codebase
rg "strokeColor.*backgroundColor|backgroundColor.*strokeColor" -B 3 -A 3 | head -80

Repository: anand-testcompare/sketchi

Length of output: 4723


🏁 Script executed:

cat -n packages/backend/convex/schema.ts | head -40

Repository: anand-testcompare/sketchi

Length of output: 1362


🏁 Script executed:

# Check where styleSettings is used in database operations
rg -n "styleSettings" packages/backend/convex/ -A 3 -B 1

Repository: anand-testcompare/sketchi

Length of output: 2279


🏁 Script executed:

rg -n "DEFAULT_STYLE_SETTINGS" packages/backend/convex/ -B 5 -A 10

Repository: anand-testcompare/sketchi

Length of output: 2214


🏁 Script executed:

# Check the complete styleSettings structure in iconLibraries.ts update mutation
sed -n '148,168p' packages/backend/convex/iconLibraries.ts

Repository: anand-testcompare/sketchi

Length of output: 635


Add bowing, randomize, and pencilFilter to styleSettings schema.

The UI sends these three fields in style controls, but the schema only accepts strokeColor, backgroundColor, strokeWidth, strokeStyle, fillStyle, roughness, and opacity. Without these fields in the schema validation, updates will fail or drop data. Update both the styleSettings object in schema.ts and the corresponding validator in iconLibraries.ts update mutation.

🤖 Prompt for AI Agents
In `@packages/backend/convex/schema.ts` around lines 4 - 21, The styleSettings
schema is missing three fields the UI sends; add bowing: v.number(), randomize:
v.boolean(), and pencilFilter: v.string() to the styleSettings object in
schema.ts (alongside strokeColor, backgroundColor, strokeWidth, strokeStyle,
fillStyle, roughness, opacity), and mirror these same fields and types in the
validator used by the update mutation in iconLibraries.ts so incoming updates
are accepted and not dropped.

anand-testcompare and others added 5 commits January 26, 2026 21:34
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@apps/web/src/app/library-generator/`[id]/page.tsx:
- Around line 263-269: The IconGrid is marked busy only by isUploading, so save
operations can still allow deletes/moves; update the isBusy prop passed to
IconGrid to include the save state (e.g., change isBusy={isUploading} to
isBusy={isUploading || isSaving} or whatever save-state variable you have),
ensure the save state variable (isSaving, isSavingLibrary, etc.) is set around
the save routine (the function that performs the library save), and ensure
handlers like handleDeleteSelected and handleMove respect/early-return when
isBusy is true so UI actions are blocked during saves.
- Around line 136-141: The code calls response.json() and then addIcon inside
handleUpload (page.tsx) which can throw a JSON parsing error that isn't
file-specific; wrap the response.json() call in its own try/catch (or validate
response text before parsing) and on failure throw or log a new Error that
includes the file.name and response.status/text so the outer catch can surface a
file-specific message; ensure you still reference payload.storageId (used by
addIcon) only after successful parse and rethrow or propagate a descriptive
error from the JSON parse block so handlers know which file failed.
- Around line 219-222: The Input component rendering the library name (value
bound to libraryName and onChange calling setLibraryName) lacks an accessible
label; update the JSX where Input is used to add either a visible <label> tied
to the input (for example, label text "Library name" with htmlFor matching the
Input's id) or provide an aria-label/aria-labelledby attribute on the Input
(ensure you set a unique id prop and reference it if using a label), so screen
readers can identify the field.

In `@apps/web/src/components/header.tsx`:
- Around line 10-16: The Home Link currently only contains an sr-only span so
sighted users see an empty box; update the Link in header.tsx (the Link element
with aria-label="Home" and className="flex h-7 w-7 ...") to include a visible
Home icon (either import and render the existing HomeIcon component or inline an
SVG) placed alongside the sr-only span, mark the icon aria-hidden="true", style
it to fit the container (match h-4 w-4 or similar) and keep the sr-only text for
screen readers so the visual and accessible labels are preserved.
🧹 Nitpick comments (3)
apps/web/src/components/mode-toggle.tsx (1)

9-13: Handle potential undefined state of resolvedTheme during hydration.

resolvedTheme can be undefined before the theme is resolved on the client (e.g., during SSR or initial hydration). If the user clicks the toggle before hydration completes, the comparison resolvedTheme === "dark" will be false, always setting the theme to "light" regardless of the user's actual preference.

Consider guarding against this edge case:

Suggested fix
 const toggleTheme = () => {
+  if (!resolvedTheme) return;
   setTheme(resolvedTheme === "dark" ? "light" : "dark");
 };
apps/web/src/components/icon-library/icon-grid.tsx (1)

157-170: Consider using lucide-react Check icon for consistency.

The codebase already imports icons from lucide-react (e.g., Minus, Plus). Using an inline SVG for the checkmark creates inconsistency.

♻️ Suggested refactor
+import { Check, Minus, Plus } from "lucide-react";
-import { Minus, Plus } from "lucide-react";

Then replace the inline SVG:

                    {isSelected && (
-                      <svg
-                        aria-hidden="true"
-                        className="h-3 w-3 text-primary"
-                        fill="none"
-                        stroke="currentColor"
-                        strokeWidth={3}
-                        viewBox="0 0 24 24"
-                      >
-                        <path
-                          d="M5 13l4 4L19 7"
-                          strokeLinecap="round"
-                          strokeLinejoin="round"
-                        />
-                      </svg>
+                      <Check aria-hidden="true" className="h-3 w-3 text-primary" />
                    )}
apps/web/src/app/library-generator/[id]/page.tsx (1)

162-175: Sequential deletion stops on first error, leaving partial state.

If one deleteIcon call fails, subsequent icons won't be deleted, but the UI will have already removed them from selection. Consider using Promise.allSettled to attempt all deletions and report combined results.

♻️ Proposed improvement
   const handleDeleteSelected = async (ids: string[]) => {
     try {
-      for (const id of ids) {
-        await deleteIcon({ iconId: id as Id<"iconItems"> });
-      }
-      toast.success(
-        ids.length === 1 ? "Icon deleted." : `${ids.length} icons deleted.`
-      );
+      const results = await Promise.allSettled(
+        ids.map((id) => deleteIcon({ iconId: id as Id<"iconItems"> }))
+      );
+      const failures = results.filter((r) => r.status === "rejected");
+      if (failures.length === 0) {
+        toast.success(
+          ids.length === 1 ? "Icon deleted." : `${ids.length} icons deleted.`
+        );
+      } else if (failures.length < ids.length) {
+        toast.warning(`Deleted ${ids.length - failures.length} of ${ids.length} icons.`);
+      } else {
+        toast.error("Failed to delete icons.");
+      }
     } catch (error) {
       const message =
         error instanceof Error ? error.message : "Failed to delete icons.";
       toast.error(message);
     }
   };

Comment on lines +136 to +141
const payload = (await response.json()) as { storageId: string };
await addIcon({
libraryId,
storageId: payload.storageId as Id<"_storage">,
originalName: file.name,
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

JSON parsing can throw on malformed responses.

If the upload succeeds (response.ok) but returns malformed JSON, response.json() will throw an unhandled error. The outer try-catch in handleUpload will catch it, but the error message won't be file-specific.

🐛 Suggested improvement
+    let payload: { storageId: string };
+    try {
+      payload = (await response.json()) as { storageId: string };
+    } catch {
+      toast.error(`Invalid response for ${file.name}.`);
+      return;
+    }
-    const payload = (await response.json()) as { storageId: string };
     await addIcon({
       libraryId,
-      storageId: payload.storageId as Id<"_storage">,
+      storageId: payload.storageId as Id<"_storage">,
       originalName: file.name,
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const payload = (await response.json()) as { storageId: string };
await addIcon({
libraryId,
storageId: payload.storageId as Id<"_storage">,
originalName: file.name,
});
let payload: { storageId: string };
try {
payload = (await response.json()) as { storageId: string };
} catch {
toast.error(`Invalid response for ${file.name}.`);
return;
}
await addIcon({
libraryId,
storageId: payload.storageId as Id<"_storage">,
originalName: file.name,
});
🤖 Prompt for AI Agents
In `@apps/web/src/app/library-generator/`[id]/page.tsx around lines 136 - 141, The
code calls response.json() and then addIcon inside handleUpload (page.tsx) which
can throw a JSON parsing error that isn't file-specific; wrap the
response.json() call in its own try/catch (or validate response text before
parsing) and on failure throw or log a new Error that includes the file.name and
response.status/text so the outer catch can surface a file-specific message;
ensure you still reference payload.storageId (used by addIcon) only after
successful parse and rethrow or propagate a descriptive error from the JSON
parse block so handlers know which file failed.

Comment on lines +219 to +222
<Input
onChange={(event) => setLibraryName(event.target.value)}
value={libraryName}
/>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Input lacks accessible label.

The library name input has no associated <label> or aria-label, making it inaccessible to screen reader users. As per coding guidelines, form inputs should have proper labels.

♿ Suggested fix
+          <label className="sr-only" htmlFor="library-name">
+            Library name
+          </label>
           <Input
+            id="library-name"
             onChange={(event) => setLibraryName(event.target.value)}
+            placeholder="Library name"
             value={libraryName}
           />

Or use aria-label:

           <Input
+            aria-label="Library name"
             onChange={(event) => setLibraryName(event.target.value)}
+            placeholder="Library name"
             value={libraryName}
           />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Input
onChange={(event) => setLibraryName(event.target.value)}
value={libraryName}
/>
<label className="sr-only" htmlFor="library-name">
Library name
</label>
<Input
id="library-name"
onChange={(event) => setLibraryName(event.target.value)}
placeholder="Library name"
value={libraryName}
/>
🤖 Prompt for AI Agents
In `@apps/web/src/app/library-generator/`[id]/page.tsx around lines 219 - 222, The
Input component rendering the library name (value bound to libraryName and
onChange calling setLibraryName) lacks an accessible label; update the JSX where
Input is used to add either a visible <label> tied to the input (for example,
label text "Library name" with htmlFor matching the Input's id) or provide an
aria-label/aria-labelledby attribute on the Input (ensure you set a unique id
prop and reference it if using a label), so screen readers can identify the
field.

Comment on lines +263 to +269
<IconGrid
icons={icons}
isBusy={isUploading}
onDeleteSelected={handleDeleteSelected}
onMove={handleMove}
styleSettings={styleSettings}
/>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

isBusy doesn't account for save operations.

The isBusy prop only reflects isUploading, but users can still delete or move icons while the library is saving. This could lead to inconsistent state if a save is in progress.

🐛 Suggested fix
           <IconGrid
             icons={icons}
-            isBusy={isUploading}
+            isBusy={isUploading || isSaving}
             onDeleteSelected={handleDeleteSelected}
             onMove={handleMove}
             styleSettings={styleSettings}
           />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<IconGrid
icons={icons}
isBusy={isUploading}
onDeleteSelected={handleDeleteSelected}
onMove={handleMove}
styleSettings={styleSettings}
/>
<IconGrid
icons={icons}
isBusy={isUploading || isSaving}
onDeleteSelected={handleDeleteSelected}
onMove={handleMove}
styleSettings={styleSettings}
/>
🤖 Prompt for AI Agents
In `@apps/web/src/app/library-generator/`[id]/page.tsx around lines 263 - 269, The
IconGrid is marked busy only by isUploading, so save operations can still allow
deletes/moves; update the isBusy prop passed to IconGrid to include the save
state (e.g., change isBusy={isUploading} to isBusy={isUploading || isSaving} or
whatever save-state variable you have), ensure the save state variable
(isSaving, isSavingLibrary, etc.) is set around the save routine (the function
that performs the library save), and ensure handlers like handleDeleteSelected
and handleMove respect/early-return when isBusy is true so UI actions are
blocked during saves.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/web/src/app/page.tsx`:
- Around line 27-32: The Link anchor is wrapping a Button causing nested
interactive elements (Link + Button) which breaks accessibility; update the
usage in page.tsx to render the Button as the Link target by using the Button's
render prop (or Base UI composition) instead of wrapping—i.e., call Button with
render={(props) => <Link {...props}><Wand2 />Open generator</Link>} or
equivalent so the Button becomes the anchor; ensure your custom Link component
forwards refs and spreads received props onto its underlying DOM element to
satisfy Base UI's render prop requirements.

anand-testcompare and others added 2 commits January 26, 2026 22:00
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/web/src/components/icon-library/sketchy-icon-preview.tsx`:
- Around line 55-82: The fetch logic leaves previous SVG content visible on new
requests or on errors; update the effect to clear stale previews by calling
setSvgText(null or empty string) when starting a new fetch (before
fetch(svgUrl)) and also in the catch block before setError, and ensure the
cleanup/cancelled logic still prevents setting state after unmount; reference
setSvgText, setIsLoading, setError, cancelled, fetch(svgUrl), and svgUrl when
making these changes.
🧹 Nitpick comments (3)
apps/web/src/app/page.tsx (2)

21-21: Add responsive breakpoints for the grid layout.

grid-cols-3 without responsive modifiers will render three cramped columns on mobile devices. Consider using responsive breakpoints for better mobile experience.

♻️ Suggested fix
-      <div className="grid grid-cols-3 gap-4">
+      <div className="grid grid-cols-1 gap-4 md:grid-cols-3">

29-29: Add aria-hidden to decorative icon.

The Wand2 icon is decorative since there's accompanying "Open" text. Hide it from screen readers to avoid redundant announcements.

♻️ Suggested fix
-              <Wand2 />
+              <Wand2 aria-hidden="true" />
apps/web/src/components/icon-library/sketchy-icon-preview.tsx (1)

48-82: Prefer async/await (and abort) for the SVG fetch effect.

The current promise chain is harder to read and makes cancellation handling awkward. Converting to async/await with an AbortController improves readability and avoids late state updates after unmount.

♻️ Suggested refactor
   useEffect(() => {
     if (!svgUrl) {
       setSvgText(null);
       setError(null);
       return;
     }

-    let cancelled = false;
-    setIsLoading(true);
-    setError(null);
-
-    fetch(svgUrl)
-      .then((res) => {
-        if (!res.ok) {
-          throw new Error(`Failed to fetch SVG: ${res.status}`);
-        }
-        return res.text();
-      })
-      .then((text) => {
-        if (!cancelled) {
-          setSvgText(text);
-          setIsLoading(false);
-        }
-      })
-      .catch((err) => {
-        if (!cancelled) {
-          setError(err instanceof Error ? err.message : "Failed to fetch SVG");
-          setIsLoading(false);
-        }
-      });
-
-    return () => {
-      cancelled = true;
-    };
+    const controller = new AbortController();
+    let cancelled = false;
+
+    const loadSvg = async () => {
+      try {
+        setIsLoading(true);
+        setError(null);
+        const res = await fetch(svgUrl, { signal: controller.signal });
+        if (!res.ok) {
+          throw new Error(`Failed to fetch SVG: ${res.status}`);
+        }
+        const text = await res.text();
+        if (!cancelled) {
+          setSvgText(text);
+        }
+      } catch (err) {
+        if (!cancelled && (err as DOMException)?.name !== "AbortError") {
+          setError(err instanceof Error ? err.message : "Failed to fetch SVG");
+        }
+      } finally {
+        if (!cancelled) {
+          setIsLoading(false);
+        }
+      }
+    };
+
+    void loadSvg();
+
+    return () => {
+      cancelled = true;
+      controller.abort();
+    };
   }, [svgUrl]);
As per coding guidelines, prefer async/await over promise chains.

Comment on lines +55 to +82
let cancelled = false;
setIsLoading(true);
setError(null);

fetch(svgUrl)
.then((res) => {
if (!res.ok) {
throw new Error(`Failed to fetch SVG: ${res.status}`);
}
return res.text();
})
.then((text) => {
if (!cancelled) {
setSvgText(text);
setIsLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to fetch SVG");
setIsLoading(false);
}
});

return () => {
cancelled = true;
};
}, [svgUrl]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Clear stale previews on fetch/parse failure.

When a fetch or parse fails, svgText remains the previous value and the container contents are left intact, so users can see a stale icon behind the error state. Clear the container and reset svgText on new requests and on errors to avoid misleading previews.

🔧 Proposed fix
   useEffect(() => {
     if (!svgUrl) {
       setSvgText(null);
       setError(null);
       return;
     }

     let cancelled = false;
     setIsLoading(true);
     setError(null);
+    setSvgText(null);
+    if (containerRef.current) {
+      containerRef.current.replaceChildren();
+    }

     fetch(svgUrl)
       .then((res) => {
         if (!res.ok) {
           throw new Error(`Failed to fetch SVG: ${res.status}`);
         }
         return res.text();
       })
       .then((text) => {
         if (!cancelled) {
           setSvgText(text);
           setIsLoading(false);
         }
       })
       .catch((err) => {
         if (!cancelled) {
           setError(err instanceof Error ? err.message : "Failed to fetch SVG");
+          setSvgText(null);
           setIsLoading(false);
         }
       });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let cancelled = false;
setIsLoading(true);
setError(null);
fetch(svgUrl)
.then((res) => {
if (!res.ok) {
throw new Error(`Failed to fetch SVG: ${res.status}`);
}
return res.text();
})
.then((text) => {
if (!cancelled) {
setSvgText(text);
setIsLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to fetch SVG");
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [svgUrl]);
let cancelled = false;
setIsLoading(true);
setError(null);
setSvgText(null);
if (containerRef.current) {
containerRef.current.replaceChildren();
}
fetch(svgUrl)
.then((res) => {
if (!res.ok) {
throw new Error(`Failed to fetch SVG: ${res.status}`);
}
return res.text();
})
.then((text) => {
if (!cancelled) {
setSvgText(text);
setIsLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to fetch SVG");
setSvgText(null);
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [svgUrl]);
🤖 Prompt for AI Agents
In `@apps/web/src/components/icon-library/sketchy-icon-preview.tsx` around lines
55 - 82, The fetch logic leaves previous SVG content visible on new requests or
on errors; update the effect to clear stale previews by calling setSvgText(null
or empty string) when starting a new fetch (before fetch(svgUrl)) and also in
the catch block before setError, and ensure the cleanup/cancelled logic still
prevents setting state after unmount; reference setSvgText, setIsLoading,
setError, cancelled, fetch(svgUrl), and svgUrl when making these changes.

- Add ZIP download support for bulk SVG export
- Fix Excalidraw export schema (add missing properties, remove deprecated strokeSharpness)
- Add text labels to Excalidraw exports (grouped with icon, hand-drawn font)
- Show labels in preview with Caveat font
- Default fillStyle to hachure, showLabel to true
- Improve icon grid layout for few icons (auto-fit)
- Add hover animation to home page card
- Style Open button with hand-drawn font
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@apps/web/src/components/icon-library/export-button.tsx`:
- Around line 127-141: The ZIP export currently uses sanitizeFileName and calls
zip.file(`${safeName}.svg`, ...) which lets JSZip overwrite entries when
different icons produce the same sanitized name; fix by deduplicating filenames
before calling zip.file: maintain a Set or Map of used names inside the export
loop, generate a candidate like `${safeName}${suffix}` (e.g., append "-1", "-2",
incrementing until unused), use that uniqueName for
zip.file(`${uniqueName}.svg`, svgText), and ensure the dedupe logic is applied
for every icon processed (references: sanitizeFileName, icons array, zip.file,
JSZip).

In `@apps/web/src/lib/icon-library/svg-to-excalidraw.ts`:
- Around line 174-201: The export flow calls svgToExcalidrawElements with
untrusted SVG and then that function appends the parsed SVG into the DOM,
enabling inline scripts/event handlers; fix by validating the raw SVG before
conversion: in export-button.tsx import the existing validateSvgText (from
svg-validate), call validateSvgText(svgText) right after fetching the SVG and
before invoking svgToExcalidrawElements, and handle validation failures (throw
or surface a user-friendly error) so only validated SVGs reach
svgToExcalidrawElements.

In `@packages/backend/convex/iconLibraries.ts`:
- Around line 245-266: The reorderIcons mutation currently validates and patches
each icon inside Promise.all which can leave partial updates if one validation
fails; change reorderIcons to first fetch and validate all orderedIds using
ctx.db.get (ensure each exists and icon.libraryId === libraryId) before
performing any ctx.db.patch calls, then apply the patches (either in a single
transaction if supported or sequentially) updating sortOrder and updatedAt;
reference the reorderIcons handler, orderedIds, ctx.db.get, and ctx.db.patch
when making the change.
🧹 Nitpick comments (4)
apps/web/src/components/icon-library/icon-grid.tsx (1)

31-37: Extract icon sizing constants instead of magic numbers.
Keeps zoom behavior consistent and easier to tune. As per coding guidelines, please extract descriptive constants.

♻️ Suggested refactor
+const DEFAULT_ICON_SIZE = 80;
+const ICON_SIZE_STEP = 20;
+const MIN_ICON_SIZE = 40;
+const MAX_ICON_SIZE = 200;
+
 export default function IconGrid({
   icons,
   onDeleteSelected,
   onMove,
   isBusy,
   styleSettings,
 }: IconGridProps) {
   const [isEditMode, setIsEditMode] = useState(false);
   const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
-  const [iconSize, setIconSize] = useState(80);
+  const [iconSize, setIconSize] = useState(DEFAULT_ICON_SIZE);

-  const handleZoomIn = () => setIconSize((prev) => Math.min(prev + 20, 200));
-  const handleZoomOut = () => setIconSize((prev) => Math.max(prev - 20, 40));
+  const handleZoomIn = () =>
+    setIconSize((prev) => Math.min(prev + ICON_SIZE_STEP, MAX_ICON_SIZE));
+  const handleZoomOut = () =>
+    setIconSize((prev) => Math.max(prev - ICON_SIZE_STEP, MIN_ICON_SIZE));
apps/web/src/app/library-generator/[id]/page.tsx (1)

166-179: Sequential deletion may leave partial state on failure.

The loop deletes icons one-by-one. If one deletion fails mid-way, remaining icons won't be processed and no rollback occurs. Consider collecting errors and reporting them at the end, or using Promise.allSettled to handle partial failures gracefully.

♻️ Suggested approach with error aggregation
   const handleDeleteSelected = async (ids: string[]) => {
+    const errors: string[] = [];
     try {
-      for (const id of ids) {
-        await deleteIcon({ iconId: id as Id<"iconItems"> });
-      }
+      const results = await Promise.allSettled(
+        ids.map((id) => deleteIcon({ iconId: id as Id<"iconItems"> }))
+      );
+      for (const result of results) {
+        if (result.status === "rejected") {
+          errors.push(result.reason instanceof Error ? result.reason.message : "Unknown error");
+        }
+      }
+      if (errors.length > 0 && errors.length < ids.length) {
+        toast.warning(`Deleted ${ids.length - errors.length} icons. ${errors.length} failed.`);
+        return;
+      }
+      if (errors.length === ids.length) {
+        toast.error("Failed to delete icons.");
+        return;
+      }
       toast.success(
         ids.length === 1 ? "Icon deleted." : `${ids.length} icons deleted.`
       );
     } catch (error) {
       const message =
         error instanceof Error ? error.message : "Failed to delete icons.";
       toast.error(message);
     }
   };
packages/backend/convex/iconLibraries.ts (2)

24-39: Unbounded loop in getUniqueSlug lacks a safety limit.

While unlikely in practice, this while (true) loop has no upper bound. If there's a database issue or unexpected state, this could loop indefinitely.

♻️ Add a safety limit
 const getUniqueSlug = async (ctx: { db: DatabaseReader }, base: string) => {
   let slug = base;
   let attempt = 1;
+  const MAX_ATTEMPTS = 1000;

-  while (true) {
+  while (attempt <= MAX_ATTEMPTS) {
     const existing = await ctx.db
       .query("iconLibraries")
       .withIndex("by_slug", (q) => q.eq("slug", slug))
       .unique();
     if (!existing) {
       return slug;
     }
     attempt += 1;
     slug = `${base}-${attempt}`;
   }
+  throw new Error(`Could not generate unique slug after ${MAX_ATTEMPTS} attempts.`);
 };

85-88: Inconsistent index usage in getBySlug.

This query uses by_library index without ordering, while list and get use by_library_order with explicit ordering. If icon order matters for display consistency, consider using the same approach.

♻️ Use consistent index and ordering
     const icons = await ctx.db
       .query("iconItems")
-      .withIndex("by_library", (q) => q.eq("libraryId", library._id))
+      .withIndex("by_library_order", (q) => q.eq("libraryId", library._id))
+      .order("asc")
       .collect();

Comment on lines +127 to +141
try {
const zip = new JSZip();

for (const icon of icons) {
if (!icon.url) {
throw new Error(`Missing icon URL for ${icon.name}.`);
}
const response = await fetch(icon.url);
if (!response.ok) {
throw new Error(`Failed to load ${icon.name}.`);
}
const svgText = await response.text();
const safeName = sanitizeFileName(icon.name);
zip.file(`${safeName}.svg`, svgText);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find the sanitizeFileName function
rg "sanitizeFileName" -A 5 -B 2

Repository: anand-testcompare/sketchi

Length of output: 2492


🏁 Script executed:

# Check the export-button.tsx file to understand context
head -n 150 apps/web/src/components/icon-library/export-button.tsx | tail -n 50

Repository: anand-testcompare/sketchi

Length of output: 1526


🏁 Script executed:

# Look for icon type definitions and data patterns
rg "interface.*[Ii]con|type.*[Ii]con" --max-count 10 -A 3

Repository: anand-testcompare/sketchi

Length of output: 7052


🌐 Web query:

JSZip file() method duplicate filename behavior overwrites

💡 Result:

In JSZip, calling zip.file(name, data) with a filename that already exists updates/replaces (overwrites) the existing entry—JSZip treats files as a map keyed by filename, so you can’t keep multiple entries with the exact same path/name in a single JSZip instance. This is explicitly described as “Add (or update) a file”. [1], [2]

If you need to avoid overwriting, you must choose unique names yourself (e.g., add a suffix like file (2).txt), or check first with zip.file(name) (returns the existing entry or null) and rename accordingly. [1], [3]

Sources: [1] JSZip file(name, data) docs (stuk.github.io), [2] JSZip API (files uses name as key) (stuk.github.io), [3] JSZip file(name) getter docs (stuk.github.io)

Citations:


ZIP export silently loses icons with colliding sanitized names.
sanitizeFileName can map different input names to the same value (e.g., "icon-1", "icon_1", "icon@1" all become "icon-1"). JSZip silently overwrites entries with duplicate filenames, causing data loss. Deduplicate with a suffix to preserve all icons.

🛠️ Suggested fix (dedupe filenames)
     try {
       const zip = new JSZip();
+      const nameCounts = new Map<string, number>();

       for (const icon of icons) {
         if (!icon.url) {
           throw new Error(`Missing icon URL for ${icon.name}.`);
         }
         const response = await fetch(icon.url);
         if (!response.ok) {
           throw new Error(`Failed to load ${icon.name}.`);
         }
         const svgText = await response.text();
         const safeName = sanitizeFileName(icon.name);
-        zip.file(`${safeName}.svg`, svgText);
+        const nextCount = (nameCounts.get(safeName) ?? 0) + 1;
+        nameCounts.set(safeName, nextCount);
+        const uniqueName =
+          nextCount > 1 ? `${safeName}-${nextCount}` : safeName;
+        zip.file(`${uniqueName}.svg`, svgText);
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
const zip = new JSZip();
for (const icon of icons) {
if (!icon.url) {
throw new Error(`Missing icon URL for ${icon.name}.`);
}
const response = await fetch(icon.url);
if (!response.ok) {
throw new Error(`Failed to load ${icon.name}.`);
}
const svgText = await response.text();
const safeName = sanitizeFileName(icon.name);
zip.file(`${safeName}.svg`, svgText);
}
try {
const zip = new JSZip();
const nameCounts = new Map<string, number>();
for (const icon of icons) {
if (!icon.url) {
throw new Error(`Missing icon URL for ${icon.name}.`);
}
const response = await fetch(icon.url);
if (!response.ok) {
throw new Error(`Failed to load ${icon.name}.`);
}
const svgText = await response.text();
const safeName = sanitizeFileName(icon.name);
const nextCount = (nameCounts.get(safeName) ?? 0) + 1;
nameCounts.set(safeName, nextCount);
const uniqueName =
nextCount > 1 ? `${safeName}-${nextCount}` : safeName;
zip.file(`${uniqueName}.svg`, svgText);
}
🤖 Prompt for AI Agents
In `@apps/web/src/components/icon-library/export-button.tsx` around lines 127 -
141, The ZIP export currently uses sanitizeFileName and calls
zip.file(`${safeName}.svg`, ...) which lets JSZip overwrite entries when
different icons produce the same sanitized name; fix by deduplicating filenames
before calling zip.file: maintain a Set or Map of used names inside the export
loop, generate a candidate like `${safeName}${suffix}` (e.g., append "-1", "-2",
incrementing until unused), use that uniqueName for
zip.file(`${uniqueName}.svg`, svgText), and ensure the dedupe logic is applied
for every icon processed (references: sanitizeFileName, icons array, zip.file,
JSZip).

Comment on lines +174 to +201
export const svgToExcalidrawElements = (
svgText: string,
styleSettings: StyleSettings,
iconName?: string
): ExcalidrawElement[] => {
const parser = new DOMParser();
const doc = parser.parseFromString(svgText, "image/svg+xml");
const parsedSvg = doc.querySelector("svg") as SVGSVGElement | null;
const parseError = doc.querySelector("parsererror");

if (!parsedSvg || parseError) {
throw new Error("Unable to parse SVG.");
}

let container: HTMLDivElement | null = null;
let svg = parsedSvg;

if (typeof document !== "undefined" && document.body) {
container = document.createElement("div");
container.style.cssText =
"position:absolute;left:-99999px;top:-99999px;width:0;height:0;overflow:hidden;visibility:hidden;";
container.appendChild(document.importNode(parsedSvg, true));
document.body.appendChild(container);
const attached = container.querySelector("svg") as SVGSVGElement | null;
if (attached) {
svg = attached;
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Search for validateSvgText function and related validation
rg -n "validateSvgText|sanitize.*[Ss]vg|svg.*sanitize" --type ts --type tsx

Repository: anand-testcompare/sketchi

Length of output: 96


🏁 Script executed:

# Search for svgToExcalidrawElements call sites to understand usage context
rg -n "svgToExcalidrawElements" --type ts --type tsx -B 2 -A 2

Repository: anand-testcompare/sketchi

Length of output: 96


🏁 Script executed:

# Check for any existing sanitization or DOMPurify usage
rg -n "DOMPurify|sanitize|xss" --type ts --type tsx

Repository: anand-testcompare/sketchi

Length of output: 96


🏁 Script executed:

# Examine the svg-to-excalidraw.ts file more closely
cat -n apps/web/src/lib/icon-library/svg-to-excalidraw.ts | head -50

Repository: anand-testcompare/sketchi

Length of output: 1501


🏁 Script executed:

# Read the specific lines from the file
sed -n '174,201p' apps/web/src/lib/icon-library/svg-to-excalidraw.ts

Repository: anand-testcompare/sketchi

Length of output: 1064


🏁 Script executed:

# Search for validateSvgText without type specification
rg -n "validateSvgText|svg-validate"

Repository: anand-testcompare/sketchi

Length of output: 599


🏁 Script executed:

# Search for svgToExcalidrawElements call sites
rg -n "svgToExcalidrawElements" -B 3 -A 3

Repository: anand-testcompare/sketchi

Length of output: 3379


🏁 Script executed:

# Check imports and validation at top of file
head -30 apps/web/src/lib/icon-library/svg-to-excalidraw.ts

Repository: anand-testcompare/sketchi

Length of output: 725


🏁 Script executed:

# Search for sanitization/XSS prevention patterns
rg -n "DOMPurify|sanitize|security|xss" -i | head -30

Repository: anand-testcompare/sketchi

Length of output: 4831


🏁 Script executed:

# Check the svg-validate.ts file to understand what validation is being done
cat apps/web/src/lib/icon-library/svg-validate.ts

Repository: anand-testcompare/sketchi

Length of output: 902


🏁 Script executed:

# Get more context on the export-button.tsx call to svgToExcalidrawElements
sed -n '65,85p' apps/web/src/components/icon-library/export-button.tsx

Repository: anand-testcompare/sketchi

Length of output: 683


Add SVG validation before DOM insertion in export-button to prevent XSS.

In export-button.tsx (line 74), svgToExcalidrawElements is called with untrusted SVG fetched from icon.url without validation. The function then appends the SVG to the DOM (line 189–190 in svg-to-excalidraw.ts), which allows inline scripts and event handlers to execute. A validateSvgText function already exists (svg-validate.ts) and is used in other call paths (library-generator, backend) but is missing here.

Import and call validateSvgText(svgText) before passing to svgToExcalidrawElements in export-button.tsx:

Proposed fix
+import { validateSvgText } from "@/lib/icon-library/svg-validate";
 import {
   type StyleSettings,
   svgToExcalidrawElements,
 } from "@/lib/icon-library/svg-to-excalidraw";
         const svgText = await response.text();
+        validateSvgText(svgText);
         const elements = svgToExcalidrawElements(
🤖 Prompt for AI Agents
In `@apps/web/src/lib/icon-library/svg-to-excalidraw.ts` around lines 174 - 201,
The export flow calls svgToExcalidrawElements with untrusted SVG and then that
function appends the parsed SVG into the DOM, enabling inline scripts/event
handlers; fix by validating the raw SVG before conversion: in export-button.tsx
import the existing validateSvgText (from svg-validate), call
validateSvgText(svgText) right after fetching the SVG and before invoking
svgToExcalidrawElements, and handle validation failures (throw or surface a
user-friendly error) so only validated SVGs reach svgToExcalidrawElements.

Comment on lines +245 to +266
export const reorderIcons = mutation({
args: {
libraryId: v.id("iconLibraries"),
orderedIds: v.array(v.id("iconItems")),
},
handler: async (ctx, { libraryId, orderedIds }) => {
const now = Date.now();

await Promise.all(
orderedIds.map(async (iconId, index) => {
const icon = await ctx.db.get(iconId);
if (!icon || icon.libraryId !== libraryId) {
throw new Error("Invalid icon reorder request.");
}
await ctx.db.patch(iconId, {
sortOrder: index,
updatedAt: now,
});
})
);
},
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Race condition in reorderIcons may cause partial updates.

Using Promise.all with validation inside each iteration means if one icon fails validation (e.g., wrong library), other patches may have already been applied. This leaves the database in an inconsistent state.

🐛 Validate all icons first, then apply patches
 export const reorderIcons = mutation({
   args: {
     libraryId: v.id("iconLibraries"),
     orderedIds: v.array(v.id("iconItems")),
   },
   handler: async (ctx, { libraryId, orderedIds }) => {
     const now = Date.now();

+    // Validate all icons belong to the library before making changes
+    const icons = await Promise.all(
+      orderedIds.map(async (iconId) => {
+        const icon = await ctx.db.get(iconId);
+        if (!icon || icon.libraryId !== libraryId) {
+          throw new Error("Invalid icon reorder request.");
+        }
+        return icon;
+      })
+    );
+
+    // Apply all patches after validation passes
     await Promise.all(
-      orderedIds.map(async (iconId, index) => {
-        const icon = await ctx.db.get(iconId);
-        if (!icon || icon.libraryId !== libraryId) {
-          throw new Error("Invalid icon reorder request.");
-        }
-        await ctx.db.patch(iconId, {
+      orderedIds.map(async (iconId, index) =>
+        ctx.db.patch(iconId, {
           sortOrder: index,
           updatedAt: now,
-        });
-      })
+        })
+      )
     );
   },
 });
🤖 Prompt for AI Agents
In `@packages/backend/convex/iconLibraries.ts` around lines 245 - 266, The
reorderIcons mutation currently validates and patches each icon inside
Promise.all which can leave partial updates if one validation fails; change
reorderIcons to first fetch and validate all orderedIds using ctx.db.get (ensure
each exists and icon.libraryId === libraryId) before performing any ctx.db.patch
calls, then apply the patches (either in a single transaction if supported or
sequentially) updating sortOrder and updatedAt; reference the reorderIcons
handler, orderedIds, ctx.db.get, and ctx.db.patch when making the change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v1.0 Icon Library Generator

1 participant