Note from the workshop-lab. The algorithmic tools, configurators and articles presented here explore lithotherapy exclusively through the lens of semiotics, cultural anthropology and generative design. This work belongs to an artistic and symbolic practice rooted in fine craftsmanship. It does not, in any way, constitute medical advice, a diagnosis, or an alternative to conventional, evidence-based therapies.
I have been making jewellery in Paris since 2013, and I write code. For a long time those were two separate jobs. This post is about the project where they finally met: a bracelet builder where people compose a bracelet bead by bead from photos of real stones, get a "composition score", and send the design to a shop that strings it by hand.
It started as a single page for my own workshop. It is now a white-label tool that other shops embed on their sites, plus a small ecosystem of static pages around it. Here is how it works under the hood, and the constraints that shaped it.
The constraints
- Shared PHP hosting, deployed over FTP. No Node server, no containers, no database server.
- One person maintains everything, so every moving part has to justify itself.
- Three languages (English, French, Spanish) from day one.
- Real stones. Every bead is a photo of an actual bead from my workshop, cut out by hand. 58 references today.
Those constraints pushed me towards a design I would recommend for small projects: a static front end, a thin PHP layer, and JSON files as the database.
Architecture at a glance
/pwa/{en,fr,es}/index.html ← the builder: one self-contained page per language (vanilla JS, PWA)
/pwa/{en,fr,es}/configs/*.json ← white-label settings, one file per shop
/pwa/api/catalog.json ← stones, properties, scoring constants (exported from the front end)
/pwa/api/publish.php ← receives a finished bracelet, recomputes everything, writes static pages
/pwa/api/lib/Litho.php ← the scoring engine, ported from JS to PHP
/pwa/api/data/*.json ← index of published creations, rate limits, settings
/creations/ /bracelets/ /pulseras/ ← generated static pages (EN / FR / ES)
No framework, no build step for the builder itself. The page loads, fetches its config, and runs.
1. The builder: one page, vanilla JS
The bracelet is a ring of slots. Its size comes from two choices: the wrist circumference (14 to 23 cm) and the bead diameter (6, 8 or 10 mm, depending on the wrist size). The number of beads is simply what fits around the wrist:
const WRIST_OPTIONS = { 14: [6, 8], 16: [8, 10], 19: [8, 10], 21: [10], 23: [10] }; // cm → bead sizes (mm)
function beadCountFor(wristCm, diamMm) { return Math.round((wristCm * 10) / diamMm); }
Each slot is absolutely positioned on a circle, and the whole ring sits in a rotating container. A few details that made a big difference in feel:
- Fly-in animation: when you tap a stone card, a clone of its image flies to the next free slot (Web Animations API), then the real bead "lands" with a short scale-and-brightness keyframe.
- Undo: removing a bead or emptying the bracelet shows a toast with an Undo button for 5 seconds (and Ctrl+Z on desktop). The state is just an array of bead objects, so undo is a copy.
-
Two layouts, one DOM. Below 700 px wide the original mobile layout is untouched; above, a
html.wideclass switches to a two-column layout. I verify the mobile layout with pixel diffs (Playwright screenshots before/after at 375, 390 and 430 px) every time I touch the desktop CSS.
// the layout switch, run before first paint
const wide = innerWidth >= 700 && innerHeight >= 560;
document.documentElement.classList.toggle('wide', wide);
2. The composition score
When the bracelet is complete, the builder computes a score out of 100 and names the result, from "Emerging Amulet" to "Alchemical Bracelet". Each stone carries symbolic attributes taken from lithotherapy traditions: a chakra position (1 to 7), an element, a "vibration level", a polarity (yin, yang, neutral) and a dominant property (grounding, soothing, protective…).
The interesting part is that position matters. In the energetic mode, the score looks at every pair of beads and asks how well their attributes go together, but a bead's neighbours count more than a bead on the opposite side of the ring. The distance is circular:
// N beads on a ring; rho = 0.5 in the current catalog
const delta = (i, j) => { const d = Math.abs(i - j); return Math.min(d, N - d); };
const weight = (i, j) => Math.pow(rho, delta(i, j) - 1); // neighbours: 1, then 0.5, 0.25…
function pairScore(phi) { // phi(a, b) → compatibility, or null if unknown
let num = 0, den = 0;
for (let i = 0; i < N; i++) for (let j = i + 1; j < N; j++) {
const v = phi(S[i], S[j]); if (v == null) continue;
const w = weight(i, j); num += w * v; den += w;
}
return den ? num / den : null;
}
Four phi functions (chakra distance, element pairs, vibration gap, property pairs) produce four weighted averages. Polarity balance and a few small bonuses (an odd number of beads, a chakra sequence that rises around the ring) complete the total, clamped to 0–100. The panel shows the five sub-scores as bars, the chakras and elements represented, and practical care notes such as stones that should not go in water.
The aesthetic mode is a different model: it rewards coverage and variety (how many chakras and elements are represented, how diverse and evenly spread the properties are, how many different stones are used) plus bonuses for known combinations.
To be clear about what this is: a generative design system built on symbolic data, in the same spirit as a colour-harmony tool. It encodes traditions; it does not measure anything about the person wearing the bracelet.
3. Never trust the browser: recompute on the server
Finished bracelets can be published as pages in a public gallery. The tempting shortcut would be to let the browser send the score and the texts. Instead, publish.php accepts exactly three things: the list of bead IDs, the display mode, and a PNG of the share card.
// publish.php (excerpt)
foreach ($ids as $id) {
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) fail('bad_ids');
if (!$L->hasBead((int)$id)) fail('unknown_bead');
}
$contents = $L->contents($clean); // full score + texts, recomputed in PHP
The PNG is decoded and re-encoded with GD, which strips anything hidden in the file. Every word on the generated page comes from catalog.json, never from the request.
That meant porting the scoring engine from JavaScript to PHP, and keeping both in sync. The subtle part was the deterministic text variation: each page picks phrasing variants with a seeded hash, and the seed must be identical in both languages. JavaScript does 32-bit multiplication with Math.imul on UTF-16 code units; PHP has neither, so the port re-implements both:
// 32-bit unsigned multiply, matching Math.imul(a, b) >>> 0
protected static function imulU(int $a, int $b): int {
$a &= 0xFFFFFFFF; $b &= 0xFFFFFFFF;
$lo = ($a & 0xFFFF) * $b;
$hi = (($a >> 16) * $b) & 0xFFFF;
return ($lo + ($hi << 16)) & 0xFFFFFFFF;
}
// FNV-1a over UTF-16 code units, like s.charCodeAt(i) in JS
foreach (self::utf16Units($s) as $u) { $h ^= $u; $h = self::imulU($h, 16777619); }
Same bracelet in, same page out, in both runtimes: that is what makes the port checkable.
4. Static pages that regenerate themselves
Each published bracelet becomes a static HTML page in three languages, plus pages for each stone and a sitemap. There is no database query at read time: the web server just serves files.
A few rules keep this from turning into a spam factory:
- Deduplication: the same set of stones (in any proportions) maps to the same page, so a visitor who tweaks one bead does not create a near-duplicate.
-
Indexing rules: only bracelets above a score threshold and with enough different stones are proposed to search engines; the rest stay
noindex. - Moderation: new creations are published in a pending state. The visitor's share link works immediately, but the page stays out of the gallery and the sitemap until it is reviewed in a small admin.
-
Rate limits per visitor and per day, stored in a JSON file behind an exclusive
flock.
When a setting or a stone description changes, one call regenerates every page from the index. Writes are atomic (write to a temp file, then rename), so a visitor never sees half a page.
5. White label without a multi-tenant backend
Shops do not get a copy of the code. They get a JSON file:
{
"nom": "Maison Opale",
"logo": "https://example.com/logo.png",
"couleurs": { "accent": "#6d3f7d", "btn-color": "#6d3f7d" },
"masquer": [12, 31],
"prix": { "4": 1.9 },
"cadre": { "epaisseur": 1, "arrondi": 16 }
}
The builder reads ?client=…, fetches configs/<client>.json, and applies it: colours become CSS custom properties (shades are derived from the main colour in JS), hidden stones are removed from the catalogue, prices are overridden. A client area lets each shop edit its file through a form, validated server-side, with a backup of the previous version.
Embedding works three ways: a direct link, an <iframe>, or a one-line script that injects a floating button. The button lives in a Shadow DOM so the host site's CSS cannot break it, and the iframe is only created on first click, so the shop's page stays fast. When the builder detects it runs inside an iframe (window.self !== window.top), it draws a discreet, configurable frame so it does not look like a pasted screenshot.
6. The holistic layer: Stone of the Day
The same catalogue feeds a sister app, Stone of the Day, which suggests a stone by intention, by numerology "life path" number, by moon phase or by zodiac season. The moon phases are computed in PHP with Jean Meeus's algorithm from Astronomical Algorithms (chapter 49), accurate to a few minutes, so the pages stay correct without any external API.
Because everything reads from one catalogue, improving a stone's description updates the builder, its score texts, the stone pages and the Stone of the Day at once. For a solo maintainer, that single source of truth is the whole point.
What I would do the same way again
-
JSON files instead of a database for a few thousand records with rare writes.
flockand atomic renames cover concurrency. - Server-side recomputation of anything that gets published. The browser only sends identifiers.
- Pixel-diff tests to protect a layout that already works, before redesigning another one.
What I would do differently
- Prices are hard-coded in euros; a currency setting per shop should have existed from the start.
- One HTML file per language means fixes have to be applied three times. A small build step generating the three files from one template would pay for itself.
Try it
- The builder: lochness-paris.com/pwa/en/
- Stone of the Day: lochness-paris.com/pwa/pierre-du-jour/en/
I'm happy to answer questions about the scoring model, the JS-to-PHP port or running a "static site that writes itself" on shared hosting.
Note from the workshop-lab: the algorithmic tools, configurators and articles presented here explore lithotherapy exclusively through the lens of semiotics, cultural anthropology and generative design. This work belongs to an artistic and symbolic practice rooted in fine craftsmanship. It does not, in any way, constitute medical advice, a diagnosis, or an alternative to conventional, evidence-based therapies.

Top comments (0)