Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ CriProx brings the fiddly parts of a playtest-card workflow into one focused des
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| 🔒 **Local-first** | Projects, imported artwork, and autosaves stay on your device. No account or hosted backend. |
| 📐 **Physical dimensions** | Millimeter-based geometry, standard 63 × 88 mm cards, matched PNG/SVG output, and 300–1200 DPI export. |
| 🃏 **Flexible artwork** | Search cards, browse sets or commander precons, choose Scryfall printings or MPC Autofill community art, or use local images. |
| 🃏 **Flexible artwork** | Search cards, browse sets, commander precons, or tokens, choose Scryfall printings or MPC Autofill community art, or use local images. |
| ✨ **Optional upscale** | Choose high-detail local 4× enhancement for Scryfall card images when preparing a print PDF; ordinary exports use the originals. |
| ✂️ **Reusable cuts** | Capture a Design Space template once, print CriProx PDFs from a PDF application, and cut with the same saved project. |
| ✅ **Compact print workflow** | Completed setup and settings collapse into summaries with green checks. Reopen any section whenever you need it. |
Expand All @@ -49,7 +49,7 @@ CriProx brings the fiddly parts of a playtest-card workflow into one focused des

## From card list to cut

1. **Build the sheet.** Import a Moxfield or Archidekt link, paste a deck list, search cards, browse a set or commander precon, or add local artwork.
1. **Build the sheet.** Import a Moxfield or Archidekt link, paste a deck list, search cards, browse sets, commander precons, or tokens, or add local artwork.
2. **Dial in the output.** Choose the machine, paper, layout, resolution, bleed, and optional card backs. Edit your preferred setup directly in Settings and save it for future projects, or also apply it to the current deck.
3. **Set up the cut template once.** In Create print PDF, download the setup PNG. Upload it to Design Space as a flat Print Then Cut image, preserve the supplied dimensions, and save the project and mat arrangement.
4. **Capture the template once.** Save that Design Space job's print output as a one-page PDF and import it into CriProx to verify and save its registration marks. The setup sections collapse with green checks; choose Done for reviewed settings, and reopen any section from its header.
Expand Down
17 changes: 17 additions & 0 deletions docs/CRICUT-WORKFLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,23 @@ day. Availability follows the source catalog rather than a fixed bundled list.

![Find a card showing commander precon contents and quantities](assets/criprox-precon-browser.png)

## Add tokens to a sheet

1. Open **Find a card → Tokens** to browse paper token printings, starting with the newest.
2. Enter a token name such as `Treasure` or `Goblin`, optionally enter a token set code such as
`TMH2`, and choose **Search tokens**. Leave both filters empty to browse all tokens.
Token set codes can differ from the main set code; **Browse sets** lists their names and codes.
3. Choose **Sort tokens** and **Direction** to sort by release date, name, set/collector number,
color, or artist. **Load more tokens** continues through the matching printings in that order.
4. Choose **Add card** under the artwork you want. Repeated additions increase that printing's
quantity; double-faced tokens retain their matching reverse artwork. Choose **Done** to review
your sheet and prepare the print PDF. Tokens share the 500-card project limit with other cards.

Token browsing needs an internet connection on first use. Successful searches are cached for one
day, and previews use the same image cache as card and set searches.

![Find a card showing the Tokens category, name and set filters, and token artwork](assets/criprox-token-browser.png)

## Understand the integration boundary

