@cartbase/storefront 0.4.0 → 0.6.0

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
@@ -43,7 +43,7 @@ NEXT_PUBLIC_CARTBASE_PUBLISHABLE_KEY= # optional, channel-scoped catalogs
43
43
  | `@cartbase/storefront/store` | Listing pages: pagination, sorting, collection/category/search templates |
44
44
  | `@cartbase/storefront/order` | Order confirmation surfaces |
45
45
  | `@cartbase/storefront/tracking` | Consent banner + consent-gated Meta Pixel / GA4 / analytics |
46
- | `@cartbase/storefront/tailwind-preset` | The design tokens (Tailwind 3 preset) |
46
+ | `@cartbase/storefront/theme` | The design system: token names plus a filled default set of values (Tailwind 4, CSS-first) |
47
47
 
48
48
  ## Documentation
49
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartbase/storefront",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Storefront SDK + UI component library for Cartbase stores: typed API client, checkout orchestration, cart drawer, product/catalog components, tracking. Source-shipped TypeScript — add it to transpilePackages.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -12,8 +12,8 @@
12
12
  "sideEffects": false,
13
13
  "files": [
14
14
  "src",
15
- "README.md",
16
- "tailwind-preset.cjs"
15
+ "theme",
16
+ "README.md"
17
17
  ],
