diff --git a/.agents/skills/shopify-development/README.md b/.agents/skills/shopify-development/README.md new file mode 100644 index 000000000..998f63cbc --- /dev/null +++ b/.agents/skills/shopify-development/README.md @@ -0,0 +1,60 @@ +# Shopify Development Skill + +Comprehensive skill for building on Shopify platform: apps, extensions, themes, and API integrations. + +## Features + +- **App Development** - OAuth authentication, GraphQL Admin API, webhooks, billing integration +- **UI Extensions** - Checkout, Admin, POS customizations with Polaris components +- **Theme Development** - Liquid templating, sections, snippets +- **Shopify Functions** - Custom discounts, payment, delivery rules + +## Structure + +``` +shopify-development/ +├── SKILL.md # Main skill file (AI-optimized) +├── README.md # This file +├── references/ +│ ├── app-development.md # OAuth, API, webhooks, billing +│ ├── extensions.md # UI extensions, Functions +│ └── themes.md # Liquid, theme architecture +└── scripts/ + ├── shopify_init.py # Interactive project scaffolding + ├── shopify_graphql.py # GraphQL utilities & templates + └── tests/ # Unit tests +``` + +## Validated GraphQL + +All GraphQL queries and mutations in this skill have been validated against Shopify Admin API 2026-01 schema using the official Shopify MCP. + +## Quick Start + +```bash +# Install Shopify CLI +npm install -g @shopify/cli@latest + +# Create new app +shopify app init + +# Start development +shopify app dev +``` + +## Usage Triggers + +This skill activates when the user mentions: + +- "shopify app", "shopify extension", "shopify theme" +- "checkout extension", "admin extension", "POS extension" +- "liquid template", "polaris", "shopify graphql" +- "shopify webhook", "shopify billing", "metafields" + +## API Version + +Current: **2026-01** (Quarterly releases with 12-month support) + +## License + +MIT diff --git a/.agents/skills/shopify-development/SKILL.md b/.agents/skills/shopify-development/SKILL.md new file mode 100644 index 000000000..349e3d983 --- /dev/null +++ b/.agents/skills/shopify-development/SKILL.md @@ -0,0 +1,368 @@ +--- +name: shopify-development +description: | + Build Shopify apps, extensions, themes using GraphQL Admin API, Shopify CLI, Polaris UI, and Liquid. + TRIGGER: "shopify", "shopify app", "checkout extension", "admin extension", "POS extension", + "shopify theme", "liquid template", "polaris", "shopify graphql", "shopify webhook", + "shopify billing", "app subscription", "metafields", "shopify functions" +metadata: + author: display studio +--- + +# Shopify Development Skill + +Use this skill when the user asks about: + +- Building Shopify apps or extensions +- Creating checkout/admin/POS UI customizations +- Developing themes with Liquid templating +- Integrating with Shopify GraphQL or REST APIs +- Implementing webhooks or billing +- Working with metafields or Shopify Functions + +--- + +## ROUTING: What to Build + +**IF user wants to integrate external services OR build merchant tools OR charge for features:** +→ Build an **App** (see `references/app-development.md`) + +**IF user wants to customize checkout OR add admin UI OR create POS actions OR implement discount rules:** +→ Build an **Extension** (see `references/extensions.md`) + +**IF user wants to customize storefront design OR modify product/collection pages:** +→ Build a **Theme** (see `references/themes.md`) + +**IF user needs both backend logic AND storefront UI:** +→ Build **App + Theme Extension** combination + +--- + +## Shopify CLI Commands + +Install CLI: + +```bash +npm install -g @shopify/cli@latest +``` + +Create and run app: + +```bash +shopify app init # Create new app +shopify app dev # Start dev server with tunnel +shopify app deploy # Build and upload to Shopify +``` + +Generate extension: + +```bash +shopify app generate extension --type checkout_ui_extension +shopify app generate extension --type admin_action +shopify app generate extension --type admin_block +shopify app generate extension --type pos_ui_extension +shopify app generate extension --type function +``` + +Theme development: + +```bash +shopify theme init # Create new theme +shopify theme dev # Start local preview at localhost:9292 +shopify theme pull --live # Pull live theme +shopify theme push --development # Push to dev theme +``` + +--- + +## Access Scopes + +Configure in `shopify.app.toml`: + +```toml +[access_scopes] +scopes = "read_products,write_products,read_orders,write_orders,read_customers" +``` + +Common scopes: + +- `read_products`, `write_products` - Product catalog access +- `read_orders`, `write_orders` - Order management +- `read_customers`, `write_customers` - Customer data +- `read_inventory`, `write_inventory` - Stock levels +- `read_fulfillments`, `write_fulfillments` - Order fulfillment + +--- + +## GraphQL Patterns (Validated against API 2026-01) + +### Query Products + +```graphql +query GetProducts($first: Int!, $query: String) { + products(first: $first, query: $query) { + edges { + node { + id + title + handle + status + variants(first: 5) { + edges { + node { + id + price + inventoryQuantity + } + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } +} +``` + +### Query Orders + +```graphql +query GetOrders($first: Int!) { + orders(first: $first) { + edges { + node { + id + name + createdAt + displayFinancialStatus + totalPriceSet { + shopMoney { + amount + currencyCode + } + } + } + } + } +} +``` + +### Set Metafields + +```graphql +mutation SetMetafields($metafields: [MetafieldsSetInput!]!) { + metafieldsSet(metafields: $metafields) { + metafields { + id + namespace + key + value + } + userErrors { + field + message + } + } +} +``` + +Variables example: + +```json +{ + "metafields": [ + { + "ownerId": "gid://shopify/Product/123", + "namespace": "custom", + "key": "care_instructions", + "value": "Handle with care", + "type": "single_line_text_field" + } + ] +} +``` + +--- + +## Checkout Extension Example + +```tsx +import { + reactExtension, + BlockStack, + TextField, + Checkbox, + useApplyAttributeChange, +} from "@shopify/ui-extensions-react/checkout"; + +export default reactExtension("purchase.checkout.block.render", () => ( + +)); + +function GiftMessage() { + const [isGift, setIsGift] = useState(false); + const [message, setMessage] = useState(""); + const applyAttributeChange = useApplyAttributeChange(); + + useEffect(() => { + if (isGift && message) { + applyAttributeChange({ + type: "updateAttribute", + key: "gift_message", + value: message, + }); + } + }, [isGift, message]); + + return ( + + + This is a gift + + {isGift && ( + + )} + + ); +} +``` + +--- + +## Liquid Template Example + +```liquid +{% comment %} Product Card Snippet {% endcomment %} +
+ + {% if product.featured_image %} + {{ product.title | escape }} + {% endif %} +

{{ product.title }}

+

{{ product.price | money }}

+ {% if product.compare_at_price > product.price %} +

Sale

+ {% endif %} +
+
+``` + +--- + +## Webhook Configuration + +In `shopify.app.toml`: + +```toml +[webhooks] +api_version = "2026-01" + +[[webhooks.subscriptions]] +topics = ["orders/create", "orders/updated"] +uri = "/webhooks/orders" + +[[webhooks.subscriptions]] +topics = ["products/update"] +uri = "/webhooks/products" + +# GDPR mandatory webhooks (required for app approval) +[webhooks.privacy_compliance] +customer_data_request_url = "/webhooks/gdpr/data-request" +customer_deletion_url = "/webhooks/gdpr/customer-deletion" +shop_deletion_url = "/webhooks/gdpr/shop-deletion" +``` + +--- + +## Best Practices + +### API Usage + +- Use GraphQL over REST for new development +- Request only fields you need (reduces query cost) +- Implement cursor-based pagination with `pageInfo.endCursor` +- Use bulk operations for processing more than 250 items +- Handle rate limits with exponential backoff + +### Security + +- Store API credentials in environment variables +- Always verify webhook HMAC signatures before processing +- Validate OAuth state parameter to prevent CSRF +- Request minimal access scopes +- Use session tokens for embedded apps + +### Performance + +- Cache API responses when data doesn't change frequently +- Use lazy loading in extensions +- Optimize images in themes using `img_url` filter +- Monitor GraphQL query costs via response headers + +--- + +## Troubleshooting + +**IF you see rate limit errors:** +→ Implement exponential backoff retry logic +→ Switch to bulk operations for large datasets +→ Monitor `X-Shopify-Shop-Api-Call-Limit` header + +**IF authentication fails:** +→ Verify the access token is still valid +→ Check that all required scopes were granted +→ Ensure OAuth flow completed successfully + +**IF extension is not appearing:** +→ Verify the extension target is correct +→ Check that extension is published via `shopify app deploy` +→ Confirm the app is installed on the test store + +**IF webhook is not receiving events:** +→ Verify the webhook URL is publicly accessible +→ Check HMAC signature validation logic +→ Review webhook logs in Partner Dashboard + +**IF GraphQL query fails:** +→ Validate query against schema (use GraphiQL explorer) +→ Check for deprecated fields in error message +→ Verify you have required access scopes + +--- + +## Reference Files + +For detailed implementation guides, read these files: + +- `references/app-development.md` - OAuth authentication flow, GraphQL mutations for products/orders/billing, webhook handlers, billing API integration +- `references/extensions.md` - Checkout UI components, Admin UI extensions, POS extensions, Shopify Functions for discounts/payment/delivery +- `references/themes.md` - Liquid syntax reference, theme directory structure, sections and snippets, common patterns + +--- + +## Scripts + +- `scripts/shopify_init.py` - Interactive project scaffolding. Run: `python scripts/shopify_init.py` +- `scripts/shopify_graphql.py` - GraphQL utilities with query templates, pagination, rate limiting. Import: `from shopify_graphql import ShopifyGraphQL` + +--- + +## Official Documentation Links + +- Shopify Developer Docs: https://shopify.dev/docs +- GraphQL Admin API Reference: https://shopify.dev/docs/api/admin-graphql +- Shopify CLI Reference: https://shopify.dev/docs/api/shopify-cli +- Polaris Design System: https://polaris.shopify.com + +API Version: 2026-01 (quarterly releases, 12-month deprecation window) diff --git a/.agents/skills/shopify-development/references/app-development.md b/.agents/skills/shopify-development/references/app-development.md new file mode 100644 index 000000000..d1134c815 --- /dev/null +++ b/.agents/skills/shopify-development/references/app-development.md @@ -0,0 +1,578 @@ +# App Development Reference + +Guide for building Shopify apps with OAuth, GraphQL/REST APIs, webhooks, and billing. + +## OAuth Authentication + +### OAuth 2.0 Flow + +**1. Redirect to Authorization URL:** + +``` +https://{shop}.myshopify.com/admin/oauth/authorize? + client_id={api_key}& + scope={scopes}& + redirect_uri={redirect_uri}& + state={nonce} +``` + +**2. Handle Callback:** + +```javascript +app.get("/auth/callback", async (req, res) => { + const { code, shop, state } = req.query; + + // Verify state to prevent CSRF + if (state !== storedState) { + return res.status(403).send("Invalid state"); + } + + // Exchange code for access token + const accessToken = await exchangeCodeForToken(shop, code); + + // Store token securely + await storeAccessToken(shop, accessToken); + + res.redirect(`https://${shop}/admin/apps/${appHandle}`); +}); +``` + +**3. Exchange Code for Token:** + +```javascript +async function exchangeCodeForToken(shop, code) { + const response = await fetch(`https://${shop}/admin/oauth/access_token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: process.env.SHOPIFY_API_KEY, + client_secret: process.env.SHOPIFY_API_SECRET, + code, + }), + }); + + const { access_token } = await response.json(); + return access_token; +} +``` + +### Access Scopes + +**Common Scopes:** + +- `read_products`, `write_products` - Product catalog +- `read_orders`, `write_orders` - Order management +- `read_customers`, `write_customers` - Customer data +- `read_inventory`, `write_inventory` - Stock levels +- `read_fulfillments`, `write_fulfillments` - Order fulfillment +- `read_shipping`, `write_shipping` - Shipping rates +- `read_analytics` - Store analytics +- `read_checkouts`, `write_checkouts` - Checkout data + +Full list: https://shopify.dev/api/usage/access-scopes + +### Session Tokens (Embedded Apps) + +For embedded apps using App Bridge: + +```javascript +import { getSessionToken } from '@shopify/app-bridge/utilities'; + +async function authenticatedFetch(url, options = {}) { + const app = createApp({ ... }); + const token = await getSessionToken(app); + + return fetch(url, { + ...options, + headers: { + ...options.headers, + 'Authorization': `Bearer ${token}` + } + }); +} +``` + +## GraphQL Admin API + +### Making Requests + +```javascript +async function graphqlRequest(shop, accessToken, query, variables = {}) { + const response = await fetch( + `https://${shop}/admin/api/2026-01/graphql.json`, + { + method: "POST", + headers: { + "X-Shopify-Access-Token": accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query, variables }), + }, + ); + + const data = await response.json(); + + if (data.errors) { + throw new Error(`GraphQL errors: ${JSON.stringify(data.errors)}`); + } + + return data.data; +} +``` + +### Product Operations + +**Create Product:** + +```graphql +mutation CreateProduct($input: ProductInput!) { + productCreate(input: $input) { + product { + id + title + handle + } + userErrors { + field + message + } + } +} +``` + +Variables: + +```json +{ + "input": { + "title": "New Product", + "productType": "Apparel", + "vendor": "Brand", + "status": "ACTIVE", + "variants": [ + { "price": "29.99", "sku": "SKU-001", "inventoryQuantity": 100 } + ] + } +} +``` + +**Update Product:** + +```graphql +mutation UpdateProduct($input: ProductInput!) { + productUpdate(input: $input) { + product { + id + title + } + userErrors { + field + message + } + } +} +``` + +**Query Products:** + +```graphql +query GetProducts($first: Int!, $query: String) { + products(first: $first, query: $query) { + edges { + node { + id + title + status + variants(first: 5) { + edges { + node { + id + price + inventoryQuantity + } + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } +} +``` + +### Order Operations + +**Query Orders:** + +```graphql +query GetOrders($first: Int!) { + orders(first: $first) { + edges { + node { + id + name + createdAt + displayFinancialStatus + totalPriceSet { + shopMoney { + amount + currencyCode + } + } + customer { + email + firstName + lastName + } + } + } + } +} +``` + +**Fulfill Order:** + +```graphql +mutation FulfillOrder($fulfillment: FulfillmentInput!) { + fulfillmentCreate(fulfillment: $fulfillment) { + fulfillment { + id + status + trackingInfo { + number + url + } + } + userErrors { + field + message + } + } +} +``` + +## Webhooks + +### Configuration + +In `shopify.app.toml`: + +```toml +[webhooks] +api_version = "2025-01" + +[[webhooks.subscriptions]] +topics = ["orders/create"] +uri = "/webhooks/orders/create" + +[[webhooks.subscriptions]] +topics = ["products/update"] +uri = "/webhooks/products/update" + +[[webhooks.subscriptions]] +topics = ["app/uninstalled"] +uri = "/webhooks/app/uninstalled" + +# GDPR mandatory webhooks +[webhooks.privacy_compliance] +customer_data_request_url = "/webhooks/gdpr/data-request" +customer_deletion_url = "/webhooks/gdpr/customer-deletion" +shop_deletion_url = "/webhooks/gdpr/shop-deletion" +``` + +### Webhook Handler + +```javascript +import crypto from "crypto"; + +function verifyWebhook(req) { + const hmac = req.headers["x-shopify-hmac-sha256"]; + const body = req.rawBody; // Raw body buffer + + const hash = crypto + .createHmac("sha256", process.env.SHOPIFY_API_SECRET) + .update(body, "utf8") + .digest("base64"); + + return hmac === hash; +} + +app.post("/webhooks/orders/create", async (req, res) => { + if (!verifyWebhook(req)) { + return res.status(401).send("Unauthorized"); + } + + const order = req.body; + console.log("New order:", order.id, order.name); + + // Process order... + + res.status(200).send("OK"); +}); +``` + +### Common Webhook Topics + +**Orders:** + +- `orders/create`, `orders/updated`, `orders/delete` +- `orders/paid`, `orders/cancelled`, `orders/fulfilled` + +**Products:** + +- `products/create`, `products/update`, `products/delete` + +**Customers:** + +- `customers/create`, `customers/update`, `customers/delete` + +**Inventory:** + +- `inventory_levels/update` + +**App:** + +- `app/uninstalled` (critical for cleanup) + +## Billing Integration + +### App Charges + +**One-time Charge:** + +```graphql +mutation CreateCharge($input: AppPurchaseOneTimeInput!) { + appPurchaseOneTimeCreate(input: $input) { + appPurchaseOneTime { + id + name + price { + amount + } + status + confirmationUrl + } + userErrors { + field + message + } + } +} +``` + +Variables: + +```json +{ + "input": { + "name": "Premium Feature", + "price": { "amount": 49.99, "currencyCode": "USD" }, + "returnUrl": "https://your-app.com/billing/callback" + } +} +``` + +**Recurring Charge (Subscription):** + +```graphql +mutation CreateSubscription( + $name: String! + $returnUrl: URL! + $lineItems: [AppSubscriptionLineItemInput!]! + $trialDays: Int +) { + appSubscriptionCreate( + name: $name + returnUrl: $returnUrl + lineItems: $lineItems + trialDays: $trialDays + ) { + appSubscription { + id + name + status + } + confirmationUrl + userErrors { + field + message + } + } +} +``` + +Variables: + +```json +{ + "name": "Monthly Subscription", + "returnUrl": "https://your-app.com/billing/callback", + "trialDays": 7, + "lineItems": [ + { + "plan": { + "appRecurringPricingDetails": { + "price": { "amount": 29.99, "currencyCode": "USD" }, + "interval": "EVERY_30_DAYS" + } + } + } + ] +} +``` + +**Usage-based Billing:** + +```graphql +mutation CreateUsageCharge( + $subscriptionLineItemId: ID! + $price: MoneyInput! + $description: String! +) { + appUsageRecordCreate( + subscriptionLineItemId: $subscriptionLineItemId + price: $price + description: $description + ) { + appUsageRecord { + id + price { + amount + currencyCode + } + description + } + userErrors { + field + message + } + } +} +``` + +Variables: + +```json +{ + "subscriptionLineItemId": "gid://shopify/AppSubscriptionLineItem/123", + "price": { "amount": "5.00", "currencyCode": "USD" }, + "description": "100 API calls used" +} +``` + +## Metafields + +### Create/Update Metafields + +```graphql +mutation SetMetafields($metafields: [MetafieldsSetInput!]!) { + metafieldsSet(metafields: $metafields) { + metafields { + id + namespace + key + value + } + userErrors { + field + message + } + } +} +``` + +Variables: + +```json +{ + "metafields": [ + { + "ownerId": "gid://shopify/Product/123", + "namespace": "custom", + "key": "instructions", + "value": "Handle with care", + "type": "single_line_text_field" + } + ] +} +``` + +**Metafield Types:** + +- `single_line_text_field`, `multi_line_text_field` +- `number_integer`, `number_decimal` +- `date`, `date_time` +- `url`, `json` +- `file_reference`, `product_reference` + +## Rate Limiting + +### GraphQL Cost-Based Limits + +**Limits:** + +- Available points: 2000 +- Restore rate: 100 points/second +- Max query cost: 2000 + +**Check Cost:** + +```javascript +const response = await graphqlRequest(shop, token, query); +const cost = response.extensions?.cost; + +console.log( + `Cost: ${cost.actualQueryCost}/${cost.throttleStatus.maximumAvailable}`, +); +``` + +**Handle Throttling:** + +```javascript +async function graphqlWithRetry(shop, token, query, retries = 3) { + for (let i = 0; i < retries; i++) { + try { + return await graphqlRequest(shop, token, query); + } catch (error) { + if (error.message.includes("Throttled") && i < retries - 1) { + await sleep(Math.pow(2, i) * 1000); // Exponential backoff + continue; + } + throw error; + } + } +} +``` + +## Best Practices + +**Security:** + +- Store credentials in environment variables +- Verify webhook HMAC signatures +- Validate OAuth state parameter +- Use HTTPS for all endpoints +- Implement rate limiting on your endpoints + +**Performance:** + +- Cache access tokens securely +- Use bulk operations for large datasets +- Implement pagination for queries +- Monitor GraphQL query costs + +**Reliability:** + +- Implement exponential backoff for retries +- Handle webhook delivery failures +- Log errors for debugging +- Monitor app health metrics + +**Compliance:** + +- Implement GDPR webhooks (mandatory) +- Handle customer data deletion requests +- Provide data export functionality +- Follow data retention policies diff --git a/.agents/skills/shopify-development/references/extensions.md b/.agents/skills/shopify-development/references/extensions.md new file mode 100644 index 000000000..8d42267f6 --- /dev/null +++ b/.agents/skills/shopify-development/references/extensions.md @@ -0,0 +1,555 @@ +# Extensions Reference + +Guide for building UI extensions and Shopify Functions. + +## Checkout UI Extensions + +Customize checkout and thank-you pages with native-rendered components. + +### Extension Points + +**Block Targets (Merchant-Configurable):** + +- `purchase.checkout.block.render` - Main checkout +- `purchase.thank-you.block.render` - Thank you page + +**Static Targets (Fixed Position):** + +- `purchase.checkout.header.render-after` +- `purchase.checkout.contact.render-before` +- `purchase.checkout.shipping-option-list.render-after` +- `purchase.checkout.payment-method-list.render-after` +- `purchase.checkout.footer.render-before` + +### Setup + +```bash +shopify app generate extension --type checkout_ui_extension +``` + +Configuration (`shopify.extension.toml`): + +```toml +api_version = "2026-01" +name = "gift-message" +type = "ui_extension" + +[[extensions.targeting]] +target = "purchase.checkout.block.render" + +[capabilities] +network_access = true +api_access = true +``` + +### Basic Example + +```javascript +import { + reactExtension, + BlockStack, + TextField, + Checkbox, + useApi, +} from "@shopify/ui-extensions-react/checkout"; + +export default reactExtension("purchase.checkout.block.render", () => ( + +)); + +function Extension() { + const [message, setMessage] = useState(""); + const [isGift, setIsGift] = useState(false); + const { applyAttributeChange } = useApi(); + + useEffect(() => { + if (isGift) { + applyAttributeChange({ + type: "updateAttribute", + key: "gift_message", + value: message, + }); + } + }, [message, isGift]); + + return ( + + + This is a gift + + {isGift && ( + + )} + + ); +} +``` + +### Common Hooks + +**useApi:** + +```javascript +const { extensionPoint, shop, storefront, i18n, sessionToken } = useApi(); +``` + +**useCartLines:** + +```javascript +const lines = useCartLines(); +lines.forEach((line) => { + console.log(line.merchandise.product.title, line.quantity); +}); +``` + +**useShippingAddress:** + +```javascript +const address = useShippingAddress(); +console.log(address.city, address.countryCode); +``` + +**useApplyCartLinesChange:** + +```javascript +const applyChange = useApplyCartLinesChange(); + +async function addItem() { + await applyChange({ + type: "addCartLine", + merchandiseId: "gid://shopify/ProductVariant/123", + quantity: 1, + }); +} +``` + +### Core Components + +**Layout:** + +- `BlockStack` - Vertical stacking +- `InlineStack` - Horizontal layout +- `Grid`, `GridItem` - Grid layout +- `View` - Container +- `Divider` - Separator + +**Input:** + +- `TextField` - Text input +- `Checkbox` - Boolean +- `Select` - Dropdown +- `DatePicker` - Date selection +- `Form` - Form wrapper + +**Display:** + +- `Text`, `Heading` - Typography +- `Banner` - Messages +- `Badge` - Status +- `Image` - Images +- `Link` - Hyperlinks +- `List`, `ListItem` - Lists + +**Interactive:** + +- `Button` - Actions +- `Modal` - Overlays +- `Pressable` - Click areas + +## Admin UI Extensions + +Extend Shopify admin interface. + +### Admin Action + +Custom actions on resource pages. + +```bash +shopify app generate extension --type admin_action +``` + +```javascript +import { + reactExtension, + AdminAction, + Button, +} from "@shopify/ui-extensions-react/admin"; + +export default reactExtension("admin.product-details.action.render", () => ( + +)); + +function Extension() { + const { data } = useData(); + + async function handleExport() { + const response = await fetch("/api/export", { + method: "POST", + body: JSON.stringify({ productId: data.product.id }), + }); + console.log("Exported:", await response.json()); + } + + return ( + Export} + /> + ); +} +``` + +**Targets:** + +- `admin.product-details.action.render` +- `admin.order-details.action.render` +- `admin.customer-details.action.render` + +### Admin Block + +Embedded content in admin pages. + +```javascript +import { + reactExtension, + BlockStack, + Text, + Badge, +} from "@shopify/ui-extensions-react/admin"; + +export default reactExtension("admin.product-details.block.render", () => ( + +)); + +function Extension() { + const { data } = useData(); + const [analytics, setAnalytics] = useState(null); + + useEffect(() => { + fetchAnalytics(data.product.id).then(setAnalytics); + }, []); + + return ( + + Product Analytics + Views: {analytics?.views || 0} + Conversions: {analytics?.conversions || 0} + + {analytics?.trending ? "Trending" : "Normal"} + + + ); +} +``` + +**Targets:** + +- `admin.product-details.block.render` +- `admin.order-details.block.render` +- `admin.customer-details.block.render` + +## POS UI Extensions + +Customize Point of Sale experience. + +### Smart Grid Tile + +Quick access action on POS home screen. + +```javascript +import { + reactExtension, + SmartGridTile, +} from "@shopify/ui-extensions-react/pos"; + +export default reactExtension("pos.home.tile.render", () => ); + +function Extension() { + function handlePress() { + // Navigate to custom workflow + } + + return ( + + ); +} +``` + +### POS Modal + +Full-screen workflow. + +```javascript +import { + reactExtension, + Screen, + BlockStack, + Button, + TextField, +} from "@shopify/ui-extensions-react/pos"; + +export default reactExtension("pos.home.modal.render", () => ); + +function Extension() { + const { navigation } = useApi(); + const [amount, setAmount] = useState(""); + + function handleIssue() { + // Issue gift card + navigation.pop(); + } + + return ( + + + + + + + + ); +} +``` + +## Customer Account Extensions + +Customize customer account pages. + +### Order Status Extension + +```javascript +import { + reactExtension, + BlockStack, + Text, + Button, +} from "@shopify/ui-extensions-react/customer-account"; + +export default reactExtension( + "customer-account.order-status.block.render", + () => , +); + +function Extension() { + const { order } = useApi(); + + function handleReturn() { + // Initiate return + } + + return ( + + Need to return? + Start return for order {order.name} + + + ); +} +``` + +**Targets:** + +- `customer-account.order-status.block.render` +- `customer-account.order-index.block.render` +- `customer-account.profile.block.render` + +## Shopify Functions + +Serverless backend customization. + +### Function Types + +**Discounts:** + +- `order_discount` - Order-level discounts +- `product_discount` - Product-specific discounts +- `shipping_discount` - Shipping discounts + +**Payment Customization:** + +- Hide/rename/reorder payment methods + +**Delivery Customization:** + +- Custom shipping options +- Delivery rules + +**Validation:** + +- Cart validation rules +- Checkout validation + +### Create Function + +```bash +shopify app generate extension --type function +``` + +### Order Discount Function + +```javascript +// input.graphql +query Input { + cart { + lines { + quantity + merchandise { + ... on ProductVariant { + product { + hasTag(tag: "bulk-discount") + } + } + } + } + } +} + +// function.js +export default function orderDiscount(input) { + const targets = input.cart.lines + .filter(line => line.merchandise.product.hasTag) + .map(line => ({ + productVariant: { id: line.merchandise.id } + })); + + if (targets.length === 0) { + return { discounts: [] }; + } + + return { + discounts: [{ + targets, + value: { + percentage: { + value: 10 // 10% discount + } + } + }] + }; +} +``` + +### Payment Customization Function + +```javascript +export default function paymentCustomization(input) { + const hidePaymentMethods = input.cart.lines.some( + (line) => line.merchandise.product.hasTag, + ); + + if (!hidePaymentMethods) { + return { operations: [] }; + } + + return { + operations: [ + { + hide: { + paymentMethodId: "gid://shopify/PaymentMethod/123", + }, + }, + ], + }; +} +``` + +### Validation Function + +```javascript +export default function cartValidation(input) { + const errors = []; + + // Max 5 items per cart + if (input.cart.lines.length > 5) { + errors.push({ + localizedMessage: "Maximum 5 items allowed per order", + target: "cart", + }); + } + + // Min $50 for wholesale + const isWholesale = input.cart.lines.some( + (line) => line.merchandise.product.hasTag, + ); + + if (isWholesale && input.cart.cost.totalAmount.amount < 50) { + errors.push({ + localizedMessage: "Wholesale orders require $50 minimum", + target: "cart", + }); + } + + return { errors }; +} +``` + +## Network Requests + +Extensions can call external APIs. + +```javascript +import { useApi } from "@shopify/ui-extensions-react/checkout"; + +function Extension() { + const { sessionToken } = useApi(); + + async function fetchData() { + const token = await sessionToken.get(); + + const response = await fetch("https://your-app.com/api/data", { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + return await response.json(); + } +} +``` + +## Best Practices + +**Performance:** + +- Lazy load data +- Memoize expensive computations +- Use loading states +- Minimize re-renders + +**UX:** + +- Provide clear error messages +- Show loading indicators +- Validate inputs +- Support keyboard navigation + +**Security:** + +- Verify session tokens on backend +- Sanitize user input +- Use HTTPS for all requests +- Don't expose sensitive data + +**Testing:** + +- Test on development stores +- Verify mobile/desktop +- Check accessibility +- Test edge cases + +## Resources + +- Checkout Extensions: https://shopify.dev/docs/api/checkout-extensions +- Admin Extensions: https://shopify.dev/docs/apps/admin/extensions +- Functions: https://shopify.dev/docs/apps/functions +- Components: https://shopify.dev/docs/api/checkout-ui-extensions/components diff --git a/.agents/skills/shopify-development/references/themes.md b/.agents/skills/shopify-development/references/themes.md new file mode 100644 index 000000000..2fc1c2b98 --- /dev/null +++ b/.agents/skills/shopify-development/references/themes.md @@ -0,0 +1,498 @@ +# Themes Reference + +Guide for developing Shopify themes with Liquid templating. + +## Liquid Templating + +### Syntax Basics + +**Objects (Output):** +```liquid +{{ product.title }} +{{ product.price | money }} +{{ customer.email }} +``` + +**Tags (Logic):** +```liquid +{% if product.available %} + +{% else %} +

