Skip to content

Smart Buylist Phase 1 Implementation Plan β€” Model, Config, Settings UI ​

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Land the Smart Buylist foundation: the BuylistOrder model, per-game Store.buylistConfig with a resolution service, GET/PUT config endpoints, and a merchant-facing Buylist Settings page reachable from the existing nav.

Architecture: Mirrors the proven pricingConfig pattern exactly β€” Store.buylistConfig is a Mixed sub-document keyed by game id, resolved through a new buylistConfigService that builds defaults and deep-merges stored overrides; rarity vocabulary comes from game plugins, never hardcoded. BuylistOrder is a fully-typed per-store collection modeled on SyncJob.

Tech Stack: Node 24 / Express / Mongoose (CommonJS), Zod, Vitest (ESM tests), React 18 + retroui + Tailwind 4.

Spec: docs/superpowers/specs/2026-07-14-smart-buylist-customer-intake-design.md (Phase 1 of 6). Later phases (quote engine, public API/portal, kiosk auth, theme extension, payouts) get their own plans.

Global Constraints ​

  • Server code is CommonJS (require); ALL test files are ESM (import) β€” including tests for CommonJS modules.
  • game is never defaulted anywhere (CLAUDE.md Β§5.5): missing/unsupported β†’ 400 or throw. Do NOT copy the req.query.game || 'mtg' pattern from the legacy pricing-config routes at server/routes/api.js:1707,1733.
  • Rarity/finish/condition literals come from real sources only (Β§5.4): rarity keys from plugin.rarities (server/plugins/mtg/index.js:24, server/plugins/pokemon/index.js:34), condition keys ['nm','lp','mp','hp','damaged'] from server/schemas/pricingConfig.js VALID_CONDITIONS.
  • Every new persisted field is declared field-by-field in a Mongoose schema and covered by a round-trip test (Β§5.2), pattern: server/models/ShopifyPokemonProductVariant.test.js.
  • After writing store.buylistConfig, ALWAYS call store.markModified('buylistConfig') (Mixed type β€” Mongoose can't detect nested writes).
  • Client UI uses retroui components from the barrel: import { Button, Card, ... } from '../../components/retroui'; class merging via cn() from lib/utils.
  • Coverage β‰₯70% on all four metrics for every modified file; npm run lint clean; Husky pre-commit runs ESLint β€” never --no-verify.
  • Commit messages: one imperative sentence, merchant-visible outcome, sentence case, no trailing period.
  • Game parity (Β§5.1): this phase is plugin-driven by construction β€” rarity vocabularies resolve per plugin, so all registered games (mtg, pokemon, riftbound) are mirrored by construction with no per-game branching. Riftbound gained inventory sync on main (PRs #355–357) mid-implementation, so it is included in the buylist settings UI rather than exempted. State this in the PR description.

Task 0: Preflight ​

Files: none (environment only)

  • [ ] Step 1: Branch from up-to-date main (Β§5.10)
bash
git fetch origin
git checkout -b claude/buylist-phase-1 origin/main
  • [ ] Step 2: Install deps and verify the suites run green before touching anything
bash
npm run install:all
npm test 2>&1 | tail -5          # expect: all passing
npm run test:client 2>&1 | tail -5

Known gotcha: in a fresh worktree npm run test:client can fail on a missing @testing-library/dom (undeclared peer dep). If so: cd client && npm install --no-save @testing-library/dom and re-run.


Task 1: BuylistOrder model ​

Files:

  • Create: server/models/BuylistOrder.js
  • Test: server/models/BuylistOrder.test.js

Interfaces:

  • Produces: Mongoose model BuylistOrder (collection buylist_orders) with fields shop, game, source, status, customer{email,name}, claimTokenHash, kioskDeviceId, lines[], payout{}, timestamps. Later phases (quote engine, public API) construct and query this model.

  • [ ] Step 1: Write the failing round-trip test

server/models/BuylistOrder.test.js:

javascript
/**
 * Round-trip tests for BuylistOrder (CLAUDE.md Β§5.2): every persisted field
 * must be declared in the schema or strict mode silently drops it. Each
 * assertion here fails if its schema line is deleted.
 */
import { describe, it, expect, beforeEach } from 'vitest';

let BuylistOrder;

beforeEach(async () => {
    const module = await import('./BuylistOrder.js');
    BuylistOrder = module.default;
});

const validDoc = {
    shop: 'test-store.myshopify.com',
    game: 'mtg',
    source: 'kiosk',
    customer: { email: 'customer@example.com', name: 'Sara C.' },
    claimTokenHash: 'a'.repeat(64),
    kioskDeviceId: 'counter-ipad-1',
    lines: [{
        rawLine: '1 Ragavan, Nimble Pilferer [MH2]',
        matchStatus: 'matched',
        cardUuid: 'abc-123',
        cardName: 'Ragavan, Nimble Pilferer',
        setCode: 'MH2',
        collectorNumber: '138',
        finish: 'normal', // MTG plugin finish vocabulary β€” normal/foil/etched, NOT 'nonfoil'
        condition: 'nm',
        quantity: 1,
        basisPrice: 48.50,
        ruleApplied: 'stopGap',
        offerPrice: 24.25,
        included: true
    }],
    payout: { method: 'store_credit', cashTotal: 24.25, creditTotal: 30.31, creditBonus: 0.25 }
};

describe('BuylistOrder model', () => {
    it('accepts a valid document', () => {
        const doc = new BuylistOrder(validDoc);
        expect(doc.validateSync()).toBeUndefined();
    });

    it('round-trips every top-level field', () => {
        const doc = new BuylistOrder(validDoc);
        expect(doc.shop).toBe('test-store.myshopify.com');
        expect(doc.game).toBe('mtg');
        expect(doc.source).toBe('kiosk');
        expect(doc.status).toBe('pending'); // default
        expect(doc.customer.email).toBe('customer@example.com');
        expect(doc.customer.name).toBe('Sara C.');
        expect(doc.claimTokenHash).toBe('a'.repeat(64));
        expect(doc.kioskDeviceId).toBe('counter-ipad-1');
        expect(doc.payout.method).toBe('store_credit');
        expect(doc.payout.creditBonus).toBe(0.25);
    });

    it('round-trips lines[] as a real array with every line field intact', () => {
        const doc = new BuylistOrder(validDoc);
        expect(Array.isArray(doc.lines)).toBe(true);
        const line = doc.lines[0];
        expect(line.rawLine).toBe('1 Ragavan, Nimble Pilferer [MH2]');
        expect(line.matchStatus).toBe('matched');
        expect(line.cardUuid).toBe('abc-123');
        expect(line.cardName).toBe('Ragavan, Nimble Pilferer');
        expect(line.setCode).toBe('MH2');
        expect(line.collectorNumber).toBe('138');
        expect(line.finish).toBe('normal');
        expect(line.condition).toBe('nm');
        expect(line.quantity).toBe(1);
        expect(line.basisPrice).toBe(48.50);
        expect(line.ruleApplied).toBe('stopGap');
        expect(line.offerPrice).toBe(24.25);
        expect(line.included).toBe(true);
    });

    it('rejects a missing game β€” no default is ever applied (Β§5.5)', () => {
        const { game, ...withoutGame } = validDoc;
        const doc = new BuylistOrder(withoutGame);
        const error = doc.validateSync();
        expect(error?.errors?.game).toBeDefined();
    });

    it('rejects unknown source and status values', () => {
        expect(new BuylistOrder({ ...validDoc, source: 'carrier-pigeon' }).validateSync()?.errors?.source).toBeDefined();
        expect(new BuylistOrder({ ...validDoc, status: 'lost' }).validateSync()?.errors?.status).toBeDefined();
    });
});
  • [ ] Step 2: Run the test to verify it fails
bash
npx vitest run server/models/BuylistOrder.test.js

Expected: FAIL β€” Cannot find module './BuylistOrder.js'

  • [ ] Step 3: Write the model

server/models/BuylistOrder.js:

javascript
const mongoose = require('mongoose');

// One line of a customer's sell list. Prices are snapshotted at quote time
// so an accepted offer doesn't drift with the market afterwards.
const buylistLineSchema = new mongoose.Schema({
    rawLine: { type: String, required: true },
    matchStatus: { type: String, enum: ['matched', 'unmatched'], required: true },
    cardUuid: String,
    cardName: String,
    setCode: String,
    collectorNumber: String,
    // Plugin finish vocabulary (e.g. MTG: normal/foil/etched β€” plugin.finishes)
    finish: String,
    // Condition keys shared with pricingConfig conditionVariants (schemas/pricingConfig.js)
    condition: { type: String, enum: ['nm', 'lp', 'mp', 'hp', 'damaged'], default: 'nm' },
    quantity: { type: Number, required: true, min: 1 },
    basisPrice: Number,
    ruleApplied: String,
    offerPrice: Number,
    included: { type: Boolean, default: true }
}, { _id: false });

const buylistOrderSchema = new mongoose.Schema({
    shop: { type: String, required: true, index: true },
    // Never defaulted β€” a missing game is a bug to surface (CLAUDE.md Β§5.5)
    game: { type: String, required: true, index: true },
    source: { type: String, enum: ['merchant', 'kiosk', 'storefront'], required: true },
    status: {
        type: String,
        enum: ['pending', 'offer_sent', 'accepted', 'declined', 'paid'],
        default: 'pending',
        index: true
    },
    customer: {
        email: { type: String, required: true },
        name: String
    },
    // Hash only β€” the claim token itself is returned once to the customer
    claimTokenHash: String,
    kioskDeviceId: String,
    lines: { type: [buylistLineSchema], default: [] },
    payout: {
        method: { type: String, enum: ['store_credit', 'cash'], default: 'store_credit' },
        cashTotal: Number,
        creditTotal: Number,
        creditBonus: Number
    }
}, { timestamps: true, collection: 'buylist_orders' });

buylistOrderSchema.index({ shop: 1, status: 1, createdAt: -1 });

module.exports = mongoose.model('BuylistOrder', buylistOrderSchema);
  • [ ] Step 4: Run the test to verify it passes
bash
npx vitest run server/models/BuylistOrder.test.js

Expected: PASS (5 tests)

  • [ ] Step 5: Commit
bash
git add server/models/BuylistOrder.js server/models/BuylistOrder.test.js
git commit -m "Add BuylistOrder model for tracking customer buy orders"

Task 2: Store.buylistConfig field ​

Files:

  • Modify: server/models/Store.js (insert directly after the pricingConfig field, ~line 95)
  • Create: server/models/Store.buylistConfig.test.js

Interfaces:

  • Produces: store.buylistConfig (Mixed, default {}), keyed by game id. Consumed by Task 3's service.

  • [ ] Step 1: Write the failing round-trip test

server/models/Store.buylistConfig.test.js:

javascript
/**
 * Round-trip test for Store.buylistConfig (CLAUDE.md Β§5.2). buylistConfig is
 * Mixed (per-game rate vocabularies are plugin-owned), but the FIELD itself
 * must be declared or strict mode drops it on write. This fails if the
 * buylistConfig schema line is deleted from Store.js.
 */
import { describe, it, expect, beforeEach } from 'vitest';

let Store;

beforeEach(async () => {
    const module = await import('./Store.js');
    Store = module.default;
});

describe('Store.buylistConfig', () => {
    it('round-trips a per-game buylist config through the schema', () => {
        const doc = new Store({
            shop: 'test-store.myshopify.com',
            accessToken: 'token',
            scope: 'read_products',
            buylistConfig: { mtg: { enabled: true, defaultRate: 0.4 } }
        });
        expect(doc.validateSync()).toBeUndefined();
        expect(doc.buylistConfig).toBeDefined();
        expect(doc.buylistConfig.mtg.enabled).toBe(true);
        expect(doc.buylistConfig.mtg.defaultRate).toBe(0.4);
    });

    it('defaults to an empty object', () => {
        const doc = new Store({
            shop: 'test-store.myshopify.com',
            accessToken: 'token',
            scope: 'read_products'
        });
        expect(doc.buylistConfig).toEqual({});
    });
});
  • [ ] Step 2: Run the test to verify it fails
bash
npx vitest run server/models/Store.buylistConfig.test.js

Expected: FAIL β€” doc.buylistConfig is undefined (field not declared yet)

  • [ ] Step 3: Declare the field in Store.js

In server/models/Store.js, immediately after the closing brace of the pricingConfig field (after ~line 95, the default: {} + },), add:

javascript
    // Per-game buylist configuration, keyed by game id (mtg, pokemon, ...).
    // Mixed for the same reason as pricingConfig above: rate vocabularies are
    // plugin-owned. Resolve via buylistConfigService.getGameBuylistConfig;
    // ALWAYS call markModified('buylistConfig') after writing.
    buylistConfig: {
        type: mongoose.Schema.Types.Mixed,
        default: {}
    },
  • [ ] Step 4: Run the test to verify it passes
bash
npx vitest run server/models/Store.buylistConfig.test.js

Expected: PASS (2 tests)

  • [ ] Step 5: Commit
bash
git add server/models/Store.js server/models/Store.buylistConfig.test.js
git commit -m "Add per-game buylistConfig storage to the Store model"

Task 3: buylistConfigService ​

Files:

  • Create: server/services/buylistConfigService.js
  • Test: server/services/buylistConfigService.test.js

Interfaces:

  • Consumes: deepMerge (exported by server/services/pricingConfigService.js), getPlugin(gameId) from server/plugins (throws on unsupported game).

  • Produces (all consumed by Task 5's routes and later phases):

    • getDefaultGameBuylistConfig(gameId) β†’ config object
    • getGameBuylistConfig(store, gameId) β†’ config object (defaults deep-merged with stored overrides)
    • applyGameBuylistConfigUpdate(store, gameId, update) β†’ merged config (writes store.buylistConfig[gameId], calls markModified)
    • buildBuylistConfigResponse(store, gameId) β†’ { game, rarities: [{id,name}], config }
  • [ ] Step 1: Write the failing tests

server/services/buylistConfigService.test.js:

javascript
import { describe, it, expect, vi } from 'vitest';
import {
    getDefaultGameBuylistConfig,
    getGameBuylistConfig,
    applyGameBuylistConfigUpdate,
    buildBuylistConfigResponse
} from './buylistConfigService.js';

const fakeStore = (buylistConfig = {}) => ({
    buylistConfig,
    markModified: vi.fn()
});

describe('getDefaultGameBuylistConfig', () => {
    it('returns the base defaults for a supported game', () => {
        const config = getDefaultGameBuylistConfig('mtg');
        expect(config.enabled).toBe(false);
        expect(config.priceBasis).toBe('market');
        expect(config.defaultRate).toBe(0.40);
        expect(config.bulk).toEqual({ enabled: true, threshold: 0.25, flatPrice: 0.05 });
        expect(config.stopGap).toEqual({ enabled: true, threshold: 5.00, rate: 0.50 });
        expect(config.conditionMultipliers).toEqual({ nm: 1.0, lp: 0.85, mp: 0.70, hp: 0.50, damaged: 0.25 });
        expect(config.storeCreditBonus).toBe(0.25);
        expect(config.instantQuote).toEqual({ web: true, kiosk: true });
    });

    it('returns a fresh object graph on every call (no shared references)', () => {
        const a = getDefaultGameBuylistConfig('mtg');
        const b = getDefaultGameBuylistConfig('mtg');
        a.bulk.threshold = 999;
        expect(b.bulk.threshold).toBe(0.25);
    });

    it('throws for an unsupported game β€” no silent fallback (Β§5.5)', () => {
        expect(() => getDefaultGameBuylistConfig('yugioh')).toThrow();
    });
});

describe('getGameBuylistConfig', () => {
    it('returns defaults when the store has no stored config', () => {
        expect(getGameBuylistConfig(fakeStore(), 'mtg').defaultRate).toBe(0.40);
    });

    it('deep-merges stored overrides over defaults', () => {
        const store = fakeStore({ mtg: { enabled: true, stopGap: { rate: 0.55 } } });
        const config = getGameBuylistConfig(store, 'mtg');
        expect(config.enabled).toBe(true);
        expect(config.stopGap.rate).toBe(0.55);
        expect(config.stopGap.threshold).toBe(5.00); // untouched default survives
    });

    it('keeps games isolated β€” a pokemon override never leaks into mtg', () => {
        const store = fakeStore({ pokemon: { defaultRate: 0.10 } });
        expect(getGameBuylistConfig(store, 'mtg').defaultRate).toBe(0.40);
        expect(getGameBuylistConfig(store, 'pokemon').defaultRate).toBe(0.10);
    });
});

describe('applyGameBuylistConfigUpdate', () => {
    it('writes the merged config under the game key and marks the path modified', () => {
        const store = fakeStore();
        const merged = applyGameBuylistConfigUpdate(store, 'mtg', { enabled: true });
        expect(merged.enabled).toBe(true);
        expect(store.buylistConfig.mtg.enabled).toBe(true);
        expect(store.markModified).toHaveBeenCalledWith('buylistConfig');
    });

    it('updating one game leaves the other game untouched', () => {
        const store = fakeStore({ mtg: { enabled: true } });
        applyGameBuylistConfigUpdate(store, 'pokemon', { defaultRate: 0.2 });
        expect(store.buylistConfig.mtg.enabled).toBe(true);
        expect(store.buylistConfig.pokemon.defaultRate).toBe(0.2);
    });
});

describe('buildBuylistConfigResponse', () => {
    it('returns game, plugin rarity metadata sorted by sortOrder, and resolved config', () => {
        const res = buildBuylistConfigResponse(fakeStore(), 'mtg');
        expect(res.game).toBe('mtg');
        // First MTG rarities by plugin sortOrder β€” from server/plugins/mtg/index.js
        expect(res.rarities[0]).toEqual({ id: 'common', name: 'Common' });
        expect(res.rarities[3]).toEqual({ id: 'mythic', name: 'Mythic Rare' });
        expect(res.config.defaultRate).toBe(0.40);
    });
});
  • [ ] Step 2: Run the tests to verify they fail
bash
npx vitest run server/services/buylistConfigService.test.js

Expected: FAIL β€” Cannot find module './buylistConfigService.js'

  • [ ] Step 3: Write the service

server/services/buylistConfigService.js:

javascript
/**
 * Per-game buylist config resolution.
 *
 * Store.buylistConfig is Mixed, keyed by game id β€” same pattern (and same
 * reasons) as Store.pricingConfig / pricingConfigService. This module builds
 * each game's defaults and merges stored overrides over them. Every consumer
 * (routes now; quote engine in phase 2) resolves through getGameBuylistConfig.
 */
/* global structuredClone */
'use strict';

const { getPlugin } = require('../plugins');
const { deepMerge } = require('./pricingConfigService');

// Condition keys match pricingConfig's conditionVariants vocabulary
// (server/schemas/pricingConfig.js VALID_CONDITIONS). Rates are fractions
// of the basis price (0.40 = pay 40%).
const BASE_BUYLIST_DEFAULTS = {
    enabled: false,
    priceBasis: 'market', // 'market' | 'lowest' | 'lower_of'
    defaultRate: 0.40,
    rarityRates: {},      // per-rarity overrides keyed by plugin rarity ids
    bulk: { enabled: true, threshold: 0.25, flatPrice: 0.05 },
    stopGap: { enabled: true, threshold: 5.00, rate: 0.50 },
    conditionMultipliers: { nm: 1.0, lp: 0.85, mp: 0.70, hp: 0.50, damaged: 0.25 },
    storeCreditBonus: 0.25,
    prioritization: {
        outOfStockBoost: { enabled: false, bonus: 0.05 },
        hotList: { enabled: false, bonus: 0.10 }
    },
    instantQuote: { web: true, kiosk: true }
};

function getDefaultGameBuylistConfig(gameId) {
    getPlugin(gameId); // throws on unsupported game β€” the Β§5.5 guard
    return structuredClone(BASE_BUYLIST_DEFAULTS);
}

function toPlain(c) {
    return c && typeof c.toObject === 'function' ? c.toObject() : c;
}

function getGameBuylistConfig(store, gameId) {
    const defaults = getDefaultGameBuylistConfig(gameId);
    const bc = toPlain(store?.buylistConfig);
    const stored = bc?.[gameId];
    return stored ? deepMerge(defaults, toPlain(stored)) : defaults;
}

function applyGameBuylistConfigUpdate(store, gameId, update) {
    const merged = deepMerge(getGameBuylistConfig(store, gameId), update);
    const bc = toPlain(store.buylistConfig);
    if (!bc || typeof bc !== 'object') {
        store.buylistConfig = {};
    }
    store.buylistConfig[gameId] = merged;
    store.markModified('buylistConfig');
    return merged;
}

/**
 * Full GET /api/buylist/config response: resolved config plus the rarity
 * metadata the client needs to render per-rarity rate rows.
 */
function buildBuylistConfigResponse(store, gameId) {
    const plugin = getPlugin(gameId);
    return {
        game: gameId,
        rarities: Object.entries(plugin.rarities)
            .sort(([, a], [, b]) => (a.sortOrder || 0) - (b.sortOrder || 0))
            .map(([id, def]) => ({ id, name: def.name })),
        config: getGameBuylistConfig(store, gameId)
    };
}

module.exports = {
    getDefaultGameBuylistConfig,
    getGameBuylistConfig,
    applyGameBuylistConfigUpdate,
    buildBuylistConfigResponse
};
  • [ ] Step 4: Run the tests to verify they pass
bash
npx vitest run server/services/buylistConfigService.test.js

Expected: PASS (9 tests)

  • [ ] Step 5: Commit
bash
git add server/services/buylistConfigService.js server/services/buylistConfigService.test.js
git commit -m "Add per-game buylist config resolution with plugin-driven defaults"

Task 4: Zod schema for buylist config updates ​

Files:

  • Create: server/schemas/buylistConfig.js
  • Modify: server/schemas/index.js (add one spread to the barrel)
  • Test: append a describe block to server/schemas/schemas.test.js

Interfaces:

  • Consumes: a game plugin instance (for plugin.rarities keys).

  • Produces: buildBuylistConfigUpdateSchema(plugin) β†’ ZodObject β€” strict partial-update schema; unknown keys anywhere are validation errors. Consumed by Task 5's PUT route.

  • [ ] Step 1: Write the failing tests

Append to server/schemas/schemas.test.js (it already imports from ./index.js β€” follow the file's existing import style at the top; add buildBuylistConfigUpdateSchema to that import):

javascript
describe('buildBuylistConfigUpdateSchema', () => {
    // Real plugin: rarity keys must come from plugin vocabulary (Β§5.4)
    let mtgPlugin;
    beforeAll(async () => {
        const { getPlugin } = await import('../plugins/index.js');
        mtgPlugin = getPlugin('mtg');
    });

    it('accepts a valid partial update', () => {
        const result = buildBuylistConfigUpdateSchema(mtgPlugin).safeParse({
            enabled: true,
            priceBasis: 'lower_of',
            defaultRate: 0.45,
            rarityRates: { mythic: 0.5, rare: 0.45 },
            stopGap: { threshold: 10, rate: 0.55 },
            storeCreditBonus: 0.3,
            instantQuote: { web: false }
        });
        expect(result.success).toBe(true);
    });

    it('rejects a rarity key outside the plugin vocabulary', () => {
        const result = buildBuylistConfigUpdateSchema(mtgPlugin).safeParse({
            rarityRates: { legendary: 0.5 } // not an MTG rarity id
        });
        expect(result.success).toBe(false);
    });

    it('rejects rates outside 0..1', () => {
        expect(buildBuylistConfigUpdateSchema(mtgPlugin).safeParse({ defaultRate: 40 }).success).toBe(false);
        expect(buildBuylistConfigUpdateSchema(mtgPlugin).safeParse({ stopGap: { rate: -0.1 } }).success).toBe(false);
    });

    it('rejects unknown top-level keys (strict)', () => {
        expect(buildBuylistConfigUpdateSchema(mtgPlugin).safeParse({ surprise: true }).success).toBe(false);
    });

    it('rejects an invalid priceBasis', () => {
        expect(buildBuylistConfigUpdateSchema(mtgPlugin).safeParse({ priceBasis: 'vibes' }).success).toBe(false);
    });
});
  • [ ] Step 2: Run to verify failure
bash
npx vitest run server/schemas/schemas.test.js

Expected: FAIL β€” buildBuylistConfigUpdateSchema is not exported

  • [ ] Step 3: Write the schema and register it in the barrel

server/schemas/buylistConfig.js:

javascript
/**
 * Buylist configuration schemas.
 *
 * Validates PUT /api/buylist/config bodies. Mirrors the shape produced by
 * buylistConfigService; rarity keys are validated strictly against the game
 * plugin's vocabulary (same approach as buildPricingConfigSchema).
 */
'use strict';

const { z } = require('zod');

// Shared with pricingConfig.js β€” condition vocabulary for all games
const VALID_CONDITIONS = ['nm', 'lp', 'mp', 'hp', 'damaged'];

// Rates are fractions of basis price: 0 = pay nothing, 1 = pay full price
const rateSchema = z.number().min(0, 'rate must be >= 0').max(1, 'rate must be <= 1');

function buildBuylistConfigUpdateSchema(plugin) {
    const rarityKeys = Object.keys(plugin.rarities);
    return z.object({
        enabled: z.boolean().optional(),
        priceBasis: z.enum(['market', 'lowest', 'lower_of']).optional(),
        defaultRate: rateSchema.optional(),
        rarityRates: z.object(Object.fromEntries(
            rarityKeys.map((r) => [r, rateSchema.optional()])
        )).strict().optional(),
        bulk: z.object({
            enabled: z.boolean().optional(),
            threshold: z.number().min(0).optional(),
            flatPrice: z.number().min(0).optional()
        }).strict().optional(),
        stopGap: z.object({
            enabled: z.boolean().optional(),
            threshold: z.number().min(0).optional(),
            rate: rateSchema.optional()
        }).strict().optional(),
        conditionMultipliers: z.record(z.enum(VALID_CONDITIONS), rateSchema).optional(),
        storeCreditBonus: rateSchema.optional(),
        prioritization: z.object({
            outOfStockBoost: z.object({ enabled: z.boolean().optional(), bonus: rateSchema.optional() }).strict().optional(),
            hotList: z.object({ enabled: z.boolean().optional(), bonus: rateSchema.optional() }).strict().optional()
        }).strict().optional(),
        instantQuote: z.object({
            web: z.boolean().optional(),
            kiosk: z.boolean().optional()
        }).strict().optional()
    }).strict();
}

module.exports = { buildBuylistConfigUpdateSchema };

In server/schemas/index.js, after the ...require('./sealedPricingConfig'), line, add:

javascript
    // Buylist
    ...require('./buylistConfig'),
  • [ ] Step 4: Run to verify pass
bash
npx vitest run server/schemas/schemas.test.js

Expected: PASS (existing 44 + 5 new)

  • [ ] Step 5: Commit
bash
git add server/schemas/buylistConfig.js server/schemas/index.js server/schemas/schemas.test.js
git commit -m "Validate buylist config updates against plugin rarity vocabularies"

Task 5: GET/PUT /api/buylist/config routes ​

Files:

  • Modify: server/routes/api.js β€” insert both routes directly after the PUT /pricing-config handler's closing }); (~line 1766)

Interfaces:

  • Consumes: buildBuylistConfigResponse / applyGameBuylistConfigUpdate (Task 3), buildBuylistConfigUpdateSchema (Task 4), req.store (set by the router's existing auth middleware β€” same as pricing-config).
  • Produces: GET /api/buylist/config?game=<id> β†’ { game, rarities, config }; PUT /api/buylist/config?game=<id> β†’ { success, game, config }. The client (Task 6) calls both.

Note on the quality bar's validate() requirement: like PUT /pricing-config, the schema here is built per-game at request time, so validation is inline safeParse with the same explanatory comment β€” this is the repo's accepted pattern for plugin-dependent schemas (see server/routes/api.js:1741-1746).

  • [ ] Step 1: Add the routes
javascript
/**
 * GET /api/buylist/config?game=<id>
 * Per-game buylist configuration plus rarity metadata for the settings UI.
 * game is REQUIRED β€” no default (CLAUDE.md Β§5.5; do not copy the legacy
 * pricing-config fallback above).
 */
router.get('/buylist/config', async (req, res) => {
    try {
        const game = req.query.game;
        const { isGameSupported } = require('../plugins');
        if (!game || !isGameSupported(game)) {
            return res.status(400).json({ error: `Missing or unsupported game: ${game}` });
        }
        const { buildBuylistConfigResponse } = require('../services/buylistConfigService');
        res.json(buildBuylistConfigResponse(req.store, game));
    } catch (error) {
        logger.error('Failed to fetch buylist config', { error: error.message });
        res.status(500).json({ error: 'Failed to fetch buylist config' });
    }
});

/**
 * PUT /api/buylist/config?game=<id>
 * Partial-update the store's per-game buylist configuration.
 * Schema depends on the game plugin, so validation is inline rather than via
 * the static validate() middleware (same pattern as PUT /pricing-config).
 */
router.put('/buylist/config', async (req, res) => {
    try {
        const game = req.query.game;
        const { isGameSupported, getPlugin } = require('../plugins');
        if (!game || !isGameSupported(game)) {
            return res.status(400).json({ error: `Missing or unsupported game: ${game}` });
        }

        const { buildBuylistConfigUpdateSchema } = require('../schemas');
        const parsed = buildBuylistConfigUpdateSchema(getPlugin(game)).safeParse(req.body);
        if (!parsed.success) {
            return res.status(400).json({
                error: 'Validation failed',
                details: parsed.error.issues.map((i) => ({
                    path: i.path.join('.'),
                    message: i.message
                }))
            });
        }

        const { applyGameBuylistConfigUpdate } = require('../services/buylistConfigService');
        const config = applyGameBuylistConfigUpdate(req.store, game, parsed.data);
        await req.store.save();

        logger.info('Updated buylist config for store', { shop: req.store.shop, game });
        res.json({ success: true, game, config });
    } catch (error) {
        logger.error('Failed to update buylist config', { error: error.message });
        res.status(500).json({ error: 'Failed to update buylist config' });
    }
});
  • [ ] Step 2: Verify the server boots and the full suite stays green
bash
npm test 2>&1 | tail -5

Expected: all passing (route handlers are exercised indirectly via schema + service tests; api.js has no route-level test file β€” matches existing repo pattern).

  • [ ] Step 3: Manual smoke test (requires docker compose up -d + npm run dev:no-worker)
bash
# In dev the standalone JWT path applies; fastest check is via the client in Task 6.
# Minimum server-side check β€” game guard behaves:
curl -s "http://localhost:3000/api/buylist/config" -H "Authorization: Bearer invalid" | head -c 200

Expected: 400 (missing game) or 401 (auth) β€” NOT a 500, and NOT a response that assumed mtg.

  • [ ] Step 4: Commit
bash
git add server/routes/api.js
git commit -m "Add per-game buylist config endpoints"

Task 6: Buylist Settings page (client) ​

Files:

  • Create: client/src/pages/buylist/BuylistPage.jsx
  • Test: client/src/pages/buylist/BuylistPage.test.jsx

Interfaces:

  • Consumes: api from client/src/utils/api.js (api.get('/buylist/config?game=...'), api.put('/buylist/config?game=...', body)); retroui barrel components.

  • Produces: default-exported BuylistPage React component. Task 7 routes /buylist to it.

  • [ ] Step 1: Write the failing test

client/src/pages/buylist/BuylistPage.test.jsx:

jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import BuylistPage from './BuylistPage';

vi.mock('../../utils/api', () => ({
  api: { get: vi.fn(), put: vi.fn() }
}));
import { api } from '../../utils/api';

const configResponse = {
  data: {
    game: 'mtg',
    rarities: [
      { id: 'common', name: 'Common' },
      { id: 'uncommon', name: 'Uncommon' },
      { id: 'rare', name: 'Rare' },
      { id: 'mythic', name: 'Mythic Rare' }
    ],
    config: {
      enabled: false,
      priceBasis: 'market',
      defaultRate: 0.4,
      rarityRates: {},
      bulk: { enabled: true, threshold: 0.25, flatPrice: 0.05 },
      stopGap: { enabled: true, threshold: 5, rate: 0.5 },
      conditionMultipliers: { nm: 1, lp: 0.85, mp: 0.7, hp: 0.5, damaged: 0.25 },
      storeCreditBonus: 0.25,
      prioritization: {
        outOfStockBoost: { enabled: false, bonus: 0.05 },
        hotList: { enabled: false, bonus: 0.1 }
      },
      instantQuote: { web: true, kiosk: true }
    }
  }
};

const gamesResponse = {
  data: { games: [{ id: 'mtg', name: 'Magic: The Gathering' }, { id: 'pokemon', name: 'Pokemon TCG' }] }
};

beforeEach(() => {
  vi.clearAllMocks();
  api.get.mockImplementation((url) =>
    url.startsWith('/games') ? Promise.resolve(gamesResponse) : Promise.resolve(configResponse)
  );
  api.put.mockResolvedValue({ data: { success: true, game: 'mtg', config: configResponse.data.config } });
});

describe('BuylistPage', () => {
  it('loads and renders the mtg buylist config', async () => {
    render(<BuylistPage />);
    await waitFor(() => expect(api.get).toHaveBeenCalledWith('/buylist/config?game=mtg'));
    expect(await screen.findByLabelText(/default rate/i)).toHaveValue(40);
    expect(screen.getByLabelText(/store credit bonus/i)).toHaveValue(25);
  });

  it('renders a rate input per plugin rarity', async () => {
    render(<BuylistPage />);
    expect(await screen.findByLabelText(/mythic rare rate/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/common rate/i)).toBeInTheDocument();
  });

  it('saves edited values as fractions via PUT with the game threaded explicitly', async () => {
    render(<BuylistPage />);
    const rateInput = await screen.findByLabelText(/default rate/i);
    fireEvent.change(rateInput, { target: { value: '45' } });
    fireEvent.click(screen.getByRole('button', { name: /save/i }));
    await waitFor(() => expect(api.put).toHaveBeenCalledWith(
      '/buylist/config?game=mtg',
      expect.objectContaining({ defaultRate: 0.45 })
    ));
    expect(await screen.findByText(/saved/i)).toBeInTheDocument();
  });
});
  • [ ] Step 2: Run to verify failure
bash
npx vitest run --config vitest.client.config.js client/src/pages/buylist/BuylistPage.test.jsx

Expected: FAIL β€” Cannot find module './BuylistPage'

  • [ ] Step 3: Write the page

client/src/pages/buylist/BuylistPage.jsx:

jsx
import { useCallback, useEffect, useState } from 'react';
import { api } from '../../utils/api';
import { Alert, Badge, Button, Card, Input, Switch, Text } from '../../components/retroui';

// Percent in the UI, fraction over the wire: the API stores 0.40, merchants read 40%.
const toPct = (fraction) => Math.round(fraction * 100);
const toFraction = (pct) => Number((Number(pct) / 100).toFixed(4));

function RateField({ id, label, value, onChange }) {
  return (
    <div className="flex items-center justify-between gap-3 py-2 border-b-2 border-muted last:border-b-0">
      <label htmlFor={id} className="font-semibold text-sm">{label}</label>
      <span className="flex items-center gap-1">
        <Input
          id={id}
          aria-label={`${label} rate`}
          type="number"
          min={0}
          max={100}
          className="w-20 text-right"
          value={value}
          onChange={(e) => onChange(e.target.value)}
        />
        <span className="text-sm font-bold">%</span>
      </span>
    </div>
  );
}

export default function BuylistPage() {
  const [games, setGames] = useState([]);
  const [game, setGame] = useState('mtg');
  const [rarities, setRarities] = useState([]);
  const [form, setForm] = useState(null);
  const [saving, setSaving] = useState(false);
  const [savedMsg, setSavedMsg] = useState('');
  const [error, setError] = useState('');

  useEffect(() => {
    api.get('/games')
      .then(({ data }) => setGames(data.games || []))
      .catch(() => setGames([]));
  }, []);

  const load = useCallback(async (gameId) => {
    setError('');
    setSavedMsg('');
    try {
      const { data } = await api.get(`/buylist/config?game=${gameId}`);
      setRarities(data.rarities);
      const c = data.config;
      setForm({
        enabled: c.enabled,
        priceBasis: c.priceBasis,
        defaultRate: toPct(c.defaultRate),
        rarityRates: Object.fromEntries(
          Object.entries(c.rarityRates || {}).map(([k, v]) => [k, toPct(v)])
        ),
        stopGapThreshold: c.stopGap.threshold,
        stopGapRate: toPct(c.stopGap.rate),
        bulkThreshold: c.bulk.threshold,
        bulkFlatPrice: c.bulk.flatPrice,
        storeCreditBonus: toPct(c.storeCreditBonus),
        instantQuoteWeb: c.instantQuote.web,
        instantQuoteKiosk: c.instantQuote.kiosk
      });
    } catch {
      setError('Failed to load buylist settings');
    }
  }, []);

  useEffect(() => { load(game); }, [game, load]);

  const save = async () => {
    setSaving(true);
    setError('');
    setSavedMsg('');
    try {
      const body = {
        enabled: form.enabled,
        priceBasis: form.priceBasis,
        defaultRate: toFraction(form.defaultRate),
        rarityRates: Object.fromEntries(
          Object.entries(form.rarityRates)
            .filter(([, v]) => v !== '' && v !== undefined)
            .map(([k, v]) => [k, toFraction(v)])
        ),
        bulk: { threshold: Number(form.bulkThreshold), flatPrice: Number(form.bulkFlatPrice) },
        stopGap: { threshold: Number(form.stopGapThreshold), rate: toFraction(form.stopGapRate) },
        storeCreditBonus: toFraction(form.storeCreditBonus),
        instantQuote: { web: form.instantQuoteWeb, kiosk: form.instantQuoteKiosk }
      };
      await api.put(`/buylist/config?game=${game}`, body);
      setSavedMsg('Buylist settings saved');
    } catch {
      setError('Failed to save buylist settings');
    } finally {
      setSaving(false);
    }
  };

  const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));

  if (!form) {
    return <div className="p-6"><Text>Loading buylist settings…</Text></div>;
  }

  return (
    <div className="p-6 space-y-6 max-w-3xl">
      <div className="flex items-center justify-between flex-wrap gap-3">
        <div>
          <Text as="h1">Buylist</Text>
          <p className="text-muted-foreground">
            Rates and rules used to price cards customers sell to you. Order intake ships in a later release.
          </p>
        </div>
        <Badge>Settings</Badge>
      </div>

      <div className="flex gap-2">
        {games.map((g) => (
          <Button
            key={g.id}
            variant={g.id === game ? 'default' : 'outline'}
            size="sm"
            onClick={() => setGame(g.id)}
          >
            {g.name}
          </Button>
        ))}
      </div>

      {error && <Alert variant="destructive">{error}</Alert>}
      {savedMsg && <Alert>{savedMsg}</Alert>}

      <Card>
        <Card.Header>
          <Card.Title>General</Card.Title>
        </Card.Header>
        <Card.Content className="pt-0">
          <div className="flex items-center justify-between gap-3 py-2">
            <label htmlFor="buylist-enabled" className="font-semibold text-sm">Buylist enabled</label>
            <Switch id="buylist-enabled" checked={form.enabled} onCheckedChange={set('enabled')} />
          </div>
          <div className="flex items-center justify-between gap-3 py-2">
            <label htmlFor="price-basis" className="font-semibold text-sm">Price basis</label>
            <select
              id="price-basis"
              className="border-2 border-black rounded px-2 py-1 font-semibold bg-card"
              value={form.priceBasis}
              onChange={(e) => set('priceBasis')(e.target.value)}
            >
              <option value="market">Market price</option>
              <option value="lowest">Lowest listing</option>
              <option value="lower_of">Lower of the two</option>
            </select>
          </div>
          <RateField id="default-rate" label="Default rate" value={form.defaultRate} onChange={set('defaultRate')} />
          <RateField id="credit-bonus" label="Store credit bonus" value={form.storeCreditBonus} onChange={set('storeCreditBonus')} />
        </Card.Content>
      </Card>

      <Card>
        <Card.Header>
          <Card.Title>Rarity rates</Card.Title>
          <Card.Description>Override the default rate per rarity. Blank = use default.</Card.Description>
        </Card.Header>
        <Card.Content className="pt-0">
          {rarities.map((r) => (
            <RateField
              key={r.id}
              id={`rarity-${r.id}`}
              label={r.name}
              value={form.rarityRates[r.id] ?? ''}
              onChange={(v) => setForm((f) => ({ ...f, rarityRates: { ...f.rarityRates, [r.id]: v } }))}
            />
          ))}
        </Card.Content>
      </Card>

      <Card>
        <Card.Header>
          <Card.Title>Overrides</Card.Title>
        </Card.Header>
        <Card.Content className="pt-0">
          <div className="flex items-center justify-between gap-3 py-2 border-b-2 border-muted">
            <label htmlFor="bulk-threshold" className="font-semibold text-sm">
              Bulk: cards under $
              <Input id="bulk-threshold" aria-label="Bulk threshold" type="number" min={0} step="0.05"
                className="w-20 mx-1 text-right inline-block"
                value={form.bulkThreshold} onChange={(e) => set('bulkThreshold')(e.target.value)} />
              pay flat $
              <Input id="bulk-flat" aria-label="Bulk flat price" type="number" min={0} step="0.01"
                className="w-20 mx-1 text-right inline-block"
                value={form.bulkFlatPrice} onChange={(e) => set('bulkFlatPrice')(e.target.value)} />
            </label>
          </div>
          <div className="flex items-center justify-between gap-3 py-2">
            <label htmlFor="stopgap-threshold" className="font-semibold text-sm">
              Stop-gap: cards over $
              <Input id="stopgap-threshold" aria-label="Stop-gap threshold" type="number" min={0} step="0.5"
                className="w-20 mx-1 text-right inline-block"
                value={form.stopGapThreshold} onChange={(e) => set('stopGapThreshold')(e.target.value)} />
              pay
              <Input id="stopgap-rate" aria-label="Stop-gap rate" type="number" min={0} max={100}
                className="w-20 mx-1 text-right inline-block"
                value={form.stopGapRate} onChange={(e) => set('stopGapRate')(e.target.value)} />
              %
            </label>
          </div>
        </Card.Content>
      </Card>

      <Card>
        <Card.Header>
          <Card.Title>Instant quotes</Card.Title>
          <Card.Description>Whether customers see a tentative offer immediately (always marked "subject to review").</Card.Description>
        </Card.Header>
        <Card.Content className="pt-0">
          <div className="flex items-center justify-between gap-3 py-2 border-b-2 border-muted">
            <label htmlFor="iq-web" className="font-semibold text-sm">On your website</label>
            <Switch id="iq-web" checked={form.instantQuoteWeb} onCheckedChange={set('instantQuoteWeb')} />
          </div>
          <div className="flex items-center justify-between gap-3 py-2">
            <label htmlFor="iq-kiosk" className="font-semibold text-sm">On the in-store kiosk</label>
            <Switch id="iq-kiosk" checked={form.instantQuoteKiosk} onCheckedChange={set('instantQuoteKiosk')} />
          </div>
        </Card.Content>
      </Card>

      <Button onClick={save} disabled={saving}>
        {saving ? 'Saving…' : 'Save settings'}
      </Button>
    </div>
  );
}

Adaptation note (not a placeholder β€” the code above is the target): if Switch's prop in client/src/components/retroui/Switch.jsx is onChange rather than Radix's onCheckedChange, match the actual component signature; check the barrel component before assuming.

  • [ ] Step 4: Run to verify pass
bash
npx vitest run --config vitest.client.config.js client/src/pages/buylist/BuylistPage.test.jsx

Expected: PASS (3 tests). If a test fails on label lookup, fix the component's label/aria-label wiring β€” do not loosen the test.

  • [ ] Step 5: Commit
bash
git add client/src/pages/buylist/
git commit -m "Add buylist settings page with per-game rates and instant-quote controls"

Task 7: Wire the nav and route ​

Files:

  • Modify: client/src/App.jsx (add /buylist route beside the /queue route, ~line 111; add the lazy/static import matching the file's existing import style)
  • Modify: client/src/components/RadixShell.jsx:89 (flip coming-soon to a real path)
  • Modify: client/src/components/RadixShell.test.jsx:75-81 (replace the coming-soon assertion)

Interfaces:

  • Consumes: BuylistPage (Task 6).

  • Produces: /buylist navigable from the Tools group in both embedded and standalone modes.

  • [ ] Step 1: Update the failing test first

Replace the test at client/src/components/RadixShell.test.jsx:75-81:

jsx
  it('navigates to /buylist when Buylist is clicked', async () => {
    renderShell();
    await waitFor(() => screen.getByText('Buylist'));
    fireEvent.click(screen.getByText('Buylist'));
    // Mirror this file's existing nav assertions (see the Dashboard/Queue
    // click tests in this file) β€” assert against the same navigate mock they use.
    expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('/buylist'));
  });

If this file's other nav tests assert differently (e.g. via MemoryRouter location instead of a mockNavigate), use that file's existing mechanism β€” the assertion target is "clicking Buylist navigates to /buylist and does NOT open the Coming Soon dialog."

  • [ ] Step 2: Run to verify the new test fails
bash
npx vitest run --config vitest.client.config.js client/src/components/RadixShell.test.jsx

Expected: FAIL β€” clicking Buylist opens the Coming Soon dialog instead of navigating

  • [ ] Step 3: Flip the nav item and add the route

client/src/components/RadixShell.jsx:89:

javascript
      { key: "buylist", label: "Buylist", path: "/buylist" },

client/src/App.jsx β€” import BuylistPage alongside the other page imports, then next to the /queue route (~line 111):

jsx
      <Route path="/buylist" element={wrap(<BuylistPage />)} />
  • [ ] Step 4: Run the full client suite and build
bash
npx vitest run --config vitest.client.config.js client/src/components/RadixShell.test.jsx
npm run test:client 2>&1 | tail -5
npm run build 2>&1 | tail -3

Expected: PASS / PASS / build exit 0

  • [ ] Step 5: Commit
bash
git add client/src/App.jsx client/src/components/RadixShell.jsx client/src/components/RadixShell.test.jsx
git commit -m "Open the Buylist nav item to the new settings page"

Task 8: Quality bars and PR ​

Files: none new

  • [ ] Step 1: Run the full Β§7 checklist
bash
npm test                     # exit 0
npm run test:client          # exit 0
npm run test:coverage        # every modified file β‰₯70% on all four metrics
npm run lint                 # exit 0, zero new warnings
npm run build                # exit 0 (client touched)
grep -rn "myshopify.com" server/models/BuylistOrder.js server/services/buylistConfigService.js server/schemas/buylistConfig.js  # only test fixtures
git diff origin/main | grep -n "|| 'mtg'\|?? 'mtg'"   # zero matches (Β§5.5)
  • [ ] Step 2: Manual end-to-end check in the dev store
bash
docker compose up -d
npm run dev:no-worker

In the browser (standalone mode): Tools β†’ Buylist renders; switch MTG ↔ Pokemon tabs (Pokemon must list its own 25+ rarities β€” proves plugin-driven vocabulary); edit Default rate to 45, Save, reload page β†’ 45 persists (proves markModified round-trip through Mongo).

  • [ ] Step 3: Open the PR
bash
git push -u origin claude/buylist-phase-1
gh pr create --title "Add Smart Buylist foundation: order model, per-game config, settings page" --body "$(cat <<'EOF'
Phase 1 of Smart Buylist (#318) per the approved spec
(docs/superpowers/specs/2026-07-14-smart-buylist-customer-intake-design.md).

- New `BuylistOrder` model (per-store, `buylist_orders`) with round-trip schema tests
- `Store.buylistConfig[gameId]` + `buylistConfigService` mirroring the pricingConfig pattern
- `GET/PUT /api/buylist/config?game=` β€” game REQUIRED, no `|| 'mtg'` fallback
- Buylist settings page under Tools β†’ Buylist (was Coming Soon)

Game parity (Β§5.1): rarity vocabularies resolve from plugins, so **mtg**,
**pokemon**, and **riftbound** are all mirrored by construction (verified in the
UI: Pokemon renders its own rarity list). Riftbound gained inventory sync on
main (PRs #355–357) mid-implementation, so it is included in the buylist
settings UI rather than exempted, with no per-game branching.

Later phases (quote engine, public API/portal, kiosk auth, theme extension,
payouts) land as separate PRs per the spec.

πŸ€– Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"

Self-review (done at plan time) ​

  • Spec coverage (Phase 1 scope): model βœ… (Task 1), buylistConfig + resolution βœ… (Tasks 2–3), validation βœ… (Task 4), endpoints βœ… (Task 5), settings UI βœ… (Task 6), nav βœ… (Task 7), quality bars + parity statement βœ… (Task 8). Out of scope by design: quote engine, public API, kiosk auth, portal, payouts (phases 2–6, separate plans).
  • Type consistency: getGameBuylistConfig/applyGameBuylistConfigUpdate/buildBuylistConfigResponse names match across Tasks 3, 5; buildBuylistConfigUpdateSchema matches across Tasks 4, 5; rates are fractions (0–1) on the wire and in storage, percent only inside the page component.
  • Known adaptation points (flagged inline, with the target behavior stated): retroui Switch prop name (Task 6), RadixShell test's navigation mock mechanism (Task 7), App.jsx import style (Task 7).