18
18
  "exports": {
19
19
  ".": "./src/index.ts",
@@ -38,7 +38,9 @@
38
38
  "./api/integrations": "./src/api/integrations.ts",
39
39
  "./api/consent": "./src/api/consent.ts",
40
40
  "./api/redirects": "./src/api/redirects.ts",
41
- "./tailwind-preset": "./tailwind-preset.cjs",
41
+ "./theme": "./theme/index.css",
42
+ "./theme/tokens.css": "./theme/tokens.css",
43
+ "./theme/theme.css": "./theme/theme.css",
42
44
  "./lib/utils": "./src/lib/utils.ts",
43
45
  "./lib/money": "./src/lib/money.ts",
44
46
  "./lib/cart-helpers": "./src/lib/cart-helpers.ts",
@@ -488,3 +488,39 @@ export async function refreshPaymentIfTerminal(
488
488
  ): Promise<RefreshPaymentResult> {
489
489
  return client.post(`/api/store/carts/${cartId}/refresh-payment-if-terminal`)
490
490
  }
491
+
492
+ // ---------------------------------------------------------------------------
493
+ // Browser-side error reporting
494
+ // ---------------------------------------------------------------------------
495
+
496
+ /** Body of POST /api/store/checkout-errors (`.strict()`). */
497
+ export interface ReportCheckoutErrorInput {
498
+ /** The orchestration hook's errorType, e.g. "place_order_error". */
499
+ error_type: string
500
+ message: string
501
+ cart_id?: string
502
+ /** Redacted context — ids, codes, flags. Never card data, never addresses. */
503
+ context?: Record<string, unknown>
504
+ }
505
+
506
+ /**
507
+ * Report a browser-side checkout failure into the platform's
508
+ * checkout_error_logs (step `browser`) so the merchant sees WHY a checkout
509
+ * died in the customer's browser — Stripe.js confirm errors, a 3DS return
510
+ * that came back not-succeeded, a place-order rejection after payment.
511
+ * `useCheckoutOrchestration` calls this BY DEFAULT when no `logError`
512
+ * override is given. Fire-and-forget by contract: swallows every failure —
513
+ * reporting an error must never break a checkout. Rate-limited server-side.
514
+ *
515
+ * Auth: anon `x-client-id`.
516
+ */
517
+ export async function reportCheckoutError(
518
+ client: StorefrontClient,
519
+ input: ReportCheckoutErrorInput
520
+ ): Promise<void> {
521
+ try {
522
+ await client.post(`/api/store/checkout-errors`, input)
523
+ } catch {
524
+ // Swallowed by contract — the sink must never take the checkout down.
525
+ }
526
+ }
@@ -116,6 +116,13 @@ export interface StoreProductVariant {
116
116
  * context. Varies by customer group — never cache shared.
117
117
  */
118
118
  calculated_price: CalculatedPrice | null
119
+ /**
120
+ * THE availability predicate, computed server-side (untracked or
121
+ * backorderable ⇒ true; otherwise kit-aware available stock > 0).
122
+ * Optional only for wire back-compat with platforms that predate it —
123
+ * when absent, components fall back to the optimistic legacy behavior.
124
+ */
125
+ in_stock?: boolean
119
126
  }
120
127
 
121
128
  export interface StoreProduct {
package/src/api/search.ts CHANGED
@@ -71,6 +71,17 @@ export interface RelatedProductsQuery extends PricingContextQuery {
71
71
  limit?: number
72
72
  }
73
73
 
74
+ /**
75
+ * Complementary products — the merchant's "goes with this" picks, for the
76
+ * slot ABOVE the product rather than the carousel below it.
77
+ *
78
+ * It has no fallback and never will: "similar to this" can be computed from
79
+ * collections and tags, "buy this with it" is a merchant judgement, and a
80
+ * guessed upsell beside the buy button is worse than an empty slot. An empty
81
+ * response means the merchant has not chosen — render nothing.
82
+ */
83
+ export type ComplementaryProductsQuery = RelatedProductsQuery
84
+
74
85
  export interface RelatedProductsResponse {
75
86
  products: StoreProduct[]
76
87
  count: number
@@ -131,3 +142,22 @@ export async function listRelatedProducts(
131
142
  query: { ...query },
132
143
  })
133
144
  }
145
+
146
+ /**
147
+ * Complementary products for a PDP: the merchant's manual picks, in their
148
+ * order, and nothing else — `auto_filled` is always false.
149
+ *
150
+ * Same endpoint as related, discriminated by `kind`, because the two lists
151
+ * are one table and one code path apart only by meaning. Auth, errors and
152
+ * channel scoping are identical to `listRelatedProducts`.
153
+ * Settings: Admin → Product → Complementary products.
154
+ */
155
+ export async function listComplementaryProducts(
156
+ client: StorefrontClient,
157
+ idOrHandle: string,
158
+ query?: ComplementaryProductsQuery
159
+ ): Promise<RelatedProductsResponse> {
160
+ return client.get(`/api/store/products/${encodeURIComponent(idOrHandle)}/related`, {
161
+ query: { ...query, kind: "complementary" },
162
+ })
163
+ }
@@ -22,6 +22,7 @@ import {
22
22
  calculateShippingOption,
23
23
  prepareCheckout,
24
24
  refreshPaymentIfTerminal as refreshPaymentIfTerminalApi,
25
+ reportCheckoutError,
25
26
  syncPaymentAmount as syncPaymentAmountApi,
26
27
  isMethodEntry,
27
28
  type PrepareCheckoutInput,
@@ -152,11 +153,21 @@ export type UseCheckoutOrchestrationOptions = {
152
153
  */
153
154
  resolveTrackingMetadata?: () => Record<string, unknown> | undefined
154
155
  /**
155
- * Operational-visibility sink (successor of @1click's logCheckoutError /
156
- * logEvent Supabase writers Cartbase has no store-side log endpoint).
157
- * Called with (errorType, message, context). Optional; defaults to no-op.
156
+ * Operational-visibility sink. Called with (errorType, message, context).
157
+ * DEFAULTS to reporting into the platform's checkout error log
158
+ * (POST /api/store/checkout-errors, step `browser`) so browser-side money
159
+ * failures reach the merchant without any wiring — a platform guarantee,
160
+ * not a merchant chore (money-chain-hardening, 2026-08-24). Override to
161
+ * add your own sink; fire-and-forget either way.
158
162
  */
159
163
  logError?: CheckoutLogError
164
+ /**
165
+ * Verbose [buy-click] console output for local debugging. OFF by default:
166
+ * the unguarded dumps used to print the full prepare-checkout payload —
167
+ * customer name, phone, email, address — into every customer's browser
168
+ * console. Redacted sink events fire regardless of this flag.
169
+ */
170
+ debug?: boolean
160
171
  }
161
172
 
162
173
  /**
@@ -253,8 +264,35 @@ export function useCheckoutOrchestration({
253
264
  orderConfirmedPath: orderConfirmedPathProp,
254
265
  onOrderPlaced,
255
266
  resolveTrackingMetadata,
256
- logError,
267
+ logError: logErrorProp,
268
+ debug = false,
257
269
  }: UseCheckoutOrchestrationOptions) {
270
+ // The default sink: the platform's own checkout error log. Browser money
271
+ // failures (Stripe confirm errors, failed 3DS returns, place-order
272
+ // rejections) land in checkout_error_logs step `browser` with no merchant
273
+ // wiring. The two per-click debug snapshots are gated behind `debug` and
274
+ // never sent by default — the sink is for FAILURES, not telemetry.
275
+ const logError: CheckoutLogError = useCallback(
276
+ (errorType, message, context) => {
277
+ if (logErrorProp) {
278
+ logErrorProp(errorType, message, context)
279
+ return
280
+ }
281
+ void reportCheckoutError(client, {
282
+ error_type: errorType,
283
+ message,
284
+ cart_id: cart?.id,
285
+ context,
286
+ })
287
+ },
288
+ // eslint-disable-next-line react-hooks/exhaustive-deps
289
+ [logErrorProp, client, cart?.id]
290
+ )
291
+ // Debug-gated console output — see the `debug` option's doc.
292
+ const dbg = debug
293
+ ? // eslint-disable-next-line no-console
294
+ (...args: unknown[]) => console.log(...args)
295
+ : () => {}
258
296
  // Resolve the order-confirmed path. Prop wins; otherwise fall back to
259
297
  // the value provided by CheckoutProvider context.
260
298
  const contextOrderConfirmedPath = useOrderConfirmedPath()
@@ -1247,9 +1285,10 @@ export function useCheckoutOrchestration({
1247
1285
  : null,
1248
1286
  hasStripeBundle: !!stripeBundle,
1249
1287
  }
1250
- // eslint-disable-next-line no-console
1251
- console.log("[buy-click] STATE", stateSnapshot)
1252
- logError?.("other", "buy_click_state", stateSnapshot)
1288
+ dbg("[buy-click] STATE", stateSnapshot)
1289
+ // Per-click telemetry goes only to a merchant-wired sink, never to the
1290
+ // default platform error log — the sink is for failures.
1291
+ logErrorProp?.("other", "buy_click_state", stateSnapshot)
1253
1292
 
1254
1293
  await flushAddressSave()
1255
1294
 
@@ -1259,8 +1298,7 @@ export function useCheckoutOrchestration({
1259
1298
  console.error("[buy-click] card path but no stripe bundle")
1260
1299
  throw new Error("Stripe not ready")
1261
1300
  }
1262
- // eslint-disable-next-line no-console
1263
- console.log("[buy-click] elements.submit() …")
1301
+ dbg("[buy-click] elements.submit() …")
1264
1302
  const { error: submitError } = await stripeBundle.submit()
1265
1303
  if (submitError) {
1266
1304
  // eslint-disable-next-line no-console
@@ -1270,9 +1308,10 @@ export function useCheckoutOrchestration({
1270
1308
  }
1271
1309
 
1272
1310
  const payload = buildPrepareCheckoutPayload()
1273
- // eslint-disable-next-line no-console
1274
- console.log("[buy-click] PAYLOAD →", payload)
1275
- logError?.("other", "buy_click_payload", {
1311
+ // debug-only: the full payload carries the customer's name, phone,
1312
+ // email and address — it must never print in a customer's console.
1313
+ dbg("[buy-click] PAYLOAD →", payload)
1314
+ logErrorProp?.("other", "buy_click_payload", {
1276
1315
  provider_id: payload.payment_provider ?? null,
1277
1316
  payment_method_id: payload.payment_method_id ?? null,
1278
1317
  shipping_method_id: payload.shipping_method_id,
@@ -1287,8 +1326,7 @@ export function useCheckoutOrchestration({
1287
1326
  throw e
1288
1327
  }
1289
1328
  )
1290
- // eslint-disable-next-line no-console
1291
- console.log("[buy-click] prepareCheckout response", {
1329
+ dbg("[buy-click] prepareCheckout response", {
1292
1330
  has_client_secret: !!prep.client_secret,
1293
1331
  provider_id: prep.provider_id,
1294
1332
  })
@@ -1348,8 +1386,7 @@ export function useCheckoutOrchestration({
1348
1386
  },
1349
1387
  }
1350
1388
 
1351
- // eslint-disable-next-line no-console
1352
- console.log("[buy-click] stripe.confirmPayment() …", {
1389
+ dbg("[buy-click] stripe.confirmPayment() …", {
1353
1390
  has_country: !!billingDetails.address.country,
1354
1391
  })
1355
1392
  const { error } = await (stripeBundle.stripe as unknown as {
@@ -1395,23 +1432,20 @@ export function useCheckoutOrchestration({
1395
1432
  // confirmPayment succeeded synchronously (no redirect needed,
1396
1433
  // e.g. non-3DS card flow). The PaymentIntent is now in
1397
1434
  // requires_capture or succeeded — complete will pass authorize.
1398
- // eslint-disable-next-line no-console
1399
- console.log("[buy-click] stripe.confirmPayment SUCCESS (no redirect)")
1400
- logError?.("stripe_confirm_succeeded", "ok", {
1435
+ dbg("[buy-click] stripe.confirmPayment SUCCESS (no redirect)")
1436
+ logErrorProp?.("stripe_confirm_succeeded", "ok", {
1401
1437
  cart_id: cart.id,
1402
1438
  client_secret_prefix: prep.client_secret.slice(0, 8),
1403
1439
  })
1404
1440
  } else if (zeroRemainderGiftPath) {
1405
- // eslint-disable-next-line no-console
1406
- console.log(
1441
+ dbg(
1407
1442
  "[buy-click] zero-remainder gift path — skipping Stripe, completing on the gift session"
1408
1443
  )
1409
- logError?.("other", "zero_remainder_gift_path", { cart_id: cart.id })
1444
+ logErrorProp?.("other", "zero_remainder_gift_path", { cart_id: cart.id })
1410
1445
  }
1411
1446
 
1412
- // eslint-disable-next-line no-console
1413
- console.log("[buy-click] placeOrder() …")
1414
- logError?.("order_placed", "called", {
1447
+ dbg("[buy-click] placeOrder() …")
1448
+ logErrorProp?.("order_placed", "called", {
1415
1449
  cart_id: cart.id,
1416
1450
  path: paymentTab,
1417
1451
  })
@@ -14,13 +14,12 @@
14
14
  * - `countryCode` (Medusa region routing) is dropped — Cartbase pricing
15
15
  * context travels with the product fetch, and the host's `addToCart`
16
16
  * closes over whatever routing it needs.
17
- * - Stock: Cartbase's store surface does NOT expose `inventory_quantity`
18
- * (availability is a search facet; the server enforces stock at add with
19
- * 400 `insufficient_inventory`). Managed-inventory variants without an
20
- * exposed quantity are treated as in stock optimistically; a thrown
21
- * `insufficient_inventory` flips the button to the out-of-stock state.
22
- * Richer DTOs that DO carry `inventory_quantity` keep the original
23
- * quantity check.
17
+ * - Stock: the variant DTO carries `in_stock`, computed server-side by THE
18
+ * availability predicate this component only reads it. The server still
19
+ * enforces stock at add (400 `insufficient_inventory` flips the button to
20
+ * the out-of-stock state). On a legacy wire without the field, managed
21
+ * variants fall back to the old optimistic behavior; richer DTOs carrying
22
+ * `inventory_quantity` keep the original quantity check.
24
23
  *
25
24
  * Variant matching is the extracted pure module `./variant-matching`
26
25
  * (handles Cartbase's option-value link shape).
@@ -121,10 +120,17 @@ export function ProductActions({
121
120
 
122
121
  const inStock = useMemo(() => {
123
122
  if (!selectedVariant) return false
123
+ // The server computes THE availability predicate and emits it as
124
+ // `in_stock` on the variant DTO — read it, never re-derive it here
125
+ // (the hand-copied clauses this replaced could not see stock levels and
126
+ // showed an enabled Add to cart on sold-out variants until a 400).
127
+ if (typeof selectedVariant.in_stock === "boolean") {
128
+ return selectedVariant.in_stock && !stockExhausted
129
+ }
130
+ // Legacy wire (platform predates the field): optimistic fallback — the
131
+ // server still enforces stock on add.
124
132
  if (!selectedVariant.manage_inventory) return true
125
133
  if (selectedVariant.allow_backorder) return true
126
- // Cartbase wire shape carries no inventory_quantity — optimistic; the
127
- // server enforces stock on add. Richer DTOs keep the original check.
128
134
  const qty = (selectedVariant as { inventory_quantity?: number })
129
135
  .inventory_quantity
130
136
  if (qty === undefined) return !stockExhausted
@@ -0,0 +1,25 @@
1
+ /*
2
+ * Cartbase storefront theme — the one line a storefront imports.
3
+ *
4
+ * @import "tailwindcss";
5
+ * @import "@cartbase/storefront/theme";
6
+ *
7
+ * That is the whole setup. There is no Tailwind config file, no preset to
8
+ * register, and no list of token names to copy: Tailwind 4 reads the theme
9
+ * from CSS, and this ships filled, so a fresh install renders as a designed
10
+ * store rather than grey boxes.
11
+ *
12
+ * To retheme, override the values from tokens.css in your own stylesheet
13
+ * AFTER this import. Never edit the vocabulary — components reference those
14
+ * names, and changing one moves every storefront at once.
15
+ */
16
+ @import "./tokens.css";
17
+ @import "./theme.css";
18
+
19
+ /* The page's own ground. Without this the body inherits the browser default
20
+ and every surface token above renders against white regardless of theme. */
21
+ body {
22
+ background-color: var(--background);
23
+ color: var(--foreground);
24
+ font-family: var(--font-body-family);
25
+ }
@@ -0,0 +1,150 @@
1
+ /*
2
+ * Cartbase storefront — the VOCABULARY.
3
+ *
4
+ * Every line maps a value from tokens.css into a Tailwind namespace, which is
5
+ * what turns it into a utility class. Registering `--color-primary` is what
6
+ * makes `bg-primary`, `text-primary`, `border-primary` and `ring-primary`
7
+ * exist. Registering `--text-h2` is what makes `text-h2` exist.
8
+ *
9
+ * THIS FILE HOLDS NO VALUES, and a theme never edits it. A theme swaps
10
+ * tokens.css and the whole library repaints, because components only ever
11
+ * reference names from here.
12
+ *
13
+ * Every namespace below is Tailwind's own. We invented none of them and we
14
+ * add none of our own, because the requirement is that a developer or a
15
+ * coding agent reads `text-h2` or `bg-primary` and needs no documentation to
16
+ * know what it is or how to change it.
17
+ */
18
+
19
+ @theme {
20
+ /* ── Colour ───────────────────────────────────────────────────────────── */
21
+ --color-background: var(--background);
22
+ --color-foreground: var(--foreground);
23
+ --color-card: var(--card);
24
+ --color-card-foreground: var(--card-foreground);
25
+ --color-popover: var(--popover);
26
+ --color-popover-foreground: var(--popover-foreground);
27
+
28
+ --color-primary: var(--primary);
29
+ --color-primary-foreground: var(--primary-foreground);
30
+ --color-secondary: var(--secondary);
31
+ --color-secondary-foreground: var(--secondary-foreground);
32
+ --color-muted: var(--muted);
33
+ --color-muted-foreground: var(--muted-foreground);
34
+ --color-accent: var(--accent);
35
+ --color-accent-foreground: var(--accent-foreground);
36
+
37
+ --color-destructive: var(--destructive);
38
+ --color-destructive-foreground: var(--destructive-foreground);
39
+ --color-success: var(--success);
40
+ --color-success-foreground: var(--success-foreground);
41
+ --color-warning: var(--warning);
42
+ --color-warning-foreground: var(--warning-foreground);
43
+
44
+ --color-border: var(--border);
45
+ --color-input: var(--input);
46
+ --color-ring: var(--ring);
47
+
48
+ /* ── Typefaces ────────────────────────────────────────────────────────── */
49
+ --font-body: var(--font-body-family);
50
+ --font-display: var(--font-display-family);
51
+ --font-mono: var(--font-mono-family);
52
+
53
+ /*
54
+ * ── Type scale ──────────────────────────────────────────────────────────
55
+ * Generated at a 1.2 ratio from a 16px base and rounded to 4px steps, the
56
+ * major-third scale Polaris uses. Named h1 to h6 because that is what every
57
+ * web developer reads without translation.
58
+ *
59
+ * The name is a STYLE, never a tag. Put `text-h2` on an <h1> when the page
60
+ * needs a smaller heading: the tag stays correct for search engines and
61
+ * screen readers, the style is a separate decision. This is exactly how
62
+ * Webflow's Client-First `heading-style-h*` classes work.
63
+ */
64
+ --text-h1: 2.5rem;
65
+ --text-h1--line-height: 1.1;
66
+ --text-h1--letter-spacing: -0.02em;
67
+ --text-h1--font-weight: 600;
68
+
69
+ --text-h2: 2rem;
70
+ --text-h2--line-height: 1.15;
71
+ --text-h2--letter-spacing: -0.015em;
72
+ --text-h2--font-weight: 600;
73
+
74
+ --text-h3: 1.75rem;
75
+ --text-h3--line-height: 1.2;
76
+ --text-h3--letter-spacing: -0.01em;
77
+ --text-h3--font-weight: 600;
78
+
79
+ --text-h4: 1.5rem;
80
+ --text-h4--line-height: 1.25;
81
+ --text-h4--font-weight: 600;
82
+
83
+ --text-h5: 1.25rem;
84
+ --text-h5--line-height: 1.3;
85
+ --text-h5--font-weight: 600;
86
+
87
+ --text-h6: 1rem;
88
+ --text-h6--line-height: 1.4;
89
+ --text-h6--font-weight: 600;
90
+
91
+ --text-body-large: 1.125rem;
92
+ --text-body-large--line-height: 1.6;
93
+
94
+ --text-body: 1rem;
95
+ --text-body--line-height: 1.6;
96
+
97
+ --text-body-small: 0.875rem;
98
+ --text-body-small--line-height: 1.5;
99
+
100
+ /* Buttons, form labels, badges — short, never a sentence. */
101
+ --text-label: 0.875rem;
102
+ --text-label--line-height: 1.2;
103
+ --text-label--font-weight: 500;
104
+
105
+ /* Fine print, metadata, timestamps. */
106
+ --text-caption: 0.75rem;
107
+ --text-caption--line-height: 1.4;
108
+
109
+ /*
110
+ * ── Weight ──────────────────────────────────────────────────────────────
111
+ * Three, not four. Bold and semibold were doing the same job in different
112
+ * places, which is how a scale rots.
113
+ */
114
+ --font-weight-regular: 400;
115
+ --font-weight-medium: 500;
116
+ --font-weight-semibold: 600;
117
+
118
+ /*
119
+ * ── Spacing ─────────────────────────────────────────────────────────────
120
+ * Tailwind's own 4px step, stated rather than assumed so a theme can widen
121
+ * the whole rhythm with one number.
122
+ */
123
+ --spacing: 0.25rem;
124
+
125
+ /* ── Shape ────────────────────────────────────────────────────────────── */
126
+ --radius-sm: calc(var(--radius-base) - 4px);
127
+ --radius-md: calc(var(--radius-base) - 2px);
128
+ --radius-lg: var(--radius-base);
129
+ --radius-xl: calc(var(--radius-base) + 4px);
130
+ --radius-2xl: calc(var(--radius-base) + 12px);
131
+
132
+ /*
133
+ * ── Elevation ───────────────────────────────────────────────────────────
134
+ * Named for what is raised, not for how big the blur is. `shadow-card` says
135
+ * what it is for; `shadow-md` says nothing and cannot be retuned per theme.
136
+ */
137
+ --shadow-card: var(--elevation-card);
138
+ --shadow-card-hover: var(--elevation-card-hover);
139
+ --shadow-popover: var(--elevation-popover);
140
+ --shadow-drawer: var(--elevation-drawer);
141
+
142
+ /* ── Motion ───────────────────────────────────────────────────────────── */
143
+ --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
144
+ --ease-entrance: cubic-bezier(0, 0, 0.2, 1);
145
+ --ease-exit: cubic-bezier(0.4, 0, 1, 1);
146
+
147
+ /* ── Layout ───────────────────────────────────────────────────────────── */
148
+ --container-page: var(--layout-page-width);
149
+ --container-prose: var(--layout-prose-width);
150
+ }
@@ -0,0 +1,106 @@
1
+ /*
2
+ * Cartbase storefront — the DEFAULT THEME's values.
3
+ *
4
+ * This file is one half of the two-layer token model. It holds VALUES and
5
+ * nothing else. The other half (theme.css) holds the NAMES and never holds a
6
+ * value. A theme replaces this file; it never touches the other one.
7
+ *
8
+ * Naming follows shadcn/ui exactly, because that is the vocabulary every
9
+ * developer and every coding agent already knows without reading a document:
10
+ * a semantic role (`primary`, `muted`, `destructive`) always paired with its
11
+ * `-foreground`, so text on a surface is never a guess.
12
+ *
13
+ * Colours are oklch. Opacity modifiers (`bg-primary/50`) work on full colour
14
+ * values in Tailwind 4, so unlike the old preset these are complete colours
15
+ * rather than bare channels waiting for a wrapper.
16
+ */
17
+
18
+ :root {
19
+ color-scheme: light;
20
+
21
+ /* ── Surfaces ─────────────────────────────────────────────────────────── */
22
+ --background: oklch(1 0 0);
23
+ --foreground: oklch(0.145 0 0);
24
+ --card: oklch(1 0 0);
25
+ --card-foreground: oklch(0.145 0 0);
26
+ --popover: oklch(1 0 0);
27
+ --popover-foreground: oklch(0.145 0 0);
28
+
29
+ /* ── Roles ────────────────────────────────────────────────────────────── */
30
+ --primary: oklch(0.205 0 0);
31
+ --primary-foreground: oklch(0.985 0 0);
32
+ --secondary: oklch(0.97 0 0);
33
+ --secondary-foreground: oklch(0.205 0 0);
34
+ --muted: oklch(0.97 0 0);
35
+ --muted-foreground: oklch(0.556 0 0);
36
+ --accent: oklch(0.97 0 0);
37
+ --accent-foreground: oklch(0.205 0 0);
38
+
39
+ /* ── Feedback ─────────────────────────────────────────────────────────── */
40
+ --destructive: oklch(0.577 0.245 27.325);
41
+ --destructive-foreground: oklch(0.985 0 0);
42
+ --success: oklch(0.648 0.15 160);
43
+ --success-foreground: oklch(0.985 0 0);
44
+ --warning: oklch(0.828 0.189 84.429);
45
+ --warning-foreground: oklch(0.279 0.077 45.635);
46
+
47
+ /* ── Lines and focus ──────────────────────────────────────────────────── */
48
+ --border: oklch(0.922 0 0);
49
+ --input: oklch(0.922 0 0);
50
+ --ring: oklch(0.708 0 0);
51
+
52
+ /* ── Typefaces ────────────────────────────────────────────────────────── */
53
+ --font-body-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
54
+ --font-display-family: ui-serif, Georgia, serif;
55
+ --font-mono-family: ui-monospace, SFMono-Regular, Menlo, monospace;
56
+
57
+ /* ── Shape ────────────────────────────────────────────────────────────── */
58
+ --radius-base: 0.5rem;
59
+
60
+ /* ── Elevation ────────────────────────────────────────────────────────── */
61
+ --elevation-card: 0 1px 2px 0 oklch(0 0 0 / 5%);
62
+ --elevation-card-hover: 0 4px 12px -2px oklch(0 0 0 / 10%), 0 2px 4px -2px oklch(0 0 0 / 6%);
63
+ --elevation-popover: 0 8px 24px -4px oklch(0 0 0 / 12%), 0 2px 6px -2px oklch(0 0 0 / 8%);
64
+ --elevation-drawer: -8px 0 32px -8px oklch(0 0 0 / 16%);
65
+
66
+ /* ── Layout ───────────────────────────────────────────────────────────── */
67
+ --layout-page-width: 80rem;
68
+ --layout-prose-width: 42rem;
69
+ }
70
+
71
+ .dark {
72
+ color-scheme: dark;
73
+
74
+ --background: oklch(0.145 0 0);
75
+ --foreground: oklch(0.985 0 0);
76
+ --card: oklch(0.205 0 0);
77
+ --card-foreground: oklch(0.985 0 0);
78
+ --popover: oklch(0.205 0 0);
79
+ --popover-foreground: oklch(0.985 0 0);
80
+
81
+ --primary: oklch(0.985 0 0);
82
+ --primary-foreground: oklch(0.205 0 0);
83
+ --secondary: oklch(0.269 0 0);
84
+ --secondary-foreground: oklch(0.985 0 0);
85
+ --muted: oklch(0.269 0 0);
86
+ --muted-foreground: oklch(0.708 0 0);
87
+ --accent: oklch(0.269 0 0);
88
+ --accent-foreground: oklch(0.985 0 0);
89
+
90
+ --destructive: oklch(0.704 0.191 22.216);
91
+ --destructive-foreground: oklch(0.985 0 0);
92
+ --success: oklch(0.696 0.17 162.48);
93
+ --success-foreground: oklch(0.145 0 0);
94
+ --warning: oklch(0.828 0.189 84.429);
95
+ --warning-foreground: oklch(0.279 0.077 45.635);
96
+
97
+ --border: oklch(1 0 0 / 12%);
98
+ --input: oklch(1 0 0 / 18%);
99
+ --ring: oklch(0.556 0 0);
100
+
101
+ /* Shadows have to be heavier on a dark ground or they vanish entirely. */
102
+ --elevation-card: 0 1px 2px 0 oklch(0 0 0 / 40%);
103
+ --elevation-card-hover: 0 4px 12px -2px oklch(0 0 0 / 55%), 0 2px 4px -2px oklch(0 0 0 / 40%);
104
+ --elevation-popover: 0 8px 24px -4px oklch(0 0 0 / 60%), 0 2px 6px -2px oklch(0 0 0 / 45%);
105
+ --elevation-drawer: -8px 0 32px -8px oklch(0 0 0 / 65%);
106
+ }
@@ -1,72 +0,0 @@
1
- /**
2
- * @cartbase/storefront Tailwind preset — shadcn/ui standard token structure
3
- * (ported verbatim from @1click/ui tailwind-preset.cjs v2.3.1)
4
- *
5
- * Uses oklch color space (modern CSS standard, matches shadcn/ui Create output).
6
- * CSS variables store raw oklch channels so Tailwind's opacity modifiers work:
7
- * --primary: 0.553 0.195 38.402;
8
- * → bg-primary = oklch(0.553 0.195 38.402)
9
- * → bg-primary/50 = oklch(0.553 0.195 38.402 / 50%)
10
- */
11
-
12
- /** @type {import('tailwindcss').Config} */
13
- module.exports = {
14
- theme: {
15
- extend: {
16
- colors: {
17
- background: "oklch(var(--background) / <alpha-value>)",
18
- foreground: "oklch(var(--foreground) / <alpha-value>)",
19
- card: {
20
- DEFAULT: "oklch(var(--card) / <alpha-value>)",
21
- foreground: "oklch(var(--card-foreground) / <alpha-value>)",
22
- },
23
- popover: {
24
- DEFAULT: "oklch(var(--popover) / <alpha-value>)",
25
- foreground: "oklch(var(--popover-foreground) / <alpha-value>)",
26
- },
27
- primary: {
28
- DEFAULT: "oklch(var(--primary) / <alpha-value>)",
29
- foreground: "oklch(var(--primary-foreground) / <alpha-value>)",
30
- },
31
- secondary: {
32
- DEFAULT: "oklch(var(--secondary) / <alpha-value>)",
33
- foreground: "oklch(var(--secondary-foreground) / <alpha-value>)",
34
- },
35
- muted: {
36
- DEFAULT: "oklch(var(--muted) / <alpha-value>)",
37
- foreground: "oklch(var(--muted-foreground) / <alpha-value>)",
38
- },
39
- accent: {
40
- DEFAULT: "oklch(var(--accent) / <alpha-value>)",
41
- foreground: "oklch(var(--accent-foreground) / <alpha-value>)",
42
- },
43
- destructive: {
44
- DEFAULT: "oklch(var(--destructive) / <alpha-value>)",
45
- foreground: "oklch(var(--destructive-foreground) / <alpha-value>)",
46
- },
47
- border: "oklch(var(--border) / <alpha-value>)",
48
- input: "oklch(var(--input) / <alpha-value>)",
49
- ring: "oklch(var(--ring) / <alpha-value>)",
50
- success: {
51
- DEFAULT: "oklch(var(--success) / <alpha-value>)",
52
- foreground: "oklch(var(--success-foreground) / <alpha-value>)",
53
- },
54
- warning: {
55
- DEFAULT: "oklch(var(--warning) / <alpha-value>)",
56
- foreground: "oklch(var(--warning-foreground) / <alpha-value>)",
57
- },
58
- },
59
- fontFamily: {
60
- display: ["var(--font-display)", "serif"],
61
- body: ["var(--font-body)", "system-ui", "sans-serif"],
62
- mono: ["var(--font-mono)", "ui-monospace", "monospace"],
63
- },
64
- borderRadius: {
65
- lg: "var(--radius)",
66
- md: "calc(var(--radius) - 2px)",
67
- sm: "calc(var(--radius) - 4px)",
68
- },
69
- },
70
- },
71
- plugins: [],
72
- }