Sold Out

+{% endif %} + +{% for product in collection.products %} + {{ product.title }} +{% endfor %} + +{% case product.type %} + {% when 'Clothing' %} + Apparel + {% when 'Shoes' %} + Footwear + {% else %} + Other +{% endcase %} +``` + +**Filters (Transform):** +```liquid +{{ product.title | upcase }} +{{ product.price | money }} +{{ product.description | strip_html | truncate: 100 }} +{{ product.image | img_url: 'medium' }} +{{ 'now' | date: '%B %d, %Y' }} +``` + +### Common Objects + +**Product:** +```liquid +{{ product.id }} +{{ product.title }} +{{ product.handle }} +{{ product.description }} +{{ product.price }} +{{ product.compare_at_price }} +{{ product.available }} +{{ product.type }} +{{ product.vendor }} +{{ product.tags }} +{{ product.images }} +{{ product.variants }} +{{ product.featured_image }} +{{ product.url }} +``` + +**Collection:** +```liquid +{{ collection.title }} +{{ collection.handle }} +{{ collection.description }} +{{ collection.products }} +{{ collection.products_count }} +{{ collection.image }} +{{ collection.url }} +``` + +**Cart:** +```liquid +{{ cart.item_count }} +{{ cart.total_price }} +{{ cart.items }} +{{ cart.note }} +{{ cart.attributes }} +``` + +**Customer:** +```liquid +{{ customer.email }} +{{ customer.first_name }} +{{ customer.last_name }} +{{ customer.orders_count }} +{{ customer.total_spent }} +{{ customer.addresses }} +{{ customer.default_address }} +``` + +**Shop:** +```liquid +{{ shop.name }} +{{ shop.email }} +{{ shop.domain }} +{{ shop.currency }} +{{ shop.money_format }} +{{ shop.enabled_payment_types }} +``` + +### Common Filters + +**String:** +- `upcase`, `downcase`, `capitalize` +- `strip_html`, `strip_newlines` +- `truncate: 100`, `truncatewords: 20` +- `replace: 'old', 'new'` + +**Number:** +- `money` - Format currency +- `round`, `ceil`, `floor` +- `times`, `divided_by`, `plus`, `minus` + +**Array:** +- `join: ', '` +- `first`, `last` +- `size` +- `map: 'property'` +- `where: 'property', 'value'` + +**URL:** +- `img_url: 'size'` - Image URL +- `url_for_type`, `url_for_vendor` +- `link_to`, `link_to_type` + +**Date:** +- `date: '%B %d, %Y'` + +## Theme Architecture + +### Directory Structure + +``` +theme/ +├── assets/ # CSS, JS, images +├── config/ # Theme settings +│ ├── settings_schema.json +│ └── settings_data.json +├── layout/ # Base templates +│ └── theme.liquid +├── locales/ # Translations +│ └── en.default.json +├── sections/ # Reusable blocks +│ ├── header.liquid +│ ├── footer.liquid +│ └── product-grid.liquid +├── snippets/ # Small components +│ ├── product-card.liquid +│ └── icon.liquid +└── templates/ # Page templates + ├── index.json + ├── product.json + ├── collection.json + └── cart.liquid +``` + +### Layout + +Base template wrapping all pages (`layout/theme.liquid`): + +```liquid + + + + + + {{ page_title }} + + {{ content_for_header }} + + + + + {% section 'header' %} + +
+ {{ content_for_layout }} +
+ + {% section 'footer' %} + + + + +``` + +### Templates + +Page-specific structures (`templates/product.json`): + +```json +{ + "sections": { + "main": { + "type": "product-template", + "settings": { + "show_vendor": true, + "show_quantity_selector": true + } + }, + "recommendations": { + "type": "product-recommendations" + } + }, + "order": ["main", "recommendations"] +} +``` + +Legacy format (`templates/product.liquid`): +```liquid +
+
+ {{ product.title }} +
+ +
+

{{ product.title }}

+

{{ product.price | money }}

