docs
/
Building on Appmint

Case study: the design studio

An on-canvas text editor with fonts, rotation, arc-curving and live pricing — 113 KB of JavaScript in one page record, read end to end.

Print Oxygen's /design is a product customizer. A customer picks a product, configures it, types text directly onto a preview of the item, arranges it, and adds the result to a cart with the preview image attached.

It is worth reading because it is at the far end of what a page application can be, and because its architecture is ordinary. There is no framework, no build step and no clever trick — just the patterns from the previous pages, applied consistently at size.

Where it livesOne page record, style.javascript
Size113,115 characters of JavaScript, 61,395 of HTML
Platform callsOne — appmint.cart.add
DependenciesNone

The shape

  window.PO_PRODUCTS = { … }        ← catalogue embedded at deploy time
  ────────────────────────────────
  boot()  ── readiness gate
      │
      ├── st = { … }                ← one state object
      │
      ├── renderControls()          ← options panel, from product schema
      ├── renderSides()             ← front / back view switcher
      ├── buildTextbar()            ← text layer toolbar
      ├── paint()                   ← canvas: product + artwork + text
      └── price()                   ← derived total
              │
              └── cart.add(item)    ← the single write

Read top to bottom, that is the whole application.

The catalogue is embedded, not fetched

The file opens with the products written straight into it:

window.PO_PRODUCTS = {
  "custom-card-design": {
    "slug": "custom-card-design",
    "name": "Custom Card Design",
    "sku": "pox-cc-cust",
    "price": 45,
    "designMode": "upload",
    "attributes": [ /* … */ ]
  },
  /* … */
};

No request, no loading state, no failure path — the options panel is rendered on the first frame.

The trade: the data is a copy. Change a product's attributes in the catalogue and this page keeps showing the old ones until you rewrite the embedded block. That is a real maintenance cost and it has to be a deliberate step in the deploy.

Embedding is right when the data is small, changes rarely, and is needed before first paint. It is wrong for anything that must be current — stock levels, prices that move, anything a customer could act on while stale. Here it covers three design products, and a mismatch would only affect which options appear, not what anyone is charged: the server prices the cart.

The readiness gate

/*__READY_GATE__*/
(function boot() {
  if (!document.getElementById('hdr') || !(window.appmint && window.appmint.cart)) {
    return void setTimeout(boot, 30);
  }
  var $ = function (id) { return document.getElementById(id); };
  // … everything else
})();

It gates on a real element from the page's own markup and on appmint.cart specifically — the namespace it actually uses — rather than on window.appmint existing. See Where your code goes.

The /*__READY_GATE__*/ and /*__END_READY_GATE__*/ markers bracket the whole application so the deploy script can find and replace it without touching the embedded catalogue above it.

One state object

var st = {
  view: 0, sel: {}, txt: {}, file: {}, art: {},
  uside: 'front', multi: {}, num: {}, date: {},
  texts: [], selT: -1
};

Seeded from the product's own attribute schema, so the state shape follows the data rather than being hard-coded:

P.attributes.forEach(function (a) {
  if (a.type === 'selection') {
    if (a.display === 'checkbox') { st.multi[a.name] = {}; }
    else { st.sel[a.name] = 0; }
  }
  else if (a.type === 'text')   { st.txt[a.name] = ''; }
  else if (a.type === 'number') { st.num[a.name] = (a.minValue != null ? +a.minValue : ''); }
  else if (a.type === 'date')   { st.date[a.name] = ''; }
});

Add an attribute to the product record and the control appears, with state behind it, without touching this file. That is the payoff for driving the UI from the schema instead of writing a panel per product.

texts and selT are the editor: an array of text layers and the index of the selected one. Each layer is flat and self-describing:

{ t, x, y, size, color, rot, curve, align, flipH, flipV, font, bold, italic, view }

Flat because every one of those is a control in the toolbar, and a flat object means the toolbar reads and writes one level deep.

Fonts the canvas can actually use

Canvas text does not wait for webfonts. Draw with a font that has not loaded and you silently get a fallback — the shapes are wrong and nothing reports it.

var l = document.createElement('link');
l.rel = 'stylesheet';
l.href = 'https://fonts.googleapis.com/css2?family=…&display=swap';
document.head.appendChild(l);

if (document.fonts && document.fonts.load) {
  TX_FONTS.forEach(function (group) {
    group[1].forEach(function (f) {
      try {
        document.fonts.load('64px "' + f + '"').then(function () {
          try { paint(); } catch (e) {}
        });
      } catch (e) {}
    });
  });
}

if (document.fonts && document.fonts.ready) {
  document.fonts.ready.then(function () { try { paint(); } catch (e) {} });
}

Three layers: request each face explicitly, repaint as each one lands, and repaint once more when the whole set settles. Around twenty-six faces across five groups — sans, serif, script, mono/terminal and blackletter.

document.fonts.load() needs a size in the specifier — '64px "Orbitron"', not 'Orbitron'. Without it the promise resolves without loading anything and your repaint draws the fallback. Every paint() is wrapped in try/catch because a repaint firing mid-teardown should never surface as a console error to a customer.

Curved text

The most involved drawing in the file, and a good illustration of keeping maths out of state:

