@commercelayer/agent-cli 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,10 +15,10 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
15
15
  | --- | --- |
16
16
  | `init [--name <business name>]` | Create a sandbox store, with no login. The name (the folder name when absent) names the organization; the org slug and store hostname derive from it, numbered when taken. Writes `store.yml` (a commented template) and `credentials`. Reuses the store if they exist. |
17
17
  | `plan` | Validate `store.yml` and the product files, and list what `apply` would create, update or delete: markets, products, and the store's name and policies. |
18
- | `apply [--allow-delete] [--full]` | Make the store match the files. Refuses deletes without `--allow-delete`. Product changes go through a Commerce Layer import: `apply` waits for it, with progress as JSON lines on stderr, and prints one result. `apply_failed` lists the products Commerce Layer refused; `apply_pending` means the import keeps going after 10 minutes, and `c11r status` follows it. An apply still running is finished before a new one starts. `--full` re-imports every product and price, after edits in the Commerce Layer dashboard. |
18
+ | `apply [--allow-delete] [--full]` | Make the store match the files. Refuses deletes without `--allow-delete`. Product changes go through a Commerce Layer import: `apply` waits for it, with progress as JSON lines on stderr, and prints one result. `apply_failed` lists the products Commerce Layer refused; While Commerce Layer is busy or unavailable, `apply` keeps reading (backing off on busy) and writes a `retrying` line. `apply_pending` means the import keeps going after 10 minutes, and `c11r status` follows it. An apply still running is finished before a new one starts. `--full` re-imports every product and price, after edits in the Commerce Layer dashboard. |
19
19
  | `catalog pull` | Read the products from the catalog source in `store.yml` (a Supabase table, a Google Sheet or a Google Merchant Center feed) into `.commercelayer/products/catalog.csv`. Then `plan` and `apply` as usual. |