+ + {% form 'product', product %} + + + + {% endform %} +
+
+``` + +### Sections + +Reusable content blocks (`sections/product-grid.liquid`): + +```liquid +
+ {% for product in section.settings.collection.products %} + + {% endfor %} +
+ +{% schema %} +{ + "name": "Product Grid", + "settings": [ + { + "type": "collection", + "id": "collection", + "label": "Collection" + }, + { + "type": "range", + "id": "products_per_row", + "min": 2, + "max": 5, + "step": 1, + "default": 4, + "label": "Products per row" + } + ], + "presets": [ + { + "name": "Product Grid" + } + ] +} +{% endschema %} +``` + +### Snippets + +Small reusable components (`snippets/product-card.liquid`): + +```liquid + +``` + +Include snippet: +```liquid +{% render 'product-card', product: product %} +``` + +## Development Workflow + +### Setup + +```bash +# Initialize new theme +shopify theme init + +# Choose Dawn (reference theme) or blank +``` + +### Local Development + +```bash +# Start local server +shopify theme dev + +# Preview at http://localhost:9292 +# Changes auto-sync to development theme +``` + +### Pull Theme + +```bash +# Pull live theme +shopify theme pull --live + +# Pull specific theme +shopify theme pull --theme=123456789 + +# Pull only templates +shopify theme pull --only=templates +``` + +### Push Theme + +```bash +# Push to development theme +shopify theme push --development + +# Create new unpublished theme +shopify theme push --unpublished + +# Push specific files +shopify theme push --only=sections,snippets +``` + +### Theme Check + +Lint theme code: +```bash +shopify theme check +shopify theme check --auto-correct +``` + +## Common Patterns + +### Product Form with Variants + +```liquid +{% form 'product', product %} + {% unless product.has_only_default_variant %} + {% for option in product.options_with_values %} +
+ + +
+ {% endfor %} + {% endunless %} + + + + + +{% endform %} +``` + +### Pagination + +```liquid +{% paginate collection.products by 12 %} + {% for product in collection.products %} + {% render 'product-card', product: product %} + {% endfor %} + + {% if paginate.pages > 1 %} + + {% endif %} +{% endpaginate %} +``` + +### Cart AJAX + +```javascript +// Add to cart +fetch('/cart/add.js', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: variantId, + quantity: 1 + }) +}) +.then(res => res.json()) +.then(item => console.log('Added:', item)); + +// Get cart +fetch('/cart.js') + .then(res => res.json()) + .then(cart => console.log('Cart:', cart)); + +// Update cart +fetch('/cart/change.js', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: lineItemKey, + quantity: 2 + }) +}) +.then(res => res.json()); +``` + +## Metafields in Themes + +Access custom data: + +```liquid +{{ product.metafields.custom.care_instructions }} +{{ product.metafields.custom.material.value }} + +{% if product.metafields.custom.featured %} + Featured +{% endif %} +``` + +## Best Practices + +**Performance:** +- Optimize images (use appropriate sizes) +- Minimize Liquid logic complexity +- Use lazy loading for images +- Defer non-critical JavaScript + +**Accessibility:** +- Use semantic HTML +- Include alt text for images +- Support keyboard navigation +- Ensure sufficient color contrast + +**SEO:** +- Use descriptive page titles +- Include meta descriptions +- Structure content with headings +- Implement schema markup + +**Code Quality:** +- Follow Shopify theme guidelines +- Use consistent naming conventions +- Comment complex logic +- Keep sections focused and reusable + +## Resources + +- Theme Development: https://shopify.dev/docs/themes +- Liquid Reference: https://shopify.dev/docs/api/liquid +- Dawn Theme: https://github.com/Shopify/dawn +- Theme Check: https://shopify.dev/docs/themes/tools/theme-check diff --git a/.agents/skills/shopify-development/scripts/.gitignore b/.agents/skills/shopify-development/scripts/.gitignore new file mode 100644 index 000000000..8abb6f180 --- /dev/null +++ b/.agents/skills/shopify-development/scripts/.gitignore @@ -0,0 +1,49 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Testing +.coverage +.pytest_cache/ +htmlcov/ +.tox/ +.nox/ +coverage.xml +*.cover +*.py,cover + +# Environments +.env +.venv +env/ +venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/.agents/skills/shopify-development/scripts/requirements.txt b/.agents/skills/shopify-development/scripts/requirements.txt new file mode 100644 index 000000000..4613a2baa --- /dev/null +++ b/.agents/skills/shopify-development/scripts/requirements.txt @@ -0,0 +1,19 @@ +# Shopify Skill Dependencies +# Python 3.10+ required + +# No Python package dependencies - uses only standard library + +# Testing dependencies (dev) +pytest>=8.0.0 +pytest-cov>=4.1.0 +pytest-mock>=3.12.0 + +# Note: This script requires the Shopify CLI tool +# Install Shopify CLI: +# npm install -g @shopify/cli @shopify/theme +# or via Homebrew (macOS): +# brew tap shopify/shopify +# brew install shopify-cli +# +# Authenticate with: +# shopify auth login diff --git a/.agents/skills/shopify-development/scripts/shopify_graphql.py b/.agents/skills/shopify-development/scripts/shopify_graphql.py new file mode 100644 index 000000000..ec8af3cb8 --- /dev/null +++ b/.agents/skills/shopify-development/scripts/shopify_graphql.py @@ -0,0 +1,428 @@ +#!/usr/bin/env python3 +""" +Shopify GraphQL Utilities + +Helper functions for common Shopify GraphQL operations. +Provides query templates, pagination helpers, and rate limit handling. + +Usage: + from shopify_graphql import ShopifyGraphQL + + client = ShopifyGraphQL(shop_domain, access_token) + products = client.get_products(first=10) +""" + +import os +import time +import json +from typing import Dict, List, Optional, Any, Generator +from dataclasses import dataclass +from urllib.request import Request, urlopen +from urllib.error import HTTPError + + +# API Configuration +API_VERSION = "2026-01" +MAX_RETRIES = 3 +RETRY_DELAY = 1.0 # seconds + + +@dataclass +class GraphQLResponse: + """Container for GraphQL response data.""" + data: Optional[Dict[str, Any]] = None + errors: Optional[List[Dict[str, Any]]] = None + extensions: Optional[Dict[str, Any]] = None + + @property + def is_success(self) -> bool: + return self.errors is None or len(self.errors) == 0 + + @property + def query_cost(self) -> Optional[int]: + """Get the actual query cost from extensions.""" + if self.extensions and 'cost' in self.extensions: + return self.extensions['cost'].get('actualQueryCost') + return None + + +class ShopifyGraphQL: + """ + Shopify GraphQL API client with built-in utilities. + + Features: + - Query templates for common operations + - Automatic pagination + - Rate limit handling with exponential backoff + - Response parsing helpers + """ + + def __init__(self, shop_domain: str, access_token: str): + """ + Initialize the GraphQL client. + + Args: + shop_domain: Store domain (e.g., 'my-store.myshopify.com') + access_token: Admin API access token + """ + self.shop_domain = shop_domain.replace('https://', '').replace('http://', '') + self.access_token = access_token + self.base_url = f"https://{self.shop_domain}/admin/api/{API_VERSION}/graphql.json" + + def execute(self, query: str, variables: Optional[Dict] = None) -> GraphQLResponse: + """ + Execute a GraphQL query/mutation. + + Args: + query: GraphQL query string + variables: Query variables + + Returns: + GraphQLResponse object + """ + payload = {"query": query} + if variables: + payload["variables"] = variables + + headers = { + "Content-Type": "application/json", + "X-Shopify-Access-Token": self.access_token + } + + for attempt in range(MAX_RETRIES): + try: + request = Request( + self.base_url, + data=json.dumps(payload).encode('utf-8'), + headers=headers, + method='POST' + ) + + with urlopen(request, timeout=30) as response: + result = json.loads(response.read().decode('utf-8')) + return GraphQLResponse( + data=result.get('data'), + errors=result.get('errors'), + extensions=result.get('extensions') + ) + + except HTTPError as e: + if e.code == 429: # Rate limited + delay = RETRY_DELAY * (2 ** attempt) + print(f"Rate limited. Retrying in {delay}s...") + time.sleep(delay) + continue + raise + except Exception as e: + if attempt == MAX_RETRIES - 1: + raise + time.sleep(RETRY_DELAY) + + return GraphQLResponse(errors=[{"message": "Max retries exceeded"}]) + + # ==================== Query Templates ==================== + + def get_products( + self, + first: int = 10, + query: Optional[str] = None, + after: Optional[str] = None + ) -> GraphQLResponse: + """ + Query products with pagination. + + Args: + first: Number of products to fetch (max 250) + query: Optional search query + after: Cursor for pagination + """ + gql = """ + query GetProducts($first: Int!, $query: String, $after: String) { + products(first: $first, query: $query, after: $after) { + edges { + node { + id + title + handle + status + totalInventory + variants(first: 5) { + edges { + node { + id + title + price + inventoryQuantity + sku + } + } + } + } + cursor + } + pageInfo { + hasNextPage + endCursor + } + } + } + """ + return self.execute(gql, {"first": first, "query": query, "after": after}) + + def get_orders( + self, + first: int = 10, + query: Optional[str] = None, + after: Optional[str] = None + ) -> GraphQLResponse: + """ + Query orders with pagination. + + Args: + first: Number of orders to fetch (max 250) + query: Optional search query (e.g., "financial_status:paid") + after: Cursor for pagination + """ + gql = """ + query GetOrders($first: Int!, $query: String, $after: String) { + orders(first: $first, query: $query, after: $after) { + edges { + node { + id + name + createdAt + displayFinancialStatus + displayFulfillmentStatus + totalPriceSet { + shopMoney { amount currencyCode } + } + customer { + id + firstName + lastName + } + lineItems(first: 5) { + edges { + node { + title + quantity + } + } + } + } + cursor + } + pageInfo { + hasNextPage + endCursor + } + } + } + """ + return self.execute(gql, {"first": first, "query": query, "after": after}) + + def get_customers( + self, + first: int = 10, + query: Optional[str] = None, + after: Optional[str] = None + ) -> GraphQLResponse: + """ + Query customers with pagination. + + Args: + first: Number of customers to fetch (max 250) + query: Optional search query + after: Cursor for pagination + """ + gql = """ + query GetCustomers($first: Int!, $query: String, $after: String) { + customers(first: $first, query: $query, after: $after) { + edges { + node { + id + firstName + lastName + displayName + defaultEmailAddress { + emailAddress + } + numberOfOrders + amountSpent { + amount + currencyCode + } + } + cursor + } + pageInfo { + hasNextPage + endCursor + } + } + } + """ + return self.execute(gql, {"first": first, "query": query, "after": after}) + + def set_metafields(self, metafields: List[Dict]) -> GraphQLResponse: + """ + Set metafields on resources. + + Args: + metafields: List of metafield inputs, each containing: + - ownerId: Resource GID + - namespace: Metafield namespace + - key: Metafield key + - value: Metafield value + - type: Metafield type + """ + gql = """ + mutation SetMetafields($metafields: [MetafieldsSetInput!]!) { + metafieldsSet(metafields: $metafields) { + metafields { + id + namespace + key + value + } + userErrors { + field + message + } + } + } + """ + return self.execute(gql, {"metafields": metafields}) + + # ==================== Pagination Helpers ==================== + + def paginate_products( + self, + batch_size: int = 50, + query: Optional[str] = None + ) -> Generator[Dict, None, None]: + """ + Generator that yields all products with automatic pagination. + + Args: + batch_size: Products per request (max 250) + query: Optional search query + + Yields: + Product dictionaries + """ + cursor = None + while True: + response = self.get_products(first=batch_size, query=query, after=cursor) + + if not response.is_success or not response.data: + break + + products = response.data.get('products', {}) + edges = products.get('edges', []) + + for edge in edges: + yield edge['node'] + + page_info = products.get('pageInfo', {}) + if not page_info.get('hasNextPage'): + break + + cursor = page_info.get('endCursor') + + def paginate_orders( + self, + batch_size: int = 50, + query: Optional[str] = None + ) -> Generator[Dict, None, None]: + """ + Generator that yields all orders with automatic pagination. + + Args: + batch_size: Orders per request (max 250) + query: Optional search query + + Yields: + Order dictionaries + """ + cursor = None + while True: + response = self.get_orders(first=batch_size, query=query, after=cursor) + + if not response.is_success or not response.data: + break + + orders = response.data.get('orders', {}) + edges = orders.get('edges', []) + + for edge in edges: + yield edge['node'] + + page_info = orders.get('pageInfo', {}) + if not page_info.get('hasNextPage'): + break + + cursor = page_info.get('endCursor') + + +# ==================== Utility Functions ==================== + +def extract_id(gid: str) -> str: + """ + Extract numeric ID from Shopify GID. + + Args: + gid: Global ID (e.g., 'gid://shopify/Product/123') + + Returns: + Numeric ID string (e.g., '123') + """ + return gid.split('/')[-1] if gid else '' + + +def build_gid(resource_type: str, id: str) -> str: + """ + Build Shopify GID from resource type and ID. + + Args: + resource_type: Resource type (e.g., 'Product', 'Order') + id: Numeric ID + + Returns: + Global ID (e.g., 'gid://shopify/Product/123') + """ + return f"gid://shopify/{resource_type}/{id}" + + +# ==================== Example Usage ==================== + +def main(): + """Example usage of ShopifyGraphQL client.""" + import os + + # Load from environment + shop = os.environ.get('SHOP_DOMAIN', 'your-store.myshopify.com') + token = os.environ.get('SHOPIFY_ACCESS_TOKEN', '') + + if not token: + print("Set SHOPIFY_ACCESS_TOKEN environment variable") + return + + client = ShopifyGraphQL(shop, token) + + # Example: Get first 5 products + print("Fetching products...") + response = client.get_products(first=5) + + if response.is_success: + products = response.data['products']['edges'] + for edge in products: + product = edge['node'] + print(f" - {product['title']} ({product['status']})") + print(f"\nQuery cost: {response.query_cost}") + else: + print(f"Errors: {response.errors}") + + +if __name__ == '__main__': + main() diff --git a/.agents/skills/shopify-development/scripts/shopify_init.py b/.agents/skills/shopify-development/scripts/shopify_init.py new file mode 100644 index 000000000..f0c664e12 --- /dev/null +++ b/.agents/skills/shopify-development/scripts/shopify_init.py @@ -0,0 +1,441 @@ +#!/usr/bin/env python3 +""" +Shopify Project Initialization Script + +Interactive script to scaffold Shopify apps, extensions, or themes. +Supports environment variable loading from multiple locations. +""" + +import os +import sys +import json +import subprocess +from pathlib import Path +from typing import Dict, Optional, List +from dataclasses import dataclass + + +@dataclass +class EnvConfig: + """Environment configuration container.""" + shopify_api_key: Optional[str] = None + shopify_api_secret: Optional[str] = None + shop_domain: Optional[str] = None + scopes: Optional[str] = None + + +class EnvLoader: + """Load environment variables from multiple sources in priority order.""" + + @staticmethod + def load_env_file(filepath: Path) -> Dict[str, str]: + """ + Load environment variables from .env file. + + Args: + filepath: Path to .env file + + Returns: + Dictionary of environment variables + """ + env_vars = {} + if not filepath.exists(): + return env_vars + + try: + with open(filepath, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + env_vars[key.strip()] = value.strip().strip('"').strip("'") + except Exception as e: + print(f"Warning: Failed to load {filepath}: {e}") + + return env_vars + + @staticmethod + def get_env_paths(skill_dir: Path) -> List[Path]: + """ + Get list of .env file paths in priority order. + + Works with any AI tool directory structure: + - .agent/skills/ (universal) + - .claude/skills/ (Claude Code) + - .gemini/skills/ (Gemini CLI) + - .cursor/skills/ (Cursor) + + Priority: process.env > skill/.env > skills/.env > agent_dir/.env + + Args: + skill_dir: Path to skill directory + + Returns: + List of .env file paths + """ + paths = [] + + # skill/.env + skill_env = skill_dir / '.env' + if skill_env.exists(): + paths.append(skill_env) + + # skills/.env + skills_env = skill_dir.parent / '.env' + if skills_env.exists(): + paths.append(skills_env) + + # agent_dir/.env (e.g., .agent, .claude, .gemini, .cursor) + agent_env = skill_dir.parent.parent / '.env' + if agent_env.exists(): + paths.append(agent_env) + + return paths + + @staticmethod + def load_config(skill_dir: Path) -> EnvConfig: + """ + Load configuration from environment variables. + + Works with any AI tool directory structure. + Priority: process.env > skill/.env > skills/.env > agent_dir/.env + + Args: + skill_dir: Path to skill directory + + Returns: + EnvConfig object + """ + config = EnvConfig() + + # Load from .env files (reverse priority order) + for env_path in reversed(EnvLoader.get_env_paths(skill_dir)): + env_vars = EnvLoader.load_env_file(env_path) + if 'SHOPIFY_API_KEY' in env_vars: + config.shopify_api_key = env_vars['SHOPIFY_API_KEY'] + if 'SHOPIFY_API_SECRET' in env_vars: + config.shopify_api_secret = env_vars['SHOPIFY_API_SECRET'] + if 'SHOP_DOMAIN' in env_vars: + config.shop_domain = env_vars['SHOP_DOMAIN'] + if 'SCOPES' in env_vars: + config.scopes = env_vars['SCOPES'] + + # Override with process environment (highest priority) + if 'SHOPIFY_API_KEY' in os.environ: + config.shopify_api_key = os.environ['SHOPIFY_API_KEY'] + if 'SHOPIFY_API_SECRET' in os.environ: + config.shopify_api_secret = os.environ['SHOPIFY_API_SECRET'] + if 'SHOP_DOMAIN' in os.environ: + config.shop_domain = os.environ['SHOP_DOMAIN'] + if 'SCOPES' in os.environ: + config.scopes = os.environ['SCOPES'] + + return config + + +class ShopifyInitializer: + """Initialize Shopify projects.""" + + def __init__(self, config: EnvConfig): + """ + Initialize ShopifyInitializer. + + Args: + config: Environment configuration + """ + self.config = config + + def prompt(self, message: str, default: Optional[str] = None) -> str: + """ + Prompt user for input. + + Args: + message: Prompt message + default: Default value + + Returns: + User input or default + """ + if default: + message = f"{message} [{default}]" + user_input = input(f"{message}: ").strip() + return user_input if user_input else (default or '') + + def select_option(self, message: str, options: List[str]) -> str: + """ + Prompt user to select from options. + + Args: + message: Prompt message + options: List of options + + Returns: + Selected option + """ + print(f"\n{message}") + for i, option in enumerate(options, 1): + print(f"{i}. {option}") + + while True: + try: + choice = int(input("Select option: ").strip()) + if 1 <= choice <= len(options): + return options[choice - 1] + print(f"Please select 1-{len(options)}") + except (ValueError, KeyboardInterrupt): + print("Invalid input") + + def check_cli_installed(self) -> bool: + """ + Check if Shopify CLI is installed. + + Returns: + True if installed, False otherwise + """ + try: + result = subprocess.run( + ['shopify', 'version'], + capture_output=True, + text=True, + timeout=5 + ) + return result.returncode == 0 + except (subprocess.SubprocessError, FileNotFoundError): + return False + + def create_app_config(self, project_dir: Path, app_name: str, scopes: str) -> None: + """ + Create shopify.app.toml configuration file. + + Args: + project_dir: Project directory + app_name: Application name + scopes: Access scopes + """ + config_content = f"""# Shopify App Configuration +name = "{app_name}" +client_id = "{self.config.shopify_api_key or 'YOUR_API_KEY'}" +application_url = "https://your-app.com" +embedded = true + +[build] +automatically_update_urls_on_dev = true +dev_store_url = "{self.config.shop_domain or 'your-store.myshopify.com'}" + +[access_scopes] +scopes = "{scopes}" + +[webhooks] +api_version = "2026-01" + +[[webhooks.subscriptions]] +topics = ["app/uninstalled"] +uri = "/webhooks/app/uninstalled" + +[webhooks.privacy_compliance] +customer_data_request_url = "/webhooks/gdpr/data-request" +customer_deletion_url = "/webhooks/gdpr/customer-deletion" +shop_deletion_url = "/webhooks/gdpr/shop-deletion" +""" + config_path = project_dir / 'shopify.app.toml' + config_path.write_text(config_content) + print(f"✓ Created {config_path}") + + def create_extension_config(self, project_dir: Path, extension_name: str, extension_type: str) -> None: + """ + Create shopify.extension.toml configuration file. + + Args: + project_dir: Project directory + extension_name: Extension name + extension_type: Extension type + """ + target_map = { + 'checkout': 'purchase.checkout.block.render', + 'admin_action': 'admin.product-details.action.render', + 'admin_block': 'admin.product-details.block.render', + 'pos': 'pos.home.tile.render', + 'function': 'function', + 'customer_account': 'customer-account.order-status.block.render', + 'theme_app': 'theme-app-extension' + } + + config_content = f"""name = "{extension_name}" +type = "ui_extension" +handle = "{extension_name.lower().replace(' ', '-')}" + +[extension_points] +api_version = "2026-01" + +[[extension_points.targets]] +target = "{target_map.get(extension_type, 'purchase.checkout.block.render')}" + +[capabilities] +network_access = true +api_access = true +""" + config_path = project_dir / 'shopify.extension.toml' + config_path.write_text(config_content) + print(f"✓ Created {config_path}") + + def create_readme(self, project_dir: Path, project_type: str, project_name: str) -> None: + """ + Create README.md file. + + Args: + project_dir: Project directory + project_type: Project type (app/extension/theme) + project_name: Project name + """ + content = f"""# {project_name} + +Shopify {project_type.capitalize()} project. + +## Setup + +```bash +# Install dependencies +npm install + +# Start development +shopify {project_type} dev +``` + +## Deployment + +```bash +# Deploy to Shopify +shopify {project_type} deploy +``` + +## Resources + +- [Shopify Documentation](https://shopify.dev/docs) +- [Shopify CLI](https://shopify.dev/docs/api/shopify-cli) +""" + readme_path = project_dir / 'README.md' + readme_path.write_text(content) + print(f"✓ Created {readme_path}") + + def init_app(self) -> None: + """Initialize Shopify app project.""" + print("\n=== Shopify App Initialization ===\n") + + app_name = self.prompt("App name", "my-shopify-app") + scopes = self.prompt("Access scopes", self.config.scopes or "read_products,write_products") + + project_dir = Path.cwd() / app_name + project_dir.mkdir(exist_ok=True) + + print(f"\nCreating app in {project_dir}...") + + self.create_app_config(project_dir, app_name, scopes) + self.create_readme(project_dir, "app", app_name) + + # Create basic package.json + package_json = { + "name": app_name.lower().replace(' ', '-'), + "version": "1.0.0", + "scripts": { + "dev": "shopify app dev", + "deploy": "shopify app deploy" + } + } + (project_dir / 'package.json').write_text(json.dumps(package_json, indent=2)) + print(f"✓ Created package.json") + + print(f"\n✓ App '{app_name}' initialized successfully!") + print(f"\nNext steps:") + print(f" cd {app_name}") + print(f" npm install") + print(f" shopify app dev") + + def init_extension(self) -> None: + """Initialize Shopify extension project.""" + print("\n=== Shopify Extension Initialization ===\n") + + extension_types = [ + 'checkout', + 'admin_action', + 'admin_block', + 'pos', + 'function', + 'customer_account', + 'theme_app' + ] + extension_type = self.select_option("Select extension type", extension_types) + + extension_name = self.prompt("Extension name", "my-extension") + + project_dir = Path.cwd() / extension_name + project_dir.mkdir(exist_ok=True) + + print(f"\nCreating extension in {project_dir}...") + + self.create_extension_config(project_dir, extension_name, extension_type) + self.create_readme(project_dir, "extension", extension_name) + + print(f"\n✓ Extension '{extension_name}' initialized successfully!") + print(f"\nNext steps:") + print(f" cd {extension_name}") + print(f" shopify app dev") + + def init_theme(self) -> None: + """Initialize Shopify theme project.""" + print("\n=== Shopify Theme Initialization ===\n") + + theme_name = self.prompt("Theme name", "my-theme") + + print(f"\nInitializing theme '{theme_name}'...") + print("\nRecommended: Use 'shopify theme init' for full theme scaffolding") + print(f"\nRun: shopify theme init {theme_name}") + + def run(self) -> None: + """Run interactive initialization.""" + print("=" * 60) + print("Shopify Project Initializer") + print("=" * 60) + + # Check CLI + if not self.check_cli_installed(): + print("\n⚠ Shopify CLI not found!") + print("Install: npm install -g @shopify/cli@latest") + sys.exit(1) + + # Select project type + project_types = ['app', 'extension', 'theme'] + project_type = self.select_option("Select project type", project_types) + + # Initialize based on type + if project_type == 'app': + self.init_app() + elif project_type == 'extension': + self.init_extension() + elif project_type == 'theme': + self.init_theme() + + +def main() -> None: + """Main entry point.""" + try: + # Get skill directory + script_dir = Path(__file__).parent + skill_dir = script_dir.parent + + # Load configuration + config = EnvLoader.load_config(skill_dir) + + # Initialize project + initializer = ShopifyInitializer(config) + initializer.run() + + except KeyboardInterrupt: + print("\n\nAborted.") + sys.exit(0) + except Exception as e: + print(f"\n✗ Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/.agents/skills/shopify-development/scripts/tests/test_shopify_init.py b/.agents/skills/shopify-development/scripts/tests/test_shopify_init.py new file mode 100644 index 000000000..bcebb7902 --- /dev/null +++ b/.agents/skills/shopify-development/scripts/tests/test_shopify_init.py @@ -0,0 +1,379 @@ +""" +Tests for shopify_init.py + +Run with: pytest test_shopify_init.py -v --cov=shopify_init --cov-report=term-missing +""" + +import os +import sys +import json +import pytest +import subprocess +from pathlib import Path +from unittest.mock import Mock, patch, mock_open, MagicMock + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from shopify_init import EnvLoader, EnvConfig, ShopifyInitializer + + +class TestEnvLoader: + """Test EnvLoader class.""" + + def test_load_env_file_success(self, tmp_path): + """Test loading valid .env file.""" + env_file = tmp_path / ".env" + env_file.write_text(""" +SHOPIFY_API_KEY=test_key +SHOPIFY_API_SECRET=test_secret +SHOP_DOMAIN=test.myshopify.com +# Comment line +SCOPES=read_products,write_products +""") + + result = EnvLoader.load_env_file(env_file) + + assert result['SHOPIFY_API_KEY'] == 'test_key' + assert result['SHOPIFY_API_SECRET'] == 'test_secret' + assert result['SHOP_DOMAIN'] == 'test.myshopify.com' + assert result['SCOPES'] == 'read_products,write_products' + + def test_load_env_file_with_quotes(self, tmp_path): + """Test loading .env file with quoted values.""" + env_file = tmp_path / ".env" + env_file.write_text(""" +SHOPIFY_API_KEY="test_key" +SHOPIFY_API_SECRET='test_secret' +""") + + result = EnvLoader.load_env_file(env_file) + + assert result['SHOPIFY_API_KEY'] == 'test_key' + assert result['SHOPIFY_API_SECRET'] == 'test_secret' + + def test_load_env_file_nonexistent(self, tmp_path): + """Test loading non-existent .env file.""" + result = EnvLoader.load_env_file(tmp_path / "nonexistent.env") + assert result == {} + + def test_load_env_file_invalid_format(self, tmp_path): + """Test loading .env file with invalid lines.""" + env_file = tmp_path / ".env" + env_file.write_text(""" +VALID_KEY=value +INVALID_LINE_NO_EQUALS +ANOTHER_VALID=test +""") + + result = EnvLoader.load_env_file(env_file) + + assert result['VALID_KEY'] == 'value' + assert result['ANOTHER_VALID'] == 'test' + assert 'INVALID_LINE_NO_EQUALS' not in result + + def test_get_env_paths(self, tmp_path): + """Test getting .env file paths from universal directory structure.""" + # Create directory structure (works with .agent, .claude, .gemini, .cursor) + agent_dir = tmp_path / ".agent" + skills_dir = agent_dir / "skills" + skill_dir = skills_dir / "shopify" + + skill_dir.mkdir(parents=True) + + # Create .env files at each level + (skill_dir / ".env").write_text("SKILL=1") + (skills_dir / ".env").write_text("SKILLS=1") + (agent_dir / ".env").write_text("AGENT=1") + + paths = EnvLoader.get_env_paths(skill_dir) + + assert len(paths) == 3 + assert skill_dir / ".env" in paths + assert skills_dir / ".env" in paths + assert agent_dir / ".env" in paths + + def test_load_config_priority(self, tmp_path, monkeypatch): + """Test configuration loading priority across different AI tool directories.""" + skill_dir = tmp_path / "skill" + skills_dir = tmp_path + agent_dir = tmp_path.parent # Could be .agent, .claude, .gemini, .cursor + + skill_dir.mkdir(parents=True) + + (skill_dir / ".env").write_text("SHOPIFY_API_KEY=skill_key") + (skills_dir / ".env").write_text("SHOPIFY_API_KEY=skills_key\nSHOP_DOMAIN=skills.myshopify.com") + + monkeypatch.setenv("SHOPIFY_API_KEY", "process_key") + + config = EnvLoader.load_config(skill_dir) + + assert config.shopify_api_key == "process_key" + # Shop domain from skills/.env + assert config.shop_domain == "skills.myshopify.com" + + def test_load_config_no_files(self, tmp_path): + """Test configuration loading with no .env files.""" + config = EnvLoader.load_config(tmp_path) + + assert config.shopify_api_key is None + assert config.shopify_api_secret is None + assert config.shop_domain is None + assert config.scopes is None + + +class TestShopifyInitializer: + """Test ShopifyInitializer class.""" + + @pytest.fixture + def config(self): + """Create test config.""" + return EnvConfig( + shopify_api_key="test_key", + shopify_api_secret="test_secret", + shop_domain="test.myshopify.com", + scopes="read_products,write_products" + ) + + @pytest.fixture + def initializer(self, config): + """Create initializer instance.""" + return ShopifyInitializer(config) + + def test_prompt_with_default(self, initializer): + """Test prompt with default value.""" + with patch('builtins.input', return_value=''): + result = initializer.prompt("Test", "default_value") + assert result == "default_value" + + def test_prompt_with_input(self, initializer): + """Test prompt with user input.""" + with patch('builtins.input', return_value='user_input'): + result = initializer.prompt("Test", "default_value") + assert result == "user_input" + + def test_select_option_valid(self, initializer): + """Test select option with valid choice.""" + options = ['app', 'extension', 'theme'] + with patch('builtins.input', return_value='2'): + result = initializer.select_option("Choose", options) + assert result == 'extension' + + def test_select_option_invalid_then_valid(self, initializer): + """Test select option with invalid then valid choice.""" + options = ['app', 'extension'] + with patch('builtins.input', side_effect=['5', 'invalid', '1']): + result = initializer.select_option("Choose", options) + assert result == 'app' + + def test_check_cli_installed_success(self, initializer): + """Test CLI installed check - success.""" + mock_result = Mock() + mock_result.returncode = 0 + + with patch('subprocess.run', return_value=mock_result): + assert initializer.check_cli_installed() is True + + def test_check_cli_installed_failure(self, initializer): + """Test CLI installed check - failure.""" + with patch('subprocess.run', side_effect=FileNotFoundError): + assert initializer.check_cli_installed() is False + + def test_create_app_config(self, initializer, tmp_path): + """Test creating app configuration file.""" + initializer.create_app_config(tmp_path, "test-app", "read_products") + + config_file = tmp_path / "shopify.app.toml" + assert config_file.exists() + + content = config_file.read_text() + assert 'name = "test-app"' in content + assert 'scopes = "read_products"' in content + assert 'client_id = "test_key"' in content + + def test_create_extension_config(self, initializer, tmp_path): + """Test creating extension configuration file.""" + initializer.create_extension_config(tmp_path, "test-ext", "checkout") + + config_file = tmp_path / "shopify.extension.toml" + assert config_file.exists() + + content = config_file.read_text() + assert 'name = "test-ext"' in content + assert 'purchase.checkout.block.render' in content + + def test_create_extension_config_admin_action(self, initializer, tmp_path): + """Test creating admin action extension config.""" + initializer.create_extension_config(tmp_path, "admin-ext", "admin_action") + + config_file = tmp_path / "shopify.extension.toml" + content = config_file.read_text() + assert 'admin.product-details.action.render' in content + + def test_create_readme(self, initializer, tmp_path): + """Test creating README file.""" + initializer.create_readme(tmp_path, "app", "Test App") + + readme_file = tmp_path / "README.md" + assert readme_file.exists() + + content = readme_file.read_text() + assert '# Test App' in content + assert 'shopify app dev' in content + + @patch('builtins.input') + @patch('builtins.print') + def test_init_app(self, mock_print, mock_input, initializer, tmp_path, monkeypatch): + """Test app initialization.""" + monkeypatch.chdir(tmp_path) + + # Mock user inputs + mock_input.side_effect = ['my-app', 'read_products,write_products'] + + initializer.init_app() + + # Check directory created + app_dir = tmp_path / "my-app" + assert app_dir.exists() + + # Check files created + assert (app_dir / "shopify.app.toml").exists() + assert (app_dir / "README.md").exists() + assert (app_dir / "package.json").exists() + + # Check package.json content + package_json = json.loads((app_dir / "package.json").read_text()) + assert package_json['name'] == 'my-app' + assert 'dev' in package_json['scripts'] + + @patch('builtins.input') + @patch('builtins.print') + def test_init_extension(self, mock_print, mock_input, initializer, tmp_path, monkeypatch): + """Test extension initialization.""" + monkeypatch.chdir(tmp_path) + + # Mock user inputs: type selection (1 = checkout), name + mock_input.side_effect = ['1', 'my-extension'] + + initializer.init_extension() + + # Check directory and files created + ext_dir = tmp_path / "my-extension" + assert ext_dir.exists() + assert (ext_dir / "shopify.extension.toml").exists() + assert (ext_dir / "README.md").exists() + + @patch('builtins.input') + @patch('builtins.print') + def test_init_theme(self, mock_print, mock_input, initializer): + """Test theme initialization.""" + mock_input.return_value = 'my-theme' + + initializer.init_theme() + + assert mock_print.called + + @patch('builtins.print') + def test_run_no_cli(self, mock_print, initializer): + """Test run when CLI not installed.""" + with patch.object(initializer, 'check_cli_installed', return_value=False): + with pytest.raises(SystemExit) as exc_info: + initializer.run() + assert exc_info.value.code == 1 + + @patch.object(ShopifyInitializer, 'check_cli_installed', return_value=True) + @patch.object(ShopifyInitializer, 'init_app') + @patch('builtins.input') + @patch('builtins.print') + def test_run_app_selected(self, mock_print, mock_input, mock_init_app, mock_cli_check, initializer): + """Test run with app selection.""" + mock_input.return_value = '1' # Select app + + initializer.run() + + mock_init_app.assert_called_once() + + @patch.object(ShopifyInitializer, 'check_cli_installed', return_value=True) + @patch.object(ShopifyInitializer, 'init_extension') + @patch('builtins.input') + @patch('builtins.print') + def test_run_extension_selected(self, mock_print, mock_input, mock_init_ext, mock_cli_check, initializer): + """Test run with extension selection.""" + mock_input.return_value = '2' # Select extension + + initializer.run() + + mock_init_ext.assert_called_once() + + +class TestMain: + """Test main function.""" + + @patch('shopify_init.ShopifyInitializer') + @patch('shopify_init.EnvLoader') + def test_main_success(self, mock_loader, mock_initializer): + """Test main function success path.""" + from shopify_init import main + + mock_config = Mock() + mock_loader.load_config.return_value = mock_config + + mock_init_instance = Mock() + mock_initializer.return_value = mock_init_instance + + with patch('builtins.print'): + main() + + mock_init_instance.run.assert_called_once() + + @patch('shopify_init.ShopifyInitializer') + @patch('sys.exit') + def test_main_keyboard_interrupt(self, mock_exit, mock_initializer): + """Test main function with keyboard interrupt.""" + from shopify_init import main + + mock_initializer.return_value.run.side_effect = KeyboardInterrupt + + with patch('builtins.print'): + main() + + mock_exit.assert_called_with(0) + + @patch('shopify_init.ShopifyInitializer') + @patch('sys.exit') + def test_main_exception(self, mock_exit, mock_initializer): + """Test main function with exception.""" + from shopify_init import main + + mock_initializer.return_value.run.side_effect = Exception("Test error") + + with patch('builtins.print'): + main() + + mock_exit.assert_called_with(1) + + +class TestEnvConfig: + """Test EnvConfig dataclass.""" + + def test_env_config_defaults(self): + """Test EnvConfig default values.""" + config = EnvConfig() + + assert config.shopify_api_key is None + assert config.shopify_api_secret is None + assert config.shop_domain is None + assert config.scopes is None + + def test_env_config_with_values(self): + """Test EnvConfig with values.""" + config = EnvConfig( + shopify_api_key="key", + shopify_api_secret="secret", + shop_domain="test.myshopify.com", + scopes="read_products" + ) + + assert config.shopify_api_key == "key" + assert config.shopify_api_secret == "secret" + assert config.shop_domain == "test.myshopify.com" + assert config.scopes == "read_products" diff --git a/.agents/skills/tailwind/SKILL.md b/.agents/skills/tailwind/SKILL.md new file mode 100644 index 000000000..b86774848 --- /dev/null +++ b/.agents/skills/tailwind/SKILL.md @@ -0,0 +1,80 @@ +--- +name: tailwind +description: >- + Tailwind CSS v4 best-practices skill covering utility-first patterns, @theme + variables, responsive design, dark mode, custom styles, performance, + accessibility, and a Figma-to-Tailwind theme generation workflow. Use when + the user mentions Tailwind, tailwindcss, @theme, utility classes, Tailwind + config, Figma design tokens, or asks to build, configure, audit, or migrate + a Tailwind CSS project (including v3 to v4 migrations). +--- + +# Tailwind CSS Best Practices + +Tailwind CSS v4 guide organized as modular rules. Covers the utility-first model, `@theme` variables, responsive/state variants, custom styles, performance, accessibility, and the **Figma → Tailwind theme workflow** for generating design tokens directly from Figma variables. + +## ROUTING: Which rule file to load + +**IF setting up Tailwind or understanding how utility classes work:** +→ Read `rules/core-utility-model.md` + +**IF working with theme variables (`@theme`), design tokens, colors, fonts, spacing:** +→ Read `rules/core-theme-variables.md` + +**IF working with responsive design, hover/focus states, dark mode, or custom variants:** +→ Read `rules/core-responsive-and-states.md` + +**IF adding custom CSS, component classes, base styles, or custom utilities:** +→ Read `rules/core-custom-styles.md` + +**IF optimizing build size, purging unused classes, or configuring content detection:** +→ Read `rules/perf-purging-and-scanning.md` + +**IF working on accessibility or dark mode strategies:** +→ Read `rules/a11y-and-dark-mode.md` + +**IF translating Figma variables/design tokens into Tailwind v4 theme CSS:** +→ Read `rules/figma-to-theme-workflow.md` + see `figma-tokens/` templates + +## Rule index + +| Topic | Description | File | +|-------|-------------|------| +| Sections overview | Categories and reading order | [rules/_sections.md](rules/_sections.md) | +| Utility model | Utility-first principles, composing classes, arbitrary values | [rules/core-utility-model.md](rules/core-utility-model.md) | +| Theme variables | `@theme` directive, namespaces, extend/override, `inline`/`static` | [rules/core-theme-variables.md](rules/core-theme-variables.md) | +| Responsive & states | Breakpoints, hover/focus/dark variants, custom variants | [rules/core-responsive-and-states.md](rules/core-responsive-and-states.md) | +| Custom styles | `@layer`, `@utility`, `@variant`, component classes | [rules/core-custom-styles.md](rules/core-custom-styles.md) | +| Performance | Content detection, JIT, build optimization | [rules/perf-purging-and-scanning.md](rules/perf-purging-and-scanning.md) | +| A11y & dark mode | Accessibility utilities, dark mode patterns | [rules/a11y-and-dark-mode.md](rules/a11y-and-dark-mode.md) | +| **Figma workflow** | **Agent workflow: Figma variables → Tailwind `@theme` CSS** | [rules/figma-to-theme-workflow.md](rules/figma-to-theme-workflow.md) | + +## Figma → Tailwind theme workflow + +This skill includes a dedicated agent workflow to convert Figma design variables into Tailwind v4 `@theme` CSS files. The workflow is described in `rules/figma-to-theme-workflow.md` and uses the annotated templates in `figma-tokens/` as output targets. + +**How to trigger it:** paste your Figma CSS variables into chat and ask the agent to generate the Tailwind theme files. + +Template files in `figma-tokens/`: +- `colors.css` — `--color-*` namespace +- `typography.css` — `--font-*`, `--text-*`, `--font-weight-*`, `--tracking-*`, `--leading-*` +- `spacing.css` — `--spacing` and `--spacing-*` +- `radius-shadows.css` — `--radius-*`, `--shadow-*` +- `breakpoints.css` — `--breakpoint-*` + +## Rule categories by priority + +| Priority | Category | Impact | Prefix | +|----------|----------|--------|--------| +| 1 | Utility model & theme | CRITICAL | `core-` | +| 2 | Responsive & states | HIGH | `core-` | +| 3 | Custom styles | HIGH | `core-` | +| 4 | Figma workflow | HIGH | (standalone) | +| 5 | Performance | MEDIUM-HIGH | `perf-` | +| 6 | Accessibility | MEDIUM | `a11y-` | + +## Coverage and maintenance + +- Coverage map: `rules/_coverage-map.md` +- Source: https://tailwindcss.com/docs (v4.2) +- Update when Tailwind releases a new major/minor version with breaking `@theme` changes. diff --git a/.agents/skills/tailwind/figma-tokens/breakpoints.css b/.agents/skills/tailwind/figma-tokens/breakpoints.css new file mode 100644 index 000000000..79b5ee319 --- /dev/null +++ b/.agents/skills/tailwind/figma-tokens/breakpoints.css @@ -0,0 +1,39 @@ +/* Figma Design Tokens — Breakpoints + * + * This file is generated by the agent from Figma CSS variables. + * See: skills/tailwind/rules/figma-to-theme-workflow.md + * + * Namespace: --breakpoint-* + * Generates: responsive variant prefixes (sm:*, md:*, lg:*, xl:*, etc.) + * + * Usage: + * @import "./figma-tokens/breakpoints.css"; + * + * Notes: + * - Tailwind breakpoints are MINIMUM widths (mobile-first). + * - Convert px to rem (÷ 16) for accessibility (respects browser font size scaling). + * - Overriding a default breakpoint (e.g. --breakpoint-sm) replaces the default value. + * - Adding a new name (e.g. --breakpoint-3xl) adds a new responsive variant. + * - Figma "Desktop" frame widths are typically MAX widths — map them to Tailwind's + * MIN-width convention: e.g. Figma "Tablet" at 768px → --breakpoint-md: 48rem + * + * Figma frame → Tailwind breakpoint mapping (common): + * Mobile: 360px → not usually a breakpoint (base/mobile-first) + * Tablet: 768px → --breakpoint-md: 48rem + * Desktop: 1024px → --breakpoint-lg: 64rem + * Wide: 1280px → --breakpoint-xl: 80rem + * + * Example output — replace with your actual tokens: + */ + +@theme { + /* Standard breakpoints (override Tailwind defaults or leave as-is) */ + --breakpoint-sm: 40rem; /* 640px */ + --breakpoint-md: 48rem; /* 768px */ + --breakpoint-lg: 64rem; /* 1024px */ + --breakpoint-xl: 80rem; /* 1280px */ + --breakpoint-2xl: 96rem; /* 1536px */ + + /* Custom breakpoints (add as needed) */ + /* --breakpoint-3xl: 120rem; */ /* 1920px */ +} diff --git a/.agents/skills/tailwind/figma-tokens/colors.css b/.agents/skills/tailwind/figma-tokens/colors.css new file mode 100644 index 000000000..b994e5764 --- /dev/null +++ b/.agents/skills/tailwind/figma-tokens/colors.css @@ -0,0 +1,42 @@ +/* Figma Design Tokens — Colors + * + * This file is generated by the agent from Figma CSS variables. + * See: skills/tailwind/rules/figma-to-theme-workflow.md + * + * Namespace: --color-* + * Generates: bg-*, text-*, border-*, ring-*, fill-*, stroke-*, decoration-*, etc. + * + * Usage: + * @import "./figma-tokens/colors.css"; + * in your main app.css (after @import "tailwindcss") + * + * Notes: + * - Use OKLCH for perceptually uniform colors when possible. + * - HEX/RGB values are also valid. + * - To completely replace Tailwind's default palette, add: --color-*: initial; + * as the first declaration inside @theme (removes ALL default color utilities). + * + * Example output — replace with your actual tokens: + */ + +@theme { + /* Brand */ + --color-brand-50: oklch(0.97 0.02 40); + --color-brand-100: oklch(0.93 0.05 40); + --color-brand-200: oklch(0.87 0.10 40); + --color-brand-300: oklch(0.79 0.15 40); + --color-brand-400: oklch(0.72 0.18 40); + --color-brand-500: oklch(0.66 0.20 40); /* primary */ + --color-brand-600: oklch(0.58 0.19 40); + --color-brand-700: oklch(0.49 0.17 40); + --color-brand-800: oklch(0.38 0.13 40); + --color-brand-900: oklch(0.27 0.09 40); + --color-brand-950: oklch(0.18 0.06 40); + + /* Neutral (surface, text, borders) */ + --color-surface: oklch(0.98 0 0); + --color-surface-muted: oklch(0.95 0 0); + --color-text: oklch(0.15 0 0); + --color-text-muted: oklch(0.45 0 0); + --color-border: oklch(0.88 0 0); +} diff --git a/.agents/skills/tailwind/figma-tokens/radius-shadows.css b/.agents/skills/tailwind/figma-tokens/radius-shadows.css new file mode 100644 index 000000000..bbd935efc --- /dev/null +++ b/.agents/skills/tailwind/figma-tokens/radius-shadows.css @@ -0,0 +1,44 @@ +/* Figma Design Tokens — Border Radius & Shadows + * + * This file is generated by the agent from Figma CSS variables. + * See: skills/tailwind/rules/figma-to-theme-workflow.md + * + * Namespaces: + * --radius-* → rounded-* utilities + * --shadow-* → shadow-* utilities + * --inset-shadow-* → inset shadow utilities + * --drop-shadow-* → drop-shadow filter utilities + * + * Usage: + * @import "./figma-tokens/radius-shadows.css"; + * + * Notes: + * - Convert px radius values to rem (÷ 16). + * - Shadow values: use rgb(0 0 0 / 0.1) modern syntax instead of rgba(). + * - Multiple shadow layers: separate with comma in the value string. + * + * Example output — replace with your actual tokens: + */ + +@theme { + /* Border radius */ + --radius-xs: 0.125rem; /* 2px */ + --radius-sm: 0.25rem; /* 4px */ + --radius-md: 0.5rem; /* 8px */ + --radius-lg: 0.75rem; /* 12px */ + --radius-xl: 1rem; /* 16px */ + --radius-2xl: 1.5rem; /* 24px */ + --radius-full: 9999px; /* pill */ + + /* Box shadows */ + --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.10), 0 1px 2px -1px rgb(0 0 0 / 0.10); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10); + --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25); + + /* Inset shadows */ + --inset-shadow-xs: inset 0 1px 1px rgb(0 0 0 / 0.05); + --inset-shadow-sm: inset 0 2px 4px rgb(0 0 0 / 0.05); +} diff --git a/.agents/skills/tailwind/figma-tokens/spacing.css b/.agents/skills/tailwind/figma-tokens/spacing.css new file mode 100644 index 000000000..96d952c11 --- /dev/null +++ b/.agents/skills/tailwind/figma-tokens/spacing.css @@ -0,0 +1,35 @@ +/* Figma Design Tokens — Spacing + * + * This file is generated by the agent from Figma CSS variables. + * See: skills/tailwind/rules/figma-to-theme-workflow.md + * + * Namespace: --spacing-* and --spacing (base unit) + * Generates: p-*, m-*, gap-*, w-*, h-*, inset-*, space-*, translate-*, etc. + * + * Usage: + * @import "./figma-tokens/spacing.css"; + * + * Notes: + * - If Figma has a single base grid unit (e.g. 4px), set --spacing to that value. + * Tailwind then uses multipliers: p-4 = 4 × --spacing. + * - If Figma exports named steps, define them as --spacing-{name} tokens. + * - Convert px to rem (÷ 16) for scalable layouts. + * - Named tokens like --spacing-md generate utilities: p-md, m-md, gap-md, etc. + * + * Example output — replace with your actual tokens: + */ + +@theme { + /* Base unit — all numeric spacing utilities are multiples of this value. + * With --spacing: 0.25rem, then: p-1 = 0.25rem, p-4 = 1rem, p-8 = 2rem, etc. */ + --spacing: 0.25rem; /* 4px base grid */ + + /* Named spacing tokens (optional — use when Figma defines semantic steps) */ + --spacing-xs: 0.25rem; /* 4px */ + --spacing-sm: 0.5rem; /* 8px */ + --spacing-md: 1rem; /* 16px */ + --spacing-lg: 1.5rem; /* 24px */ + --spacing-xl: 2.5rem; /* 40px */ + --spacing-2xl: 4rem; /* 64px */ + --spacing-3xl: 6rem; /* 96px */ +} diff --git a/.agents/skills/tailwind/figma-tokens/typography.css b/.agents/skills/tailwind/figma-tokens/typography.css new file mode 100644 index 000000000..1aaf50b91 --- /dev/null +++ b/.agents/skills/tailwind/figma-tokens/typography.css @@ -0,0 +1,65 @@ +/* Figma Design Tokens — Typography + * + * This file is generated by the agent from Figma CSS variables. + * See: skills/tailwind/rules/figma-to-theme-workflow.md + * + * Namespaces: + * --font-* → font-family utilities (font-sans, font-heading, etc.) + * --text-* → font-size utilities (text-sm, text-xl, etc.) + * --font-weight-* → font-weight utilities (font-normal, font-bold, etc.) + * --tracking-* → letter-spacing utilities (tracking-tight, etc.) + * --leading-* → line-height utilities (leading-normal, etc.) + * + * Usage: + * @import "./figma-tokens/typography.css"; + * + * Notes: + * - px values should be converted to rem (÷ 16). + * - Include --text-*--line-height companion vars when Figma provides line-height. + * - Font stacks: always include a generic fallback (sans-serif, monospace, etc.) + * + * Example output — replace with your actual tokens: + */ + +@theme { + /* Font families */ + --font-heading: "Neue Haas Grotesk Display", ui-sans-serif, sans-serif; + --font-body: "Inter", ui-sans-serif, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, monospace; + + /* Font sizes (px → rem: value / 16) */ + --text-xs: 0.75rem; /* 12px */ + --text-sm: 0.875rem; /* 14px */ + --text-base: 1rem; /* 16px */ + --text-lg: 1.125rem; /* 18px */ + --text-xl: 1.25rem; /* 20px */ + --text-2xl: 1.5rem; /* 24px */ + --text-3xl: 2rem; /* 32px */ + --text-4xl: 3rem; /* 48px */ + --text-5xl: 4rem; /* 64px */ + + /* Line heights (companion vars — optional but recommended) */ + --text-xs--line-height: 1.5; + --text-sm--line-height: 1.5; + --text-base--line-height: 1.5; + --text-lg--line-height: 1.6; + --text-xl--line-height: 1.4; + --text-2xl--line-height: 1.3; + --text-3xl--line-height: 1.2; + --text-4xl--line-height: 1.1; + --text-5xl--line-height: 1; + + /* Letter spacing */ + --tracking-tight: -0.03em; + --tracking-normal: 0; + --tracking-wide: 0.04em; + --tracking-widest: 0.1em; + + /* Line heights (standalone) */ + --leading-none: 1; + --leading-tight: 1.25; + --leading-snug: 1.375; + --leading-normal: 1.5; + --leading-relaxed: 1.625; + --leading-loose: 2; +} diff --git a/.agents/skills/tailwind/rules/a11y-and-dark-mode.md b/.agents/skills/tailwind/rules/a11y-and-dark-mode.md new file mode 100644 index 000000000..1c5336b60 --- /dev/null +++ b/.agents/skills/tailwind/rules/a11y-and-dark-mode.md @@ -0,0 +1,135 @@ +# a11y-and-dark-mode + +Why it matters: accessibility and color scheme support are not afterthoughts — they affect usability for a large portion of users and are increasingly required by law (WCAG compliance). + +## Screen Reader Utilities + +```html + + + + + + Skip to main content + +``` + +## Focus Styles + +Never remove focus rings without a proper replacement: + +```html + + + + + +``` + +`focus-visible:` only shows the ring for keyboard navigation, not mouse clicks. + +## Color Contrast + +Use Tailwind's palette with contrast in mind: + +```html + +

