From fd781140e5f1c8879005929a3f838cfd99f3d14b Mon Sep 17 00:00:00 2001 From: varutasu <104105839+varutasu@users.noreply.github.com> Date: Fri, 29 May 2026 09:53:40 -0500 Subject: [PATCH] Align UI copy: My Collection vs Lists (#54) * Align UI copy with My Collection vs Lists vocabulary. Replace stale ownership/list labels across pages and components, add lib/collection-vocabulary.js as the single copy source, document the taxonomy in AGENTS.md, and gate retired strings in CI. Co-authored-by: Cursor * Fix remaining list/collection copy gaps from review. Sweep community, settings, share modal, scanner create-list modal, and invite flows for vocabulary consistency before merge. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- .cursor/rules/ui-and-theming.mdc | 8 +++ .github/workflows/ci.yml | 34 +++++++++++ AGENTS.md | 14 +++++ components/BulkSelectionToolbar.js | 5 +- components/CardItem.js | 11 ++-- components/CollectionSelectionModal.js | 17 +++--- components/Layout.js | 6 +- components/LoginCTA.js | 2 +- components/ScannedCardItem.js | 9 +-- components/ScannerDestinationPicker.js | 12 ++-- components/ShareModal.js | 6 +- docs/SCHEMA_MAP.md | 6 +- lib/collection-vocabulary.js | 40 ++++++++++++ pages/card/[id].js | 13 ++-- pages/cards.js | 21 ++++--- pages/collection/[identifier].js | 33 +++++----- pages/collections.js | 61 ++++++++++--------- pages/community/collections.js | 13 ++-- pages/dashboard.js | 15 ++--- pages/index.js | 10 +-- pages/invite/accept.js | 8 +-- pages/invite/decline.js | 8 +-- pages/my-cards.js | 11 ++-- pages/profile.js | 4 +- pages/scanner.js | 19 +++--- pages/settings.js | 8 +-- .../ScannerDestinationPicker.test.js | 12 ++-- 27 files changed, 258 insertions(+), 148 deletions(-) create mode 100644 lib/collection-vocabulary.js diff --git a/.cursor/rules/ui-and-theming.mdc b/.cursor/rules/ui-and-theming.mdc index 7ab5134..ffafb3f 100644 --- a/.cursor/rules/ui-and-theming.mdc +++ b/.cursor/rules/ui-and-theming.mdc @@ -59,3 +59,11 @@ Don't duplicate navigation in a page — extend `NavigationContent` inside Layou ## Branding The canonical product brand is **Deck Hearth** (two words, internal cap), ratified 2026-05-24 in the `pick-a-name` convoy. The repo directory + GitHub project name remain `tcg-vault` until the queued `rename-repo-and-vercel-project` convoy ships. New UI copy MUST use `Deck Hearth` verbatim — do not introduce a third name, do not abbreviate to "DH" outside the logo glyph (currently used in `components/Layout.js` lines 621, 714 as the sidebar logo monogram). + +## Product vocabulary (ownership vs lists) + +- **My Collection** — global ownership (`user_cards`, `/my-cards`). Never say "Owned Cards" or "Mark Owned" in UI. +- **Lists** — curated binders (`collections` table, `/collections`). Say "List" in buttons/modals; URL stays `/collections` until a future slug convoy. +- **Synced binder** — display name for the system collection (`is_system_collection`); DB literal `'All My Cards'` is internal only. +- Import copy from `lib/collection-vocabulary.js` (`VOCAB`, `collectionDisplayName`, `formatProcessedDestination`). +- CI `forbidden-stale-strings` enforces the three retired phrases in `pages/` + `components/`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e18782..c99febe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -208,6 +208,40 @@ jobs: fi echo "OK: no client-side LLM key leakage patterns detected." + forbidden-stale-strings: + name: No stale ownership/collection copy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Fail if forbidden UI strings appear in pages/ or components/ + run: | + # rename-collections-vocabulary convoy — ownership vs curated-list taxonomy. + # API/DB literals (e.g. system collection name) live under pages/api/ only. + PATTERNS=( + 'Mark Owned' + 'Owned Cards' + 'All My Cards' + ) + FOUND=() + for pattern in "${PATTERNS[@]}"; do + while IFS= read -r line; do + FOUND+=("$line") + done < <(grep -rFn "$pattern" pages/ components/ \ + --include='*.js' \ + --exclude-dir=api 2>/dev/null || true) + done + if [ ${#FOUND[@]} -gt 0 ]; then + echo "::error::Forbidden stale UI string(s) in pages/ or components/. Use lib/collection-vocabulary.js labels — see AGENTS.md § Product vocabulary." + printf '%s\n' "${FOUND[@]}" | sort -u | while IFS= read -r line; do + file=$(echo "$line" | cut -d: -f1) + lineno=$(echo "$line" | cut -d: -f2) + text=$(echo "$line" | cut -d: -f3-) + echo "::error file=${file},line=${lineno}::${text}" + done + exit 1 + fi + echo "OK: no forbidden stale strings in pages/ or components/." + test: name: Unit tests (vitest) runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index ca78b54..a34a4fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,20 @@ Guidance for agents and humans working in this repo. Prefer existing patterns ov > as a historical regression-lock per Risk 4 of the pick-a-name > convoy. +## Product vocabulary + +User-facing copy distinguishes **ownership** (everything you own) from **curated lists** (binders/subsets). Import labels from `lib/collection-vocabulary.js` (`VOCAB`, `collectionDisplayName`) rather than hardcoding strings. + +| Concept | UI label | Route / schema | Notes | +| --- | --- | --- | --- | +| Global ownership | **My Collection** | `/my-cards`, `user_cards` | Scanner default destination; replaces "Owned" / "Mark Owned" | +| Curated list / binder | **List** / **Lists** | `/collections`, `/collection/[id]`, `collections` table | URL slug unchanged in v1; nav says "Lists" | +| Auto-sync system list | **Synced binder** (display) | `collections` row with `is_system_collection = true` | DB name stays `'All My Cards'` — never render; use `collectionDisplayName()` | +| Add ownership | **Add to My Collection** | `POST /api/user-cards`, scanner bulk | Replaces "Mark Owned" / "Mark as Owned" | +| Add to curated list | **Add to List** | `POST /api/collections/:id/cards` | Replaces "Add to Collection" in scanner/card flows | + +CI job `forbidden-stale-strings` blocks `"Mark Owned"`, `"Owned Cards"`, and `"All My Cards"` in `pages/` + `components/` (API literals exempt). + ## 1. Project overview A web app for managing trading-card-game collections (Magic, Pokémon, Lorcana). Users authenticate, build collections + decks, scan physical cards via a camera+AI-OCR flow, and share publicly. Admin users curate the card database. diff --git a/components/BulkSelectionToolbar.js b/components/BulkSelectionToolbar.js index f135307..21438a7 100644 --- a/components/BulkSelectionToolbar.js +++ b/components/BulkSelectionToolbar.js @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { VOCAB } from '../lib/collection-vocabulary.js'; export default function BulkSelectionToolbar({ selectedCards, @@ -96,7 +97,7 @@ export default function BulkSelectionToolbar({ - Own + {VOCAB.MY_COLLECTION} @@ -137,7 +138,7 @@ export default function BulkSelectionToolbar({ - Remove from Owned + {VOCAB.REMOVE_FROM_MY_COLLECTION}
diff --git a/components/CardItem.js b/components/CardItem.js index bc2291e..6ce70ee 100644 --- a/components/CardItem.js +++ b/components/CardItem.js @@ -1,5 +1,6 @@ import { useState } from 'react'; import { useRouter } from 'next/router'; +import { VOCAB } from '../lib/collection-vocabulary.js'; // Expanding Add Button Component function ExpandingAddButton({ card, onAddToCollection, onAddToDeck, onMarkAsOwned }) { @@ -31,7 +32,7 @@ function ExpandingAddButton({ card, onAddToCollection, onAddToDeck, onMarkAsOwne onClick={(e) => handleOptionClick(e, () => onAddToCollection([card]))} className="p-1.5 rounded-md shadow-lg transition-all duration-150 hover:scale-105 flex items-center space-x-1 text-xs whitespace-nowrap" style={{ backgroundColor: 'var(--accent-flame)', color: 'white' }} - title="Add to Collection" + title={VOCAB.ADD_TO_LIST} > @@ -55,7 +56,7 @@ function ExpandingAddButton({ card, onAddToCollection, onAddToDeck, onMarkAsOwne onClick={(e) => handleOptionClick(e, onMarkAsOwned)} className="p-1.5 rounded-md shadow-lg transition-all duration-150 hover:scale-105 flex items-center space-x-1 text-xs whitespace-nowrap" style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }} - title="Mark as Owned" + title={VOCAB.ADD_TO_MY_COLLECTION} > @@ -74,7 +75,7 @@ function ExpandingAddButton({ card, onAddToCollection, onAddToDeck, onMarkAsOwne color: 'white', boxShadow: '0 0 0 2px var(--accent-ember), 0 4px 6px -1px rgba(216, 67, 21, 0.3)' }} - title={isExpanded ? "Add to Collection" : "Show Options"} + title={isExpanded ? VOCAB.ADD_TO_LIST : 'Show Options'} > @@ -381,7 +382,7 @@ export default function CardItem({ card={card} onAddToCollection={onAddToCollection} onAddToDeck={onAddToDeck} - onMarkAsOwned={() => alert('Mark as owned (coming soon)')} + onMarkAsOwned={() => alert(`${VOCAB.ADD_TO_MY_COLLECTION} (coming soon)`)} /> diff --git a/components/CollectionSelectionModal.js b/components/CollectionSelectionModal.js index 5014ee4..b90c967 100644 --- a/components/CollectionSelectionModal.js +++ b/components/CollectionSelectionModal.js @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { VOCAB } from '../lib/collection-vocabulary.js'; export default function CollectionSelectionModal({ isOpen, @@ -132,7 +133,7 @@ export default function CollectionSelectionModal({

- Add to Collections + {VOCAB.ADD_TO_LISTS}

@@ -184,7 +185,7 @@ export default function CollectionSelectionModal({
setSearchQuery(e.target.value)} className="w-full px-4 py-2 pl-10 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white" @@ -200,7 +201,7 @@ export default function CollectionSelectionModal({ {loading ? (
-

Loading collections...

+

Loading lists...

) : filteredCollections.length === 0 ? (
@@ -208,10 +209,10 @@ export default function CollectionSelectionModal({

- {searchQuery ? 'No collections match your search' : 'No collections found'} + {searchQuery ? 'No lists match your search' : 'No lists found'}

- {searchQuery ? 'Try a different search term' : 'Create your first collection to get started'} + {searchQuery ? 'Try a different search term' : 'Create your first list to get started'}

) : ( @@ -305,7 +306,7 @@ export default function CollectionSelectionModal({
{selectedCollections.length > 0 && ( - {selectedCollections.length} collection{selectedCollections.length !== 1 ? 's' : ''} selected + {selectedCollections.length} list{selectedCollections.length !== 1 ? 's' : ''} selected )}
@@ -332,7 +333,7 @@ export default function CollectionSelectionModal({ Adding...
) : ( - `Add to ${selectedCollections.length} Collection${selectedCollections.length !== 1 ? 's' : ''}` + `Add to ${selectedCollections.length} List${selectedCollections.length !== 1 ? 's' : ''}` )} diff --git a/components/Layout.js b/components/Layout.js index 84a14fa..b15d357 100644 --- a/components/Layout.js +++ b/components/Layout.js @@ -176,7 +176,7 @@ function NavigationContent({ user, router, onItemClick }) { active: router.pathname === '/dashboard' || router.pathname === '/collections' || router.pathname === '/my-cards' || router.pathname === '/decks' || router.pathname === '/analytics', expanded: true, items: [ - { name: 'Collections', href: '/collections', active: router.pathname === '/collections' }, + { name: 'Lists', href: '/collections', active: router.pathname === '/collections' }, { name: 'Cards', href: '/my-cards', active: router.pathname === '/my-cards' }, { name: 'Decks', href: '/decks', active: router.pathname === '/decks' }, { name: 'Analytics', href: '/analytics', active: router.pathname === '/analytics', isPlaceholder: true } @@ -201,7 +201,7 @@ function NavigationContent({ user, router, onItemClick }) { active: router.pathname.startsWith('/community'), expanded: isCommunityExpanded, items: [ - { name: 'Collections', href: '/community/collections', active: router.pathname === '/community/collections' }, + { name: 'Lists', href: '/community/collections', active: router.pathname === '/community/collections' }, { name: 'Decks', href: '/community/decks', active: router.pathname === '/community/decks' }, { name: 'Forums', href: '/community/forums', active: router.pathname === '/community/forums' } ] @@ -791,7 +791,7 @@ export default function Layout({ children, user = null, showSearch = false }) {
setSearchQuery(e.target.value)} diff --git a/components/LoginCTA.js b/components/LoginCTA.js index 7d19620..e97a57b 100644 --- a/components/LoginCTA.js +++ b/components/LoginCTA.js @@ -1,6 +1,6 @@ import Link from 'next/link'; -export default function LoginCTA({ message = "Sign up to create your own collections and more!" }) { +export default function LoginCTA({ message = 'Sign up to create your own lists and more!' }) { return (
- {isAdding ? 'Adding…' : 'Mark Owned'} + {isAdding ? 'Adding…' : VOCAB.ADD_TO_MY_COLLECTION}
diff --git a/components/ScannerDestinationPicker.js b/components/ScannerDestinationPicker.js index 617b8a4..0d5ece5 100644 --- a/components/ScannerDestinationPicker.js +++ b/components/ScannerDestinationPicker.js @@ -1,3 +1,5 @@ +import { VOCAB } from '../lib/collection-vocabulary.js'; + const GAME_OPTIONS = [ { value: 'All', label: 'All games' }, { value: 'MTG', label: 'Magic' }, @@ -30,7 +32,7 @@ export default function ScannerDestinationPicker({ const handleTypeChange = (type) => { if (type === 'owned') { - onDestinationChange({ type: 'owned', id: null, label: 'My owned cards' }); + onDestinationChange({ type: 'owned', id: null, label: VOCAB.MY_COLLECTION }); return; } if (type === 'collection' && filteredCollections.length > 0) { @@ -122,8 +124,8 @@ export default function ScannerDestinationPicker({
Destination type {[ - { type: 'owned', label: '💎 Owned' }, - { type: 'collection', label: '📚 Collection' }, + { type: 'owned', label: `💎 ${VOCAB.MY_COLLECTION}` }, + { type: 'collection', label: `📚 ${VOCAB.LIST}` }, { type: 'deck', label: '🃏 Deck' }, ].map((option) => { const isActive = destination?.type === option.type; @@ -154,7 +156,7 @@ export default function ScannerDestinationPicker({ {destination?.type === 'collection' && filteredCollections.length > 0 && (
@@ -918,7 +919,7 @@ export default function CollectionView() { rows="3" value={editForm.description} onChange={(e) => setEditForm({...editForm, description: e.target.value})} - placeholder="Describe your collection" + placeholder="Describe your list" />
@@ -936,7 +937,7 @@ export default function CollectionView() {

Make this collection discoverable by other users @@ -989,10 +990,10 @@ export default function CollectionView() {

- Delete Collection + Delete List

- Are you sure you want to delete "{collection.name}"? This action cannot be undone and will permanently remove all cards and data associated with this collection. + Are you sure you want to delete "{collectionDisplayName(collection)}"? This action cannot be undone and will permanently remove all cards and data associated with this list.

@@ -1037,7 +1038,7 @@ export default function CollectionView() { {/* Show login CTA for non-authenticated users */} {!user && collection?.isPublic && ( - + )} ); diff --git a/pages/collections.js b/pages/collections.js index 41e46a9..1b93f50 100644 --- a/pages/collections.js +++ b/pages/collections.js @@ -4,6 +4,7 @@ import Layout from '../components/Layout'; import PermissionIndicator from '../components/PermissionIndicator'; import { useAuth } from '../lib/use-auth'; import Link from 'next/link'; +import { VOCAB, collectionDisplayName } from '../lib/collection-vocabulary.js'; export default function Collections() { const router = useRouter(); @@ -154,7 +155,7 @@ export default function Collections() { fetchCollections(); } else { const error = await response.json(); - alert(error.error || 'Failed to create collection'); + alert(error.error || 'Failed to create list'); } } catch (error) { console.error('Error creating collection:', error); @@ -185,7 +186,7 @@ export default function Collections() { setEditTagInput(''); // Clear the tag input } else { const error = await response.json(); - alert(error.error || 'Failed to update collection'); + alert(error.error || 'Failed to update list'); } } catch (error) { console.error('Error updating collection:', error); @@ -194,7 +195,7 @@ export default function Collections() { }; const handleDeleteCollection = async (collectionId) => { - if (confirm('Are you sure you want to delete this collection? This action cannot be undone.')) { + if (confirm('Are you sure you want to delete this list? This action cannot be undone.')) { try { const response = await fetch(`/api/collections/${collectionId}`, { method: 'DELETE' @@ -204,7 +205,7 @@ export default function Collections() { fetchCollections(); // Refresh the list } else { const error = await response.json(); - alert(error.error || 'Failed to delete collection'); + alert(error.error || 'Failed to delete list'); } } catch (error) { console.error('Error deleting collection:', error); @@ -388,10 +389,10 @@ export default function Collections() {

- My Collections + {VOCAB.LISTS}

- Collections you own, collaborate on, or have been shared with you + Lists you own, collaborate on, or have been shared with you

@@ -414,7 +415,7 @@ export default function Collections() { color: 'white' }} > - + Create Collection + + Create List
@@ -426,7 +427,7 @@ export default function Collections() {
setSearchQuery(e.target.value)} @@ -454,12 +455,12 @@ export default function Collections() {
📦

- {searchQuery ? 'No collections found' : 'No collections yet'} + {searchQuery ? 'No lists found' : 'No lists yet'}

{searchQuery ? 'Try adjusting your search terms' - : 'Create your first collection to get started' + : 'Create your first list to get started' }

{!searchQuery && ( @@ -471,7 +472,7 @@ export default function Collections() { color: 'white' }} > - Create Collection + Create List )}
@@ -493,7 +494,7 @@ export default function Collections() {

- {collection.name} + {collectionDisplayName(collection)}

{/* System collection indicator */} {collection.isSystemCollection && ( @@ -506,7 +507,7 @@ export default function Collections() {
- Automatically syncs with your owned cards + {VOCAB.SYSTEM_COLLECTION_SYNC_HINT}
@@ -638,19 +639,19 @@ export default function Collections() {

- Create New Collection + Create New List

setNewCollection({...newCollection, name: e.target.value})} - placeholder="Enter collection name" + placeholder="Enter list name" />
@@ -662,7 +663,7 @@ export default function Collections() { rows="3" value={newCollection.description} onChange={(e) => setNewCollection({...newCollection, description: e.target.value})} - placeholder="Describe your collection" + placeholder="Describe your list" />
@@ -734,7 +735,7 @@ export default function Collections() {
)}

- Add tags to help organize and categorize your collection + Add tags to help organize and categorize your list

@@ -742,10 +743,10 @@ export default function Collections() {

- Make this collection discoverable by other users + Make this list discoverable by other users

@@ -799,10 +800,10 @@ export default function Collections() {
🎉

- Collection Created! + List Created!

- Your collection "{createdCollection.name}" has been created successfully. + Your list "{createdCollection.name}" has been created successfully.

@@ -838,12 +839,12 @@ export default function Collections() {

- Edit Collection + Edit List

- Make this collection discoverable by other users + Make this list discoverable by other users

diff --git a/pages/community/collections.js b/pages/community/collections.js index 72f767c..b45f739 100644 --- a/pages/community/collections.js +++ b/pages/community/collections.js @@ -4,6 +4,7 @@ import Link from 'next/link'; import Layout from '../../components/Layout'; import PermissionIndicator from '../../components/PermissionIndicator'; import { useAuth } from '../../lib/use-auth'; +import { VOCAB } from '../../lib/collection-vocabulary.js'; export default function CommunityCollections() { const router = useRouter(); @@ -198,10 +199,10 @@ export default function CommunityCollections() {

- Community Collections + Community Lists

- Discover public collections shared by the community + Discover public lists shared by the community

@@ -211,7 +212,7 @@ export default function CommunityCollections() {
setSearchQuery(e.target.value)} @@ -239,12 +240,12 @@ export default function CommunityCollections() {
🌍

- {searchQuery ? 'No collections found' : 'No public collections yet'} + {searchQuery ? 'No lists found' : 'No public lists yet'}

{searchQuery ? 'Try adjusting your search terms' - : 'Be the first to share a public collection with the community!' + : 'Be the first to share a public list with the community!' }

{!searchQuery && ( @@ -255,7 +256,7 @@ export default function CommunityCollections() { color: 'white' }} > - Go to My Collections + Go to My Lists )} diff --git a/pages/dashboard.js b/pages/dashboard.js index 8ba0f8f..a8cef08 100644 --- a/pages/dashboard.js +++ b/pages/dashboard.js @@ -4,6 +4,7 @@ import Layout from '../components/Layout'; import PermissionIndicator from '../components/PermissionIndicator'; import { useAuth } from '../lib/use-auth'; import Link from 'next/link'; +import { VOCAB } from '../lib/collection-vocabulary.js'; export default function Dashboard() { const router = useRouter(); @@ -79,7 +80,7 @@ export default function Dashboard() {

- My Collections + {VOCAB.LISTS}

Manage your collection of trading cards and decks @@ -92,7 +93,7 @@ export default function Dashboard() { - Create Collection + Create List

@@ -119,7 +120,7 @@ export default function Dashboard() {

{collections.length}

-

Collections

+

Lists

@@ -159,7 +160,7 @@ export default function Dashboard() { {/* Collections Grid */}
-

Recent Collections

+

Recent Lists

{collections.length === 0 ? (
@@ -167,13 +168,13 @@ export default function Dashboard() {
-

No Collections Yet

+

No Lists Yet

Create your first collection to start organizing your cards

@@ -219,7 +220,7 @@ export default function Dashboard() {
diff --git a/pages/index.js b/pages/index.js index 03a6724..c2f0f9f 100644 --- a/pages/index.js +++ b/pages/index.js @@ -118,7 +118,7 @@ export default function Home() { borderColor: 'var(--accent-ember)' }} > - Explore Collections + Explore Lists
@@ -137,7 +137,7 @@ export default function Home() {
-

Organize Collections

+

Organize Lists

Create custom collections, track card values, and organize by sets, rarity, or any system that works for you.

@@ -173,7 +173,7 @@ export default function Home() {

- Featured Collections + Featured Lists

Discover amazing collections from our community @@ -223,7 +223,7 @@ export default function Home() {

{collection.name}

- {collection.description || 'A carefully curated collection'} + {collection.description || 'A carefully curated list'}

@@ -247,7 +247,7 @@ export default function Home() { border: '2px solid var(--accent-ember)' }} > - View All Collections + View All Lists diff --git a/pages/invite/accept.js b/pages/invite/accept.js index 56907c5..61e062d 100644 --- a/pages/invite/accept.js +++ b/pages/invite/accept.js @@ -69,14 +69,14 @@ export default function AcceptInvite() { Invitation Accepted!

- You now have access to the collection "{result.collection.name}". + You now have access to the list "{result.collection.name}".

@@ -104,7 +104,7 @@ export default function AcceptInvite() { onClick={() => router.push('/collections')} className="w-full px-6 py-3 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200" > - Browse Collections + Browse Lists {collections.length > 0 && ( @@ -703,9 +704,9 @@ export default function Scanner() { disabled={isProcessing} className="px-4 py-2 rounded-lg font-medium" style={{ backgroundColor: 'var(--accent-ember)', color: 'white', border: 'none' }} - aria-label="Add selected cards to collection" + aria-label="Add selected cards to list" > - + {collections.map(collection => (