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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ All notable public Spark changes should be recorded here.

Spark follows human-readable release notes rather than a package-manager version contract. The release version is stored in `manifest.json` and mirrored in [README.md](README.md) and `CLAUDE.md`. When releasing, update all three version markers, add a dated changelog section, and publish a Git tag or GitHub release when the repo is ready for external consumers to pin versions.

## Unreleased
## 1.3.0 - 2026-09-03

- `layouts/base.html` now renders the platform `{% pixels %}` tag in a new `pixels` block, immediately before the theme script stack. The platform injects every app's storefront event tracker (Google Analytics 4, Google Tag Manager, Klaviyo, Taboola) as hidden iframes through that tag, and Intro Bootstrap reaches it indirectly through `{% core_js %}`. Spark replaced `core_js` with `spark-platform.js` and never re-added `pixels`, so no tracker iframe was rendered, `window.customerEventManager` never existed, and no app received `page_viewed`, `product_viewed`, `product_added_to_cart`, or `checkout_*` events on any Spark store. The tag is a platform builtin, needs no `{% load %}`, does not depend on jQuery, and respects the platform's `disable_pixel` guard.
- `scripts/sass-compat.py --check` now rejects function names the platform's Sass pass claims as its own colour built-ins, under two rules that differ in what the author should do. `sass-builtin-as-css-filter` covers `invert()`, `saturate()`, `grayscale()`, and `opacity()`: these are real CSS filter functions, so a valid `filter: brightness(0) invert(1)` fails the upload with "Could not compile CSS. Please check Scss Syntax." while `make css-check` passes locally; use a filter function Sass does not claim, or assign through a custom property. `sass-colour-function-in-css` covers `lighten()`, `darken()`, `complement()`, and `desaturate()`, which are not CSS functions at all and mean Sass source reached the generated output; emit the computed colour instead. Custom-property declarations are exempt from both, because Sass leaves a custom property's value alone; that is why Tailwind's own `--tw-grayscale: grayscale(100%)` compiles and stays accepted.
- Rebuilt `assets/main.css`. Tailwind scans the repo's own docs and scripts for class candidates, so naming the banned functions in this change's prose generates an unused `.invert` utility. The committed CSS has to match a fresh build for the `css-drift` gate, so the rebuild ships with the change.
- Add-to-cart no longer stays dead after a purchase. Checkout consumes the cart but the theme kept its id in `sessionStorage` and the `storefront_cart_id` cookie, and the platform answers a consumed id with a resolved `success: false` / `cart_not_found` payload rather than a rejected request, so `SparkCartClient.addToCart` never took its create-and-retry path: every add failed until the shopper cleared browser storage. `addToCart` now treats that payload like an expired cart (forget the id, `createCart`, retry once). `getCart` forgets the id when the platform no longer knows the cart, which is what the cart badge asks on the first storefront page after the platform's order-confirmation page, and the line, quantity, and voucher mutations forget it too so a stale id cannot leak into the next add. Reproduced on aptest.29next.store on 2026-09-03.
- `scripts/check-templates.py` now rejects unsupported backslash escapes inside quoted template filter arguments. Django only understands `\"` and `\\` there, so `split:"\n"` silently split on the two literal characters instead of a newline; the platform renders it without error and the defect only shows once real settings data flows through. Use `|linebreaksbr|split:"<br>"` for the newline case. Comments and verbatim blocks stay exempt (#52).
- Tailwind's content scan is now limited to the directories that carry class names (`layouts`, `templates`, `partials`, `assets/js`) via `source(none)` in `css/input.css`. Prose in docs, scripts, and tests no longer compiles stray utilities into `assets/main.css`; twelve unused ones were removed from the shipped CSS. A new directory containing class attributes generates nothing until it is added to the list, and `tests/test_tailwind_source_guard.py` pins that list so the omission fails a test instead of silently dropping storefront styles (#53).
- Python bytecode from the tooling tests is now gitignored, so `make test` and `make verify-theme` no longer leave untracked `__pycache__` directories behind (#54).

## 1.2.0 - 2026-09-01

Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## Overview
Spark is a modern starter theme for Next Commerce storefronts. Tailwind CSS + vanilla JS. Clean, minimal commerce aesthetic. Intended to replace Intro Bootstrap as the default starter theme and become a product in its own right.

**Current version:** 1.2.0
**Current version:** 1.3.0
**Repo:** `NextCommerceCo/spark` (public starter theme)
**ntk config:** `config.yml` is gitignored and store-specific. Create it with `ntk init`; never commit store credentials.
**Companion skill:** Use the [next-theme-dev skill](https://github.com/NextCommerceCo/skills/tree/main/next-theme-dev) for AI-assisted Spark, Intro Bootstrap, and custom Next Commerce theme work.
Expand All @@ -22,7 +22,7 @@ Spark is a modern starter theme for Next Commerce storefronts. Tailwind CSS + va
- **Performance load order:** `layouts/base.html` separates metadata, LCP preloads, CSS, rare priority JS, body content, ordered theme JS, footer app hooks, and tracking. See `docs/performance-load-order.md` before adding head scripts or preloads.
- **Templates:** Django Template Language (DTL)
- **Icons:** SVG partials in `partials/icons/`
- **jQuery:** REMOVED. Zero jQuery, zero Bootstrap. `spark-platform.js` replaces `{% core_js %}` with vanilla JS.
- **jQuery:** REMOVED. Zero jQuery, zero Bootstrap. `spark-platform.js` replaces `{% core_js %}` with vanilla JS. The one piece of `core_js` that Spark still needs is the platform's `{% pixels %}` tag (app event-tracker iframes + `customerEventManager`); `layouts/base.html` renders it in its own `pixels` block before the theme script stack. Do not remove it.
- **Design System:** See [DESIGN.md](DESIGN.md) for all visual decisions.

## CSS Pipeline
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A modern starter theme for Next Commerce. Tailwind CSS v4, vanilla JS + Web Components, zero jQuery, zero Bootstrap, no bundler required.

**Current version:** 1.2.0
**Current version:** 1.3.0

**Release status:** Public starter theme. Spark is installable on NEXT stores via `ntk` and is used for public-facing storefront builds. The theme is ready for theme developers to clone, inspect, adapt, and push to stores they control.

Expand Down
143 changes: 114 additions & 29 deletions assets/js/spark-cart.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@
setCookie(CART_ID_KEY, id, 30);
}

/**
* Forget the stored cart id. Called once the platform reports the cart is
* gone, so the next addToCart creates a fresh cart instead of retrying a
* consumed one. Checkout completion does not clear this on its own: the
* order-confirmation page is rendered by the platform, not the theme.
*/
function clearCartId() {
try {
sessionStorage.removeItem(CART_ID_KEY);
} catch(e) {}
setCookie(CART_ID_KEY, '', -1);
}

/* --- Currency formatting --- */

function formatMoney(amount, currency) {
Expand Down Expand Up @@ -221,6 +234,61 @@
msg.indexOf('invalid') !== -1;
}

/**
* Helper: check if a *resolved* mutation result reports a missing cart.
* The platform answers a consumed cart id with HTTP 200 and
* `{ success: false, errors: { nonFieldErrors: [[{ code: 'cart_not_found' }]] }, cart: null }`,
* which never reaches the rejection path that isCartExpiredError guards.
* Walks whatever shape `errors` takes (object, nested arrays, strings).
*
* Deliberately stricter than isCartExpiredError: resolved results also
* carry ordinary validation errors ("Invalid quantity", "Invalid voucher
* code"), and the broad 'invalid' substring there would wipe a live cart.
* Only the cart_not_found code, or a message naming the cart as not found,
* counts.
*/
function isCartNotFoundMessage(message) {
if (typeof message !== 'string') return false;
var msg = message.toLowerCase();
return msg.indexOf('cart') !== -1 && msg.indexOf('not found') !== -1;
}

function isCartNotFoundResult(result) {
if (!result || result.success !== false || !result.errors) return false;
var found = false;
(function walk(node, depth) {
if (found || node == null || depth > 6) return;
if (typeof node === 'string') {
found = isCartNotFoundMessage(node);
return;
}
if (typeof node !== 'object') return;
if (node.code === 'cart_not_found' || isCartNotFoundMessage(node.message)) {
found = true;
return;
}
var keys = Object.keys(node);
for (var i = 0; i < keys.length && !found; i++) walk(node[keys[i]], depth + 1);
})(result.errors, 0);
return found;
}

/**
* Helper: shared post-mutation handling. Clears the stored id when the
* platform says the cart is gone; otherwise persists the id and notifies.
*/
function noteMutationResult(client, result, action) {
if (isCartNotFoundResult(result)) {
clearCartId();
return result;
}
if (result && result.cart) {
setCartId(result.cart.id);
client._dispatchCartUpdated(result.cart, action);
}
return result;
}

/**
* Create a new empty cart. Stores the cart ID for future requests.
* @returns {Promise<Object>} Full cart object
Expand All @@ -244,9 +312,18 @@
var id = cartId || getCartId();
if (!id) return Promise.resolve(null);
return this._request(GET_CART, { id: id }).then(function(data) {
return data.cart || null;
if (!data.cart) {
// The platform no longer knows this cart (typically consumed by
// checkout). Forget it so the next add starts a fresh cart.
clearCartId();
return null;
}
return data.cart;
}).catch(function(err) {
if (isCartExpiredError(err)) return null;
if (isCartExpiredError(err)) {
clearCartId();
return null;
}
throw err;
});
};
Expand All @@ -263,7 +340,14 @@
var self = this;
quantity = quantity || 1;

function doAdd(cartId) {
function recreateAndRetry() {
clearCartId();
return self.createCart().then(function(cart) {
return doAdd(cart.id, false);
});
}

function doAdd(cartId, recover) {
var lineInput = { productPk: productPk, quantity: quantity };

if (isUpsell) lineInput.isUpsell = true;
Expand All @@ -289,30 +373,33 @@
cartId: cartId,
lines: [lineInput]
};
// Settle the request into an outcome first so the recovery below
// runs exactly once: a failure inside recreateAndRetry must not
// re-enter this branch.
return self._request(ADD_CART_LINES, { input: input }).then(function(data) {
var result = data.addCartLines;
if (result && result.cart) {
setCartId(result.cart.id);
self._dispatchCartUpdated(result.cart, 'add');
return { data: data };
}, function(err) {
return { err: err };
}).then(function(outcome) {
if (outcome.err) {
if (recover && isCartExpiredError(outcome.err)) return recreateAndRetry();
throw outcome.err;
}
return result;
var result = outcome.data.addCartLines;
// The platform answers a consumed cart id with a resolved
// `success: false` / `cart_not_found` payload, not a rejection.
if (recover && isCartNotFoundResult(result)) return recreateAndRetry();
return noteMutationResult(self, result, 'add');
});
}

var cartId = getCartId();
if (cartId) {
return doAdd(cartId).catch(function(err) {
if (isCartExpiredError(err)) {
return self.createCart().then(function(cart) {
return doAdd(cart.id);
});
}
throw err;
});
return doAdd(cartId, true);
}

return this.createCart().then(function(cart) {
return doAdd(cart.id);
return doAdd(cart.id, false);
});
};

Expand All @@ -326,12 +413,7 @@
var self = this;
var input = { cartId: cartId, lines: lines };
return this._request(UPDATE_CART_LINES, { input: input }).then(function(data) {
var result = data.updateCartLines;
if (result && result.cart) {
setCartId(result.cart.id);
self._dispatchCartUpdated(result.cart, 'update');
}
return result;
return noteMutationResult(self, data.updateCartLines, 'update');
});
};

Expand All @@ -345,12 +427,7 @@
var self = this;
var input = { cartId: cartId, lineIds: lineIds };
return this._request(REMOVE_CART_LINES, { input: input }).then(function(data) {
var result = data.removeCartLines;
if (result && result.cart) {
setCartId(result.cart.id);
self._dispatchCartUpdated(result.cart, 'remove');
}
return result;
return noteMutationResult(self, data.removeCartLines, 'remove');
});
};

Expand All @@ -365,6 +442,10 @@
var input = { cartId: cartId, vouchers: [code] };
return this._request(ADD_VOUCHER, { input: input }).then(function(data) {
var result = data.addVoucher;
if (isCartNotFoundResult(result)) {
clearCartId();
return result;
}
if (result && result.cart) {
self._dispatchCartUpdated(result.cart, 'voucher_add');
}
Expand All @@ -383,6 +464,10 @@
var input = { cartId: cartId, vouchers: [voucherCode] };
return this._request(REMOVE_VOUCHER, { input: input }).then(function(data) {
var result = data.removeVoucher;
if (isCartNotFoundResult(result)) {
clearCartId();
return result;
}
if (result && result.cart) {
self._dispatchCartUpdated(result.cart, 'voucher_remove');
}
Expand Down
6 changes: 4 additions & 2 deletions docs/performance-load-order.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ The goal is simple: the browser should discover metadata, LCP assets, and CSS be
5. `extrahead` and `head_app_hooks` for platform-critical head snippets.
6. Body content through `content_wrapper`.
7. Non-critical UI shells such as the side cart.
8. Minimal global JavaScript: `SparkEvents`, `SparkCartLoader`, `theme.js`, and platform compatibility.
9. Page `component_scripts`, page `extrascripts`, inline Spark enhancements, footer app hooks, and `tracking`.
8. `pixels`: the platform's `{% pixels %}` tag, which renders one hidden iframe per installed app event tracker (GA4, GTM, Klaviyo, Taboola) and `customerEventManager`. It runs before the theme script stack so its `fetch` hook is in place before `spark-cart` and friends make requests. Intro Bootstrap gets this through `{% core_js %}`; Spark dropped `core_js` and renders the tag itself.
9. Minimal global JavaScript: `SparkEvents`, `SparkCartLoader`, `theme.js`, and platform compatibility.
10. Page `component_scripts`, page `extrascripts`, inline Spark enhancements, footer app hooks, and `tracking`.

## Theme-Side Rules

Expand Down Expand Up @@ -45,6 +46,7 @@ Other templates should add a `critical_preloads` override only when they have a

Spark's global layout loads only:

- the platform `{% pixels %}` output (`pixels.min.js` plus the app tracker iframes; not a theme asset)
- `spark-events.js`
- `spark-cart-loader.js`
- `theme.js`
Expand Down
5 changes: 5 additions & 0 deletions layouts/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,11 @@
{% include 'partials/side_cart.html' %}
{% endblock side_cart %}

{# Platform event trackers (GA4, GTM, Klaviyo, Taboola apps subscribe through these frames). Rendered by core_js in Intro Bootstrap; Spark dropped core_js and must render it itself. #}
{% block pixels %}
{% pixels %}
{% endblock pixels %}

{# Theme JS - dependency ordered, zero jQuery, zero Bootstrap. #}
{% block scripts %}
<script type="text/javascript" src="{{ 'js/spark-events.js'|asset_url }}"></script>
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"name": "Spark",
"version": "1.2.0"
"version": "1.3.0"
}
Loading
Loading