Hard to read

+ + +

Easy to read

+

Also fine

+``` + +Verify contrast ratios when defining custom `@theme` colors. Prefer OKLCH for predictable perceptual lightness. + +## Reduced Motion + +```html + +
+``` + +```css +/* In custom CSS */ +.animated-element { + transition: transform 300ms ease; + + @variant motion-reduce { + transition: none; + } +} +``` + +## Forced Colors (High Contrast Mode) + +```html + +
+ Important UI element +
+ + +
+ Regular content +
+``` + +## Dark Mode — Design Guidance + +When implementing dark mode, ensure: + +1. **All interactive states have dark variants:** `hover:`, `focus:`, `active:` need `dark:` counterparts. + +```html + +
Larger shadow
+``` + +## Adding custom utilities with `@utility` + +```css +/* Simple custom utility */ +@utility content-auto { + content-visibility: auto; +} + +/* Complex utility with nested selectors */ +@utility scrollbar-hidden { + &::-webkit-scrollbar { display: none; } + scrollbar-width: none; +} +``` + +```html + +
+
+``` + +## Using `@variant` in custom CSS + +```css +.custom-element { + background: var(--color-white); + + @variant dark { + background: var(--color-gray-900); + } + + @variant hover { + background: var(--color-gray-50); + } +} +``` + +## Custom variants with `@custom-variant` + +```css +/* Shorthand (no nesting needed) */ +@custom-variant hocus (&:hover, &:focus); + +/* With nesting */ +@custom-variant any-hover { + @media (any-hover: hover) { + &:hover { @slot; } + } +} +``` + +```html + +Only underlines on hover-capable devices +``` + +## When NOT to use `@apply` + +```css +/* Incorrect — using @apply for one-off element styles */ +.hero { @apply text-4xl font-bold mb-8 text-gray-900; } + +/* Correct — write utilities in HTML directly */ +/*

*/ + +/* @apply is appropriate for genuinely reusable component abstractions */ +@layer components { + .form-input { + @apply w-full rounded-md border border-gray-300 px-3 py-2 text-sm + focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500; + } +} +``` + +## Docs + +- https://tailwindcss.com/docs/adding-custom-styles +- https://tailwindcss.com/docs/functions-and-directives diff --git a/.agents/skills/tailwind/rules/core-responsive-and-states.md b/.agents/skills/tailwind/rules/core-responsive-and-states.md new file mode 100644 index 000000000..3df9e2c2e --- /dev/null +++ b/.agents/skills/tailwind/rules/core-responsive-and-states.md @@ -0,0 +1,164 @@ +# core-responsive-and-states + +Why it matters: Tailwind's modifier system is the primary way to handle responsive design, interactive states, and dark mode — understanding how modifiers compose prevents redundant CSS and ensures consistent behavior. + +## Responsive Design (mobile-first) + +```html + +
+``` + +Default breakpoints (from `@theme`): + +| Modifier | Min-width | +|----------|-----------| +| `sm:` | 40rem (640px) | +| `md:` | 48rem (768px) | +| `lg:` | 64rem (1024px) | +| `xl:` | 80rem (1280px) | +| `2xl:` | 96rem (1536px) | + +Custom breakpoint: + +```css +@theme { + --breakpoint-3xl: 120rem; +} +``` + +```html +
...
+``` + +## State Modifiers + +### Hover, Focus, Active + +```html + +``` + +### Group and Peer + +```html + +
+

Title

+

Description

+
+ + + + + +``` + +### First, Last, Odd, Even + +```html +
    +
  • Item
  • +