20
- | `storefront init [--force]` | Write `storefront/index.html`: a static page on [drop-in.js](https://commercelayer.github.io/drop-in.js/) listing the products in `store.yml`, grouped by top-level category with a navigation, with a minicart that slides in on add to cart, and the hosted cart and checkout, wired to the sandbox's sales channel. Refuses to overwrite without the flag. |
21
- | `storefront publish` | Upload the folder at `storefront.path` (default `storefront`) to the store host. |
20
+ | `storefront init [--force]` | Write `storefront/index.html`: a static page on [drop-in.js](https://commercelayer.github.io/drop-in.js/) listing the products in `store.yml`, grouped by top-level category with a navigation, with a minicart that slides in on add to cart, styled through theme tokens at the top of its `<style>` (`--paper`, `--ink`, `--muted`, `--line`, `--accent`, `--on-accent`, `--radius`, `--font`) that also restyle drop-in.js's prices, buttons and links, so a dark theme is a token change. Product images go in `storefront/img/` (`image_url: img/<file>`); products with no image get a neutral tile. and the hosted cart and checkout, wired to the sandbox's sales channel. Refuses to overwrite without the flag. |
21
+ | `storefront publish` | Upload the folder at `storefront.path` (default `storefront`) to the store host, leaving out dotfiles. Lists products whose `image_url` points at a file that is not in the folder (`missing_images`). |
22
22
  | `orders test [--sku <id>] [--quantity <n>] [--country <CC>] [--code <discount>] [--via-cart] [--decline]` | Buy from the store over UCP as a shopping agent would: discover, search, (cart,) checkout, address, pay with the sandbox handler, read the order back. Prints the order and every step. |
23
23
  | `orders list` | The orders placed in the store, newest first, as UCP order entities. |
24
24
  | `orders show <id or number>` | One order. |
@@ -70,7 +70,7 @@ COFFEE-ETH-250,"Ethiopia Yirgacheffe, 250 g","Washed, floral, bright.",Coffee >
70
70
  COFFEE-COL-250,"Colombia Huila, 250 g",,Coffee > Single origin,12.00,13.50
71
71
  ```
72
72
  Errors name the file (and line, for CSV) and the sku, so one product file can be fixed without reading the rest. Moving a product between `store.yml` and a file is not a change to the store: `plan` keys everything by sku. The catalog has no stock: inventory is tracked live in Commerce Layer, and the sandbox treats every product as in stock. Prices in these files are what the store charges; a published storefront reads prices and availability live from Commerce Layer.
73
- Rules `plan` and `apply` enforce: a name, at least one market and one product; every market has `code`, `name`, `countries` and an ISO 4217 `currency`; codes and skus are unique across all files; a product has only `sku`, `name`, `description`, `image_url`, `category` and `prices`; a `category` is a path of names from the top level, separated by ` > `; every product has a price in the currency of every market; `shipping`, if present, has `method` and a non-negative `price`; policy entries are URLs. Currency, languages and tax follow from the countries.
73
+ Rules `plan` and `apply` enforce: a name, at least one market and one product; every market has `code`, `name`, `countries` and an ISO 4217 `currency`; codes and skus are unique across all files; a product has only `sku`, `name`, `description`, `image_url`, `category` and `prices`; a `category` is a path of names from the top level, separated by ` > `; an `image_url` is an `https://` URL or a path in the storefront folder such as `img/eth.jpg` (served from the store host, given to shopping agents as a full URL); every product has a price in the currency of every market; `shipping`, if present, has `method` and a non-negative `price`; policy entries are URLs. Currency, languages and tax follow from the countries.
74
74
  ### A catalog the human edits
75
75
  When the products live in a tool the human edits too, `store.yml` names it and `c11r catalog pull` copies it into `.commercelayer/products/catalog.csv`, sorted by sku: same source, same file. Pull applies the product rules above before it writes, with errors naming the row in the source, and reports the skus added, changed and removed. Removed products leave the store only through `apply --allow-delete`. Every source has the same columns: `sku`, `name`, `description`, `image_url`, `category`, and one price column per market currency, named by its ISO 4217 code in lowercase (`eur`, `usd`).
76
76
  ```yaml
@@ -11,6 +11,8 @@ function publicState(r) {
11
11
  }
12
12
  /** Like Commerce Layer refusing a sku in an import. Only the stub does this, so tests can exercise apply_failed. */
13
13
  const REFUSED = /^REFUSED-/;
14
+ /** C11R_STUB_BUSY_READS=n: the first n reads of a running import answer 503 commercelayer_busy, as when Commerce Layer rate-limits. Per process. */
15
+ let busyReads = Number(process.env.C11R_STUB_BUSY_READS) || 0;
14
16
  /** Items an import gets through per read: small, so the CLI polls a few times. */
15
17
  const ITEMS_PER_READ = 2;
16
18
  /**
@@ -83,6 +85,10 @@ export function handleManagement(req) {
83
85
  return error(410, 'expired', `Sandbox ${org} expired on ${record.expires_at}.`, 'Remove .commercelayer/credentials and run `c11r init` to create a new sandbox; store.yml is kept.');
84
86
  }
85
87
  if (req.method === 'GET' && rest === '') {
88
+ if (record.apply?.status === 'in_progress' && busyReads > 0) {
89
+ busyReads--;
90
+ return error(503, 'commercelayer_busy', 'Commerce Layer is rate-limiting this organization.', 'Wait a minute and try again; never edit store.yml for this.');
91
+ }
86
92
  advance(record);
87
93
  writeDb(db);
88
94
  return json(200, publicState(record));
@@ -98,8 +98,9 @@ function variant(ctx, p, market) {
98
98
  // Inventory lives in Commerce Layer; the sandbox catalog has no stock, so everything is in stock.
99
99
  availability: { available: true, status: 'in_stock' },
100
100
  };
101
+ // A path in the storefront (img/eth.jpg) is served from the store host: shopping agents get the full URL.
101
102
  if (p.image_url)
102
- v.media = [{ type: 'image', url: p.image_url }];
103
+ v.media = [{ type: 'image', url: new URL(p.image_url, `${ctx.origin}/`).href }];
103
104
  if (ctx.shared.has('dev.ucp.shopping.fulfillment')) {
104
105
  const o = shippingOption(market);
105
106
  v.fulfillment = { methods: [{ type: 'shipping', description: { plain: `Ships to ${market.countries.join(', ')}` }, availability: v.availability, options: [{ id: o.id, title: o.title, description: o.description }] }] };
package/dist/commands.js CHANGED
@@ -6,7 +6,7 @@ import path from 'node:path';
6
6
  import { createApi } from './api/client.js';
7
7
  import { ignoreCredentials, paths, readCredentials, requireCredentials, writeCredentials } from './files.js';
8
8
  import { CliError, fail, progress } from './output.js';
9
- import { diff, hashStore, loadStore, storefrontPath, template, toApplied } from './store.js';
9
+ import { diff, hashStore, isLocalImage, loadStore, storefrontPath, template, toApplied } from './store.js';
10
10
  import { storefrontHtml } from './storefront.js';
11
11
  import { pull } from './catalog.js';
12
12
  import { buy } from './ucp/buy.js';
@@ -18,7 +18,7 @@ export const VERSION = createRequire(import.meta.url)('../package.json').version
18
18
  export const HELP = {
19
19
  init: 'Create a sandbox store for this repo, with no login: c11r init --name "<business name>" (the folder name when --name is absent). The name becomes the organization\'s name, and the org slug and store hostname derive from it. Writes .commercelayer/store.yml and .commercelayer/credentials. Reuses the store if store.yml already exists.',
20
20
  plan: 'Show what apply would create, update or delete: markets, products and the store\'s name and policies, from .commercelayer/store.yml and the product files under .commercelayer/products/ (*.yml lists, *.csv rows, merged by sku).',
21
- apply: 'Make the store match .commercelayer/store.yml and .commercelayer/products/. Refuses deletes unless --allow-delete is given. Product changes go through a Commerce Layer import: apply waits for it (progress as JSON lines on stderr) and prints one result; if it stops with apply_pending the import keeps going, so run `c11r status` to follow it. An apply still running is finished first. --full re-imports every product and price, after edits in the Commerce Layer dashboard.',
21
+ apply: 'Make the store match .commercelayer/store.yml and .commercelayer/products/. Refuses deletes unless --allow-delete is given. Product changes go through a Commerce Layer import: apply waits for it (progress as JSON lines on stderr) and prints one result; if it stops with apply_pending the import keeps going, so run `c11r status` to follow it. While Commerce Layer is busy or unavailable it keeps reading, with a retrying line. An apply still running is finished first. --full re-imports every product and price, after edits in the Commerce Layer dashboard.',
22
22
  'catalog pull': 'Read the products from the catalog source in store.yml (catalog: { source: supabase | sheets | merchant, url, table }) and write them to .commercelayer/products/catalog.csv; then plan and apply as usual. Columns: sku, name, description, image_url, category and one price column per market currency, in lowercase (eur, usd). Supabase reads C11R_SUPABASE_URL and C11R_SUPABASE_KEY (the anon key) from .commercelayer/credentials or the environment; a Google Sheet needs its CSV link or a link anyone can open; a Merchant Center feed is RSS/XML or TSV.',
23
23
  'storefront init': 'Write storefront/index.html: a static page on drop-in.js listing the products in store.yml, with a minicart that opens on add to cart, and the hosted cart and checkout. Refuses to overwrite unless --force is given.',
24
24
  'storefront publish': 'Upload the storefront folder (store.yml storefront.path, default storefront) to the sandbox preview URL.',
@@ -29,9 +29,15 @@ export const HELP = {
29
29
  'ucp check': 'Walk every UCP capability the store advertises and report what passes. Places and pays one real test order on the way. Exits 1 when a check fails.',
30
30
  status: 'Show the sandbox, what is applied, and whether the store files have changes not yet applied (the same changes plan lists).',
31
31
  claim: 'Start keeping this sandbox store: returns a URL and a code for the human to approve in the browser.',
32
- help: 'Describe the commands. `c11r help <command>` for one command.',
32
+ help: 'Describe the commands. `c11r help <command>` for one command, `c11r help <group>` (catalog, orders, storefront, ucp) for a group.',
33
33
  };
34
34
  export function help(name) {
35
+ if (name && !HELP[name]) {
36
+ // A group (catalog, orders, storefront, ucp): its commands.
37
+ const group = Object.entries(HELP).filter(([k]) => k.startsWith(`${name} `));
38
+ if (group.length)
39
+ return { group: name, commands: Object.fromEntries(group) };
40
+ }
35
41
  if (name) {
36
42
  if (!HELP[name])
37
43
  fail('unknown_command', `There is no command "${name}".`, `Commands: ${Object.keys(HELP).join(', ')}.`);
@@ -80,12 +86,17 @@ export async function plan() {
80
86
  /** How often apply reads a running import, and when it stops waiting. Env overrides are for tests. */
81
87
  const POLL_MS = Number(process.env.C11R_APPLY_POLL_MS) || 2000;
82
88
  const WAIT_MS = Number(process.env.C11R_APPLY_WAIT_MS) || 10 * 60 * 1000;
89
+ /** Errors that say nothing about the import itself: apply keeps polling through them. */
90
+ const TRANSIENT = new Set(['commercelayer_busy', 'commercelayer_unavailable', 'api_error', 'api_unreachable']);
91
+ /** The longest wait between reads while Commerce Layer answers busy (429). */
92
+ const MAX_BACKOFF_MS = 30_000;
83
93
  const jobSummary = (j) => ({ id: j.id, status: j.status, step: j.step, processed: j.processed, total: j.total });
84
94
  /** Read the store until its apply job is no longer in progress, with a progress line per change. */
85
95
  async function follow(api, state) {
86
96
  const until = Date.now() + WAIT_MS;
87
97
  let s = state;
88
98
  let last = '';
99
+ let wait = POLL_MS;
89
100
  while (s.apply?.status === 'in_progress') {
90
101
  const line = JSON.stringify(jobSummary(s.apply));
91
102
  if (line !== last)
@@ -95,8 +106,18 @@ async function follow(api, state) {
95
106
  const j = s.apply;
96
107
  fail('apply_pending', `The apply is still running: ${j.processed} of ${j.total} items done (step ${j.step}).`, 'The apply keeps going; run `c11r status` to follow it.');
97
108
  }
98
- await new Promise((r) => setTimeout(r, POLL_MS));
99
- s = await api.getStore();
109
+ await new Promise((r) => setTimeout(r, Math.min(wait, Math.max(0, until - Date.now()))));
110
+ try {
111
+ s = await api.getStore();
112
+ wait = POLL_MS;
113
+ }
114
+ catch (e) {
115
+ // The import runs on regardless: report the error and read again, backing off while Commerce Layer is busy.
116
+ if (!(e instanceof CliError) || !TRANSIENT.has(e.code))
117
+ throw e;
118
+ wait = e.code === 'commercelayer_busy' ? Math.min(MAX_BACKOFF_MS, wait * 2) : POLL_MS;
119
+ progress({ retrying: { code: e.code, message: e.message, next_read_ms: wait } });
120
+ }
100
121
  }
101
122
  return s;
102
123
  }
@@ -146,12 +167,17 @@ export async function storefrontInit(flags) {
146
167
  fs.mkdirSync(dir, { recursive: true });
147
168
  const file = path.join(dir, 'index.html');
148
169
  fs.writeFileSync(file, storefrontHtml(desired, state.sales_channel_client_id));
170
+ // img/ is where product images go (image_url: img/<file>); .gitkeep keeps the empty folder in git, and publish skips dotfiles.
171
+ const keep = path.join(dir, 'img', '.gitkeep');
172
+ fs.mkdirSync(path.dirname(keep), { recursive: true });
173
+ if (!fs.existsSync(keep))
174
+ fs.writeFileSync(keep, '');
149
175
  return {
150
176
  status: 'created',
151
- files: [file],
177
+ files: [file, keep],
152
178
  markets: desired.markets.map((m) => m.code),
153
179
  products: desired.products.length,
154
- next: `Set storefront.path: ${dir} in ${paths.store}, make the page the human's, then run c11r storefront publish.`,
180
+ next: `Set storefront.path: ${dir} in ${paths.store}, make the page the human's (restyle it through the theme tokens at the top of its <style>; product images go in ${dir}/img/ with image_url: img/<file>), then run c11r storefront publish.`,
155
181
  };
156
182
  }