function drawArc(str, curveDeg) {
  var arc = curveDeg * Math.PI / 180;
  var chars = str.split('');
  var ws = chars.map(function (c) { return ctx.measureText(c).width; });
  var total = ws.reduce(function (a, b) { return a + b; }, 0);

  if (Math.abs(arc) < 0.001 || !total) { ctx.fillText(str, 0, 0); return; }

  var r = total / Math.abs(arc);
  var dir = arc > 0 ? 1 : -1;
  // … rotate the context per character, advancing by its own width
}

The state holds one number — curve, in degrees, signed. Everything else is derived at draw time: radius from the measured text width, direction from the sign, per-character advance from each glyph's measured width. Positive curves one way, negative the other, zero short-circuits to a straight fillText.

Because the radius comes from the measured width, the curve stays visually consistent as the text, size or font changes. Storing a radius instead would mean recomputing it on every one of those edits, and getting it wrong somewhere.

Dragging

function mv(e) {
  var p = ptr(e);
  e.preventDefault();
  if (mode === 'drag') { ar.x = p.x - off.x; ar.y = p.y - off.y; }
  else { ar.w = Math.max(50, Math.min(W * 0.95, Math.hypot(p.x - ar.x, p.y - ar.y) * 1.5)); }
  paint();
}

canvas.addEventListener('mousedown', down);
addEventListener('mousemove', mv);
addEventListener('mouseup', up);

canvas.addEventListener('touchstart', down, { passive: false });
canvas.addEventListener('touchmove',  mv,   { passive: false });
addEventListener('touchend', up);

Three things worth copying:

Move and up listen on window, not the canvas. Drag past the edge and the gesture continues instead of sticking.

Touch and mouse share handlers, normalised by ptr(e). The alternative is two code paths that drift.

Resize is clamped at both ends — never below 50px, never past 95% of the canvas — so the object cannot be lost.

This is the one place that bypasses appmint.state entirely. A state.set per mouse-move would notify every subscriber at pointer frequency to redraw one canvas.

Pricing, and who is allowed to decide it

The panel shows a running total derived from the base price plus per-option deltas. It is display only.

The cart item carries the figures, but the server prices the cart:

var item = {
  id: slug + '|design|' + Date.now(),
  sku: (P.sku || slug),
  name: P.name,
  unitPrice: (t == null ? 0 : t),
  quantity: qty,
  image: exportPreview(),
  attributes: chosen,
  options: opts,
  price: (t == null ? 0 : t),
  amount: (t == null ? 0 : t) * qty,
  description: (P.spec || '')
};

t == null is the quote case — products priced on enquiry carry zero rather than a guess.

The displayed total is a preview. What the customer pays comes back from the server after cart.recalc(). See the read/write asymmetry — deriving a figure for display is fine; treating it as authoritative is not.

The configuration has to survive as words

A preview image is not enough. Whoever prints this needs to know what was chosen, and so does the customer on their receipt. So the configuration is flattened into attributes and options alongside the image:

if (st.texts.length) {
  var _ts = st.texts.map(function (t) {
    return '"' + String(t.t).replace(/\n/g, ' / ') + '" (' + t.color + ' · ' + (t.view || 'front') + ')';
  }).join('; ');
  chosen['Text on design'] = _ts;
  opts.push({ name: 'canvas-text', label: 'Text on design', value: _ts, price: 0 });
}

chosen['Artwork'] =
  (P.attributes.some(function (a) { return a.type === 'file' && (st.file[a.name] || []).length; }))
    ? 'Uploaded'
    : (st.texts.length ? 'Typed text' : 'Design assist');

Newlines become / because this lands in order lines, confirmation emails and packing slips, none of which handle embedded line breaks well.

exportPreview() renders the canvas to a 400px-wide image and attaches it. In upload mode it uses the customer's own file instead.

Whatever your configurator produces, write it out as human-readable text as well as structured data. Every downstream reader — the customer, the person fulfilling it, support handling a complaint — needs words, not an object graph.

The single write

var b = $('ds-add');
b.disabled = true;
b.textContent = 'Adding…';

var go = function () { location.href = '/order'; };
window.appmint.cart.add(item).then(go).catch(go);

Disable, label, commit, navigate.

.then(go).catch(go) navigates on either outcome. That looks wrong and is deliberate: cart.add writes to the local cart store before the network settles, so the item is there regardless, and the order page reads the real cart anyway. Trapping the customer on the designer because a response was slow would be worse than sending them to a page that shows the truth.

Copy the disable-and-label, not necessarily the shared handler. .catch(go) is right when the local write already happened and the destination re-reads authoritative state. It is wrong wherever the failure means the work is lost — there, tell the customer and let them retry.

What this demonstrates

The ceiling is high. Canvas editing, webfont loading, touch gestures, derived pricing and cart integration, in a page record.

The architecture is unremarkable. One state object, named render functions, delegation, full redraws, one commit. Nothing here is specific to this platform except the readiness gate and cart.add.

The constraints shaped it well. One write at the end means no drafts, no partial state, nothing to reconcile, nothing to migrate. Schema-driven controls mean new product options need no code.

Embedding was a choice with a cost, made deliberately for three slow-moving products, and it has to be re-done on every catalogue change.

Where it would stop working

Honestly: if the customer needed to save a design and come back to it. That is repeated writes of a custom record, which the read/write asymmetry does not allow from a page. It would mean a server, and at that point the whole thing belongs in a real application.

Everything else here — more products, more attribute types, more editing tools — is more of the same file.