+``` + +## Dark Mode + +### Strategy 1: media query (default) + +```css +/* app.css */ +@import "tailwindcss"; +/* dark: variant uses prefers-color-scheme by default */ +``` + +```html +
+ Adapts to OS preference +
+``` + +### Strategy 2: class-based (manual toggle) + +```css +@import "tailwindcss"; + +@variant dark (&:where(.dark, .dark *)); +``` + +```html + + + ... + +``` + +```js +// Toggle dark mode +document.documentElement.classList.toggle('dark') +``` + +### Strategy 3: data attribute + +```css +@variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); +``` + +```html +... +``` + +## Custom Variants + +```css +/* @custom-variant for data attributes */ +@custom-variant theme-brand (&:where([data-theme="brand"] *)); +``` + +```html + +
...
+ +``` + +## Stacking modifiers + +```html + +} + /> + ); +} +``` + +**Targets:** + +- `admin.product-details.action.render` +- `admin.order-details.action.render` +- `admin.customer-details.action.render` + +### Admin Block + +Embedded content in admin pages. + +```javascript +import { + reactExtension, + BlockStack, + Text, + Badge, +} from "@shopify/ui-extensions-react/admin"; + +export default reactExtension("admin.product-details.block.render", () => ( + +)); + +function Extension() { + const { data } = useData(); + const [analytics, setAnalytics] = useState(null); + + useEffect(() => { + fetchAnalytics(data.product.id).then(setAnalytics); + }, []); + + return ( + + Product Analytics + Views: {analytics?.views || 0} + Conversions: {analytics?.conversions || 0} + + {analytics?.trending ? "Trending" : "Normal"} + + + ); +} +``` + +**Targets:** + +- `admin.product-details.block.render` +- `admin.order-details.block.render` +- `admin.customer-details.block.render` + +## POS UI Extensions + +Customize Point of Sale experience. + +### Smart Grid Tile + +Quick access action on POS home screen. + +```javascript +import { + reactExtension, + SmartGridTile, +} from "@shopify/ui-extensions-react/pos"; + +export default reactExtension("pos.home.tile.render", () => ); + +function Extension() { + function handlePress() { + // Navigate to custom workflow + } + + return ( + + ); +} +``` + +### POS Modal + +Full-screen workflow. + +```javascript +import { + reactExtension, + Screen, + BlockStack, + Button, + TextField, +} from "@shopify/ui-extensions-react/pos"; + +export default reactExtension("pos.home.modal.render", () => ); + +function Extension() { + const { navigation } = useApi(); + const [amount, setAmount] = useState(""); + + function handleIssue() { + // Issue gift card + navigation.pop(); + } + + return ( + + + + + + + + ); +} +``` + +## Customer Account Extensions + +Customize customer account pages. + +### Order Status Extension + +```javascript +import { + reactExtension, + BlockStack, + Text, + Button, +} from "@shopify/ui-extensions-react/customer-account"; + +export default reactExtension( + "customer-account.order-status.block.render", + () => , +); + +function Extension() { + const { order } = useApi(); + + function handleReturn() { + // Initiate return + } + + return ( + + Need to return? + Start return for order {order.name} + + + ); +} +``` + +**Targets:** + +- `customer-account.order-status.block.render` +- `customer-account.order-index.block.render` +- `customer-account.profile.block.render` + +## Shopify Functions + +Serverless backend customization. + +### Function Types + +**Discounts:** + +- `order_discount` - Order-level discounts +- `product_discount` - Product-specific discounts +- `shipping_discount` - Shipping discounts + +**Payment Customization:** + +- Hide/rename/reorder payment methods + +**Delivery Customization:** + +- Custom shipping options +- Delivery rules + +**Validation:** + +- Cart validation rules +- Checkout validation + +### Create Function + +```bash +shopify app generate extension --type function +``` + +### Order Discount Function + +```javascript +// input.graphql +query Input { + cart { + lines { + quantity + merchandise { + ... on ProductVariant { + product { + hasTag(tag: "bulk-discount") + } + } + } + } + } +} + +// function.js +export default function orderDiscount(input) { + const targets = input.cart.lines + .filter(line => line.merchandise.product.hasTag) + .map(line => ({ + productVariant: { id: line.merchandise.id } + })); + + if (targets.length === 0) { + return { discounts: [] }; + } + + return { + discounts: [{ + targets, + value: { + percentage: { + value: 10 // 10% discount + } + } + }] + }; +} +``` + +### Payment Customization Function + +```javascript +export default function paymentCustomization(input) { + const hidePaymentMethods = input.cart.lines.some( + (line) => line.merchandise.product.hasTag, + ); + + if (!hidePaymentMethods) { + return { operations: [] }; + } + + return { + operations: [ + { + hide: { + paymentMethodId: "gid://shopify/PaymentMethod/123", + }, + }, + ], + }; +} +``` + +### Validation Function + +```javascript +export default function cartValidation(input) { + const errors = []; + + // Max 5 items per cart + if (input.cart.lines.length > 5) { + errors.push({ + localizedMessage: "Maximum 5 items allowed per order", + target: "cart", + }); + } + + // Min $50 for wholesale + const isWholesale = input.cart.lines.some( + (line) => line.merchandise.product.hasTag, + ); + + if (isWholesale && input.cart.cost.totalAmount.amount < 50) { + errors.push({ + localizedMessage: "Wholesale orders require $50 minimum", + target: "cart", + }); + } + + return { errors }; +} +``` + +## Network Requests + +Extensions can call external APIs. + +```javascript +import { useApi } from "@shopify/ui-extensions-react/checkout"; + +function Extension() { + const { sessionToken } = useApi(); + + async function fetchData() { + const token = await sessionToken.get(); + + const response = await fetch("https://your-app.com/api/data", { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + return await response.json(); + } +} +``` + +## Best Practices + +**Performance:** + +- Lazy load data +- Memoize expensive computations +- Use loading states +- Minimize re-renders + +**UX:** + +- Provide clear error messages +- Show loading indicators +- Validate inputs +- Support keyboard navigation + +**Security:** + +- Verify session tokens on backend +- Sanitize user input +- Use HTTPS for all requests +- Don't expose sensitive data + +**Testing:** + +- Test on development stores +- Verify mobile/desktop +- Check accessibility +- Test edge cases + +## Resources + +- Checkout Extensions: https://shopify.dev/docs/api/checkout-extensions +- Admin Extensions: https://shopify.dev/docs/apps/admin/extensions +- Functions: https://shopify.dev/docs/apps/functions +- Components: https://shopify.dev/docs/api/checkout-ui-extensions/components diff --git a/.claude/skills/shopify-development/references/themes.md b/.claude/skills/shopify-development/references/themes.md new file mode 100644 index 000000000..2fc1c2b98 --- /dev/null +++ b/.claude/skills/shopify-development/references/themes.md @@ -0,0 +1,498 @@ +# Themes Reference + +Guide for developing Shopify themes with Liquid templating. + +## Liquid Templating + +### Syntax Basics + +**Objects (Output):** +```liquid +{{ product.title }} +{{ product.price | money }} +{{ customer.email }} +``` + +**Tags (Logic):** +```liquid +{% if product.available %} + +{% else %} +

Sold Out

+{% endif %} + +{% for product in collection.products %} + {{ product.title }} +{% endfor %} + +{% case product.type %} + {% when 'Clothing' %} + Apparel + {% when 'Shoes' %} + Footwear + {% else %} + Other +{% endcase %} +``` + +**Filters (Transform):** +```liquid +{{ product.title | upcase }} +{{ product.price | money }} +{{ product.description | strip_html | truncate: 100 }} +{{ product.image | img_url: 'medium' }} +{{ 'now' | date: '%B %d, %Y' }} +``` + +### Common Objects + +**Product:** +```liquid +{{ product.id }} +{{ product.title }} +{{ product.handle }} +{{ product.description }} +{{ product.price }} +{{ product.compare_at_price }} +{{ product.available }} +{{ product.type }} +{{ product.vendor }} +{{ product.tags }} +{{ product.images }} +{{ product.variants }} +{{ product.featured_image }} +{{ product.url }} +``` + +**Collection:** +```liquid +{{ collection.title }} +{{ collection.handle }} +{{ collection.description }} +{{ collection.products }} +{{ collection.products_count }} +{{ collection.image }} +{{ collection.url }} +``` + +**Cart:** +```liquid +{{ cart.item_count }} +{{ cart.total_price }} +{{ cart.items }} +{{ cart.note }} +{{ cart.attributes }} +``` + +**Customer:** +```liquid +{{ customer.email }} +{{ customer.first_name }} +{{ customer.last_name }} +{{ customer.orders_count }} +{{ customer.total_spent }} +{{ customer.addresses }} +{{ customer.default_address }} +``` + +**Shop:** +```liquid +{{ shop.name }} +{{ shop.email }} +{{ shop.domain }} +{{ shop.currency }} +{{ shop.money_format }} +{{ shop.enabled_payment_types }} +``` + +### Common Filters + +**String:** +- `upcase`, `downcase`, `capitalize` +- `strip_html`, `strip_newlines` +- `truncate: 100`, `truncatewords: 20` +- `replace: 'old', 'new'` + +**Number:** +- `money` - Format currency +- `round`, `ceil`, `floor` +- `times`, `divided_by`, `plus`, `minus` + +**Array:** +- `join: ', '` +- `first`, `last` +- `size` +- `map: 'property'` +- `where: 'property', 'value'` + +**URL:** +- `img_url: 'size'` - Image URL +- `url_for_type`, `url_for_vendor` +- `link_to`, `link_to_type` + +**Date:** +- `date: '%B %d, %Y'` + +## Theme Architecture + +### Directory Structure + +``` +theme/ +├── assets/ # CSS, JS, images +├── config/ # Theme settings +│ ├── settings_schema.json +│ └── settings_data.json +├── layout/ # Base templates +│ └── theme.liquid +├── locales/ # Translations +│ └── en.default.json +├── sections/ # Reusable blocks +│ ├── header.liquid +│ ├── footer.liquid +│ └── product-grid.liquid +├── snippets/ # Small components +│ ├── product-card.liquid +│ └── icon.liquid +└── templates/ # Page templates + ├── index.json + ├── product.json + ├── collection.json + └── cart.liquid +``` + +### Layout + +Base template wrapping all pages (`layout/theme.liquid`): + +```liquid + + + + + + {{ page_title }} + + {{ content_for_header }} + + + + + {% section 'header' %} + +
+ {{ content_for_layout }} +
+ + {% section 'footer' %} + + + + +``` + +### Templates + +Page-specific structures (`templates/product.json`): + +```json +{ + "sections": { + "main": { + "type": "product-template", + "settings": { + "show_vendor": true, + "show_quantity_selector": true + } + }, + "recommendations": { + "type": "product-recommendations" + } + }, + "order": ["main", "recommendations"] +} +``` + +Legacy format (`templates/product.liquid`): +```liquid +
+
+ {{ product.title }} +
+ +
+

{{ product.title }}

+

{{ product.price | money }}

+ + {% form 'product', product %} + + + + {% endform %} +
+
+``` + +### Sections + +Reusable content blocks (`sections/product-grid.liquid`): + +```liquid +
+ {% for product in section.settings.collection.products %} + + {% endfor %} +
+ +{% schema %} +{ + "name": "Product Grid", + "settings": [ + { + "type": "collection", + "id": "collection", + "label": "Collection" + }, + { + "type": "range", + "id": "products_per_row", + "min": 2, + "max": 5, + "step": 1, + "default": 4, + "label": "Products per row" + } + ], + "presets": [ + { + "name": "Product Grid" + } + ] +} +{% endschema %} +``` + +### Snippets + +Small reusable components (`snippets/product-card.liquid`): + +```liquid + +``` + +Include snippet: +```liquid +{% render 'product-card', product: product %} +``` + +## Development Workflow + +### Setup + +```bash +# Initialize new theme +shopify theme init + +# Choose Dawn (reference theme) or blank +``` + +### Local Development + +```bash +# Start local server +shopify theme dev + +# Preview at http://localhost:9292 +# Changes auto-sync to development theme +``` + +### Pull Theme + +```bash +# Pull live theme +shopify theme pull --live + +# Pull specific theme +shopify theme pull --theme=123456789 + +# Pull only templates +shopify theme pull --only=templates +``` + +### Push Theme + +```bash +# Push to development theme +shopify theme push --development + +# Create new unpublished theme +shopify theme push --unpublished + +# Push specific files +shopify theme push --only=sections,snippets +``` + +### Theme Check + +Lint theme code: +```bash +shopify theme check +shopify theme check --auto-correct +``` + +## Common Patterns + +### Product Form with Variants + +```liquid +{% form 'product', product %} + {% unless product.has_only_default_variant %} + {% for option in product.options_with_values %} +
+ + +
+ {% endfor %} + {% endunless %} + + + + + +{% endform %} +``` + +### Pagination + +```liquid +{% paginate collection.products by 12 %} + {% for product in collection.products %} + {% render 'product-card', product: product %} + {% endfor %} + + {% if paginate.pages > 1 %} + + {% endif %} +{% endpaginate %} +``` + +### Cart AJAX + +```javascript +// Add to cart +fetch('/cart/add.js', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: variantId, + quantity: 1 + }) +}) +.then(res => res.json()) +.then(item => console.log('Added:', item)); + +// Get cart +fetch('/cart.js') + .then(res => res.json()) + .then(cart => console.log('Cart:', cart)); + +// Update cart +fetch('/cart/change.js', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: lineItemKey, + quantity: 2 + }) +}) +.then(res => res.json()); +``` + +## Metafields in Themes + +Access custom data: + +```liquid +{{ product.metafields.custom.care_instructions }} +{{ product.metafields.custom.material.value }} + +{% if product.metafields.custom.featured %} + Featured +{% endif %} +``` + +## Best Practices + +**Performance:** +- Optimize images (use appropriate sizes) +- Minimize Liquid logic complexity +- Use lazy loading for images +- Defer non-critical JavaScript + +**Accessibility:** +- Use semantic HTML +- Include alt text for images +- Support keyboard navigation +- Ensure sufficient color contrast + +**SEO:** +- Use descriptive page titles +- Include meta descriptions +- Structure content with headings +- Implement schema markup + +**Code Quality:** +- Follow Shopify theme guidelines +- Use consistent naming conventions +- Comment complex logic +- Keep sections focused and reusable + +## Resources + +- Theme Development: https://shopify.dev/docs/themes +- Liquid Reference: https://shopify.dev/docs/api/liquid +- Dawn Theme: https://github.com/Shopify/dawn +- Theme Check: https://shopify.dev/docs/themes/tools/theme-check diff --git a/.claude/skills/shopify-development/scripts/.gitignore b/.claude/skills/shopify-development/scripts/.gitignore new file mode 100644 index 000000000..8abb6f180 --- /dev/null +++ b/.claude/skills/shopify-development/scripts/.gitignore @@ -0,0 +1,49 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Testing +.coverage +.pytest_cache/ +htmlcov/ +.tox/ +.nox/ +coverage.xml +*.cover +*.py,cover + +# Environments +.env +.venv +env/ +venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/.claude/skills/shopify-development/scripts/requirements.txt b/.claude/skills/shopify-development/scripts/requirements.txt new file mode 100644 index 000000000..4613a2baa --- /dev/null +++ b/.claude/skills/shopify-development/scripts/requirements.txt @@ -0,0 +1,19 @@ +# Shopify Skill Dependencies +# Python 3.10+ required + +# No Python package dependencies - uses only standard library + +# Testing dependencies (dev) +pytest>=8.0.0 +pytest-cov>=4.1.0 +pytest-mock>=3.12.0 + +# Note: This script requires the Shopify CLI tool +# Install Shopify CLI: +# npm install -g @shopify/cli @shopify/theme +# or via Homebrew (macOS): +# brew tap shopify/shopify +# brew install shopify-cli +# +# Authenticate with: +# shopify auth login diff --git a/.claude/skills/shopify-development/scripts/shopify_graphql.py b/.claude/skills/shopify-development/scripts/shopify_graphql.py new file mode 100644 index 000000000..ec8af3cb8 --- /dev/null +++ b/.claude/skills/shopify-development/scripts/shopify_graphql.py @@ -0,0 +1,428 @@ +#!/usr/bin/env python3 +""" +Shopify GraphQL Utilities + +Helper functions for common Shopify GraphQL operations. +Provides query templates, pagination helpers, and rate limit handling. + +Usage: + from shopify_graphql import ShopifyGraphQL + + client = ShopifyGraphQL(shop_domain, access_token) + products = client.get_products(first=10) +""" + +import os +import time +import json +from typing import Dict, List, Optional, Any, Generator +from dataclasses import dataclass +from urllib.request import Request, urlopen +from urllib.error import HTTPError + + +# API Configuration +API_VERSION = "2026-01" +MAX_RETRIES = 3 +RETRY_DELAY = 1.0 # seconds + + +@dataclass +class GraphQLResponse: + """Container for GraphQL response data.""" + data: Optional[Dict[str, Any]] = None + errors: Optional[List[Dict[str, Any]]] = None + extensions: Optional[Dict[str, Any]] = None + + @property + def is_success(self) -> bool: + return self.errors is None or len(self.errors) == 0 + + @property + def query_cost(self) -> Optional[int]: + """Get the actual query cost from extensions.""" + if self.extensions and 'cost' in self.extensions: + return self.extensions['cost'].get('actualQueryCost') + return None + + +class ShopifyGraphQL: + """ + Shopify GraphQL API client with built-in utilities. + + Features: + - Query templates for common operations + - Automatic pagination + - Rate limit handling with exponential backoff + - Response parsing helpers + """ + + def __init__(self, shop_domain: str, access_token: str): + """ + Initialize the GraphQL client. + + Args: + shop_domain: Store domain (e.g., 'my-store.myshopify.com') + access_token: Admin API access token + """ + self.shop_domain = shop_domain.replace('https://', '').replace('http://', '') + self.access_token = access_token + self.base_url = f"https://{self.shop_domain}/admin/api/{API_VERSION}/graphql.json" + + def execute(self, query: str, variables: Optional[Dict] = None) -> GraphQLResponse: + """ + Execute a GraphQL query/mutation. + + Args: + query: GraphQL query string + variables: Query variables + + Returns: + GraphQLResponse object + """ + payload = {"query": query} + if variables: + payload["variables"] = variables + + headers = { + "Content-Type": "application/json", + "X-Shopify-Access-Token": self.access_token + } + + for attempt in range(MAX_RETRIES): + try: + request = Request( + self.base_url, + data=json.dumps(payload).encode('utf-8'), + headers=headers, + method='POST' + ) + + with urlopen(request, timeout=30) as response: + result = json.loads(response.read().decode('utf-8')) + return GraphQLResponse( + data=result.get('data'), + errors=result.get('errors'), + extensions=result.get('extensions') + ) + + except HTTPError as e: + if e.code == 429: # Rate limited + delay = RETRY_DELAY * (2 ** attempt) + print(f"Rate limited. Retrying in {delay}s...") + time.sleep(delay) + continue + raise + except Exception as e: + if attempt == MAX_RETRIES - 1: + raise + time.sleep(RETRY_DELAY) + + return GraphQLResponse(errors=[{"message": "Max retries exceeded"}]) + + # ==================== Query Templates ==================== + + def get_products( + self, + first: int = 10, + query: Optional[str] = None, + after: Optional[str] = None + ) -> GraphQLResponse: + """ + Query products with pagination. + + Args: + first: Number of products to fetch (max 250) + query: Optional search query + after: Cursor for pagination + """ + gql = """ + query GetProducts($first: Int!, $query: String, $after: String) { + products(first: $first, query: $query, after: $after) { + edges { + node { + id + title + handle + status + totalInventory + variants(first: 5) { + edges { + node { + id + title + price + inventoryQuantity + sku + } + } + } + } + cursor + } + pageInfo { + hasNextPage + endCursor + } + } + } + """ + return self.execute(gql, {"first": first, "query": query, "after": after}) + + def get_orders( + self, + first: int = 10, + query: Optional[str] = None, + after: Optional[str] = None + ) -> GraphQLResponse: + """ + Query orders with pagination. + + Args: + first: Number of orders to fetch (max 250) + query: Optional search query (e.g., "financial_status:paid") + after: Cursor for pagination + """ + gql = """ + query GetOrders($first: Int!, $query: String, $after: String) { + orders(first: $first, query: $query, after: $after) { + edges { + node { + id + name + createdAt + displayFinancialStatus + displayFulfillmentStatus + totalPriceSet { + shopMoney { amount currencyCode } + } + customer { + id + firstName + lastName + } + lineItems(first: 5) { + edges { + node { + title + quantity + } + } + } + } + cursor + } + pageInfo { + hasNextPage + endCursor + } + } + } + """ + return self.execute(gql, {"first": first, "query": query, "after": after}) + + def get_customers( + self, + first: int = 10, + query: Optional[str] = None, + after: Optional[str] = None + ) -> GraphQLResponse: + """ + Query customers with pagination. + + Args: + first: Number of customers to fetch (max 250) + query: Optional search query + after: Cursor for pagination + """ + gql = """ + query GetCustomers($first: Int!, $query: String, $after: String) { + customers(first: $first, query: $query, after: $after) { + edges { + node { + id + firstName + lastName + displayName + defaultEmailAddress { + emailAddress + } + numberOfOrders + amountSpent { + amount + currencyCode + } + } + cursor + } + pageInfo { + hasNextPage + endCursor + } + } + } + """ + return self.execute(gql, {"first": first, "query": query, "after": after}) + + def set_metafields(self, metafields: List[Dict]) -> GraphQLResponse: + """ + Set metafields on resources. + + Args: + metafields: List of metafield inputs, each containing: + - ownerId: Resource GID + - namespace: Metafield namespace + - key: Metafield key + - value: Metafield value + - type: Metafield type + """ + gql = """ + mutation SetMetafields($metafields: [MetafieldsSetInput!]!) { + metafieldsSet(metafields: $metafields) { + metafields { + id + namespace + key + value + } + userErrors { + field + message + } + } + } + """ + return self.execute(gql, {"metafields": metafields}) + + # ==================== Pagination Helpers ==================== + + def paginate_products( + self, + batch_size: int = 50, + query: Optional[str] = None + ) -> Generator[Dict, None, None]: + """ + Generator that yields all products with automatic pagination. + + Args: + batch_size: Products per request (max 250) + query: Optional search query + + Yields: + Product dictionaries + """ + cursor = None + while True: + response = self.get_products(first=batch_size, query=query, after=cursor) + + if not response.is_success or not response.data: + break + + products = response.data.get('products', {}) + edges = products.get('edges', []) + + for edge in edges: + yield edge['node'] + + page_info = products.get('pageInfo', {}) + if not page_info.get('hasNextPage'): + break + + cursor = page_info.get('endCursor') + + def paginate_orders( + self, + batch_size: int = 50, + query: Optional[str] = None + ) -> Generator[Dict, None, None]: + """ + Generator that yields all orders with automatic pagination. + + Args: + batch_size: Orders per request (max 250) + query: Optional search query + + Yields: + Order dictionaries + """ + cursor = None + while True: + response = self.get_orders(first=batch_size, query=query, after=cursor) + + if not response.is_success or not response.data: + break + + orders = response.data.get('orders', {}) + edges = orders.get('edges', []) + + for edge in edges: + yield edge['node'] + + page_info = orders.get('pageInfo', {}) + if not page_info.get('hasNextPage'): + break + + cursor = page_info.get('endCursor') + + +# ==================== Utility Functions ==================== + +def extract_id(gid: str) -> str: + """ + Extract numeric ID from Shopify GID. + + Args: + gid: Global ID (e.g., 'gid://shopify/Product/123') + + Returns: + Numeric ID string (e.g., '123') + """ + return gid.split('/')[-1] if gid else '' + + +def build_gid(resource_type: str, id: str) -> str: + """ + Build Shopify GID from resource type and ID. + + Args: + resource_type: Resource type (e.g., 'Product', 'Order') + id: Numeric ID + + Returns: + Global ID (e.g., 'gid://shopify/Product/123') + """ + return f"gid://shopify/{resource_type}/{id}" + + +# ==================== Example Usage ==================== + +def main(): + """Example usage of ShopifyGraphQL client.""" + import os + + # Load from environment + shop = os.environ.get('SHOP_DOMAIN', 'your-store.myshopify.com') + token = os.environ.get('SHOPIFY_ACCESS_TOKEN', '') + + if not token: + print("Set SHOPIFY_ACCESS_TOKEN environment variable") + return + + client = ShopifyGraphQL(shop, token) + + # Example: Get first 5 products + print("Fetching products...") + response = client.get_products(first=5) + + if response.is_success: + products = response.data['products']['edges'] + for edge in products: + product = edge['node'] + print(f" - {product['title']} ({product['status']})") + print(f"\nQuery cost: {response.query_cost}") + else: + print(f"Errors: {response.errors}") + + +if __name__ == '__main__': + main() diff --git a/.claude/skills/shopify-development/scripts/shopify_init.py b/.claude/skills/shopify-development/scripts/shopify_init.py new file mode 100644 index 000000000..f0c664e12 --- /dev/null +++ b/.claude/skills/shopify-development/scripts/shopify_init.py @@ -0,0 +1,441 @@ +#!/usr/bin/env python3 +""" +Shopify Project Initialization Script + +Interactive script to scaffold Shopify apps, extensions, or themes. +Supports environment variable loading from multiple locations. +""" + +import os +import sys +import json +import subprocess +from pathlib import Path +from typing import Dict, Optional, List +from dataclasses import dataclass + + +@dataclass +class EnvConfig: + """Environment configuration container.""" + shopify_api_key: Optional[str] = None + shopify_api_secret: Optional[str] = None + shop_domain: Optional[str] = None + scopes: Optional[str] = None + + +class EnvLoader: + """Load environment variables from multiple sources in priority order.""" + + @staticmethod + def load_env_file(filepath: Path) -> Dict[str, str]: + """ + Load environment variables from .env file. + + Args: + filepath: Path to .env file + + Returns: + Dictionary of environment variables + """ + env_vars = {} + if not filepath.exists(): + return env_vars + + try: + with open(filepath, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + env_vars[key.strip()] = value.strip().strip('"').strip("'") + except Exception as e: + print(f"Warning: Failed to load {filepath}: {e}") + + return env_vars + + @staticmethod + def get_env_paths(skill_dir: Path) -> List[Path]: + """ + Get list of .env file paths in priority order. + + Works with any AI tool directory structure: + - .agent/skills/ (universal) + - .claude/skills/ (Claude Code) + - .gemini/skills/ (Gemini CLI) + - .cursor/skills/ (Cursor) + + Priority: process.env > skill/.env > skills/.env > agent_dir/.env + + Args: + skill_dir: Path to skill directory + + Returns: + List of .env file paths + """ + paths = [] + + # skill/.env + skill_env = skill_dir / '.env' + if skill_env.exists(): + paths.append(skill_env) + + # skills/.env + skills_env = skill_dir.parent / '.env' + if skills_env.exists(): + paths.append(skills_env) + + # agent_dir/.env (e.g., .agent, .claude, .gemini, .cursor) + agent_env = skill_dir.parent.parent / '.env' + if agent_env.exists(): + paths.append(agent_env) + + return paths + + @staticmethod + def load_config(skill_dir: Path) -> EnvConfig: + """ + Load configuration from environment variables. + + Works with any AI tool directory structure. + Priority: process.env > skill/.env > skills/.env > agent_dir/.env + + Args: + skill_dir: Path to skill directory + + Returns: + EnvConfig object + """ + config = EnvConfig() + + # Load from .env files (reverse priority order) + for env_path in reversed(EnvLoader.get_env_paths(skill_dir)): + env_vars = EnvLoader.load_env_file(env_path) + if 'SHOPIFY_API_KEY' in env_vars: + config.shopify_api_key = env_vars['SHOPIFY_API_KEY'] + if 'SHOPIFY_API_SECRET' in env_vars: + config.shopify_api_secret = env_vars['SHOPIFY_API_SECRET'] + if 'SHOP_DOMAIN' in env_vars: + config.shop_domain = env_vars['SHOP_DOMAIN'] + if 'SCOPES' in env_vars: + config.scopes = env_vars['SCOPES'] + + # Override with process environment (highest priority) + if 'SHOPIFY_API_KEY' in os.environ: + config.shopify_api_key = os.environ['SHOPIFY_API_KEY'] + if 'SHOPIFY_API_SECRET' in os.environ: + config.shopify_api_secret = os.environ['SHOPIFY_API_SECRET'] + if 'SHOP_DOMAIN' in os.environ: + config.shop_domain = os.environ['SHOP_DOMAIN'] + if 'SCOPES' in os.environ: + config.scopes = os.environ['SCOPES'] + + return config + + +class ShopifyInitializer: + """Initialize Shopify projects.""" + + def __init__(self, config: EnvConfig): + """ + Initialize ShopifyInitializer. + + Args: + config: Environment configuration + """ + self.config = config + + def prompt(self, message: str, default: Optional[str] = None) -> str: + """ + Prompt user for input. + + Args: + message: Prompt message + default: Default value + + Returns: + User input or default + """ + if default: + message = f"{message} [{default}]" + user_input = input(f"{message}: ").strip() + return user_input if user_input else (default or '') + + def select_option(self, message: str, options: List[str]) -> str: + """ + Prompt user to select from options. + + Args: + message: Prompt message + options: List of options + + Returns: + Selected option + """ + print(f"\n{message}") + for i, option in enumerate(options, 1): + print(f"{i}. {option}") + + while True: + try: + choice = int(input("Select option: ").strip()) + if 1 <= choice <= len(options): + return options[choice - 1] + print(f"Please select 1-{len(options)}") + except (ValueError, KeyboardInterrupt): + print("Invalid input") + + def check_cli_installed(self) -> bool: + """ + Check if Shopify CLI is installed. + + Returns: + True if installed, False otherwise + """ + try: + result = subprocess.run( + ['shopify', 'version'], + capture_output=True, + text=True, + timeout=5 + ) + return result.returncode == 0 + except (subprocess.SubprocessError, FileNotFoundError): + return False + + def create_app_config(self, project_dir: Path, app_name: str, scopes: str) -> None: + """ + Create shopify.app.toml configuration file. + + Args: + project_dir: Project directory + app_name: Application name + scopes: Access scopes + """ + config_content = f"""# Shopify App Configuration +name = "{app_name}" +client_id = "{self.config.shopify_api_key or 'YOUR_API_KEY'}" +application_url = "https://your-app.com" +embedded = true + +[build] +automatically_update_urls_on_dev = true +dev_store_url = "{self.config.shop_domain or 'your-store.myshopify.com'}" + +[access_scopes] +scopes = "{scopes}" + +[webhooks] +api_version = "2026-01" + +[[webhooks.subscriptions]] +topics = ["app/uninstalled"] +uri = "/webhooks/app/uninstalled" + +[webhooks.privacy_compliance] +customer_data_request_url = "/webhooks/gdpr/data-request" +customer_deletion_url = "/webhooks/gdpr/customer-deletion" +shop_deletion_url = "/webhooks/gdpr/shop-deletion" +""" + config_path = project_dir / 'shopify.app.toml' + config_path.write_text(config_content) + print(f"✓ Created {config_path}") + + def create_extension_config(self, project_dir: Path, extension_name: str, extension_type: str) -> None: + """ + Create shopify.extension.toml configuration file. + + Args: + project_dir: Project directory + extension_name: Extension name + extension_type: Extension type + """ + target_map = { + 'checkout': 'purchase.checkout.block.render', + 'admin_action': 'admin.product-details.action.render', + 'admin_block': 'admin.product-details.block.render', + 'pos': 'pos.home.tile.render', + 'function': 'function', + 'customer_account': 'customer-account.order-status.block.render', + 'theme_app': 'theme-app-extension' + } + + config_content = f"""name = "{extension_name}" +type = "ui_extension" +handle = "{extension_name.lower().replace(' ', '-')}" + +[extension_points] +api_version = "2026-01" + +[[extension_points.targets]] +target = "{target_map.get(extension_type, 'purchase.checkout.block.render')}" + +[capabilities] +network_access = true +api_access = true +""" + config_path = project_dir / 'shopify.extension.toml' + config_path.write_text(config_content) + print(f"✓ Created {config_path}") + + def create_readme(self, project_dir: Path, project_type: str, project_name: str) -> None: + """ + Create README.md file. + + Args: + project_dir: Project directory + project_type: Project type (app/extension/theme) + project_name: Project name + """ + content = f"""# {project_name} + +Shopify {project_type.capitalize()} project. + +## Setup + +```bash +# Install dependencies +npm install + +# Start development +shopify {project_type} dev +``` + +## Deployment + +```bash +# Deploy to Shopify +shopify {project_type} deploy +``` + +## Resources + +- [Shopify Documentation](https://shopify.dev/docs) +- [Shopify CLI](https://shopify.dev/docs/api/shopify-cli) +""" + readme_path = project_dir / 'README.md' + readme_path.write_text(content) + print(f"✓ Created {readme_path}") + + def init_app(self) -> None: + """Initialize Shopify app project.""" + print("\n=== Shopify App Initialization ===\n") + + app_name = self.prompt("App name", "my-shopify-app") + scopes = self.prompt("Access scopes", self.config.scopes or "read_products,write_products") + + project_dir = Path.cwd() / app_name + project_dir.mkdir(exist_ok=True) + + print(f"\nCreating app in {project_dir}...") + + self.create_app_config(project_dir, app_name, scopes) + self.create_readme(project_dir, "app", app_name) + + # Create basic package.json + package_json = { + "name": app_name.lower().replace(' ', '-'), + "version": "1.0.0", + "scripts": { + "dev": "shopify app dev", + "deploy": "shopify app deploy" + } + } + (project_dir / 'package.json').write_text(json.dumps(package_json, indent=2)) + print(f"✓ Created package.json") + + print(f"\n✓ App '{app_name}' initialized successfully!") + print(f"\nNext steps:") + print(f" cd {app_name}") + print(f" npm install") + print(f" shopify app dev") + + def init_extension(self) -> None: + """Initialize Shopify extension project.""" + print("\n=== Shopify Extension Initialization ===\n") + + extension_types = [ + 'checkout', + 'admin_action', + 'admin_block', + 'pos', + 'function', + 'customer_account', + 'theme_app' + ] + extension_type = self.select_option("Select extension type", extension_types) + + extension_name = self.prompt("Extension name", "my-extension") + + project_dir = Path.cwd() / extension_name + project_dir.mkdir(exist_ok=True) + + print(f"\nCreating extension in {project_dir}...") + + self.create_extension_config(project_dir, extension_name, extension_type) + self.create_readme(project_dir, "extension", extension_name) + + print(f"\n✓ Extension '{extension_name}' initialized successfully!") + print(f"\nNext steps:") + print(f" cd {extension_name}") + print(f" shopify app dev") + + def init_theme(self) -> None: + """Initialize Shopify theme project.""" + print("\n=== Shopify Theme Initialization ===\n") + + theme_name = self.prompt("Theme name", "my-theme") + + print(f"\nInitializing theme '{theme_name}'...") + print("\nRecommended: Use 'shopify theme init' for full theme scaffolding") + print(f"\nRun: shopify theme init {theme_name}") + + def run(self) -> None: + """Run interactive initialization.""" + print("=" * 60) + print("Shopify Project Initializer") + print("=" * 60) + + # Check CLI + if not self.check_cli_installed(): + print("\n⚠ Shopify CLI not found!") + print("Install: npm install -g @shopify/cli@latest") + sys.exit(1) + + # Select project type + project_types = ['app', 'extension', 'theme'] + project_type = self.select_option("Select project type", project_types) + + # Initialize based on type + if project_type == 'app': + self.init_app() + elif project_type == 'extension': + self.init_extension() + elif project_type == 'theme': + self.init_theme() + + +def main() -> None: + """Main entry point.""" + try: + # Get skill directory + script_dir = Path(__file__).parent + skill_dir = script_dir.parent + + # Load configuration + config = EnvLoader.load_config(skill_dir) + + # Initialize project + initializer = ShopifyInitializer(config) + initializer.run() + + except KeyboardInterrupt: + print("\n\nAborted.") + sys.exit(0) + except Exception as e: + print(f"\n✗ Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/.claude/skills/shopify-development/scripts/tests/test_shopify_init.py b/.claude/skills/shopify-development/scripts/tests/test_shopify_init.py new file mode 100644 index 000000000..bcebb7902 --- /dev/null +++ b/.claude/skills/shopify-development/scripts/tests/test_shopify_init.py @@ -0,0 +1,379 @@ +""" +Tests for shopify_init.py + +Run with: pytest test_shopify_init.py -v --cov=shopify_init --cov-report=term-missing +""" + +import os +import sys +import json +import pytest +import subprocess +from pathlib import Path +from unittest.mock import Mock, patch, mock_open, MagicMock + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from shopify_init import EnvLoader, EnvConfig, ShopifyInitializer + + +class TestEnvLoader: + """Test EnvLoader class.""" + + def test_load_env_file_success(self, tmp_path): + """Test loading valid .env file.""" + env_file = tmp_path / ".env" + env_file.write_text(""" +SHOPIFY_API_KEY=test_key +SHOPIFY_API_SECRET=test_secret +SHOP_DOMAIN=test.myshopify.com +# Comment line +SCOPES=read_products,write_products +""") + + result = EnvLoader.load_env_file(env_file) + + assert result['SHOPIFY_API_KEY'] == 'test_key' + assert result['SHOPIFY_API_SECRET'] == 'test_secret' + assert result['SHOP_DOMAIN'] == 'test.myshopify.com' + assert result['SCOPES'] == 'read_products,write_products' + + def test_load_env_file_with_quotes(self, tmp_path): + """Test loading .env file with quoted values.""" + env_file = tmp_path / ".env" + env_file.write_text(""" +SHOPIFY_API_KEY="test_key" +SHOPIFY_API_SECRET='test_secret' +""") + + result = EnvLoader.load_env_file(env_file) + + assert result['SHOPIFY_API_KEY'] == 'test_key' + assert result['SHOPIFY_API_SECRET'] == 'test_secret' + + def test_load_env_file_nonexistent(self, tmp_path): + """Test loading non-existent .env file.""" + result = EnvLoader.load_env_file(tmp_path / "nonexistent.env") + assert result == {} + + def test_load_env_file_invalid_format(self, tmp_path): + """Test loading .env file with invalid lines.""" + env_file = tmp_path / ".env" + env_file.write_text(""" +VALID_KEY=value +INVALID_LINE_NO_EQUALS +ANOTHER_VALID=test +""") + + result = EnvLoader.load_env_file(env_file) + + assert result['VALID_KEY'] == 'value' + assert result['ANOTHER_VALID'] == 'test' + assert 'INVALID_LINE_NO_EQUALS' not in result + + def test_get_env_paths(self, tmp_path): + """Test getting .env file paths from universal directory structure.""" + # Create directory structure (works with .agent, .claude, .gemini, .cursor) + agent_dir = tmp_path / ".agent" + skills_dir = agent_dir / "skills" + skill_dir = skills_dir / "shopify" + + skill_dir.mkdir(parents=True) + + # Create .env files at each level + (skill_dir / ".env").write_text("SKILL=1") + (skills_dir / ".env").write_text("SKILLS=1") + (agent_dir / ".env").write_text("AGENT=1") + + paths = EnvLoader.get_env_paths(skill_dir) + + assert len(paths) == 3 + assert skill_dir / ".env" in paths + assert skills_dir / ".env" in paths + assert agent_dir / ".env" in paths + + def test_load_config_priority(self, tmp_path, monkeypatch): + """Test configuration loading priority across different AI tool directories.""" + skill_dir = tmp_path / "skill" + skills_dir = tmp_path + agent_dir = tmp_path.parent # Could be .agent, .claude, .gemini, .cursor + + skill_dir.mkdir(parents=True) + + (skill_dir / ".env").write_text("SHOPIFY_API_KEY=skill_key") + (skills_dir / ".env").write_text("SHOPIFY_API_KEY=skills_key\nSHOP_DOMAIN=skills.myshopify.com") + + monkeypatch.setenv("SHOPIFY_API_KEY", "process_key") + + config = EnvLoader.load_config(skill_dir) + + assert config.shopify_api_key == "process_key" + # Shop domain from skills/.env + assert config.shop_domain == "skills.myshopify.com" + + def test_load_config_no_files(self, tmp_path): + """Test configuration loading with no .env files.""" + config = EnvLoader.load_config(tmp_path) + + assert config.shopify_api_key is None + assert config.shopify_api_secret is None + assert config.shop_domain is None + assert config.scopes is None + + +class TestShopifyInitializer: + """Test ShopifyInitializer class.""" + + @pytest.fixture + def config(self): + """Create test config.""" + return EnvConfig( + shopify_api_key="test_key", + shopify_api_secret="test_secret", + shop_domain="test.myshopify.com", + scopes="read_products,write_products" + ) + + @pytest.fixture + def initializer(self, config): + """Create initializer instance.""" + return ShopifyInitializer(config) + + def test_prompt_with_default(self, initializer): + """Test prompt with default value.""" + with patch('builtins.input', return_value=''): + result = initializer.prompt("Test", "default_value") + assert result == "default_value" + + def test_prompt_with_input(self, initializer): + """Test prompt with user input.""" + with patch('builtins.input', return_value='user_input'): + result = initializer.prompt("Test", "default_value") + assert result == "user_input" + + def test_select_option_valid(self, initializer): + """Test select option with valid choice.""" + options = ['app', 'extension', 'theme'] + with patch('builtins.input', return_value='2'): + result = initializer.select_option("Choose", options) + assert result == 'extension' + + def test_select_option_invalid_then_valid(self, initializer): + """Test select option with invalid then valid choice.""" + options = ['app', 'extension'] + with patch('builtins.input', side_effect=['5', 'invalid', '1']): + result = initializer.select_option("Choose", options) + assert result == 'app' + + def test_check_cli_installed_success(self, initializer): + """Test CLI installed check - success.""" + mock_result = Mock() + mock_result.returncode = 0 + + with patch('subprocess.run', return_value=mock_result): + assert initializer.check_cli_installed() is True + + def test_check_cli_installed_failure(self, initializer): + """Test CLI installed check - failure.""" + with patch('subprocess.run', side_effect=FileNotFoundError): + assert initializer.check_cli_installed() is False + + def test_create_app_config(self, initializer, tmp_path): + """Test creating app configuration file.""" + initializer.create_app_config(tmp_path, "test-app", "read_products") + + config_file = tmp_path / "shopify.app.toml" + assert config_file.exists() + + content = config_file.read_text() + assert 'name = "test-app"' in content + assert 'scopes = "read_products"' in content + assert 'client_id = "test_key"' in content + + def test_create_extension_config(self, initializer, tmp_path): + """Test creating extension configuration file.""" + initializer.create_extension_config(tmp_path, "test-ext", "checkout") + + config_file = tmp_path / "shopify.extension.toml" + assert config_file.exists() + + content = config_file.read_text() + assert 'name = "test-ext"' in content + assert 'purchase.checkout.block.render' in content + + def test_create_extension_config_admin_action(self, initializer, tmp_path): + """Test creating admin action extension config.""" + initializer.create_extension_config(tmp_path, "admin-ext", "admin_action") + + config_file = tmp_path / "shopify.extension.toml" + content = config_file.read_text() + assert 'admin.product-details.action.render' in content + + def test_create_readme(self, initializer, tmp_path): + """Test creating README file.""" + initializer.create_readme(tmp_path, "app", "Test App") + + readme_file = tmp_path / "README.md" + assert readme_file.exists() + + content = readme_file.read_text() + assert '# Test App' in content + assert 'shopify app dev' in content + + @patch('builtins.input') + @patch('builtins.print') + def test_init_app(self, mock_print, mock_input, initializer, tmp_path, monkeypatch): + """Test app initialization.""" + monkeypatch.chdir(tmp_path) + + # Mock user inputs + mock_input.side_effect = ['my-app', 'read_products,write_products'] + + initializer.init_app() + + # Check directory created + app_dir = tmp_path / "my-app" + assert app_dir.exists() + + # Check files created + assert (app_dir / "shopify.app.toml").exists() + assert (app_dir / "README.md").exists() + assert (app_dir / "package.json").exists() + + # Check package.json content + package_json = json.loads((app_dir / "package.json").read_text()) + assert package_json['name'] == 'my-app' + assert 'dev' in package_json['scripts'] + + @patch('builtins.input') + @patch('builtins.print') + def test_init_extension(self, mock_print, mock_input, initializer, tmp_path, monkeypatch): + """Test extension initialization.""" + monkeypatch.chdir(tmp_path) + + # Mock user inputs: type selection (1 = checkout), name + mock_input.side_effect = ['1', 'my-extension'] + + initializer.init_extension() + + # Check directory and files created + ext_dir = tmp_path / "my-extension" + assert ext_dir.exists() + assert (ext_dir / "shopify.extension.toml").exists() + assert (ext_dir / "README.md").exists() + + @patch('builtins.input') + @patch('builtins.print') + def test_init_theme(self, mock_print, mock_input, initializer): + """Test theme initialization.""" + mock_input.return_value = 'my-theme' + + initializer.init_theme() + + assert mock_print.called + + @patch('builtins.print') + def test_run_no_cli(self, mock_print, initializer): + """Test run when CLI not installed.""" + with patch.object(initializer, 'check_cli_installed', return_value=False): + with pytest.raises(SystemExit) as exc_info: + initializer.run() + assert exc_info.value.code == 1 + + @patch.object(ShopifyInitializer, 'check_cli_installed', return_value=True) + @patch.object(ShopifyInitializer, 'init_app') + @patch('builtins.input') + @patch('builtins.print') + def test_run_app_selected(self, mock_print, mock_input, mock_init_app, mock_cli_check, initializer): + """Test run with app selection.""" + mock_input.return_value = '1' # Select app + + initializer.run() + + mock_init_app.assert_called_once() + + @patch.object(ShopifyInitializer, 'check_cli_installed', return_value=True) + @patch.object(ShopifyInitializer, 'init_extension') + @patch('builtins.input') + @patch('builtins.print') + def test_run_extension_selected(self, mock_print, mock_input, mock_init_ext, mock_cli_check, initializer): + """Test run with extension selection.""" + mock_input.return_value = '2' # Select extension + + initializer.run() + + mock_init_ext.assert_called_once() + + +class TestMain: + """Test main function.""" + + @patch('shopify_init.ShopifyInitializer') + @patch('shopify_init.EnvLoader') + def test_main_success(self, mock_loader, mock_initializer): + """Test main function success path.""" + from shopify_init import main + + mock_config = Mock() + mock_loader.load_config.return_value = mock_config + + mock_init_instance = Mock() + mock_initializer.return_value = mock_init_instance + + with patch('builtins.print'): + main() + + mock_init_instance.run.assert_called_once() + + @patch('shopify_init.ShopifyInitializer') + @patch('sys.exit') + def test_main_keyboard_interrupt(self, mock_exit, mock_initializer): + """Test main function with keyboard interrupt.""" + from shopify_init import main + + mock_initializer.return_value.run.side_effect = KeyboardInterrupt + + with patch('builtins.print'): + main() + + mock_exit.assert_called_with(0) + + @patch('shopify_init.ShopifyInitializer') + @patch('sys.exit') + def test_main_exception(self, mock_exit, mock_initializer): + """Test main function with exception.""" + from shopify_init import main + + mock_initializer.return_value.run.side_effect = Exception("Test error") + + with patch('builtins.print'): + main() + + mock_exit.assert_called_with(1) + + +class TestEnvConfig: + """Test EnvConfig dataclass.""" + + def test_env_config_defaults(self): + """Test EnvConfig default values.""" + config = EnvConfig() + + assert config.shopify_api_key is None + assert config.shopify_api_secret is None + assert config.shop_domain is None + assert config.scopes is None + + def test_env_config_with_values(self): + """Test EnvConfig with values.""" + config = EnvConfig( + shopify_api_key="key", + shopify_api_secret="secret", + shop_domain="test.myshopify.com", + scopes="read_products" + ) + + assert config.shopify_api_key == "key" + assert config.shopify_api_secret == "secret" + assert config.shop_domain == "test.myshopify.com" + assert config.scopes == "read_products" diff --git a/.claude/skills/tailwind/SKILL.md b/.claude/skills/tailwind/SKILL.md new file mode 100644 index 000000000..b86774848 --- /dev/null +++ b/.claude/skills/tailwind/SKILL.md @@ -0,0 +1,80 @@ +--- +name: tailwind +description: >- + Tailwind CSS v4 best-practices skill covering utility-first patterns, @theme + variables, responsive design, dark mode, custom styles, performance, + accessibility, and a Figma-to-Tailwind theme generation workflow. Use when + the user mentions Tailwind, tailwindcss, @theme, utility classes, Tailwind + config, Figma design tokens, or asks to build, configure, audit, or migrate + a Tailwind CSS project (including v3 to v4 migrations). +--- + +# Tailwind CSS Best Practices + +Tailwind CSS v4 guide organized as modular rules. Covers the utility-first model, `@theme` variables, responsive/state variants, custom styles, performance, accessibility, and the **Figma → Tailwind theme workflow** for generating design tokens directly from Figma variables. + +## ROUTING: Which rule file to load + +**IF setting up Tailwind or understanding how utility classes work:** +→ Read `rules/core-utility-model.md` + +**IF working with theme variables (`@theme`), design tokens, colors, fonts, spacing:** +→ Read `rules/core-theme-variables.md` + +**IF working with responsive design, hover/focus states, dark mode, or custom variants:** +→ Read `rules/core-responsive-and-states.md` + +**IF adding custom CSS, component classes, base styles, or custom utilities:** +→ Read `rules/core-custom-styles.md` + +**IF optimizing build size, purging unused classes, or configuring content detection:** +→ Read `rules/perf-purging-and-scanning.md` + +**IF working on accessibility or dark mode strategies:** +→ Read `rules/a11y-and-dark-mode.md` + +**IF translating Figma variables/design tokens into Tailwind v4 theme CSS:** +→ Read `rules/figma-to-theme-workflow.md` + see `figma-tokens/` templates + +## Rule index + +| Topic | Description | File | +|-------|-------------|------| +| Sections overview | Categories and reading order | [rules/_sections.md](rules/_sections.md) | +| Utility model | Utility-first principles, composing classes, arbitrary values | [rules/core-utility-model.md](rules/core-utility-model.md) | +| Theme variables | `@theme` directive, namespaces, extend/override, `inline`/`static` | [rules/core-theme-variables.md](rules/core-theme-variables.md) | +| Responsive & states | Breakpoints, hover/focus/dark variants, custom variants | [rules/core-responsive-and-states.md](rules/core-responsive-and-states.md) | +| Custom styles | `@layer`, `@utility`, `@variant`, component classes | [rules/core-custom-styles.md](rules/core-custom-styles.md) | +| Performance | Content detection, JIT, build optimization | [rules/perf-purging-and-scanning.md](rules/perf-purging-and-scanning.md) | +| A11y & dark mode | Accessibility utilities, dark mode patterns | [rules/a11y-and-dark-mode.md](rules/a11y-and-dark-mode.md) | +| **Figma workflow** | **Agent workflow: Figma variables → Tailwind `@theme` CSS** | [rules/figma-to-theme-workflow.md](rules/figma-to-theme-workflow.md) | + +## Figma → Tailwind theme workflow + +This skill includes a dedicated agent workflow to convert Figma design variables into Tailwind v4 `@theme` CSS files. The workflow is described in `rules/figma-to-theme-workflow.md` and uses the annotated templates in `figma-tokens/` as output targets. + +**How to trigger it:** paste your Figma CSS variables into chat and ask the agent to generate the Tailwind theme files. + +Template files in `figma-tokens/`: +- `colors.css` — `--color-*` namespace +- `typography.css` — `--font-*`, `--text-*`, `--font-weight-*`, `--tracking-*`, `--leading-*` +- `spacing.css` — `--spacing` and `--spacing-*` +- `radius-shadows.css` — `--radius-*`, `--shadow-*` +- `breakpoints.css` — `--breakpoint-*` + +## Rule categories by priority + +| Priority | Category | Impact | Prefix | +|----------|----------|--------|--------| +| 1 | Utility model & theme | CRITICAL | `core-` | +| 2 | Responsive & states | HIGH | `core-` | +| 3 | Custom styles | HIGH | `core-` | +| 4 | Figma workflow | HIGH | (standalone) | +| 5 | Performance | MEDIUM-HIGH | `perf-` | +| 6 | Accessibility | MEDIUM | `a11y-` | + +## Coverage and maintenance + +- Coverage map: `rules/_coverage-map.md` +- Source: https://tailwindcss.com/docs (v4.2) +- Update when Tailwind releases a new major/minor version with breaking `@theme` changes. diff --git a/.claude/skills/tailwind/figma-tokens/breakpoints.css b/.claude/skills/tailwind/figma-tokens/breakpoints.css new file mode 100644 index 000000000..79b5ee319 --- /dev/null +++ b/.claude/skills/tailwind/figma-tokens/breakpoints.css @@ -0,0 +1,39 @@ +/* Figma Design Tokens — Breakpoints + * + * This file is generated by the agent from Figma CSS variables. + * See: skills/tailwind/rules/figma-to-theme-workflow.md + * + * Namespace: --breakpoint-* + * Generates: responsive variant prefixes (sm:*, md:*, lg:*, xl:*, etc.) + * + * Usage: + * @import "./figma-tokens/breakpoints.css"; + * + * Notes: + * - Tailwind breakpoints are MINIMUM widths (mobile-first). + * - Convert px to rem (÷ 16) for accessibility (respects browser font size scaling). + * - Overriding a default breakpoint (e.g. --breakpoint-sm) replaces the default value. + * - Adding a new name (e.g. --breakpoint-3xl) adds a new responsive variant. + * - Figma "Desktop" frame widths are typically MAX widths — map them to Tailwind's + * MIN-width convention: e.g. Figma "Tablet" at 768px → --breakpoint-md: 48rem + * + * Figma frame → Tailwind breakpoint mapping (common): + * Mobile: 360px → not usually a breakpoint (base/mobile-first) + * Tablet: 768px → --breakpoint-md: 48rem + * Desktop: 1024px → --breakpoint-lg: 64rem + * Wide: 1280px → --breakpoint-xl: 80rem + * + * Example output — replace with your actual tokens: + */ + +@theme { + /* Standard breakpoints (override Tailwind defaults or leave as-is) */ + --breakpoint-sm: 40rem; /* 640px */ + --breakpoint-md: 48rem; /* 768px */ + --breakpoint-lg: 64rem; /* 1024px */ + --breakpoint-xl: 80rem; /* 1280px */ + --breakpoint-2xl: 96rem; /* 1536px */ + + /* Custom breakpoints (add as needed) */ + /* --breakpoint-3xl: 120rem; */ /* 1920px */ +} diff --git a/.claude/skills/tailwind/figma-tokens/colors.css b/.claude/skills/tailwind/figma-tokens/colors.css new file mode 100644 index 000000000..b994e5764 --- /dev/null +++ b/.claude/skills/tailwind/figma-tokens/colors.css @@ -0,0 +1,42 @@ +/* Figma Design Tokens — Colors + * + * This file is generated by the agent from Figma CSS variables. + * See: skills/tailwind/rules/figma-to-theme-workflow.md + * + * Namespace: --color-* + * Generates: bg-*, text-*, border-*, ring-*, fill-*, stroke-*, decoration-*, etc. + * + * Usage: + * @import "./figma-tokens/colors.css"; + * in your main app.css (after @import "tailwindcss") + * + * Notes: + * - Use OKLCH for perceptually uniform colors when possible. + * - HEX/RGB values are also valid. + * - To completely replace Tailwind's default palette, add: --color-*: initial; + * as the first declaration inside @theme (removes ALL default color utilities). + * + * Example output — replace with your actual tokens: + */ + +@theme { + /* Brand */ + --color-brand-50: oklch(0.97 0.02 40); + --color-brand-100: oklch(0.93 0.05 40); + --color-brand-200: oklch(0.87 0.10 40); + --color-brand-300: oklch(0.79 0.15 40); + --color-brand-400: oklch(0.72 0.18 40); + --color-brand-500: oklch(0.66 0.20 40); /* primary */ + --color-brand-600: oklch(0.58 0.19 40); + --color-brand-700: oklch(0.49 0.17 40); + --color-brand-800: oklch(0.38 0.13 40); + --color-brand-900: oklch(0.27 0.09 40); + --color-brand-950: oklch(0.18 0.06 40); + + /* Neutral (surface, text, borders) */ + --color-surface: oklch(0.98 0 0); + --color-surface-muted: oklch(0.95 0 0); + --color-text: oklch(0.15 0 0); + --color-text-muted: oklch(0.45 0 0); + --color-border: oklch(0.88 0 0); +} diff --git a/.claude/skills/tailwind/figma-tokens/radius-shadows.css b/.claude/skills/tailwind/figma-tokens/radius-shadows.css new file mode 100644 index 000000000..bbd935efc --- /dev/null +++ b/.claude/skills/tailwind/figma-tokens/radius-shadows.css @@ -0,0 +1,44 @@ +/* Figma Design Tokens — Border Radius & Shadows + * + * This file is generated by the agent from Figma CSS variables. + * See: skills/tailwind/rules/figma-to-theme-workflow.md + * + * Namespaces: + * --radius-* → rounded-* utilities + * --shadow-* → shadow-* utilities + * --inset-shadow-* → inset shadow utilities + * --drop-shadow-* → drop-shadow filter utilities + * + * Usage: + * @import "./figma-tokens/radius-shadows.css"; + * + * Notes: + * - Convert px radius values to rem (÷ 16). + * - Shadow values: use rgb(0 0 0 / 0.1) modern syntax instead of rgba(). + * - Multiple shadow layers: separate with comma in the value string. + * + * Example output — replace with your actual tokens: + */ + +@theme { + /* Border radius */ + --radius-xs: 0.125rem; /* 2px */ + --radius-sm: 0.25rem; /* 4px */ + --radius-md: 0.5rem; /* 8px */ + --radius-lg: 0.75rem; /* 12px */ + --radius-xl: 1rem; /* 16px */ + --radius-2xl: 1.5rem; /* 24px */ + --radius-full: 9999px; /* pill */ + + /* Box shadows */ + --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.10), 0 1px 2px -1px rgb(0 0 0 / 0.10); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10); + --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25); + + /* Inset shadows */ + --inset-shadow-xs: inset 0 1px 1px rgb(0 0 0 / 0.05); + --inset-shadow-sm: inset 0 2px 4px rgb(0 0 0 / 0.05); +} diff --git a/.claude/skills/tailwind/figma-tokens/spacing.css b/.claude/skills/tailwind/figma-tokens/spacing.css new file mode 100644 index 000000000..96d952c11 --- /dev/null +++ b/.claude/skills/tailwind/figma-tokens/spacing.css @@ -0,0 +1,35 @@ +/* Figma Design Tokens — Spacing + * + * This file is generated by the agent from Figma CSS variables. + * See: skills/tailwind/rules/figma-to-theme-workflow.md + * + * Namespace: --spacing-* and --spacing (base unit) + * Generates: p-*, m-*, gap-*, w-*, h-*, inset-*, space-*, translate-*, etc. + * + * Usage: + * @import "./figma-tokens/spacing.css"; + * + * Notes: + * - If Figma has a single base grid unit (e.g. 4px), set --spacing to that value. + * Tailwind then uses multipliers: p-4 = 4 × --spacing. + * - If Figma exports named steps, define them as --spacing-{name} tokens. + * - Convert px to rem (÷ 16) for scalable layouts. + * - Named tokens like --spacing-md generate utilities: p-md, m-md, gap-md, etc. + * + * Example output — replace with your actual tokens: + */ + +@theme { + /* Base unit — all numeric spacing utilities are multiples of this value. + * With --spacing: 0.25rem, then: p-1 = 0.25rem, p-4 = 1rem, p-8 = 2rem, etc. */ + --spacing: 0.25rem; /* 4px base grid */ + + /* Named spacing tokens (optional — use when Figma defines semantic steps) */ + --spacing-xs: 0.25rem; /* 4px */ + --spacing-sm: 0.5rem; /* 8px */ + --spacing-md: 1rem; /* 16px */ + --spacing-lg: 1.5rem; /* 24px */ + --spacing-xl: 2.5rem; /* 40px */ + --spacing-2xl: 4rem; /* 64px */ + --spacing-3xl: 6rem; /* 96px */ +} diff --git a/.claude/skills/tailwind/figma-tokens/typography.css b/.claude/skills/tailwind/figma-tokens/typography.css new file mode 100644 index 000000000..1aaf50b91 --- /dev/null +++ b/.claude/skills/tailwind/figma-tokens/typography.css @@ -0,0 +1,65 @@ +/* Figma Design Tokens — Typography + * + * This file is generated by the agent from Figma CSS variables. + * See: skills/tailwind/rules/figma-to-theme-workflow.md + * + * Namespaces: + * --font-* → font-family utilities (font-sans, font-heading, etc.) + * --text-* → font-size utilities (text-sm, text-xl, etc.) + * --font-weight-* → font-weight utilities (font-normal, font-bold, etc.) + * --tracking-* → letter-spacing utilities (tracking-tight, etc.) + * --leading-* → line-height utilities (leading-normal, etc.) + * + * Usage: + * @import "./figma-tokens/typography.css"; + * + * Notes: + * - px values should be converted to rem (÷ 16). + * - Include --text-*--line-height companion vars when Figma provides line-height. + * - Font stacks: always include a generic fallback (sans-serif, monospace, etc.) + * + * Example output — replace with your actual tokens: + */ + +@theme { + /* Font families */ + --font-heading: "Neue Haas Grotesk Display", ui-sans-serif, sans-serif; + --font-body: "Inter", ui-sans-serif, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, monospace; + + /* Font sizes (px → rem: value / 16) */ + --text-xs: 0.75rem; /* 12px */ + --text-sm: 0.875rem; /* 14px */ + --text-base: 1rem; /* 16px */ + --text-lg: 1.125rem; /* 18px */ + --text-xl: 1.25rem; /* 20px */ + --text-2xl: 1.5rem; /* 24px */ + --text-3xl: 2rem; /* 32px */ + --text-4xl: 3rem; /* 48px */ + --text-5xl: 4rem; /* 64px */ + + /* Line heights (companion vars — optional but recommended) */ + --text-xs--line-height: 1.5; + --text-sm--line-height: 1.5; + --text-base--line-height: 1.5; + --text-lg--line-height: 1.6; + --text-xl--line-height: 1.4; + --text-2xl--line-height: 1.3; + --text-3xl--line-height: 1.2; + --text-4xl--line-height: 1.1; + --text-5xl--line-height: 1; + + /* Letter spacing */ + --tracking-tight: -0.03em; + --tracking-normal: 0; + --tracking-wide: 0.04em; + --tracking-widest: 0.1em; + + /* Line heights (standalone) */ + --leading-none: 1; + --leading-tight: 1.25; + --leading-snug: 1.375; + --leading-normal: 1.5; + --leading-relaxed: 1.625; + --leading-loose: 2; +} diff --git a/.claude/skills/tailwind/rules/a11y-and-dark-mode.md b/.claude/skills/tailwind/rules/a11y-and-dark-mode.md new file mode 100644 index 000000000..1c5336b60 --- /dev/null +++ b/.claude/skills/tailwind/rules/a11y-and-dark-mode.md @@ -0,0 +1,135 @@ +# a11y-and-dark-mode + +Why it matters: accessibility and color scheme support are not afterthoughts — they affect usability for a large portion of users and are increasingly required by law (WCAG compliance). + +## Screen Reader Utilities + +```html + + + + + + Skip to main content + +``` + +## Focus Styles + +Never remove focus rings without a proper replacement: + +```html + + + + + +``` + +`focus-visible:` only shows the ring for keyboard navigation, not mouse clicks. + +## Color Contrast + +Use Tailwind's palette with contrast in mind: + +```html + +

Hard to read

+ + +

Easy to read

+

Also fine

+``` + +Verify contrast ratios when defining custom `@theme` colors. Prefer OKLCH for predictable perceptual lightness. + +## Reduced Motion + +```html + +
+``` + +```css +/* In custom CSS */ +.animated-element { + transition: transform 300ms ease; + + @variant motion-reduce { + transition: none; + } +} +``` + +## Forced Colors (High Contrast Mode) + +```html + +
+ Important UI element +
+ + +
+ Regular content +
+``` + +## Dark Mode — Design Guidance + +When implementing dark mode, ensure: + +1. **All interactive states have dark variants:** `hover:`, `focus:`, `active:` need `dark:` counterparts. + +```html + +
Larger shadow
+``` + +## Adding custom utilities with `@utility` + +```css +/* Simple custom utility */ +@utility content-auto { + content-visibility: auto; +} + +/* Complex utility with nested selectors */ +@utility scrollbar-hidden { + &::-webkit-scrollbar { display: none; } + scrollbar-width: none; +} +``` + +```html + +
+
+``` + +## Using `@variant` in custom CSS + +```css +.custom-element { + background: var(--color-white); + + @variant dark { + background: var(--color-gray-900); + } + + @variant hover { + background: var(--color-gray-50); + } +} +``` + +## Custom variants with `@custom-variant` + +```css +/* Shorthand (no nesting needed) */ +@custom-variant hocus (&:hover, &:focus); + +/* With nesting */ +@custom-variant any-hover { + @media (any-hover: hover) { + &:hover { @slot; } + } +} +``` + +```html + +Only underlines on hover-capable devices +``` + +## When NOT to use `@apply` + +```css +/* Incorrect — using @apply for one-off element styles */ +.hero { @apply text-4xl font-bold mb-8 text-gray-900; } + +/* Correct — write utilities in HTML directly */ +/*

*/ + +/* @apply is appropriate for genuinely reusable component abstractions */ +@layer components { + .form-input { + @apply w-full rounded-md border border-gray-300 px-3 py-2 text-sm + focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500; + } +} +``` + +## Docs + +- https://tailwindcss.com/docs/adding-custom-styles +- https://tailwindcss.com/docs/functions-and-directives diff --git a/.claude/skills/tailwind/rules/core-responsive-and-states.md b/.claude/skills/tailwind/rules/core-responsive-and-states.md new file mode 100644 index 000000000..3df9e2c2e --- /dev/null +++ b/.claude/skills/tailwind/rules/core-responsive-and-states.md @@ -0,0 +1,164 @@ +# core-responsive-and-states + +Why it matters: Tailwind's modifier system is the primary way to handle responsive design, interactive states, and dark mode — understanding how modifiers compose prevents redundant CSS and ensures consistent behavior. + +## Responsive Design (mobile-first) + +```html + +
+``` + +Default breakpoints (from `@theme`): + +| Modifier | Min-width | +|----------|-----------| +| `sm:` | 40rem (640px) | +| `md:` | 48rem (768px) | +| `lg:` | 64rem (1024px) | +| `xl:` | 80rem (1280px) | +| `2xl:` | 96rem (1536px) | + +Custom breakpoint: + +```css +@theme { + --breakpoint-3xl: 120rem; +} +``` + +```html +
...
+``` + +## State Modifiers + +### Hover, Focus, Active + +```html + +``` + +### Group and Peer + +```html + +
+

Title

+

Description

+
+ + + + + +``` + +### First, Last, Odd, Even + +```html +
    +
  • Item
  • +
+``` + +## Dark Mode + +### Strategy 1: media query (default) + +```css +/* app.css */ +@import "tailwindcss"; +/* dark: variant uses prefers-color-scheme by default */ +``` + +```html +
+ Adapts to OS preference +
+``` + +### Strategy 2: class-based (manual toggle) + +```css +@import "tailwindcss"; + +@variant dark (&:where(.dark, .dark *)); +``` + +```html + + + ... + +``` + +```js +// Toggle dark mode +document.documentElement.classList.toggle('dark') +``` + +### Strategy 3: data attribute + +```css +@variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); +``` + +```html +... +``` + +## Custom Variants + +```css +/* @custom-variant for data attributes */ +@custom-variant theme-brand (&:where([data-theme="brand"] *)); +``` + +```html + +
...
+ +``` + +## Stacking modifiers + +```html + + +
+ +

+ + + + +
0 %}hidden{% endif %}> +

Your cart is empty.

+ Continue shopping +
+ +
+
+

Subtotal

+

{{ cart.total_price | money }}

+
+ View cart +
+ +
+
+ +

+ +{% schema %} +{ + "name": "Cart drawer", + "settings": [] +} +{% endschema %} diff --git a/sections/cart.liquid b/sections/cart.liquid index 6d7e49841..02382713c 100644 --- a/sections/cart.liquid +++ b/sections/cart.liquid @@ -7,27 +7,75 @@

{{ 'cart.title' | t }}

-
- +
+

+ + +
0 %}hidden{% endif %}> +

Your cart is empty.

+ Continue shopping +
+ +
    {% for item in cart.items %} -
- - - - +
  • + + {% if item.image %} + + {% render 'image', image: item.image, width: 160, class: 'h-full w-full object-cover' %} + + {% endif %} + +
    + {{ item.product.title }} + {% if item.variant.title != 'Default Title' %} +

    {{ item.variant.title }}

    + {% endif %} + +
    + + {{ item.quantity }} + + +
    + +

    {{ item.final_line_price | money }}

    +
    +
  • {% endfor %} -
    - {% render 'image', image: item.image, url: item.url %} - -

    {{ item.product.title }}

    - {{ 'cart.remove' | t | link_to: item.url_to_remove }} -
    - - -
    + + +
    +

    Subtotal: {{ cart.total_price | money }}

    + + + + + + + + + + - - +
    + +
    +
    +
    {% schema %} { diff --git a/sections/collection.liquid b/sections/collection.liquid index e5a786805..31e44cfd6 100644 --- a/sections/collection.liquid +++ b/sections/collection.liquid @@ -7,35 +7,151 @@

    {{ collection.title }}

    -
    - {% paginate collection.products by 20 %} - {% for product in collection.products %} -
    - {% if product.featured_image %} - {% render 'image', - class: 'collection-product__image', - image: product.featured_image, - url: product.url, - width: 400, - height: 400, - crop: 'center' - %} - {% endif %} -
    -

    {{ product.title | escape | link_to: product.url }}

    -

    {{ product.price | money }}

    +{% paginate collection.products by 20 %} +
    +

    + + +
    +
    +
    + +
    + + + + Clear all +
    + + {% if collection.filters.size > 0 %} +
    + {% for filter in collection.filters %} +
    + {{ filter.label }} + + {% case filter.type %} + {% when 'list', 'boolean' %} + {% for filter_value in filter.values %} + + {% endfor %} + {% when 'price_range' %} +
    + + +
    + {% endcase %} +
    + {% endfor %} +
    + {% endif %} +
    + + {% assign has_active_filters = false %} + {% for filter in collection.filters %} + {% if filter.active_values.size > 0 %} + {% assign has_active_filters = true %} + {% endif %} + {% if filter.type == 'price_range' %} + {% if filter.min_value.value or filter.max_value.value %} + {% assign has_active_filters = true %} + {% endif %} + {% endif %} {% endfor %} - {{ paginate | default_pagination }} - {% endpaginate %} -
    + {% if has_active_filters %} +
    + {% for filter in collection.filters %} + {% for filter_value in filter.active_values %} + + {{ filter_value.label }} + + {% endfor %} + + {% if filter.type == 'price_range' %} + {% if filter.min_value.value or filter.max_value.value %} + + {{ filter.min_value.value | default: 0 | money_without_currency }} - {{ filter.max_value.value | default: filter.range_max | money_without_currency }} + + + {% endif %} + {% endif %} + {% endfor %} +
    + {% endif %} + +
    + {% for product in collection.products %} + {% render 'product-card', product: product, enable_quick_buy: true %} + {% else %} +

    No products found for current filters.

    + {% endfor %} +
    + +
    + {% if paginate.next %} + + {% endif %} + +

    + +
    + {{ paginate | default_pagination }} +
    +
    +
    +{% endpaginate %} {% stylesheet %} .collection-products { display: grid; - grid-template-columns: repeat(auto-fill, minmax(500px, 1fr)); + gap: 1rem; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } {% endstylesheet %} diff --git a/sections/header.liquid b/sections/header.liquid index d873bdda7..8bfc5ba36 100644 --- a/sections/header.liquid +++ b/sections/header.liquid @@ -1,77 +1,93 @@ -
    -

    - {{ shop.name | link_to: routes.root_url }} -

    +
    +

    + {{ shop.name | link_to: routes.root_url }} +

    -
    - {% for link in section.settings.menu.links %} - {{ link.title | link_to: link.url }} - {% endfor %} -
    +
    + {% for link in section.settings.menu.links %} + {{ link.title | link_to: link.url }} + {% endfor %} +
    -
    - {% if shop.customer_accounts_enabled %} - - {{ 'icon-account.svg' | inline_asset_content }} - - {% endif %} +
    + {% if shop.customer_accounts_enabled %} + + Account + + {% endif %} - - {% if cart.item_count > 0 %} - {{ cart.item_count }} - {% endif %} + + {{ 'search.title' | t }} + - {{ 'icon-cart.svg' | inline_asset_content }} - -
    + + + + {{ 'cart.title' | t }} + +
    {% stylesheet %} - header { - height: 5rem; - display: flex; - align-items: center; - justify-content: space-between; - } - header a { - position: relative; - text-decoration: none; - color: var(--color-foreground); - display: flex; - align-items: center; - justify-content: center; - } - header a sup { - position: absolute; - left: 100%; - overflow: hidden; - max-width: var(--page-margin); - } - header svg { - width: 2rem; - } - header .header__menu, - header .header__icons { - display: flex; - gap: 1rem; - } + header { + height: 5rem; + display: flex; + align-items: center; + justify-content: space-between; + } + header a { + position: relative; + text-decoration: none; + color: var(--color-foreground); + display: flex; + align-items: center; + justify-content: center; + } + header a sup { + position: absolute; + left: 100%; + overflow: hidden; + max-width: var(--page-margin); + } + header .header__menu, + header .header__icons { + display: flex; + gap: 1rem; + } {% endstylesheet %} {% schema %} { - "name": "t:general.header", - "settings": [ - { - "type": "link_list", - "id": "menu", - "label": "t:labels.menu" - }, - { - "type": "link_list", - "id": "customer_account_menu", - "label": "t:labels.customer_account_menu", - "default": "customer-account-main-menu" - } - ] + "name": "t:general.header", + "settings": [ + { + "type": "link_list", + "id": "menu", + "label": "t:labels.menu" + }, + { + "type": "link_list", + "id": "customer_account_menu", + "label": "t:labels.customer_account_menu", + "default": "customer-account-main-menu" + } + ] } {% endschema %} diff --git a/sections/hello-world.liquid b/sections/hello-world.liquid deleted file mode 100644 index 839823ac0..000000000 --- a/sections/hello-world.liquid +++ /dev/null @@ -1,144 +0,0 @@ -{% comment %} - Welcome to Shopify theme development! -{% endcomment %} - -
    -
    -
    -

    Hello, World!

    - -

    - The Skeleton theme is a minimal, carefully structured Shopify theme designed to help you quickly get started. - Designed with modularity, maintainability, and Shopify's best practices in mind. -

    - -

    - Themes shape the online store experience for merchants and their customers. Build fast, flexible themes at scale - using Liquid, Shopify's theme templating language, along with HTML, CSS, JavaScript, and JSON. -

    -
    -
    - -
    -
    -
    - -
    -
    -

    Key Concepts

    -

    - Shopify themes are a package of template files, building blocks, and supporting assets. Use these building blocks - to create modular, customizable themes. -

    -

    {{ 'Learn more about key concepts' | link_to: 'https://shopify.dev/docs/storefronts/themes/architecture' }}

    -
    - -
    -

    Liquid

    -

    - The Liquid templating language is the backbone of Shopify themes, and is used to load dynamic content on - storefronts. Extend Liquid objects to store and present custom data using metafields. -

    -

    {{ 'View the Liquid reference' | link_to: 'https://shopify.dev/docs/api/liquid' }}

    -
    - -
    -

    Best Practices

    -

    - To optimize your theme development experience, Shopify has established a set of best practices that you can refer - to when developing your theme and setting up your toolchains and processes. -

    -

    {{ 'Follow best practices' | link_to: 'https://shopify.dev/docs/storefronts/themes/best-practices' }}

    -
    -
    - -{% stylesheet %} - .welcome { - display: grid; - grid-template-columns: var(--content-grid); - background-color: #f6f6f7; - padding: 72px 0; - } - - .welcome-content { - grid-column: 2; - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - width: 100%; - padding: 0 24px; - } - - .welcome-description { - max-width: 80ch; - line-height: 1.4; - margin-top: 1.5rem; - } - - .icon { - width: 300px; - } - - .highlights { - display: grid; - gap: 2rem; - grid-template-columns: repeat(3, 1fr); - margin-top: 50px; - } - - @media (max-width: 1100px) { - .highlights { - grid-template-columns: 1fr; - } - } - - .highlight { - display: flex; - flex-direction: column; - height: 100%; - padding: 24px; - border-radius: 8px; - background-color: #eef3ff; - color: rgb(92, 95, 98); - line-height: 1.4; - } - - .highlight > * + * { - margin-top: 1rem; - } - - .highlight h3 { - font-size: 1rem; - color: rgb(32, 34, 35); - } - - .highlight-description { - flex: 1 1; - } - - .highlight a { - display: flex; - width: fit-content; - background-color: rgb(250, 251, 251); - box-shadow: rgba(0, 0, 0, 0.2) 0px -3px 0px 0px inset, rgba(255, 255, 255, 0.9) 0px 2px 0px 0px inset; - border: 1px solid rgb(140, 145, 150); - border-radius: 4px; - color: rgb(92, 95, 98); - padding: 3px 10px 5px; - text-decoration: none; - } -{% endstylesheet %} - -{% schema %} -{ - "name": "Hello World", - "settings": [], - "presets": [ - { - "name": "Hello World Template", - "category": "Demo" - } - ] -} -{% endschema %} diff --git a/sections/index.liquid b/sections/index.liquid new file mode 100644 index 000000000..fe2d1a495 --- /dev/null +++ b/sections/index.liquid @@ -0,0 +1,3 @@ +
    +

    Hello World

    +
    diff --git a/sections/product-recommendations.liquid b/sections/product-recommendations.liquid new file mode 100644 index 000000000..afbd2ce4b --- /dev/null +++ b/sections/product-recommendations.liquid @@ -0,0 +1,36 @@ +
    + {% if recommendations.performed and recommendations.products_count > 0 %} +
    +

    You may also like

    +
      + {% for product in recommendations.products limit: section.settings.products_limit %} +
    • + {% render 'product-card', product: product %} +
    • + {% endfor %} +
    +
    + {% endif %} +
    + +{% schema %} +{ + "name": "Product recommendations", + "settings": [ + { + "type": "range", + "id": "products_limit", + "label": "Number of products", + "min": 2, + "max": 8, + "step": 1, + "default": 4 + } + ], + "presets": [ + { + "name": "Product recommendations" + } + ] +} +{% endschema %} diff --git a/sections/product.liquid b/sections/product.liquid index 9664dfda5..0fad29a5c 100644 --- a/sections/product.liquid +++ b/sections/product.liquid @@ -1,52 +1,138 @@ {% comment %} - This section is used in the product template to render product page with - media, content, and add-to-cart form. + This section is used in the product template to render product page with + media, content, and add-to-cart form. - https://shopify.dev/docs/storefronts/themes/architecture/templates/product + https://shopify.dev/docs/storefronts/themes/architecture/templates/product {% endcomment %} -
    - {% for image in product.images %} - {% render 'image', class: 'product-image', image: image %} - {% endfor %} -
    +{%- assign current_variant = product.selected_or_first_available_variant -%} -
    -

    {{ product.title }}

    -

    {{ product.price | money }}

    -

    {{ product.description }}

    -
    +
    + + +
    +
    +

    {{ product.title }}

    +

    {{ current_variant.price | money }}

    +

    + {%- unless current_variant.available -%}Sold out{%- endunless -%} +

    +

    {{ product.description }}

    +
    + +
    + {% form 'product', product, id: 'product-form', data-js: 'product-form' %} + {% render 'variant-picker', product: product, current_variant: current_variant %} -
    - {% form 'product', product %} - {% assign current_variant = product.selected_or_first_available_variant %} - - - - - - - {{ form | payment_button }} - {% endform %} + + + + +

    + + {{ form | payment_button }} + {% endform %} +
    +
    +
    + + + + + {% schema %} { - "name": "t:general.product", - "settings": [], - "disabled_on": { - "groups": ["header", "footer"] - } + "name": "t:general.product", + "settings": [], + "disabled_on": { + "groups": ["header", "footer"] + } } {% endschema %} diff --git a/sections/search-drawer.liquid b/sections/search-drawer.liquid new file mode 100644 index 000000000..35cc7f470 --- /dev/null +++ b/sections/search-drawer.liquid @@ -0,0 +1,58 @@ + + +{% schema %} +{ + "name": "Search drawer", + "settings": [] +} +{% endschema %} diff --git a/sections/search.liquid b/sections/search.liquid index 7177c1818..57c855b17 100644 --- a/sections/search.liquid +++ b/sections/search.liquid @@ -7,48 +7,69 @@

    {{ 'search.title' | t }}

    -
    - - -
    +
    + + +

    -
    - {% paginate search.results by 20 %} - {% # Search result items may be an article, a page, or a product. %} - {% for result in search.results %} -
    - {% assign featured_image = result.featured_image | default: result.image %} - {% if featured_image %} - {% render 'image', class: 'search-result__image', image: featured_image, url: result.url, width: 400 %} - {% endif %} -
    -

    - {{ result.title | link_to: result.url }} - {% if result.price %} - {{ result.price | money_with_currency }} - {% endif %} -

    + {% if search.performed %} + {% if search.results_count == 0 %} +

    {{ 'search.no_results_html' | t: terms: search.terms }}

    + {% else %} +

    {{ 'search.results_for_html' | t: terms: search.terms, count: search.results_count }}

    + +
    + {% paginate search.results by 20 %} + {% # Search result items may be an article, a page, or a product. %} + {% for result in search.results %} +
    + {% assign featured_image = result.featured_image | default: result.image %} + {% if featured_image %} + {% render 'image', class: 'search-result__image', image: featured_image, url: result.url, width: 400 %} + {% endif %} +
    +

    + {{ result.title | link_to: result.url }} + {% if result.price %} + {{ result.price | money_with_currency }} + {% endif %} +

    +
    -
    - {% endfor %} + {% endfor %} - {{ paginate | default_pagination }} - {% endpaginate %} -
    + {{ paginate | default_pagination }} + {% endpaginate %} +
    + {% endif %} {% endif %} -{% endif %} +
    {% stylesheet %} .search-results { diff --git a/snippets/css-variables.liquid b/snippets/css-variables.liquid deleted file mode 100644 index 99b5497a3..000000000 --- a/snippets/css-variables.liquid +++ /dev/null @@ -1,18 +0,0 @@ -{% style %} - {% # Loads all font variantions with display: swap %} - {{ settings.type_primary_font | font_face: font_display: 'swap' }} - {{ settings.type_primary_font | font_modify: 'weight', 'bold' | font_face: font_display: 'swap' }} - {{ settings.type_primary_font | font_modify: 'weight', 'bold' | font_modify: 'style', 'italic' | font_face: font_display: 'swap' }} - {{ settings.type_primary_font | font_modify: 'style', 'italic' | font_face: font_display: 'swap' }} - - :root { - --font-primary--family: {{ settings.type_primary_font.family }}, {{ settings.type_primary_font.fallback_families }}; - --font-primary--style: {{ settings.type_primary_font.style }}; - --font-primary--weight: {{ settings.type_primary_font.weight }}; - --page-width: {{ settings.max_page_width }}; - --page-margin: {{ settings.min_page_margin }}px; - --color-background: {{ settings.background_color }}; - --color-foreground: {{ settings.foreground_color }}; - --style-border-radius-inputs: {{ settings.input_corner_radius }}px; - } -{% endstyle %} diff --git a/snippets/meta-tags.liquid b/snippets/meta-tags.liquid index 67299c04f..45e9169cd 100644 --- a/snippets/meta-tags.liquid +++ b/snippets/meta-tags.liquid @@ -1,3 +1,12 @@ +{% doc %} + Renders all essential HTML meta tags: charset, viewport, Open Graph, Twitter Card, + JSON-LD structured data, canonical URL, and page title. + + Reads Shopify global objects directly (no parameters required): + - `page_title`, `page_description`, `canonical_url`, `page_image` + - `shop`, `product`, `cart`, `request`, `current_tags`, `current_page` +{% enddoc %} + diff --git a/snippets/product-card-quick-buy.liquid b/snippets/product-card-quick-buy.liquid new file mode 100644 index 000000000..505b5d976 --- /dev/null +++ b/snippets/product-card-quick-buy.liquid @@ -0,0 +1,27 @@ +{% doc %} + Product-card quick-buy molecule. + + Renders quick-buy CTA when possible, otherwise a product-page fallback link. + + @param {product} product - Product object + @param {variant} [quick_buy_variant] - Variant used for quick buy + @param {boolean} [enable_quick_buy] - Enables quick-buy behavior +{% enddoc %} + +{% if enable_quick_buy and quick_buy_variant %} + +{% else %} + {{ 'collections.view_options' | t }} +{% endif %} diff --git a/snippets/product-card.liquid b/snippets/product-card.liquid new file mode 100644 index 000000000..c7ca089fd --- /dev/null +++ b/snippets/product-card.liquid @@ -0,0 +1,39 @@ +{% doc %} + Reusable product card for collection-like grids. + + Shows image, title, price, and a quick-buy control when the product can be + added directly (single default variant available). + + @param {product} product - Product object to render + @param {boolean} [enable_quick_buy] - Enables quick buy CTA when possible +{% enddoc %} + +{%- liquid + assign quick_buy_variant = null + if product.available and product.has_only_default_variant + assign quick_buy_variant = product.selected_or_first_available_variant + endif +-%} + +
    + {% if product.featured_image %} + {% render 'image', + class: 'collection-product__image', + image: product.featured_image, + url: product.url, + width: 400, + height: 400, + crop: 'center' + %} + {% endif %} + +
    +

    {{ product.title | escape | link_to: product.url }}

    +

    {{ product.price | money }}

    + {% render 'product-card-quick-buy', + product: product, + quick_buy_variant: quick_buy_variant, + enable_quick_buy: enable_quick_buy + %} +
    +
    diff --git a/snippets/product-media-image.liquid b/snippets/product-media-image.liquid new file mode 100644 index 000000000..43ee7ce04 --- /dev/null +++ b/snippets/product-media-image.liquid @@ -0,0 +1,11 @@ +{% comment %} + Renders a product image media item using media_tag. + Accepts: + - media: {Object} Shopify media object (media_type: 'image') + - class: {String} CSS classes applied to the generated img element +{% endcomment %} +{% if media != blank %} + {{ media | media_tag: class: class, widths: '600,900,1200', sizes: '(min-width: 1024px) 50vw, 100vw' }} +{% else %} + {{ 'product-1' | placeholder_svg_tag: class }} +{% endif %} diff --git a/snippets/product-media-video.liquid b/snippets/product-media-video.liquid new file mode 100644 index 000000000..0aed54932 --- /dev/null +++ b/snippets/product-media-video.liquid @@ -0,0 +1,13 @@ +{% comment %} + Renders a product video media item using media_tag. + Accepts: + - media: {Object} Shopify media object (media_type: 'video' or 'external_video') + - class: {String} CSS classes applied to the wrapper div +{% endcomment %} +{% if media != blank %} +
    + {{ media | media_tag: loop: false, muted: false }} +
    +{% else %} +
    {{ 'product-1' | placeholder_svg_tag }}
    +{% endif %} diff --git a/snippets/variant-picker.liquid b/snippets/variant-picker.liquid new file mode 100644 index 000000000..7f920dde3 --- /dev/null +++ b/snippets/variant-picker.liquid @@ -0,0 +1,31 @@ +{% doc %} + Renders the variant picker for a product form. + Outputs a hidden input for the selected variant ID and one button group + per product option. Must be rendered inside a {% form 'product' %} block. + + @param {product} product - The product object + @param {variant} current_variant - The currently selected variant (used for server-side aria-pressed) +{% enddoc %} + + + +{% for option in product.options_with_values %} +
    +

    + {{ option.name }}: + {{- option.selected_value -}} +

    + {% for value in option.values %} + + {% endfor %} +
    +{% endfor %} diff --git a/snippets/vite-tag.liquid b/snippets/vite-tag.liquid new file mode 100644 index 000000000..9a66579f4 --- /dev/null +++ b/snippets/vite-tag.liquid @@ -0,0 +1,86 @@ +{% comment %} + IMPORTANT: This snippet is automatically generated by vite-plugin-shopify. + Do not attempt to modify this file directly, as any changes will be overwritten by the next build. +{% endcomment %} +{% liquid + assign entry = entry | default: vite-tag + assign path = entry | replace: '@ts/', 'ts/' | replace: '@css/', 'css/' | replace: '~/', '../' | replace: '@/', '../' +%} +{% if path == "/frontend/entrypoints/css/main.css" or path == "css/main.css" %} + {{ 'main-JmcnsjPw.css' | asset_url | split: '?' | first | stylesheet_tag: preload: preload_stylesheet }} +{% elsif path == "/frontend/entrypoints/ts/404.ts" or path == "ts/404.ts" %} + +{% elsif path == "/frontend/entrypoints/ts/article.ts" or path == "ts/article.ts" %} + +{% elsif path == "/frontend/entrypoints/ts/blog.ts" or path == "ts/blog.ts" %} + +{% elsif path == "/frontend/entrypoints/ts/cart.ts" or path == "ts/cart.ts" %} + + + +{% elsif path == "/frontend/entrypoints/ts/cart/drawer.ts" or path == "ts/cart/drawer.ts" %} + + + + +{% elsif path == "/frontend/entrypoints/ts/cart/page.ts" or path == "ts/cart/page.ts" %} + + + +{% elsif path == "/frontend/entrypoints/ts/collection.ts" or path == "ts/collection.ts" %} + + + +{% elsif path == "/frontend/entrypoints/ts/gift-card.ts" or path == "ts/gift-card.ts" %} + +{% elsif path == "/frontend/entrypoints/ts/index.ts" or path == "ts/index.ts" %} + +{% elsif path == "/frontend/entrypoints/ts/list-collections.ts" or path == "ts/list-collections.ts" %} + +{% elsif path == "/frontend/entrypoints/ts/page.ts" or path == "ts/page.ts" %} + +{% elsif path == "/frontend/entrypoints/ts/password.ts" or path == "ts/password.ts" %} + +{% elsif path == "/frontend/entrypoints/ts/product.ts" or path == "ts/product.ts" %} + + + + + + + + + +{% elsif path == "/frontend/entrypoints/ts/product/handlers.ts" or path == "ts/product/handlers.ts" %} + + + + + + + + +{% elsif path == "/frontend/entrypoints/ts/product/recommendations.ts" or path == "ts/product/recommendations.ts" %} + +{% elsif path == "/frontend/entrypoints/ts/product/state.ts" or path == "ts/product/state.ts" %} + +{% elsif path == "/frontend/entrypoints/ts/product/sync.ts" or path == "ts/product/sync.ts" %} + + + + +{% elsif path == "/frontend/entrypoints/ts/search.ts" or path == "ts/search.ts" %} + +{% elsif path == "/frontend/entrypoints/ts/search/drawer.ts" or path == "ts/search/drawer.ts" %} + +{% elsif path == "/frontend/entrypoints/ts/theme.ts" or path == "ts/theme.ts" %} + +{% elsif path == "/frontend/entrypoints/ts/utils/cart-events.ts" or path == "ts/utils/cart-events.ts" %} + +{% elsif path == "/frontend/entrypoints/ts/utils/cart.ts" or path == "ts/utils/cart.ts" %} + +{% elsif path == "/frontend/entrypoints/ts/utils/section-rendering.ts" or path == "ts/utils/section-rendering.ts" %} + +{% elsif path == "/frontend/entrypoints/ts/utils/variant-picker.ts" or path == "ts/utils/variant-picker.ts" %} + +{% endif %} diff --git a/templates/gift_card.liquid b/templates/gift_card.liquid index db5d8289b..9dbd72f1f 100644 --- a/templates/gift_card.liquid +++ b/templates/gift_card.liquid @@ -2,72 +2,71 @@ - - {% # Inlined CSS Variables %} - {% render 'css-variables' %} + + {% # Social, title, etc. %} + {% render 'meta-tags' %} - {% # Load and preload the critical CSS %} - {{ 'critical.css' | asset_url | stylesheet_tag: preload: true }} + {% style %} + main { + text-align: center; + } + main img { + display: unset; + } + {% endstyle %} - {% # Social, title, etc. %} - {% render 'meta-tags' %} + {% liquid + render 'vite-tag' with 'ts/theme.ts' + render 'vite-tag' with 'ts/gift-card.ts' + %} - {% style %} - main { - text-align: center; - } - main img { - display: unset; - } - {% endstyle %} + {{ content_for_header }} + - {{ content_for_header }} - + +
    +
    +

    {{ gift_card.balance | money }}

    - -
    -
    -

    {{ gift_card.balance | money }}

    + {% if gift_card.enabled == false or gift_card.expired %} +

    {{ 'gift_card.expired' | t }}

    + {% endif %} - {% if gift_card.enabled == false or gift_card.expired %} -

    {{ 'gift_card.expired' | t }}

    - {% endif %} + {% if gift_card.expires_on %} + {% assign expires_on = gift_card.expires_on | date: '%B %e, %Y' %} +

    + {{ 'gift_card.expires_on' | t: expires_on: expires_on }} +

    + {% endif %} - {% if gift_card.expires_on %} - {% assign expires_on = gift_card.expires_on | date: '%B %e, %Y' %} -

    - {{ 'gift_card.expires_on' | t: expires_on: expires_on }} -

    - {% endif %} +

    + {% if settings.logo %} + {{ settings.logo | image_url: width: 300 | image_tag: alt: shop.name }} + {% else %} + {{ 'gift_card.card' | t }} + {% endif %} +

    -

    - {% if settings.logo %} - {{ settings.logo | image_url: width: 300 | image_tag: alt: shop.name }} - {% else %} - {{ 'gift_card.card' | t }} - {% endif %} -

    +

    {{ shop.name }}

    +

    {{ 'gift_card.use_at_checkout' | t }}

    +

    {{ gift_card.code | format_code }}

    -

    {{ shop.name }}

    -

    {{ 'gift_card.use_at_checkout' | t }}

    -

    {{ gift_card.code | format_code }}

    - - {% if gift_card.pass_url %} - - {{ 'gift_card.add_to_apple_wallet' | t }} - - {% endif %} -
    -
    - + {% if gift_card.pass_url %} + + {{ 'gift_card.add_to_apple_wallet' | t }} + + {% endif %} +
    +
    + diff --git a/templates/index.json b/templates/index.json index 1f827db93..464e66c89 100644 --- a/templates/index.json +++ b/templates/index.json @@ -10,11 +10,9 @@ { "sections": { "main": { - "type": "hello-world", + "type": "index", "settings": {} } }, - "order": [ - "main" - ] + "order": ["main"] } diff --git a/templates/product.json b/templates/product.json index 8329ddc68..b1568732e 100644 --- a/templates/product.json +++ b/templates/product.json @@ -12,9 +12,14 @@ "main": { "type": "product", "settings": {} + }, + "product-recommendations": { + "type": "product-recommendations", + "settings": {} } }, "order": [ - "main" + "main", + "product-recommendations" ] } diff --git a/todo.md b/todo.md new file mode 100644 index 000000000..f94869761 --- /dev/null +++ b/todo.md @@ -0,0 +1,21 @@ +# TODO - Shopify Skeleton Theme + +## Now +- [ ] Verify mobile PLP filters on real devices (open/close/apply/clear/back-forward) +- [ ] Run manual smoke pass: PDP, PLP, cart page, minicart, search drawer +- [ ] Run `theme-check` when local Shopify CLI/Node deps are fixed + +## Next +- [ ] P2 cleanup: copy consistency, small interaction polish, remove dead notes + +## Done (Latest) +- [x] P0 complete: section rendering foundation, Shopify docs parity, PDP media reliability +- [x] P1 complete except final manual verification +- [x] Search and cart core hardening shipped (predictive drawer, cart notes/attributes) +- [x] Reusable `product-card` snippet added with PLP quick buy (single-variant) + PDP fallback +- [x] PDP recommendations no longer tied to variant switching +- [x] PDP media now keeps variant-primary media first in viewer + thumbnails while keeping shared media +- [x] Core checks passing in this environment: `bun run typecheck`, `bun run build` + +## Notes +- `theme-check` is currently unavailable in this environment (`command not found` / local CLI dependency issue) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..a6fe6cf9e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "baseUrl": ".", + "paths": { + "@ts/*": ["frontend/entrypoints/ts/*"], + "@css/*": ["frontend/entrypoints/css/*"] + }, + "strict": true, + "noEmit": true, + "allowJs": false, + "checkJs": false, + "isolatedModules": true, + "resolveJsonModule": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "types": ["vite/client"], + }, + "include": ["frontend/**/*.ts", "vite.config.js"], +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 000000000..892c4dd8f --- /dev/null +++ b/vite.config.js @@ -0,0 +1,37 @@ +import tailwindcss from "@tailwindcss/vite"; +import { fileURLToPath, URL } from "node:url"; +import { defaultAllowedOrigins, defineConfig, loadEnv } from "vite"; +import shopify from "vite-plugin-shopify"; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + const isTunnelEnabled = env.SHOPIFY_VITE_TUNNEL === "true"; + const storeDomain = env.SHOPIFY_STORE_DOMAIN; + const corsOrigins = [ + defaultAllowedOrigins, + "http://127.0.0.1:9292", + "http://localhost:9292", + ]; + + if (storeDomain) { + corsOrigins.push(`https://${storeDomain}`); + } + + return { + plugins: [shopify({ tunnel: isTunnelEnabled }), tailwindcss()], + publicDir: "public", + resolve: { + alias: { + "@ts": fileURLToPath(new URL("./frontend/entrypoints/ts", import.meta.url)), + "@css": fileURLToPath(new URL("./frontend/entrypoints/css", import.meta.url)), + }, + }, + server: { + port: 5173, + strictPort: true, + cors: { + origin: corsOrigins, + }, + }, + }; +});