157
183
  export async function storefrontPublish() {
@@ -160,16 +186,36 @@ export async function storefrontPublish() {
160
186
  if (!fs.existsSync(path.join(dir, 'index.html'))) {
161
187
  fail('no_storefront', `There is no ${path.join(dir, 'index.html')}.`, 'Run `c11r storefront init` to create one, or set storefront.path in store.yml to the folder that has index.html.');
162
188
  }
189
+ // Dotfiles (.gitkeep, .DS_Store, .env) and dot folders stay on this machine.
163
190
  const files = fs
164
191
  .readdirSync(dir, { recursive: true, withFileTypes: true })
165
192
  .filter((e) => e.isFile())
166
- .map((e) => {
167
- const full = path.join(e.parentPath, e.name);
168
- return { path: path.relative(dir, full).split(path.sep).join('/'), content_base64: fs.readFileSync(full).toString('base64') };
169
- });
193
+ .map((e) => path.relative(dir, path.join(e.parentPath, e.name)).split(path.sep).join('/'))
194
+ .filter((rel) => !rel.split('/').some((part) => part.startsWith('.')))
195
+ .map((rel) => ({ path: rel, content_base64: fs.readFileSync(path.join(dir, rel)).toString('base64') }));
196
+ // Products whose image_url is a path in the storefront that is not there.
197
+ let missingImages = [];
198
+ try {
199
+ const uploaded = new Set(files.map((f) => f.path));
200
+ missingImages = loadStore()
201
+ .products.filter((p) => p.image_url && isLocalImage(p.image_url) && !uploaded.has(p.image_url.replace(/^\.?\//, '')))
202
+ .map((p) => `${p.sku}: ${p.image_url}`);
203
+ }
204
+ catch (e) {
205
+ if (!(e instanceof CliError))
206
+ throw e;
207
+ }
170
208
  const published = await api.publishStorefront(dir, files);
171
209
  const state = await api.getStore();
172
- return { status: 'published', preview_url: state.preview_url, path: dir, files: published.files, published_at: published.published_at };
210
+ return {
211
+ status: 'published',
212
+ preview_url: state.preview_url,
213
+ path: dir,
214
+ files: published.files,
215
+ published_at: published.published_at,
216
+ missing_images: missingImages.length ? missingImages : undefined,
217
+ next: missingImages.length ? `Add the missing images under ${dir}/ (image_url is relative to it), or fix image_url, then publish again.` : undefined,
218
+ };
173
219
  }
174
220
  /** The store's origin on the web: where it speaks UCP. */
175
221
  async function storeOrigin() {
package/dist/store.js CHANGED
@@ -153,6 +153,12 @@ export function checkProducts(sourced, currencies) {
153
153
  const why = unknown.includes('stock') ? 'Stock is not part of the catalog: inventory is tracked live in Commerce Layer. ' : '';
154
154
  fail('invalid_product', `Product ${p.sku} in ${source} has ${unknown.length === 1 ? 'a field' : 'fields'} not in the catalog: ${unknown.join(', ')}.`, `${why}A product has ${PRODUCT_KEYS.join(', ')}. Remove the other fields.`);
155
155
  }
156
+ if (p.image_url !== undefined) {
157
+ const url = String(p.image_url);
158
+ const ok = isLocalImage(url) ? !url.split('/').includes('..') && url.trim() === url && url !== '' : /^https?:\/\/\S+$/.test(url);
159
+ if (!ok)
160
+ fail('invalid_product', `Product ${p.sku} in ${source} has image_url "${url}".`, 'Use a full https:// URL, or a path in the storefront folder such as img/eth.jpg (no "..").');
161
+ }
156
162
  if (p.category !== undefined) {
157
163
  const parts = typeof p.category === 'string' ? p.category.split('>').map((c) => c.trim()) : [''];
158
164
  if (parts.some((c) => !c))
@@ -169,6 +175,8 @@ export function checkProducts(sourced, currencies) {
169
175
  }
170
176
  return products;
171
177
  }
178
+ /** An image_url that is a path in the storefront (img/eth.jpg), not a URL. */
179
+ export const isLocalImage = (url) => !/^[a-z][a-z0-9+.-]*:/i.test(url) && !url.startsWith('//');
172
180
  export function toApplied(store) {
173
181
  return {
174
182
  markets: Object.fromEntries(store.markets.map((m) => [m.code, m])),
@@ -5,7 +5,8 @@ const esc = (t) => String(t ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<
5
5
  export function storefrontHtml(store, clientId) {
6
6
  const name = esc(store.name);
7
7
  const card = (p, h) => ` <article class="product">
8
- ${p.image_url ? ` <img src="${esc(p.image_url)}" alt="">\n` : ''} <${h}>${esc(p.name)}</${h}>
8
+ ${p.image_url ? ` <img src="${esc(p.image_url)}" alt="">` : ' <div class="placeholder" aria-hidden="true"></div>'}
9
+ <${h}>${esc(p.name)}</${h}>
9
10
  ${p.description ? ` <p>${esc(p.description)}</p>\n` : ''} <cl-price code="${esc(p.sku)}"><cl-price-amount type="compare-at"></cl-price-amount> <cl-price-amount type="price"></cl-price-amount></cl-price>
10
11
  <cl-availability code="${esc(p.sku)}"><cl-availability-status type="available">In stock</cl-availability-status><cl-availability-status type="unavailable">Sold out</cl-availability-status></cl-availability>
11
12
  <cl-add-to-cart code="${esc(p.sku)}">Add to cart</cl-add-to-cart>
@@ -45,7 +46,8 @@ ${p.description ? ` <p>${esc(p.description)}</p>\n` : ''} <cl-pric
45
46
  <meta charset="utf-8">
46
47
  <meta name="viewport" content="width=device-width, initial-scale=1">
47
48
  <title>${name}</title>
48
- <!-- Commerce Layer drop-in.js: prices, availability, add to cart, the minicart (a slide-in panel, styled by minicart.css) and links to the hosted cart and checkout. -->
49
+ <!-- Commerce Layer drop-in.js: prices, availability, add to cart, the minicart (a slide-in panel, styled by minicart.css) and links to the hosted cart and checkout.
50
+ Product images: put them in img/ next to this page and set image_url: img/<file> in the catalog. -->
49
51
  <link href="https://cdn.jsdelivr.net/npm/@commercelayer/drop-in.js@2/dist/drop-in/drop-in.css" rel="stylesheet">
50
52
  <link href="https://cdn.jsdelivr.net/npm/@commercelayer/drop-in.js@2/dist/drop-in/minicart.css" rel="stylesheet">
51
53
  <script>
@@ -59,38 +61,69 @@ ${p.description ? ` <p>${esc(p.description)}</p>\n` : ''} <cl-pric
59
61
  </script>
60
62
  <script type="module" src="https://cdn.jsdelivr.net/npm/@commercelayer/drop-in.js@2/dist/drop-in/drop-in.esm.js"></script>
61
63
  <style>
62
- :root { color-scheme: light; --ink: #16181d; --muted: #5d6572; --line: #e3e6ea; --accent: #1f5eff; }
63
- body { margin: 0; font-family: system-ui, sans-serif; color: var(--ink); background: #fff; }
64
- header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 20px 24px; border-bottom: 1px solid var(--line); }
64
+ /* Theme: restyle the page by changing these tokens; a dark theme is color-scheme: dark with paper and ink swapped.
65
+ Keep this <style> after the drop-in.js stylesheets: its rules use the same selectors as drop-in.css, so they win by coming later. */
66
+ :root {
67
+ color-scheme: light;
68
+ --paper: #ffffff; /* page background */
69
+ --ink: #16181d; /* text and prices */
70
+ --muted: #5d6572; /* secondary text, compare-at prices, availability */
71
+ --line: #e3e6ea; /* borders, disabled buttons */
72
+ --accent: #1f5eff; /* buttons, link hover */
73
+ --on-accent: #ffffff; /* text on the accent */
74
+ --radius: 8px;
75
+ --font: system-ui, sans-serif;
76
+ /* drop-in.css's own defaults (add to cart, cart count) read these. */
77
+ --cl-color-primary: var(--accent);
78
+ --cl-color-white: var(--on-accent);
79
+ }
80
+ body { margin: 0; font-family: var(--font); color: var(--ink); background: var(--paper); }
81
+ a { color: inherit; }
82
+ header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; padding: 16px 24px; border-bottom: 1px solid var(--line); }
65
83
  header h1 { margin: 0; font-size: 20px; }
66
- header nav { display: flex; align-items: center; gap: 16px; }
84
+ /* The nav never wraps inside: on a narrow screen it moves under the store name as a whole. */
85
+ header nav { display: flex; align-items: center; gap: 16px; margin-left: auto; white-space: nowrap; }
86
+ header select { font: inherit; color: inherit; background: var(--paper); border: 1px solid var(--line); border-radius: calc(var(--radius) / 2); padding: 4px 6px; }
67
87
  nav.categories { display: flex; flex-wrap: wrap; gap: 8px 20px; padding: 14px 24px; border-bottom: 1px solid var(--line); }
68
- nav.categories a { color: var(--ink); text-decoration: none; font-size: 15px; }
88
+ nav.categories a { text-decoration: none; font-size: 15px; }
69
89
  nav.categories a:hover { color: var(--accent); }
70
90
  main { max-width: 1100px; margin: 0 auto; padding: 32px 24px; }
71
91
  main section { scroll-margin-top: 16px; }
72
92
  main section + section { margin-top: 40px; }
73
93
  main section > h2 { margin: 0 0 16px; font-size: 22px; }
74
94
  .grid { display: grid; gap: 24px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
75
- .product { display: flex; flex-direction: column; gap: 8px; padding: 20px; border: 1px solid var(--line); border-radius: 8px; }
76
- .product img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 6px; }
95
+ .product { display: flex; flex-direction: column; gap: 8px; padding: 20px; border: 1px solid var(--line); border-radius: var(--radius); }
96
+ .product img, .product .placeholder { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: calc(var(--radius) - 2px); }
97
+ /* No image: a plain tile a shade off the page, in any theme. */
98
+ .product .placeholder { background: color-mix(in srgb, var(--ink) 6%, var(--paper)); }
77
99
  .product h2, .product h3 { margin: 0; font-size: 17px; }
78
100
  .product p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
79
- cl-price { font-weight: 600; }
80
- cl-price-amount[type="compare-at"] { color: var(--muted); font-weight: 400; text-decoration: line-through; }
101
+ /* drop-in.css colours cl-price-amount itself (#101111), so inheriting is not enough: set it here. */
102
+ cl-price-amount { color: var(--ink); font-weight: 600; }
103
+ cl-price-amount[type="compare-at"] { color: var(--muted); font-weight: 400; }
81
104
  cl-availability { font-size: 13px; color: var(--muted); }
82
- cl-add-to-cart { margin-top: auto; }
105
+ /* cl-add-to-cart is the button itself (it has no ::part); drop-in.css colours it with these same selectors. */
106
+ cl-add-to-cart { margin-top: auto; align-self: flex-start; padding: 10px 16px; border-radius: calc(var(--radius) - 2px); font: inherit; font-weight: 600; background-color: var(--line); color: var(--muted); }
107
+ cl-add-to-cart:not([aria-disabled=true]) { background-color: var(--accent); color: var(--on-accent); }
108
+ /* cl-checkout-link renders a plain <a> (no shadow DOM); without an order it is aria-disabled. */
109
+ cl-checkout-link a { display: inline-block; padding: 8px 14px; border-radius: calc(var(--radius) - 2px); background: var(--accent); color: var(--on-accent); font-weight: 600; text-decoration: none; }
110
+ cl-checkout-link[aria-disabled="true"] a { background: var(--line); color: var(--muted); }
83
111
  /* drop-in.css styles the count as a badge beside a cart icon (absolute, pushed right). The link is text here, so render it inline: "Cart (3)". */
84
112
  cl-cart-link cl-cart-count { position: static; transform: none; display: inline; min-width: 0; height: auto; padding: 0; border-radius: 0; background: none; color: inherit; font-size: inherit; }
85
- cl-add-to-cart::part(button), cl-checkout-link::part(a) { display: inline-block; padding: 10px 16px; border-radius: 6px; background: var(--accent); color: #fff; font: inherit; border: 0; cursor: pointer; text-decoration: none; }
86
113
  footer { padding: 24px; border-top: 1px solid var(--line); color: var(--muted); font-size: 13px; }
114
+ @media (max-width: 480px) {
115
+ header, nav.categories { padding-left: 16px; padding-right: 16px; }
116
+ header nav { gap: 12px; }
117
+ .market-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
118
+ main { padding: 24px 16px; }
119
+ }
87
120
  </style>
88
121
  </head>
89
122
  <body>
90
123
  <header>
91
124
  <h1>${name}</h1>
92
125
  <nav>
93
- <label>Market
126
+ <label><span class="market-label">Market</span>
94
127
  <select onchange="location.search = '?market=' + this.value">
95
128
  ${options}
96
129
  </select>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercelayer/agent-cli",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "c11r: the Commerce Layer CLI for coding agents. Builds a sandbox store that people and shopping agents can buy from.",
5
5
  "homepage": "https://commercelayer.ai",
6
6
  "keywords": [