CriProx does **not** generate or reproduce Cricut registration marks, create native Design Space project files, or control the machine. Design Space creates the sensor marks and owns the actual Print Then Cut job. CriProx's registered-print workflow preserves marks from a PDF that Design Space produced for the exact saved job.
Expand Down
6 changes: 6 additions & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
quantities, including basic lands. Tokens, display commanders, and other supplemental cards are
excluded. Whole-deck additions require every printing to have artwork and enough room within
the 500-card project limit; unavailable printings are listed with a retry option.
- Choose **Find a card → Tokens** to browse paper token printings, newest first by default.
Search by token name, optionally narrow to a token set code such as `TMH2`, or leave both
filters empty to browse all tokens. Sort by release date, name, set/collector number, color,
or artist in either direction; **Load more tokens** continues in that order.
- Add the exact token artwork shown, including double-faced tokens with their matching reverse
artwork. Repeated additions increase that printing's quantity within the 500-card project limit.
- Start with four example Scryfall card records in a new workspace.
- Search loaded cards and filter printings by set and collector number.
- Select either face of a double-faced card from the inspector.
Expand Down
Binary file added docs/assets/criprox-token-browser.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 13 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import SettingsModal from './components/SettingsModal';
import CardSearchResults from './components/CardSearchResults';
import SetBrowser from './components/SetBrowser';
import PreconBrowser from './components/PreconBrowser';
import TokenBrowser from './components/TokenBrowser';
import type { PreconEntry } from './lib/precons';
import {
useCallback,
Expand Down Expand Up @@ -685,7 +686,7 @@ function CardSearchModal({
remaining: number;
close: () => void;
}) {
const [mode, setMode] = useState<'search' | 'sets' | 'precons'>('search');
const [mode, setMode] = useState<'search' | 'sets' | 'precons' | 'tokens'>('search');
const searchInput = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState(''),
[cards, setCards] = useState<Card[]>([]),
Expand Down Expand Up @@ -763,7 +764,7 @@ function CardSearchModal({
className="card-search-modal"
initialFocus={searchInput}
title="Find a card"
subtitle="Search cards and tokens, browse sets, or explore commander precon decks."
subtitle="Search cards, browse sets or tokens, or explore commander precon decks."
close={() => {
if (!busy) close();
}}
Expand Down Expand Up @@ -794,6 +795,14 @@ function CardSearchModal({
>
<Layers3 size={15} /> Commander precons
</button>
<button
className={mode === 'tokens' ? 'primary compact' : 'secondary compact'}
aria-pressed={mode === 'tokens'}
disabled={!!busy}
onClick={() => setMode('tokens')}
>
<Grid2X2 size={15} /> Tokens
</button>
</div>
{Object.values(added).reduce((sum, count) => sum + count, 0) > 0 && (
<div className="card-search-summary" aria-live="polite">
Expand All @@ -806,6 +815,8 @@ function CardSearchModal({
<SetBrowser add={addCard} added={added} remaining={remaining} />
) : mode === 'precons' ? (
<PreconBrowser add={addCard} addDeck={addPrecon} added={added} remaining={remaining} />
) : mode === 'tokens' ? (
<TokenBrowser add={addCard} added={added} remaining={remaining} />
) : (
<>
<form className="card-search-form" onSubmit={search}>
Expand Down
167 changes: 167 additions & 0 deletions src/components/TokenBrowser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { useEffect, useRef, useState } from 'react';
import { Grid2X2, LoaderCircle, Search } from 'lucide-react';
import {
searchTokens,
TOKEN_CARD_SORTS,
type SetCardDirection,
type TokenCardSort,
} from '../lib/scryfall';
import type { Card } from '../lib/types';
import CardSearchResults, { type CardSearchResultsProps } from './CardSearchResults';

export default function TokenBrowser(props: Omit<CardSearchResultsProps, 'cards'>) {
const [name, setName] = useState('');
const [code, setCode] = useState('');
const [filter, setFilter] = useState({ name: '', code: '' });
const [order, setOrder] = useState<TokenCardSort>('released');
const [direction, setDirection] = useState<SetCardDirection>('desc');
const [cards, setCards] = useState<Card[]>([]);
const [total, setTotal] = useState(0);
const [next, setNext] = useState<string>();
const [busy, setBusy] = useState('Loading tokens…');
const [error, setError] = useState('');
const [retry, setRetry] = useState(0);
const generation = useRef(0);

useEffect(() => {
const current = ++generation.current;
setCards([]);
setTotal(0);
setNext(undefined);
setError('');
setBusy('Loading tokens…');
void searchTokens(filter.name, filter.code, order, direction)
.then((result) => {
if (generation.current !== current) return;
setCards(result.cards);
setTotal(result.total);
setNext(result.next);
})
.catch((reason) => {
if (generation.current === current)
setError(reason instanceof Error ? reason.message : 'Could not load tokens. Try again.');
})
.finally(() => {
if (generation.current === current) setBusy('');
});
return () => {
++generation.current;
};
}, [filter, order, direction, retry]);

async function loadMore() {
if (!next || busy) return;
const current = generation.current;
setBusy('Loading more tokens…');
setError('');
try {
const result = await searchTokens(filter.name, filter.code, order, direction, next);
if (generation.current !== current) return;
setCards((previous) => [
...previous,
...result.cards.filter((card) => !previous.some((item) => item.id === card.id)),
]);
setNext(result.next);
} catch (reason) {
if (generation.current === current)
setError(reason instanceof Error ? reason.message : 'Could not load tokens. Try again.');
} finally {
if (generation.current === current) setBusy('');
}
}

return (
<div className="set-browser" aria-busy={!!busy}>
<form
className="set-browser-controls"
onSubmit={(event) => {
event.preventDefault();
setFilter({ name: name.trim(), code: code.trim() });
}}
>
<div className="search-field">
<Search size={17} />
<input
autoFocus
aria-label="Filter tokens by name"
placeholder="Token name, e.g. Treasure…"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="search-field">
<Search size={17} />
<input
aria-label="Filter tokens by set code"
placeholder="Token set code (optional), e.g. TMH2…"
value={code}
onChange={(event) => setCode(event.target.value)}
/>
</div>
<button className="primary compact" type="submit">
<Search size={16} /> Search tokens
</button>
</form>
<div className="card-search-summary">
<span>Browse paper token printings. Leave both filters empty to see all tokens.</span>
</div>
<div className="set-browser-controls">
<label>
Sort tokens
<select value={order} onChange={(event) => setOrder(event.target.value as TokenCardSort)}>
{TOKEN_CARD_SORTS.map((sort) => (
<option key={sort.value} value={sort.value}>
{sort.label}
</option>
))}
</select>
</label>
<label>
Direction
<select
value={direction}
onChange={(event) => setDirection(event.target.value as SetCardDirection)}
>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
</label>
</div>
<div className="card-search-summary" aria-live="polite">
{!busy && !error && (
<span>
{cards.length} of {total} token printings shown
</span>
)}
</div>
{error && (
<div className="error-box" role="alert">
{error}
{!next && (
<button className="secondary compact" onClick={() => setRetry((value) => value + 1)}>
Try again
</button>
)}
</div>
)}
{busy && (
<div className="loading" role="status">
<LoaderCircle className="spin" size={18} /> {busy}
</div>
)}
{!busy && !error && !cards.length && (
<div className="card-search-empty">
<Grid2X2 size={31} strokeWidth={1.3} />
<strong>No matching tokens</strong>
<span>Try another token name or token set code, or clear both filters.</span>
</div>
)}
<CardSearchResults {...props} cards={cards} />
{next && !busy && (
<button className="secondary load-more" onClick={() => void loadMore()}>
Load more tokens
</button>
)}
</div>
);
}
50 changes: 47 additions & 3 deletions src/lib/scryfall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,42 @@ export const SET_CARD_SORTS = [
export type SetCardSort = (typeof SET_CARD_SORTS)[number]['value'];
export type SetCardDirection = 'asc' | 'desc';

export const TOKEN_CARD_SORTS = [
{ value: 'released', label: 'Release date' },
{ value: 'name', label: 'Name' },
{ value: 'set', label: 'Set / collector number' },
{ value: 'color', label: 'Color' },
{ value: 'artist', label: 'Artist' },
] as const;
export type TokenCardSort = (typeof TOKEN_CARD_SORTS)[number]['value'];

export function scryfallTokenSearchPath(
name = '',
code = '',
order: TokenCardSort = 'released',
direction: SetCardDirection = 'desc',
) {
const normalized = code.trim().toLowerCase();
if (normalized && !/^[a-z0-9]+$/.test(normalized))
throw new Error('Enter a valid token set code, such as TMH2.');
const escapedName = name.trim().replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const query = `is:token game:paper${escapedName ? ` name:"${escapedName}"` : ''}${normalized ? ` set:${normalized}` : ''}`;
return `/cards/search?q=${encodeURIComponent(query)}&unique=prints&order=${order}&dir=${direction}&include_extras=true&include_variations=true`;
}

export async function searchTokens(
name = '',
code = '',
order: TokenCardSort = 'released',
direction: SetCardDirection = 'desc',
next?: string,
) {
const path = next
? new URL(next).pathname + new URL(next).search
: scryfallTokenSearchPath(name, code, order, direction);
return searchPrintings(path, 'token-cards:v1', !!next);
}

export async function listSets(): Promise<ScryfallSet[]> {
const key = 'scryfall-sets:v1';
const cached = await Promise.resolve()
Expand Down Expand Up @@ -303,7 +339,15 @@ export async function searchSetCards(
const path = next
? new URL(next).pathname + new URL(next).search
: scryfallSetSearchPath(code, order, direction);
const key = `set-cards:v1:${path}`;
return searchPrintings(path, 'set-cards:v1', !!next);
}

async function searchPrintings(
path: string,
cachePrefix: string,
isNextPage: boolean,
): Promise<{ cards: Card[]; total: number; next?: string }> {
const key = `${cachePrefix}:${path}`;
const cached = await Promise.resolve()
.then(() => get<{ time: number; data: Collection }>(key))
.catch(() => undefined);
Expand All @@ -312,8 +356,8 @@ export async function searchSetCards(
result =
cached && Date.now() - cached.time < 86400000 ? cached.data : await request<Collection>(path);
} catch (error) {
// Announced sets can exist in the catalog before any paper cards are searchable.
if (!next && error instanceof ScryfallRequestError && error.status === 404)
// Scryfall returns 404 for searches without matches, including announced sets.
if (!isNextPage && error instanceof ScryfallRequestError && error.status === 404)
return { cards: [], total: 0 };
throw error;
}
Expand Down
Loading
Loading