feat(bin-designer): add synced organization tags to saved designs#1936
Conversation
Introduces an optional `tags` field on saved designs that scales with the power-user tail (production: design counts reach p95=15, max 69 per user). This is the data-model foundation for the upcoming designs-manager rebuild (tag chips, filtering, bulk tag actions). Tags sync cross-device by riding alongside the design `name` (a sibling of `params`, not inside it): - `SavedDesign.tags` + `normalizeTags` (trim/dedupe/cap: 12 tags, 32 chars) - persisted in IndexedDB (saveDesign/duplicate); `updateDesignTags` helper - carried through `DesignSyncPayload` + the design sync adapter (LWW: a remote tag array wins; a legacy payload without tags falls back to local) - sanitized + stored server-side in the design envelope (`sanitizeTags`), with matching client/server caps as a documented cross-boundary contract `tags` is optional everywhere (absent = untagged) so there is no destructive migration and existing saved designs are untouched. The sync-admin drift math is unaffected: tags appear with the same key+value on both the blob and the index-size sides, so they cancel in `blob.size - sizeBytes` exactly like `name` (verified by a regression test + a clean prod audit).
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR adds an optional
Confidence Score: 5/5Safe to merge — tags are optional everywhere, no destructive migration, and the client/server normalization functions are provably identical. All changed paths are additive and backwards-compatible. The cross-boundary sync contract (client normalizeTags ↔ server sanitizeTags) uses the same regex, trim, and slice order, verified by the test suite. LWW semantics correctly distinguish an explicit empty array from an absent key. The byte-delta accounting for the sync-admin drift check is mathematically unchanged by the addition of tags. No files require special attention. Important Files Changed
Reviews (2): Last reviewed commit: "refactor(bin-designer): address tag revi..." | Re-trigger Greptile |
There was a problem hiding this comment.
Pull request overview
Adds an optional tags field to saved bin designs, threading it end-to-end from IndexedDB storage through the sync adapter, sync engine payload type, and server PUT/GET endpoint, with matching client/server normalization helpers and drift-accounting updates. This is the data-model groundwork for a forthcoming designs-manager rebuild (no UI yet).
Changes:
- New
normalizeTags(client) andsanitizeTags(server) helpers sharing a12 × 32-char cap as an explicit cross-boundary contract. SavedDesign/DesignSyncPayload/ design envelope gain optionaltags; storage,duplicateDesign, and newupdateDesignTagspersist and preserve them; adapter applies LWW (remote array wins, missing field falls back to local).- sync-admin drift accounting + tests updated so
tagscancels on both sides ofblob.size − sizeBytes.
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| src/features/bin-designer/utils/tags.ts | New client tag normalizer with shared limits |
| src/features/bin-designer/utils/tags.test.ts | Unit tests for normalizeTags |
| src/features/bin-designer/types/index.ts | Adds optional tags to SavedDesign |
| src/features/bin-designer/storage/DesignerStorage.ts | Persists tags, preserves on omit, adds updateDesignTags, carries through duplicate |
| src/features/bin-designer/storage/DesignerStorage.test.ts | Round-trip / normalize / preserve / clear / duplicate tests |
| src/features/bin-designer/sync/designAdapter.ts | Unwraps remote tags, LWW apply, list/get include tags |
| src/features/bin-designer/sync/designAdapter.test.ts | Tag carry-through and LWW/legacy fallback tests |
| src/core/sync/adapters/types.ts | Adds optional tags to DesignSyncPayload |
| api/lib/designerValidation.ts | Server sanitizeTags + matching constants |
| api/sync/designs/[id].ts | Unwrap/sanitize/store/tiebreak tags; envelope always carries tags array |
| api/sync/designs/[id].test.ts | Sanitize round-trip + always-array envelope tests; size-accounting updated |
| scripts/sync-admin/lib/delta.ts | Comment update: tags are byte-neutral in envelope/index delta |
| scripts/sync-admin/tests/findings.test.ts | Regression fixtures for tagged/untagged drift-free designs |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- normalizeTags now strips control chars to match the server's sanitizeTags, so the cross-boundary contract holds for all inputs (prevents a one-cycle sync flicker on control-char tags) - updateDesignTags passes tags through raw and lets saveDesign normalize, matching updateDesignName (drops redundant double-normalization) - document the tags field, storage helper, and sync semantics in the README
|
Thanks for the review! Addressed in dbfe88a:
|
…bulk actions (#1939) * feat(bin-designer): organize saved designs with tags, filtering, and bulk actions Rebuilds the saved-designs manager so it scales with the power-user tail (production design counts reach p95=15, max 69 per user) instead of the cramped single-column list. - Wider dialog (max-w-lg → max-w-4xl) with a denser auto-fill thumbnail grid - Per-design tag chips on cards; "Edit tags" action opens a tag editor (single design) backed by updateDesignTags - Tag filter bar (chip toggles, AND-match, clear-filters) above the list - Bulk-selection mode: multi-select to Delete (single confirm), Export, or Tag a batch at once New pieces, each unit-tested: useDesignSelection reducer, tagFilter (collect/filter) utils, DesignTagChips, TagInput, TagEditDialog, TagFilterBar, BulkActionBar. Tags reuse the synced field added in #1936. i18n added across all 9 locales. Verified visually (normal / filtered / selection) via Playwright. * chore: bump Total JS size-limit to 1652 kB for designs-manager UI * fix(bin-designer): address designs-manager review feedback - handleBulkDelete: remove only successfully-deleted IDs from the list so a partial storage failure can't drop a still-present design from the UI (Greptile) - prune active tag filters whose tag no longer exists, so the list can't get stuck showing nothing while the (hidden) filter bar offers no way to clear it - skip the tag write (updatedAt bump + sync) when a single/bulk tag edit doesn't actually change a design's tag set (new tagsEqual helper, tested) - TagEditDialog: pass a localized close-button aria-label (common.closeDialog) * refactor(bin-designer): tighten designs-manager after review pass - wire the previously-dead useDesignSelection.prune: drop selected IDs for designs removed via the row menu while in selection mode, so the count and bulk actions stay accurate - drop unused selection API (CLEAR action, enter() id-seeding) — nothing in the UI used them and they can't be caught by knip as object properties - extract toggleTag() helper for the case-insensitive filter chip toggle (was an inline ternary in JSX); tested - remove restatement/section-divider comments and the redundant DesignTagChips container aria-label; keep only WHY comments * style(bin-designer): bottom-align design card date row Pin the date/actions row to the card bottom (mt-auto) so dates line up across a grid row regardless of how many tags each card shows — cards with fewer tags were floating their date higher, breaking the row's visual baseline.
What & why
Adds an optional
tagsfield to saved bin designs so users can organize a growing library. Production cloud-sync data shows design counts are long-tailed — median 1, but p90=10, p95=15, max 69 per user — so the designs library needs real organization, not just a flat list.This PR is the data-model foundation (no UI yet). It's stage 1 of 3:
How tags work
Tags sync across a user's devices by riding alongside the design
name(a sibling ofparams, not inside it):SavedDesign.tags+ a sharednormalizeTagshelper (trim, dedupe case-insensitively, cap at 12 tags × 32 chars)saveDesign/duplicateDesign; newupdateDesignTagshelperDesignSyncPayloadand the design sync adapter — LWW: a remote tag array (even empty) wins; a legacy payload with no tags falls back to localsanitizeTags); client and server caps are kept identical as a documented cross-boundary contractSafety
tagsis optional everywhere (absent = untagged) → no destructive migration; existing saved designs are untouched.tagsappears with the same key+value on both the blob envelope and the index-size calc, so it cancels inblob.size − sizeBytesexactly likename. Verified by new regression tests and a clean read-only production audit (0 findings, 731 blobs / 122 users).Tests
normalizeTags/sanitizeTagsunit tests (client + server caps)typecheck ✅ · lint ✅ (0 errors) · knip ✅ · affected tests ✅