diff --git a/.github/pr-assets/846/after-multi-skill-detected.png b/.github/pr-assets/846/after-multi-skill-detected.png new file mode 100644 index 00000000..e0dde19e Binary files /dev/null and b/.github/pr-assets/846/after-multi-skill-detected.png differ diff --git a/.github/pr-assets/846/after-reupload-pre-confirm.png b/.github/pr-assets/846/after-reupload-pre-confirm.png new file mode 100644 index 00000000..8cce97b1 Binary files /dev/null and b/.github/pr-assets/846/after-reupload-pre-confirm.png differ diff --git a/.github/pr-assets/846/after-reupload-unchanged.png b/.github/pr-assets/846/after-reupload-unchanged.png new file mode 100644 index 00000000..4006de2e Binary files /dev/null and b/.github/pr-assets/846/after-reupload-unchanged.png differ diff --git a/.github/pr-assets/846/after-skills-list-populated.png b/.github/pr-assets/846/after-skills-list-populated.png new file mode 100644 index 00000000..1a5efe62 Binary files /dev/null and b/.github/pr-assets/846/after-skills-list-populated.png differ diff --git a/.github/pr-assets/846/after-upload-complete.png b/.github/pr-assets/846/after-upload-complete.png new file mode 100644 index 00000000..5e1bfdce Binary files /dev/null and b/.github/pr-assets/846/after-upload-complete.png differ diff --git a/.github/pr-assets/846/after-upload-dialog-empty.png b/.github/pr-assets/846/after-upload-dialog-empty.png new file mode 100644 index 00000000..65db9d06 Binary files /dev/null and b/.github/pr-assets/846/after-upload-dialog-empty.png differ diff --git a/.github/pr-assets/846/before-custom-empty.png b/.github/pr-assets/846/before-custom-empty.png new file mode 100644 index 00000000..25535aa0 Binary files /dev/null and b/.github/pr-assets/846/before-custom-empty.png differ diff --git a/.github/pr-assets/846/dashboard-overview.png b/.github/pr-assets/846/dashboard-overview.png new file mode 100644 index 00000000..99cc4a6c Binary files /dev/null and b/.github/pr-assets/846/dashboard-overview.png differ diff --git a/.github/pr-assets/846/skills-page.png b/.github/pr-assets/846/skills-page.png new file mode 100644 index 00000000..3e15b427 Binary files /dev/null and b/.github/pr-assets/846/skills-page.png differ diff --git a/internal/http/skills_upload.go b/internal/http/skills_upload.go index 29626661..909c8f42 100644 --- a/internal/http/skills_upload.go +++ b/internal/http/skills_upload.go @@ -56,13 +56,11 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { defer os.Remove(tmp.Name()) defer tmp.Close() - hasher := sha256.New() - size, err := io.Copy(io.MultiWriter(tmp, hasher), file) + size, err := io.Copy(tmp, file) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save upload")}) return } - fileHash := fmt.Sprintf("%x", hasher.Sum(nil)) // Open as zip zr, err := zip.OpenReader(tmp.Name()) @@ -126,11 +124,30 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { return } + // Compute content hash of SKILL.md for idempotency check. + // Using SKILL.md content (not ZIP hash) so content-identical uploads are deduplicated + // even when packaged into different ZIP files (e.g. multi-skill split upload). + skillHash := fmt.Sprintf("%x", sha256.Sum256([]byte(skillContent))) + tenantSkillsBase := h.tenantSkillsDir(r) uploadLock := h.skillUploadLock(filepath.Join(tenantSkillsBase, slug)) uploadLock.Lock() defer uploadLock.Unlock() + // Check whether content is unchanged from the current stored version. + // Performed under lock to avoid TOCTOU race where concurrent uploads + // could both pass the hash check before either creates a new version. + existingHash, existingVer, skillExists := h.skills.GetSkillHashBySlug(r.Context(), slug) + if skillExists && existingHash != "" && existingHash == skillHash { + writeJSON(w, http.StatusOK, map[string]any{ + "slug": slug, + "version": existingVer, + "name": name, + "status": "unchanged", + }) + return + } + // Determine version (always increment — includes archived skills so re-upload gets v2+) version := h.skills.GetNextVersion(r.Context(), slug) @@ -191,12 +208,14 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { Version: version, FilePath: destDir, FileSize: size, - FileHash: &fileHash, + FileHash: &skillHash, // SKILL.md content hash for idempotency (not ZIP hash) Frontmatter: frontmatter, } // Scan and check dependencies - response := map[string]any{"slug": slug, "version": version, "name": name, "status": "active"} + // is_new is true only when no previous version of this skill existed (first upload). + isNew := !skillExists + response := map[string]any{"slug": slug, "version": version, "name": name, "status": "active", "is_new": isNew} depState := uploadSkillDepState{} depsCtx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), uploadDepsInstallTimeout) defer cancel() diff --git a/internal/http/skills_upload_test.go b/internal/http/skills_upload_test.go index 87108c42..ec00067a 100644 --- a/internal/http/skills_upload_test.go +++ b/internal/http/skills_upload_test.go @@ -365,11 +365,12 @@ func skillMarkdown(name, slug string) string { } type skillManageStoreStub struct { - baseDir string - version int64 - nextBySlug map[string]int - skills map[uuid.UUID]store.SkillInfo - systemDirs map[string]string + baseDir string + version int64 + nextBySlug map[string]int + skills map[uuid.UUID]store.SkillInfo + systemDirs map[string]string + hashBySlug map[string]string // slug -> SKILL.md content hash (most recent) } func newSkillManageStoreStub(baseDir string) *skillManageStoreStub { @@ -378,6 +379,7 @@ func newSkillManageStoreStub(baseDir string) *skillManageStoreStub { nextBySlug: map[string]int{}, skills: map[uuid.UUID]store.SkillInfo{}, systemDirs: map[string]string{}, + hashBySlug: map[string]string{}, } } @@ -444,9 +446,23 @@ func (s *skillManageStoreStub) CreateSkillManaged(_ context.Context, p store.Ski Enabled: true, MissingDeps: append([]string(nil), p.MissingDeps...), } + // Track the content hash for idempotency checks (mirrors handler behaviour). + if p.FileHash != nil { + s.hashBySlug[p.Slug] = *p.FileHash + } return id, nil } +func (s *skillManageStoreStub) GetSkillHashBySlug(_ context.Context, slug string) (string, int, bool) { + hash, ok := s.hashBySlug[slug] + if !ok { + return "", 0, false + } + // Find the latest version for this slug. + version := s.nextBySlug[slug] + return hash, version, true +} + func (s *skillManageStoreStub) UpdateSkill(_ context.Context, id uuid.UUID, updates map[string]any) error { skill, ok := s.skills[id] if !ok { @@ -529,3 +545,160 @@ func (s *skillManageStoreStub) ListWithGrantStatus(context.Context, uuid.UUID) ( func (s *skillManageStoreStub) GetSkillFilePath(context.Context, uuid.UUID) (string, string, int, bool, bool) { return "", "", 0, false, false } + +// --------------------------------------------------------------------------- +// Hash comparison / idempotency tests +// --------------------------------------------------------------------------- + +func TestHandleUpload_IdenticalContent_ReturnsUnchanged(t *testing.T) { + handler, skillStore, ctx, _ := newTestUploadHandler(t) + stubUploadDepFns(t, + func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) { + return nil, nil + }, + func(*skills.SkillManifest) (bool, []string) { return true, nil }, + ) + + files := map[string]string{ + "SKILL.md": skillMarkdown("Hash Skill", "hash-skill"), + } + + // First upload — expect 201 Created, version 1. + req1 := newZipUploadRequest(t, ctx, files) + w1 := httptest.NewRecorder() + handler.handleUpload(w1, req1) + if w1.Code != http.StatusCreated { + t.Fatalf("first upload: status = %d, body = %s", w1.Code, w1.Body.String()) + } + + // Second upload with identical content — expect 200 unchanged. + req2 := newZipUploadRequest(t, ctx, files) + w2 := httptest.NewRecorder() + handler.handleUpload(w2, req2) + if w2.Code != http.StatusOK { + t.Fatalf("second upload: status = %d, body = %s", w2.Code, w2.Body.String()) + } + + var resp struct { + Status string `json:"status"` + Version int `json:"version"` + Slug string `json:"slug"` + Name string `json:"name"` + } + if err := json.NewDecoder(w2.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Status != "unchanged" { + t.Fatalf("status = %q, want unchanged", resp.Status) + } + if resp.Version != 1 { + t.Fatalf("version = %d, want 1 (no new version created)", resp.Version) + } + if resp.Slug != "hash-skill" { + t.Fatalf("slug = %q, want hash-skill", resp.Slug) + } + + // Verify no second DB row was created. + if got := skillStore.nextBySlug["hash-skill"]; got != 1 { + t.Fatalf("nextBySlug[hash-skill] = %d, want 1 (unchanged should not create new version)", got) + } +} + +func TestHandleUpload_ChangedContent_BumpsVersion(t *testing.T) { + handler, skillStore, ctx, _ := newTestUploadHandler(t) + stubUploadDepFns(t, + func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) { + return nil, nil + }, + func(*skills.SkillManifest) (bool, []string) { return true, nil }, + ) + + // First upload. + req1 := newZipUploadRequest(t, ctx, map[string]string{ + "SKILL.md": "---\nname: Change Skill\nslug: change-skill\n---\nOriginal body\n", + }) + w1 := httptest.NewRecorder() + handler.handleUpload(w1, req1) + if w1.Code != http.StatusCreated { + t.Fatalf("first upload: status = %d, body = %s", w1.Code, w1.Body.String()) + } + + // Second upload with different SKILL.md content. + req2 := newZipUploadRequest(t, ctx, map[string]string{ + "SKILL.md": "---\nname: Change Skill\nslug: change-skill\n---\nUpdated body with new description\n", + }) + w2 := httptest.NewRecorder() + handler.handleUpload(w2, req2) + if w2.Code != http.StatusCreated { + t.Fatalf("second upload: status = %d, body = %s", w2.Code, w2.Body.String()) + } + + var resp struct { + Status string `json:"status"` + Version int `json:"version"` + } + if err := json.NewDecoder(w2.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Status != "active" { + t.Fatalf("status = %q, want active", resp.Status) + } + if resp.Version != 2 { + t.Fatalf("version = %d, want 2", resp.Version) + } + + // Verify DB has version 2. + if got := skillStore.nextBySlug["change-skill"]; got != 2 { + t.Fatalf("nextBySlug[change-skill] = %d, want 2", got) + } +} + +func TestHandleUpload_ResponseIncludesIsNew(t *testing.T) { + handler, _, ctx, _ := newTestUploadHandler(t) + stubUploadDepFns(t, + func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) { + return nil, nil + }, + func(*skills.SkillManifest) (bool, []string) { return true, nil }, + ) + + // Brand-new skill upload should have is_new=true. + req1 := newZipUploadRequest(t, ctx, map[string]string{ + "SKILL.md": skillMarkdown("IsNew Skill", "isnew-skill"), + }) + w1 := httptest.NewRecorder() + handler.handleUpload(w1, req1) + if w1.Code != http.StatusCreated { + t.Fatalf("first upload: status = %d, body = %s", w1.Code, w1.Body.String()) + } + + var resp1 struct { + IsNew bool `json:"is_new"` + } + if err := json.NewDecoder(w1.Body).Decode(&resp1); err != nil { + t.Fatalf("decode first response: %v", err) + } + if !resp1.IsNew { + t.Fatal("first upload: expected is_new=true") + } + + // Changed-content re-upload should have is_new=false. + req2 := newZipUploadRequest(t, ctx, map[string]string{ + "SKILL.md": "---\nname: IsNew Skill\nslug: isnew-skill\n---\nDifferent body content\n", + }) + w2 := httptest.NewRecorder() + handler.handleUpload(w2, req2) + if w2.Code != http.StatusCreated { + t.Fatalf("second upload: status = %d, body = %s", w2.Code, w2.Body.String()) + } + + var resp2 struct { + IsNew bool `json:"is_new"` + } + if err := json.NewDecoder(w2.Body).Decode(&resp2); err != nil { + t.Fatalf("decode second response: %v", err) + } + if resp2.IsNew { + t.Fatal("second upload (changed content): expected is_new=false") + } +} diff --git a/internal/store/pg/skills_crud.go b/internal/store/pg/skills_crud.go index 082afd74..fef92e9f 100644 --- a/internal/store/pg/skills_crud.go +++ b/internal/store/pg/skills_crud.go @@ -222,6 +222,22 @@ func (s *PGSkillStore) GetNextVersion(ctx context.Context, slug string) int { return maxVersion + 1 } +// GetSkillHashBySlug returns the file_hash and version of the latest non-deleted skill +// version for the given slug, scoped to the current tenant. +// Returns ok=false when no matching row exists. +func (s *PGSkillStore) GetSkillHashBySlug(ctx context.Context, slug string) (string, int, bool) { + tid := tenantIDForInsert(ctx) + var hash string + var version int + err := s.db.QueryRowContext(ctx, + `SELECT COALESCE(file_hash, ''), version FROM skills + WHERE slug = $1 AND tenant_id = $2 AND status != 'deleted' + ORDER BY version DESC LIMIT 1`, + slug, tid, + ).Scan(&hash, &version) + return hash, version, err == nil +} + // GetNextVersionLocked computes the next version atomically using an advisory lock. // Safe for concurrent write paths (patch, create). Returns version and a cleanup func // that MUST be called to release the lock (commits the transaction). diff --git a/internal/store/skill_store.go b/internal/store/skill_store.go index b2fbb78c..b5b18f39 100644 --- a/internal/store/skill_store.go +++ b/internal/store/skill_store.go @@ -108,6 +108,9 @@ type SkillManageStore interface { GetSkillOwnerIDBySlug(ctx context.Context, slug string) (string, bool) GetNextVersion(ctx context.Context, slug string) int GetNextVersionLocked(ctx context.Context, slug string) (int, func() error, error) + // GetSkillHashBySlug returns the content hash and version of the latest non-deleted skill + // version for the given slug (tenant-scoped). Returns ok=false if no skill exists. + GetSkillHashBySlug(ctx context.Context, slug string) (hash string, version int, ok bool) IsSystemSkill(slug string) bool // System skill management ListAllSkills(ctx context.Context) []SkillInfo diff --git a/internal/store/sqlitestore/skills_crud.go b/internal/store/sqlitestore/skills_crud.go index 21bf50f2..9d550465 100644 --- a/internal/store/sqlitestore/skills_crud.go +++ b/internal/store/sqlitestore/skills_crud.go @@ -187,6 +187,21 @@ func (s *SQLiteSkillStore) GetSkillFilePath(ctx context.Context, id uuid.UUID) ( return filePath, slug, version, isSystem, err == nil } +// GetSkillHashBySlug returns the file_hash and version of the latest non-deleted skill +// version for the given slug. +// Returns ok=false when no matching row exists. +func (s *SQLiteSkillStore) GetSkillHashBySlug(ctx context.Context, slug string) (string, int, bool) { + var hash string + var version int + err := s.db.QueryRowContext(ctx, + `SELECT COALESCE(file_hash, ''), version FROM skills + WHERE slug = ? AND status != 'deleted' + ORDER BY version DESC LIMIT 1`, + slug, + ).Scan(&hash, &version) + return hash, version, err == nil +} + func (s *SQLiteSkillStore) GetNextVersion(ctx context.Context, slug string) int { var maxVersion int s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(version), 0) FROM skills WHERE slug = ?", slug).Scan(&maxVersion) diff --git a/ui/web/package.json b/ui/web/package.json index 8fcc8054..9487f389 100644 --- a/ui/web/package.json +++ b/ui/web/package.json @@ -57,10 +57,13 @@ "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^6.0.1", + "@vitest/ui": "^4.1.4", + "jsdom": "^29.0.2", "tailwindcss": "^4.2.2", "typescript": "^6.0.2", "unified": "^11.0.5", "vite": "^8.0.8", + "vitest": "^4.1.4", "ws": "^8.20.0" }, "packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a" diff --git a/ui/web/pnpm-lock.yaml b/ui/web/pnpm-lock.yaml index 29b3179f..678b0a0a 100644 --- a/ui/web/pnpm-lock.yaml +++ b/ui/web/pnpm-lock.yaml @@ -144,6 +144,12 @@ importers: '@vitejs/plugin-react': specifier: ^6.0.1 version: 6.0.1(vite@8.0.8(@types/node@25.5.0)(jiti@2.6.1)) + '@vitest/ui': + specifier: ^4.1.4 + version: 4.1.4(vitest@4.1.4) + jsdom: + specifier: ^29.0.2 + version: 29.0.2 tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -156,6 +162,9 @@ importers: vite: specifier: ^8.0.8 version: 8.0.8(@types/node@25.5.0)(jiti@2.6.1) + vitest: + specifier: ^4.1.4 + version: 4.1.4(@types/node@25.5.0)(@vitest/ui@4.1.4)(jsdom@29.0.2)(vite@8.0.8(@types/node@25.5.0)(jiti@2.6.1)) ws: specifier: ^8.20.0 version: 8.20.0 @@ -165,6 +174,17 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@asamuzakjp/css-color@5.1.10': + resolution: {integrity: sha512-02OhhkKtgNRuicQ/nF3TRnGsxL9wp0r3Y7VlKWyOHHGmGyvXv03y+PnymU8FKFJMTjIr1Bk8U2g1HWSLrpAHww==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.0.9': + resolution: {integrity: sha512-r3ElRr7y8ucyN2KdICwGsmj19RoN13CLCa/pvGydghWK6ZzeKQ+TcDjVdtEZz2ElpndM5jXw//B9CEee0mWnVg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -172,6 +192,10 @@ packages: '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@chevrotain/cst-dts-gen@12.0.0': resolution: {integrity: sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==} @@ -187,6 +211,42 @@ packages: '@chevrotain/utils@12.0.0': resolution: {integrity: sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.2': + resolution: {integrity: sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -218,6 +278,15 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -272,6 +341,9 @@ packages: '@oxc-project/types@0.124.0': resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1206,6 +1278,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -1302,6 +1377,9 @@ packages: '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -1368,6 +1446,40 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/expect@4.1.4': + resolution: {integrity: sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==} + + '@vitest/mocker@4.1.4': + resolution: {integrity: sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.4': + resolution: {integrity: sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==} + + '@vitest/runner@4.1.4': + resolution: {integrity: sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==} + + '@vitest/snapshot@4.1.4': + resolution: {integrity: sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==} + + '@vitest/spy@4.1.4': + resolution: {integrity: sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==} + + '@vitest/ui@4.1.4': + resolution: {integrity: sha512-EgFR7nlj5iTDYZYCvavjFokNYwr3c3ry0sFiCg+N7B233Nwp+NNx7eoF/XvMWDCKY71xXAG3kFkt97ZHBJVL8A==} + peerDependencies: + vitest: 4.1.4 + + '@vitest/utils@4.1.4': + resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==} + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -1377,12 +1489,23 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -1425,6 +1548,9 @@ packages: confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -1438,6 +1564,10 @@ packages: cose-base@2.2.0: resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -1602,6 +1732,10 @@ packages: dagre-d3-es@7.0.14: resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} @@ -1620,6 +1754,9 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -1651,6 +1788,9 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-toolkit@1.45.1: resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} @@ -1661,6 +1801,9 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -1668,6 +1811,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -1680,6 +1827,12 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + framer-motion@12.38.0: resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} peerDependencies: @@ -1771,6 +1924,10 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} @@ -1827,6 +1984,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -1834,6 +1994,15 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jsdom@29.0.2: + resolution: {integrity: sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} @@ -1940,6 +2109,10 @@ packages: lowlight@3.3.0: resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + lru-cache@11.3.3: + resolution: {integrity: sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==} + engines: {node: 20 || >=22} + lucide-react@1.7.0: resolution: {integrity: sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==} peerDependencies: @@ -2004,6 +2177,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + mermaid@11.14.0: resolution: {integrity: sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==} @@ -2106,6 +2282,10 @@ packages: motion-utils@12.36.0: resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2117,6 +2297,9 @@ packages: obliterator@2.0.5: resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} @@ -2132,6 +2315,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} @@ -2168,6 +2354,10 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + radix-ui@1.4.3: resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} peerDependencies: @@ -2313,6 +2503,10 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} @@ -2336,6 +2530,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2345,9 +2543,16 @@ packages: setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sigma@3.0.2: resolution: {integrity: sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ==} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2355,6 +2560,12 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -2370,6 +2581,9 @@ packages: stylis@4.3.6: resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} @@ -2383,6 +2597,9 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.1.1: resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} engines: {node: '>=18'} @@ -2391,6 +2608,29 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.28: + resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==} + + tldts@7.0.28: + resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==} + hasBin: true + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -2415,6 +2655,10 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@7.24.7: + resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==} + engines: {node: '>=20.18.1'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -2526,6 +2770,47 @@ packages: yaml: optional: true + vitest@4.1.4: + resolution: {integrity: sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.4 + '@vitest/browser-preview': 4.1.4 + '@vitest/browser-webdriverio': 4.1.4 + '@vitest/coverage-istanbul': 4.1.4 + '@vitest/coverage-v8': 4.1.4 + '@vitest/ui': 4.1.4 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -2550,9 +2835,30 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + ws@8.20.0: resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} engines: {node: '>=10.0.0'} @@ -2565,6 +2871,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -2596,10 +2909,30 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.1.1 + '@asamuzakjp/css-color@5.1.10': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.0.9': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/runtime@7.29.2': {} '@braintree/sanitize-url@7.1.2': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@chevrotain/cst-dts-gen@12.0.0': dependencies: '@chevrotain/gast': 12.0.0 @@ -2615,6 +2948,30 @@ snapshots: '@chevrotain/utils@12.0.0': {} + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.2(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.4)': dependencies: react: 19.2.4 @@ -2656,6 +3013,8 @@ snapshots: tslib: 2.8.1 optional: true + '@exodus/bytes@1.15.0': {} + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -2718,6 +3077,8 @@ snapshots: '@oxc-project/types@0.124.0': {} + '@polka/url@1.0.0-next.29': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -3631,6 +3992,11 @@ snapshots: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} '@types/d3-axis@3.0.6': @@ -3752,6 +4118,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -3809,16 +4177,76 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: 8.0.8(@types/node@25.5.0)(jiti@2.6.1) + '@vitest/expect@4.1.4': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.4 + '@vitest/utils': 4.1.4 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.4(vite@8.0.8(@types/node@25.5.0)(jiti@2.6.1))': + dependencies: + '@vitest/spy': 4.1.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.8(@types/node@25.5.0)(jiti@2.6.1) + + '@vitest/pretty-format@4.1.4': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.4': + dependencies: + '@vitest/utils': 4.1.4 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.4': + dependencies: + '@vitest/pretty-format': 4.1.4 + '@vitest/utils': 4.1.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.4': {} + + '@vitest/ui@4.1.4(vitest@4.1.4)': + dependencies: + '@vitest/utils': 4.1.4 + fflate: 0.8.2 + flatted: 3.4.2 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vitest: 4.1.4(@types/node@25.5.0)(@vitest/ui@4.1.4)(jsdom@29.0.2)(vite@8.0.8(@types/node@25.5.0)(jiti@2.6.1)) + + '@vitest/utils@4.1.4': + dependencies: + '@vitest/pretty-format': 4.1.4 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn@8.16.0: {} aria-hidden@1.2.6: dependencies: tslib: 2.8.1 + assertion-error@2.0.1: {} + bail@2.0.2: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + ccount@2.0.1: {} + chai@6.2.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -3854,6 +4282,8 @@ snapshots: confbox@0.1.8: {} + convert-source-map@2.0.0: {} + cookie@1.1.1: {} core-util-is@1.0.3: {} @@ -3866,6 +4296,11 @@ snapshots: dependencies: layout-base: 2.0.1 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + cssesc@3.0.0: {} csstype@3.2.3: {} @@ -4054,6 +4489,13 @@ snapshots: d3: 7.9.0 lodash-es: 4.18.1 + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + date-fns@4.1.0: {} dayjs@1.11.20: {} @@ -4064,6 +4506,8 @@ snapshots: decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -4093,22 +4537,34 @@ snapshots: entities@6.0.1: {} + es-module-lexer@2.0.0: {} + es-toolkit@1.45.1: {} escape-string-regexp@5.0.0: {} estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + eventemitter3@5.0.4: {} events@3.3.0: {} + expect-type@1.3.0: {} + extend@3.0.2: {} fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + fflate@0.8.2: {} + + flatted@3.4.2: {} + framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: motion-dom: 12.38.0 @@ -4236,6 +4692,12 @@ snapshots: highlight.js@11.11.1: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + html-parse-stringify@3.0.1: dependencies: void-elements: 3.1.0 @@ -4279,10 +4741,38 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + isarray@1.0.0: {} jiti@2.6.1: {} + jsdom@29.0.2: + dependencies: + '@asamuzakjp/css-color': 5.1.10 + '@asamuzakjp/dom-selector': 7.0.9 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.2(css-tree@3.2.1) + '@exodus/bytes': 1.15.0 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.3.3 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.24.7 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jszip@3.10.1: dependencies: lie: 3.3.0 @@ -4372,6 +4862,8 @@ snapshots: devlop: 1.1.0 highlight.js: 11.11.1 + lru-cache@11.3.3: {} + lucide-react@1.7.0(react@19.2.4): dependencies: react: 19.2.4 @@ -4549,6 +5041,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} + mermaid@11.14.0: dependencies: '@braintree/sanitize-url': 7.1.2 @@ -4791,12 +5285,16 @@ snapshots: motion-utils@12.36.0: {} + mrmime@2.0.1: {} + ms@2.1.3: {} nanoid@3.3.11: {} obliterator@2.0.5: {} + obug@2.1.1: {} + package-manager-detector@1.6.0: {} pako@1.0.11: {} @@ -4819,6 +5317,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + path-data-parser@0.1.0: {} pathe@2.0.3: {} @@ -4855,6 +5357,8 @@ snapshots: property-information@7.1.0: {} + punycode@2.3.1: {} + radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@radix-ui/primitive': 1.1.3 @@ -5101,6 +5605,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + require-from-string@2.0.2: {} + reselect@5.1.1: {} robust-predicates@3.0.3: {} @@ -5139,12 +5645,18 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} set-cookie-parser@2.7.2: {} setimmediate@1.0.5: {} + siginfo@2.0.0: {} + sigma@3.0.2(graphology-types@0.24.8): dependencies: events: 3.3.0 @@ -5152,10 +5664,20 @@ snapshots: transitivePeerDependencies: - graphology-types + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + source-map-js@1.2.1: {} space-separated-tokens@2.0.2: {} + stackback@0.0.2: {} + + std-env@4.0.0: {} + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -5175,6 +5697,8 @@ snapshots: stylis@4.3.6: {} + symbol-tree@3.2.4: {} + tailwind-merge@3.5.0: {} tailwindcss@4.2.2: {} @@ -5183,6 +5707,8 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + tinyexec@1.1.1: {} tinyglobby@0.2.16: @@ -5190,6 +5716,24 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + + tldts-core@7.0.28: {} + + tldts@7.0.28: + dependencies: + tldts-core: 7.0.28 + + totalist@3.0.1: {} + + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.28 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -5204,6 +5748,8 @@ snapshots: undici-types@7.18.2: {} + undici@7.24.7: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -5314,6 +5860,35 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 + vitest@4.1.4(@types/node@25.5.0)(@vitest/ui@4.1.4)(jsdom@29.0.2)(vite@8.0.8(@types/node@25.5.0)(jiti@2.6.1)): + dependencies: + '@vitest/expect': 4.1.4 + '@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@25.5.0)(jiti@2.6.1)) + '@vitest/pretty-format': 4.1.4 + '@vitest/runner': 4.1.4 + '@vitest/snapshot': 4.1.4 + '@vitest/spy': 4.1.4 + '@vitest/utils': 4.1.4 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.8(@types/node@25.5.0)(jiti@2.6.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.5.0 + '@vitest/ui': 4.1.4(vitest@4.1.4) + jsdom: 29.0.2 + transitivePeerDependencies: + - msw + void-elements@3.1.0: {} vscode-jsonrpc@8.2.0: {} @@ -5333,10 +5908,35 @@ snapshots: vscode-uri@3.1.0: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-namespaces@2.0.1: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + ws@8.20.0: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + zod@4.3.6: {} zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): diff --git a/ui/web/src/i18n/locales/en/skills.json b/ui/web/src/i18n/locales/en/skills.json index b4670f28..a5f1dce3 100644 --- a/ui/web/src/i18n/locales/en/skills.json +++ b/ui/web/src/i18n/locales/en/skills.json @@ -46,7 +46,13 @@ "validCount": "{{valid}} of {{total}} valid", "uploadCount": "Upload ({{count}})", "successCount": "{{success}} of {{total}} uploaded successfully", - "remove": "Remove file" + "remove": "Remove file", + "tooManySkills": "ZIP contains too many skills (max 50)", + "multiDetected": "{{count}} skills detected", + "skillUnchanged": "Content unchanged — skipped", + "slugConflict": "Slug conflicts with a system skill", + "unchanged": "Unchanged", + "new": "New" }, "edit": { "title": "Edit Skill", diff --git a/ui/web/src/i18n/locales/vi/skills.json b/ui/web/src/i18n/locales/vi/skills.json index 550fd145..9f35cecb 100644 --- a/ui/web/src/i18n/locales/vi/skills.json +++ b/ui/web/src/i18n/locales/vi/skills.json @@ -46,7 +46,13 @@ "validCount": "{{valid}} / {{total}} hợp lệ", "uploadCount": "Tải lên ({{count}})", "successCount": "{{success}} / {{total}} đã tải lên thành công", - "remove": "Xóa tệp" + "remove": "Xóa tệp", + "tooManySkills": "ZIP chứa quá nhiều kỹ năng (tối đa 50)", + "multiDetected": "Phát hiện {{count}} kỹ năng", + "skillUnchanged": "Nội dung không đổi — đã bỏ qua", + "slugConflict": "Slug trùng với kỹ năng hệ thống", + "unchanged": "Không đổi", + "new": "Mới" }, "edit": { "title": "Chỉnh sửa skill", diff --git a/ui/web/src/i18n/locales/zh/skills.json b/ui/web/src/i18n/locales/zh/skills.json index 00f056f7..1ec658e9 100644 --- a/ui/web/src/i18n/locales/zh/skills.json +++ b/ui/web/src/i18n/locales/zh/skills.json @@ -46,7 +46,13 @@ "validCount": "{{valid}} / {{total}} 有效", "uploadCount": "上传 ({{count}})", "successCount": "{{success}} / {{total}} 上传成功", - "remove": "移除文件" + "remove": "移除文件", + "tooManySkills": "ZIP 包含过多技能(最多 50 个)", + "multiDetected": "检测到 {{count}} 个技能", + "skillUnchanged": "内容未更改 — 已跳过", + "slugConflict": "Slug 与系统技能冲突", + "unchanged": "未更改", + "new": "新建" }, "edit": { "title": "编辑Skill", diff --git a/ui/web/src/pages/skills/hooks/use-skills.ts b/ui/web/src/pages/skills/hooks/use-skills.ts index 68e727b5..438abeac 100644 --- a/ui/web/src/pages/skills/hooks/use-skills.ts +++ b/ui/web/src/pages/skills/hooks/use-skills.ts @@ -12,11 +12,14 @@ import type { SkillInfo, SkillFile, SkillVersions } from "@/types/skill"; export type { SkillInfo, SkillFile, SkillVersions }; export type SkillUploadResponse = { - id: string; + /** Absent when status is "unchanged" */ + id?: string; slug: string; version: number; name: string; + /** "active" | "unchanged" | "archived" */ status?: string; + is_new?: boolean; deps_warning?: string; deps_errors?: string[]; missing_deps?: string[]; diff --git a/ui/web/src/pages/skills/lib/create-skill-sub-zip.test.ts b/ui/web/src/pages/skills/lib/create-skill-sub-zip.test.ts new file mode 100644 index 00000000..522d2008 --- /dev/null +++ b/ui/web/src/pages/skills/lib/create-skill-sub-zip.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest"; +import JSZip from "jszip"; +import { createSkillSubZip } from "./create-skill-sub-zip"; + +describe("createSkillSubZip", () => { + it("extracts only files under the specified directory", async () => { + const zip = new JSZip(); + zip.file("pdf/SKILL.md", "---\nname: PDF\n---\n# PDF"); + zip.file("pdf/scripts/convert.py", "print('convert')"); + zip.file("csv/SKILL.md", "---\nname: CSV\n---\n# CSV"); + + const subFile = await createSkillSubZip(zip, "pdf"); + const subZip = await JSZip.loadAsync(subFile); + const paths = Object.keys(subZip.files).filter((p) => !subZip.files[p]!.dir); + + expect(paths.sort()).toEqual(["SKILL.md", "scripts/convert.py"]); + expect(subFile.name).toBe("pdf.zip"); + }); + + it("handles nested subdirectories within skill dir", async () => { + const zip = new JSZip(); + zip.file("my-skill/SKILL.md", "content"); + zip.file("my-skill/scripts/helpers/util.py", "util"); + zip.file("my-skill/references/guide.md", "guide"); + + const subFile = await createSkillSubZip(zip, "my-skill"); + const subZip = await JSZip.loadAsync(subFile); + const paths = Object.keys(subZip.files).filter((p) => !subZip.files[p]!.dir); + + expect(paths.sort()).toEqual(["SKILL.md", "references/guide.md", "scripts/helpers/util.py"]); + }); + + it("returns empty ZIP for non-existent directory", async () => { + const zip = new JSZip(); + zip.file("other/SKILL.md", "content"); + + const subFile = await createSkillSubZip(zip, "missing"); + const subZip = await JSZip.loadAsync(subFile); + const paths = Object.keys(subZip.files).filter((p) => !subZip.files[p]!.dir); + + expect(paths).toEqual([]); + }); + + it("produces a File with correct mime type", async () => { + const zip = new JSZip(); + zip.file("skill-a/SKILL.md", "content"); + + const subFile = await createSkillSubZip(zip, "skill-a"); + + expect(subFile).toBeInstanceOf(File); + expect(subFile.type).toBe("application/zip"); + expect(subFile.name).toBe("skill-a.zip"); + }); + + it("does not include directory entries in extracted ZIP", async () => { + const zip = new JSZip(); + // Explicitly add a directory entry alongside a file + zip.folder("tool/scripts"); + zip.file("tool/scripts/run.sh", "#!/bin/bash"); + + const subFile = await createSkillSubZip(zip, "tool"); + const subZip = await JSZip.loadAsync(subFile); + const dirEntries = Object.values(subZip.files).filter((f) => f.dir); + + // Directory entries may be created implicitly by JSZip but no explicit + // empty dir entries from the source should leak as files + const fileEntries = Object.keys(subZip.files).filter((p) => !subZip.files[p]!.dir); + expect(fileEntries).toEqual(["scripts/run.sh"]); + // dirEntries may exist (JSZip auto-creates them) — just ensure files are right + expect(dirEntries.length).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/ui/web/src/pages/skills/lib/create-skill-sub-zip.ts b/ui/web/src/pages/skills/lib/create-skill-sub-zip.ts new file mode 100644 index 00000000..d7d97250 --- /dev/null +++ b/ui/web/src/pages/skills/lib/create-skill-sub-zip.ts @@ -0,0 +1,27 @@ +/** Utility for extracting a single skill's subdirectory into a standalone ZIP file. */ +import JSZip from "jszip"; + +/** + * Extract files under `dir/` from a pre-parsed JSZip instance, strip the + * directory prefix, and return a new File suitable for upload. + * + * Accepts a JSZip instance (not a File) so the caller can parse the original + * ZIP once and reuse it across multiple skill extractions — O(N) instead of + * O(N*M) where M is ZIP parse cost. + */ +export async function createSkillSubZip(zip: JSZip, dir: string): Promise { + const sub = new JSZip(); + const prefix = dir + "/"; + + for (const [path, entry] of Object.entries(zip.files)) { + if (path.startsWith(prefix) && !entry.dir) { + const relativePath = path.slice(prefix.length); + if (relativePath) { + sub.file(relativePath, await entry.async("blob")); + } + } + } + + const blob = await sub.generateAsync({ type: "blob" }); + return new File([blob], `${dir}.zip`, { type: "application/zip" }); +} diff --git a/ui/web/src/pages/skills/lib/skill-upload-types.ts b/ui/web/src/pages/skills/lib/skill-upload-types.ts new file mode 100644 index 00000000..5295fe34 --- /dev/null +++ b/ui/web/src/pages/skills/lib/skill-upload-types.ts @@ -0,0 +1,29 @@ +/** Shared types for the skill upload dialog and its sub-components. */ + +/** Per-skill lifecycle status */ +export type SkillStatus = + | "validating" + | "valid" // new skill, ready to upload + | "unchanged" // server returned identical hash — skipped + | "invalid" + | "uploading" + | "success" + | "warning" // uploaded but deps_warning present + | "error"; + +export interface SkillEntry { + id: string; + dir: string; + status: SkillStatus; + name?: string; + slug?: string; + contentHash?: string; + error?: string; +} + +export interface FileEntry { + id: string; + file: File; + /** One entry per detected skill in this ZIP */ + skills: SkillEntry[]; +} diff --git a/ui/web/src/pages/skills/lib/validate-skill-zip.test.ts b/ui/web/src/pages/skills/lib/validate-skill-zip.test.ts new file mode 100644 index 00000000..f20294b3 --- /dev/null +++ b/ui/web/src/pages/skills/lib/validate-skill-zip.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from "vitest"; +import JSZip from "jszip"; +// Import the functions we'll create/modify +import { validateSkillZip, validateMultiSkillZip } from "./validate-skill-zip"; + +// Helper: create a ZIP file from an object mapping paths to content +async function createTestZip(files: Record): Promise { + const zip = new JSZip(); + for (const [path, content] of Object.entries(files)) { + zip.file(path, content); + } + const blob = await zip.generateAsync({ type: "blob" }); + return new File([blob], "test.zip", { type: "application/zip" }); +} + +const validFrontmatter = `--- +name: Test Skill +slug: test-skill +description: A test skill +--- +# Test Skill Content`; + +const validFrontmatter2 = `--- +name: Another Skill +slug: another-skill +description: Another test +--- +# Another Skill`; + +const noNameFrontmatter = `--- +description: Missing name +--- +# No Name`; + +describe("validateSkillZip (backward compat)", () => { + it("validates single-skill ZIP at root", async () => { + const file = await createTestZip({ "SKILL.md": validFrontmatter }); + const result = await validateSkillZip(file); + expect(result.valid).toBe(true); + expect(result.name).toBe("Test Skill"); + expect(result.slug).toBe("test-skill"); + }); + + it("validates single-skill ZIP in subdirectory", async () => { + const file = await createTestZip({ "my-skill/SKILL.md": validFrontmatter }); + const result = await validateSkillZip(file); + expect(result.valid).toBe(true); + expect(result.name).toBe("Test Skill"); + }); + + it("rejects ZIP without SKILL.md", async () => { + const file = await createTestZip({ "readme.txt": "hello" }); + const result = await validateSkillZip(file); + expect(result.valid).toBe(false); + expect(result.error).toBe("upload.noSkillMd"); + }); + + it("rejects empty SKILL.md", async () => { + const file = await createTestZip({ "SKILL.md": " " }); + const result = await validateSkillZip(file); + expect(result.valid).toBe(false); + expect(result.error).toBe("upload.emptySkillMd"); + }); + + it("rejects missing frontmatter name", async () => { + const file = await createTestZip({ "SKILL.md": noNameFrontmatter }); + const result = await validateSkillZip(file); + expect(result.valid).toBe(false); + expect(result.error).toBe("upload.nameRequired"); + }); + + it("auto-slugifies name when no slug provided", async () => { + const fm = `---\nname: My Cool Tool\ndescription: test\n---\n# Content`; + const file = await createTestZip({ "SKILL.md": fm }); + const result = await validateSkillZip(file); + expect(result.valid).toBe(true); + expect(result.slug).toBe("my-cool-tool"); + }); +}); + +describe("validateMultiSkillZip", () => { + it("detects multiple skills in ZIP", async () => { + const file = await createTestZip({ + "pdf/SKILL.md": validFrontmatter, + "csv/SKILL.md": validFrontmatter2, + }); + const result = await validateMultiSkillZip(file); + expect(result.skills).toHaveLength(2); + expect(result.skills.map(s => s.slug).sort()).toEqual(["another-skill", "test-skill"]); + }); + + it("root SKILL.md returns single entry (no subdir scan)", async () => { + const file = await createTestZip({ + "SKILL.md": validFrontmatter, + "subdir/SKILL.md": validFrontmatter2, + }); + const result = await validateMultiSkillZip(file); + expect(result.skills).toHaveLength(1); + expect(result.skills[0]!.slug).toBe("test-skill"); + }); + + it("computes per-skill SHA-256 contentHash", async () => { + const file = await createTestZip({ "skill/SKILL.md": validFrontmatter }); + const result = await validateMultiSkillZip(file); + expect(result.skills[0]!.contentHash).toBeDefined(); + expect(result.skills[0]!.contentHash).toMatch(/^[a-f0-9]{64}$/); + }); + + it("ignores directories without SKILL.md", async () => { + const file = await createTestZip({ + "pdf/SKILL.md": validFrontmatter, + "assets/image.png": "binary", + }); + const result = await validateMultiSkillZip(file); + expect(result.skills).toHaveLength(1); + expect(result.skills[0]!.dir).toBe("pdf"); + }); + + it("reports per-skill validation errors", async () => { + const file = await createTestZip({ + "good/SKILL.md": validFrontmatter, + "bad/SKILL.md": noNameFrontmatter, + }); + const result = await validateMultiSkillZip(file); + expect(result.skills).toHaveLength(2); + const good = result.skills.find(s => s.dir === "good"); + const bad = result.skills.find(s => s.dir === "bad"); + expect(good?.valid).toBe(true); + expect(bad?.valid).toBe(false); + expect(bad?.error).toBe("upload.nameRequired"); + }); + + it("backward compat: single-skill ZIP returns skills array of 1", async () => { + const file = await createTestZip({ "SKILL.md": validFrontmatter }); + const result = await validateMultiSkillZip(file); + expect(result.skills).toHaveLength(1); + expect(result.skills[0]!.valid).toBe(true); + expect(result.skills[0]!.name).toBe("Test Skill"); + }); +}); diff --git a/ui/web/src/pages/skills/lib/validate-skill-zip.ts b/ui/web/src/pages/skills/lib/validate-skill-zip.ts index 1ba76163..a55abeec 100644 --- a/ui/web/src/pages/skills/lib/validate-skill-zip.ts +++ b/ui/web/src/pages/skills/lib/validate-skill-zip.ts @@ -1,5 +1,8 @@ /** Client-side validation for skill ZIP files before upload. - * Mirrors server-side checks in internal/http/skills_upload.go */ + * Mirrors server-side checks in internal/http/skills_upload.go + * + * Supports both single-skill ZIPs (SKILL.md at root or one top-level dir) + * and multi-skill ZIPs (one SKILL.md per top-level directory). */ import JSZip from "jszip"; export interface SkillZipValidation { @@ -12,70 +15,179 @@ export interface SkillZipValidation { errorDetail?: string; } +/** Per-skill entry returned by validateMultiSkillZip */ +export interface SkillValidationEntry { + valid: boolean; + /** Top-level directory name, or "" for root-level SKILL.md */ + dir: string; + name?: string; + slug?: string; + description?: string; + /** SHA-256 hex digest of the SKILL.md content */ + contentHash?: string; + /** i18n key under "upload." namespace */ + error?: string; + errorDetail?: string; +} + +export interface MultiSkillZipValidation { + skills: SkillValidationEntry[]; + /** Top-level ZIP error (corrupt file, not a ZIP, too large) */ + error?: string; +} + // Constants matching server-side (internal/http/skills.go) const MAX_SKILL_SIZE = 20 * 1024 * 1024; // 20MB +const MAX_SKILLS_PER_ZIP = 50; const SLUG_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---/; -/** Validate a skill ZIP file client-side. JSZip is lazy-loaded. */ +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Validate a skill ZIP file client-side — backward-compatible single-skill path. + * Delegates to validateMultiSkillZip and returns the first skill's result. + */ export async function validateSkillZip(file: File): Promise { + const multi = await validateMultiSkillZip(file); + if (multi.error) return { valid: false, error: multi.error }; + const first = multi.skills[0]; + if (!first) return { valid: false, error: "upload.noSkillMd" }; + return { + valid: first.valid, + name: first.name, + slug: first.slug, + description: first.description, + error: first.error, + errorDetail: first.errorDetail, + }; +} + +/** + * Validate a ZIP that may contain one or multiple skills. + * + * Detection logic: + * - If SKILL.md exists at root → single-skill mode (root entry, dir="") + * - Otherwise → scan each top-level directory for SKILL.md + * + * Returns one SkillValidationEntry per detected SKILL.md, each independently + * validated with a SHA-256 contentHash. + */ +export async function validateMultiSkillZip(file: File): Promise { if (!file.name.toLowerCase().endsWith(".zip")) { - return { valid: false, error: "upload.onlyZip" }; + return { skills: [], error: "upload.onlyZip" }; } if (file.size > MAX_SKILL_SIZE) { - return { valid: false, error: "upload.tooLarge" }; + return { skills: [], error: "upload.tooLarge" }; } let zip: JSZip; try { zip = await JSZip.loadAsync(file); } catch { - return { valid: false, error: "upload.invalidZip" }; + return { skills: [], error: "upload.invalidZip" }; } - // Find SKILL.md at root or inside single top-level directory - const skillMdContent = await findSkillMd(zip); - if (skillMdContent === null) { - return { valid: false, error: "upload.noSkillMd" }; + const entries = await findAllSkillMds(zip); + if (entries.length === 0) { + return { skills: [] }; } - if (!skillMdContent.trim()) { - return { valid: false, error: "upload.emptySkillMd" }; + if (entries.length > MAX_SKILLS_PER_ZIP) { + return { skills: [], error: "upload.tooManySkills" }; } - // Parse frontmatter - const match = skillMdContent.match(FRONTMATTER_REGEX); - if (!match?.[1]) { - return { valid: false, error: "upload.noFrontmatter" }; - } - const fields = parseFrontmatterFields(match[1]); - if (!fields.name) { - return { valid: false, error: "upload.nameRequired" }; - } + // Validate each skill entry concurrently + const skills = await Promise.all( + entries.map(({ dir, content }) => validateSkillEntry(dir, content)), + ); - const slug = fields.slug || slugify(fields.name); - if (!SLUG_REGEX.test(slug)) { - return { valid: false, error: "upload.invalidSlug", errorDetail: slug }; - } - - return { valid: true, name: fields.name, slug, description: fields.description }; + return { skills }; } -/** Find SKILL.md content — root level or inside a single top-level directory */ -async function findSkillMd(zip: JSZip): Promise { - // Try root +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +interface SkillMdEntry { + dir: string; + content: string; +} + +/** + * Find all SKILL.md files in the ZIP. + * + * Priority rule: if SKILL.md exists at root level, return only that one entry + * (single-skill mode). Otherwise collect one per top-level directory. + */ +async function findAllSkillMds(zip: JSZip): Promise { + // Root-level SKILL.md → single-skill mode if (zip.files["SKILL.md"] && !zip.files["SKILL.md"].dir) { - return zip.files["SKILL.md"].async("string"); + const content = await zip.files["SKILL.md"].async("string"); + return [{ dir: "", content }]; } - // Try single top-level dir (e.g. "my-skill/SKILL.md") + + // Multi-skill: collect directories that contain a SKILL.md const paths = Object.keys(zip.files); - const topDirs = new Set(paths.map((p) => p.split("/")[0]).filter(Boolean)); - for (const dir of topDirs) { + const topDirs = new Set( + paths + .map((p) => p.split("/")[0]) + .filter((d): d is string => Boolean(d)), + ); + + const results: SkillMdEntry[] = []; + // Process dirs in stable sorted order for deterministic output + for (const dir of [...topDirs].sort()) { const key = dir + "/SKILL.md"; if (zip.files[key] && !zip.files[key].dir) { - return zip.files[key].async("string"); + const content = await zip.files[key].async("string"); + results.push({ dir, content }); } } - return null; + return results; +} + +/** Validate a single SKILL.md entry, compute its hash, return SkillValidationEntry */ +async function validateSkillEntry(dir: string, content: string): Promise { + if (!content.trim()) { + return { valid: false, dir, error: "upload.emptySkillMd" }; + } + + const match = content.match(FRONTMATTER_REGEX); + if (!match?.[1]) { + return { valid: false, dir, error: "upload.noFrontmatter" }; + } + + const fields = parseFrontmatterFields(match[1]); + if (!fields.name) { + return { valid: false, dir, error: "upload.nameRequired" }; + } + + const slug = fields.slug ?? slugify(fields.name); + if (!SLUG_REGEX.test(slug)) { + return { valid: false, dir, error: "upload.invalidSlug", errorDetail: slug }; + } + + const contentHash = await hashContent(content); + + return { + valid: true, + dir, + name: fields.name, + slug, + description: fields.description, + contentHash, + }; +} + +/** SHA-256 hex digest using Web Crypto API */ +async function hashContent(content: string): Promise { + const data = new TextEncoder().encode(content); + const buf = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); } /** Simple key: value parser matching server's parseSkillFrontmatter() */ diff --git a/ui/web/src/pages/skills/skill-upload-dialog.tsx b/ui/web/src/pages/skills/skill-upload-dialog.tsx index 1e32297a..a3ef8d29 100644 --- a/ui/web/src/pages/skills/skill-upload-dialog.tsx +++ b/ui/web/src/pages/skills/skill-upload-dialog.tsx @@ -1,6 +1,6 @@ import { useState, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { Upload, CheckCircle2, XCircle, Loader2, TriangleAlert, X } from "lucide-react"; +import { Upload } from "lucide-react"; import { Dialog, DialogContent, @@ -10,20 +10,13 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { validateSkillZip } from "./lib/validate-skill-zip"; +import { validateMultiSkillZip } from "./lib/validate-skill-zip"; +import { createSkillSubZip } from "./lib/create-skill-sub-zip"; import { uniqueId } from "@/lib/utils"; import type { SkillUploadResponse } from "./hooks/use-skills"; - -type FileStatus = "validating" | "valid" | "invalid" | "uploading" | "success" | "warning" | "error"; - -interface FileEntry { - id: string; - file: File; - status: FileStatus; - name?: string; - slug?: string; - error?: string; -} +import type { FileEntry, SkillStatus } from "./lib/skill-upload-types"; +import { FileEntryBlock } from "./skill-upload-entry"; +import JSZip from "jszip"; interface SkillUploadDialogProps { open: boolean; @@ -39,43 +32,68 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD const [done, setDone] = useState(false); const inputRef = useRef(null); + // --------------------------------------------------------------------------- + // File handling + // --------------------------------------------------------------------------- + const addFiles = async (fileList: FileList) => { const newFiles = Array.from(fileList); - // Build pending list before setState — no side effects inside updater const existingNames = new Set(entries.map((e) => e.file.name)); const fresh = newFiles.filter((f) => !existingNames.has(f.name)); if (fresh.length === 0) return; + // Add placeholder entries with validating status const pending: FileEntry[] = fresh.map((f) => ({ id: uniqueId(), file: f, - status: "validating" as const, + skills: [{ id: uniqueId(), dir: "", status: "validating" as const }], })); setEntries((prev) => [...prev, ...pending]); - // Validate all files concurrently, catch per-file errors + // Validate all files concurrently const results = await Promise.all( pending.map(async (entry) => { try { - return { id: entry.id, result: await validateSkillZip(entry.file) }; + const validation = await validateMultiSkillZip(entry.file); + const placeholderId = entry.skills[0]?.id ?? uniqueId(); + if (validation.error) { + return { + id: entry.id, + skills: [{ id: placeholderId, dir: "", status: "invalid" as SkillStatus, error: validation.error }], + }; + } + if (validation.skills.length === 0) { + return { + id: entry.id, + skills: [{ id: placeholderId, dir: "", status: "invalid" as SkillStatus, error: "upload.noSkillMd" }], + }; + } + return { + id: entry.id, + skills: validation.skills.map((s) => ({ + id: uniqueId(), + dir: s.dir, + status: s.valid ? ("valid" as SkillStatus) : ("invalid" as SkillStatus), + name: s.name, + slug: s.slug, + contentHash: s.contentHash, + error: s.error, + })), + }; } catch { - return { id: entry.id, result: { valid: false, error: "upload.invalidZip" } as const }; + return { + id: entry.id, + skills: [{ id: entry.skills[0]?.id ?? uniqueId(), dir: "", status: "invalid" as SkillStatus, error: "upload.invalidZip" }], + }; } }), ); + setEntries((prev) => prev.map((e) => { const match = results.find((r) => r.id === e.id); - if (!match) return e; - const { result } = match; - return { - ...e, - status: result.valid ? "valid" : "invalid", - name: "name" in result ? result.name : undefined, - slug: "slug" in result ? result.slug : undefined, - error: result.error, - }; + return match ? { ...e, skills: match.skills } : e; }), ); }; @@ -84,43 +102,112 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD setEntries((prev) => prev.filter((e) => e.id !== id)); }; + // --------------------------------------------------------------------------- + // Upload — parse each ZIP once, reuse across all skills in that file + // --------------------------------------------------------------------------- + const handleSubmit = async () => { - const validEntries = entries.filter((e) => e.status === "valid"); - if (validEntries.length === 0) return; + const actionable = entries.flatMap((e) => + e.skills + .filter((s) => s.status === "valid") + .map((s) => ({ fileEntry: e, skill: s })), + ); + if (actionable.length === 0) return; + setUploading(true); - for (const entry of validEntries) { + // Cache parsed JSZip instances keyed by FileEntry id — avoids re-parsing + // the same ZIP blob for every skill in a multi-skill archive (O(N) vs O(N*M)). + const parsedZips = new Map(); + + for (const { fileEntry, skill } of actionable) { setEntries((prev) => - prev.map((e) => (e.id === entry.id ? { ...e, status: "uploading" } : e)), + prev.map((e) => + e.id === fileEntry.id + ? { ...e, skills: e.skills.map((s) => s.id === skill.id ? { ...s, status: "uploading" as SkillStatus } : s) } + : e, + ), ); + try { - const result = await onUpload(entry.file); - const detail = result.deps_warning + let uploadFile: File; + if (skill.dir && fileEntry.skills.length > 1) { + // Parse the ZIP once per FileEntry; reuse on subsequent skills + if (!parsedZips.has(fileEntry.id)) { + parsedZips.set(fileEntry.id, await JSZip.loadAsync(fileEntry.file)); + } + uploadFile = await createSkillSubZip(parsedZips.get(fileEntry.id)!, skill.dir); + } else { + uploadFile = fileEntry.file; + } + + const result = await onUpload(uploadFile); + + if (result.status === "unchanged") { + setEntries((prev) => + prev.map((e) => + e.id === fileEntry.id + ? { ...e, skills: e.skills.map((s) => s.id === skill.id ? { ...s, status: "unchanged" as SkillStatus } : s) } + : e, + ), + ); + continue; + } + + const depDetail = result.deps_warning ? result.deps_errors?.length ? `${result.deps_warning}: ${result.deps_errors.join("; ")}` : result.deps_warning : undefined; + setEntries((prev) => - prev.map((e) => (e.id === entry.id ? { - ...e, - status: result.deps_warning ? "warning" : "success", - error: detail, - } : e)), + prev.map((e) => + e.id === fileEntry.id + ? { + ...e, + skills: e.skills.map((s) => + s.id === skill.id + ? { + ...s, + status: result.deps_warning ? ("warning" as SkillStatus) : ("success" as SkillStatus), + error: depDetail, + } + : s, + ), + } + : e, + ), ); } catch (err) { setEntries((prev) => prev.map((e) => - e.id === entry.id - ? { ...e, status: "error", error: err instanceof Error ? err.message : t("upload.failed") } + e.id === fileEntry.id + ? { + ...e, + skills: e.skills.map((s) => + s.id === skill.id + ? { + ...s, + status: "error" as SkillStatus, + error: err instanceof Error ? err.message : t("upload.failed"), + } + : s, + ), + } : e, ), ); } } + setUploading(false); setDone(true); }; + // --------------------------------------------------------------------------- + // Dialog housekeeping + // --------------------------------------------------------------------------- + const handleClose = (v: boolean) => { if (uploading) return; setEntries([]); @@ -132,21 +219,25 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setDragging(false); - if (e.dataTransfer.files.length > 0) { - addFiles(e.dataTransfer.files); - } + if (e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files); }; const handleInputChange = (e: React.ChangeEvent) => { - if (e.target.files && e.target.files.length > 0) { - addFiles(e.target.files); - } - // Reset so same files can be re-selected + if (e.target.files && e.target.files.length > 0) addFiles(e.target.files); if (inputRef.current) inputRef.current.value = ""; }; - const validCount = entries.filter((e) => e.status === "valid").length; - const successCount = entries.filter((e) => e.status === "success" || e.status === "warning").length; + // --------------------------------------------------------------------------- + // Derived counts (skill level, not file level) + // --------------------------------------------------------------------------- + + const allSkills = entries.flatMap((e) => e.skills); + const actionableCount = allSkills.filter((s) => s.status === "valid").length; + const successCount = allSkills.filter((s) => s.status === "success" || s.status === "warning").length; + + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- return ( @@ -156,7 +247,7 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD {t("upload.description")} - {/* Drop zone — always visible unless uploading/done */} + {/* Drop zone — hidden once upload starts or finishes */} {!uploading && !done && (
inputRef.current?.click()} - onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); inputRef.current?.click(); } }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + inputRef.current?.click(); + } + }} onDragOver={(e) => { e.preventDefault(); setDragging(true); }} onDragEnter={(e) => { e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} @@ -186,11 +282,11 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
)} - {/* File list */} + {/* File + skill list */} {entries.length > 0 && (
{entries.map((entry) => ( - removeEntry(entry.id)} @@ -201,15 +297,15 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
)} - {/* Summary */} + {/* Summary line */} {entries.length > 0 && !done && !uploading && (

- {t("upload.validCount", { valid: validCount, total: entries.length })} + {t("upload.validCount", { valid: actionableCount, total: allSkills.length })}

)} {done && (

- {t("upload.successCount", { success: successCount, total: entries.length })} + {t("upload.successCount", { success: successCount, total: allSkills.filter((s) => s.status !== "unchanged" && s.status !== "invalid").length })}

)} @@ -220,10 +316,10 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD {done ? ( ) : ( - )} @@ -231,69 +327,3 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
); } - -/** Single file entry row with status icon, name, and remove button */ -function FileEntryRow({ - entry, - onRemove, - uploading, - t, -}: { - entry: FileEntry; - onRemove: () => void; - uploading: boolean; - t: (key: string, opts?: Record) => string; -}) { - const sizeKB = (entry.file.size / 1024).toFixed(1); - - return ( -
- -
-
- - {entry.name || entry.file.name} - - {sizeKB} KB -
- {entry.status === "invalid" || entry.status === "error" ? ( -

{entry.error ? t(entry.error) : t("upload.failed")}

- ) : entry.status === "warning" ? ( -

{entry.error ?? t("upload.failed")}

- ) : entry.status === "validating" ? ( -

{t("upload.validating")}

- ) : entry.name && entry.status !== "success" ? ( -

{entry.file.name}

- ) : null} -
- {/* Remove button — hidden during upload and for completed entries */} - {!uploading && entry.status !== "uploading" && entry.status !== "success" && ( - - )} -
- ); -} - -function StatusIcon({ status }: { status: FileStatus }) { - switch (status) { - case "validating": - case "uploading": - return ; - case "valid": - return ; - case "success": - return ; - case "warning": - return ; - case "invalid": - case "error": - return ; - } -} diff --git a/ui/web/src/pages/skills/skill-upload-entry.tsx b/ui/web/src/pages/skills/skill-upload-entry.tsx new file mode 100644 index 00000000..39a60817 --- /dev/null +++ b/ui/web/src/pages/skills/skill-upload-entry.tsx @@ -0,0 +1,221 @@ +/** Sub-components for rendering individual file entries and skill rows in the upload dialog. */ +import { CheckCircle2, XCircle, Loader2, TriangleAlert, X, Package } from "lucide-react"; +import type { FileEntry, SkillEntry, SkillStatus } from "./lib/skill-upload-types"; + +type TFunc = (key: string, opts?: Record) => string; + +// --------------------------------------------------------------------------- +// FileEntryBlock — renders a ZIP file with its skill rows +// --------------------------------------------------------------------------- + +export function FileEntryBlock({ + entry, + onRemove, + uploading, + t, +}: { + entry: FileEntry; + onRemove: () => void; + uploading: boolean; + t: TFunc; +}) { + const isMulti = entry.skills.length > 1; + const sizeKB = (entry.file.size / 1024).toFixed(1); + const isValidating = entry.skills.some((s) => s.status === "validating"); + + if (!isMulti) { + // Single-skill: one row, ZIP filename as subtitle + const skill = entry.skills[0]!; + return ( + + ); + } + + // Multi-skill: group header + individual skill rows + return ( +
+ {/* ZIP group header */} +
+ + {entry.file.name} + {sizeKB} KB + {isValidating ? null : ( + + {t("upload.multiDetected", { count: entry.skills.length })} + + )} + {!uploading && ( + + )} +
+ + {/* Individual skill rows — indented */} +
+ {entry.skills.map((skill) => ( + + ))} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// SkillRow — single skill entry row +// --------------------------------------------------------------------------- + +export function SkillRow({ + skill, + primaryLabel, + subtitle, + sizeKB, + showSize, + onRemove, + uploading, + t, + indent = false, +}: { + skill: SkillEntry; + primaryLabel: string; + subtitle?: string; + sizeKB?: string; + showSize: boolean; + onRemove?: () => void; + uploading: boolean; + t: TFunc; + indent?: boolean; +}) { + const canRemove = !uploading && skill.status !== "uploading" && skill.status !== "success" && onRemove; + + return ( +
+ +
+
+ {primaryLabel} + + {showSize && sizeKB && ( + {sizeKB} KB + )} +
+ +
+ {canRemove && ( + + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// SkillSubtitle +// --------------------------------------------------------------------------- + +export function SkillSubtitle({ + skill, + subtitle, + t, +}: { + skill: SkillEntry; + subtitle?: string; + t: TFunc; +}) { + if (skill.status === "invalid" || skill.status === "error") { + return ( +

+ {skill.error ? t(skill.error) : t("upload.failed")} +

+ ); + } + if (skill.status === "warning") { + return

{skill.error ?? t("upload.failed")}

; + } + if (skill.status === "validating") { + return

{t("upload.validating")}

; + } + if (skill.status === "unchanged") { + return ( +

+ {t("upload.skillUnchanged")} +

+ ); + } + if (subtitle && skill.status !== "success") { + return

{subtitle}

; + } + return null; +} + +// --------------------------------------------------------------------------- +// SkillBadge — colored badge for NEW / UNCHANGED / ERROR states +// --------------------------------------------------------------------------- + +export function SkillBadge({ status, t }: { status: SkillStatus; t: TFunc }) { + const base = "shrink-0 text-[10px] font-semibold uppercase px-1.5 py-0.5 rounded"; + switch (status) { + case "valid": + return {t("upload.new")}; + case "unchanged": + return {t("upload.unchanged")}; + case "invalid": + case "error": + return {t("upload.failed")}; + default: + return null; + } +} + +// --------------------------------------------------------------------------- +// SkillStatusIcon +// --------------------------------------------------------------------------- + +export function SkillStatusIcon({ status }: { status: SkillStatus }) { + switch (status) { + case "validating": + case "uploading": + return ; + case "valid": + return ; + case "unchanged": + return ; + case "success": + return ; + case "warning": + return ; + case "invalid": + case "error": + return ; + } +} diff --git a/ui/web/vitest.config.ts b/ui/web/vitest.config.ts new file mode 100644 index 00000000..3c246862 --- /dev/null +++ b/ui/web/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + test: { + // jsdom provides browser globals: crypto.subtle, Blob, File, TextEncoder + environment: "jsdom", + globals: false, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +});