diff --git a/assets/scripts/rubiks/scrambler.js b/assets/scripts/rubiks/scrambler.js
new file mode 100644
index 0000000..10a03c3
--- /dev/null
+++ b/assets/scripts/rubiks/scrambler.js
@@ -0,0 +1,123 @@
+class RubiksCubeScrambler extends HTMLElement {
+ static #RandomMoveHysteresisMaxLength = 2;
+
+ #shadowRoot;
+ #movesListElement;
+
+ #numberOfMovesToGenerate = 25;
+
+ constructor() {
+ super();
+ this.#shadowRoot = this.attachShadow({ mode: "open" });
+ }
+
+ scramble() {
+ console.log("Randomizing Rubik's cube...");
+
+ const movesList = this.#movesListElement;
+
+ while (movesList.childElementCount > this.#numberOfMovesToGenerate) {
+ movesList.removeChild(movesList.lastChild);
+ }
+
+ let randomMoveHysteresis = [];
+
+ for (let i = 0; i < this.#numberOfMovesToGenerate; i++) {
+ const randomMove = this.#randomMove(randomMoveHysteresis);
+
+ let moveItem;
+ if (i < movesList.childElementCount) {
+ moveItem = movesList.children[i];
+ } else {
+ moveItem = document.createElement("li");
+ movesList.appendChild(moveItem);
+ }
+
+ moveItem.classList.add("scrambler__move");
+ moveItem.classList.remove("scrambler__move--start", "scrambler__move--end");
+
+ if (randomMove.includes("2")) {
+ moveItem.classList.add("scrambler__move--start");
+ } else if (randomMove.includes("'")) {
+ moveItem.classList.add("scrambler__move--end");
+ }
+
+ moveItem.innerText = randomMove;
+ }
+ }
+
+ #randomMove(hysteresis) {
+ const faces = "FBLRUD";
+
+ let move;
+ do {
+ move = faces.charAt(Math.floor(Math.random() * faces.length));
+ } while (hysteresis && hysteresis.includes(move));
+
+ if (hysteresis) {
+ hysteresis.unshift(move);
+ while (hysteresis.length > RubiksCubeScrambler.#RandomMoveHysteresisMaxLength) {
+ hysteresis.pop();
+ }
+ }
+
+ const modifierFactor = Math.random();
+ if (modifierFactor < 0.33333) {
+ move = "2" + move;
+ } else if (modifierFactor < 0.666666) {
+ move = move + "'";
+ }
+
+ return move;
+ }
+
+ #removeAllMoves() {
+ const element = this.#movesListElement;
+ while (element.hasChildNodes()) {
+ element.removeChild(element.lastChild);
+ }
+ }
+
+ // MARK: Custom Element
+
+ connectedCallback() {
+ let template = document.getElementById("rubiks-cube-scrambler-template");
+ console.assert(template, "Couldn't find RubiksCubeScrambler component template in the document");
+
+ const shadowRoot = this.#shadowRoot;
+ shadowRoot.appendChild(template.content.cloneNode(true));
+
+ this.#movesListElement = shadowRoot.querySelector(".scrambler__move-list");
+
+ shadowRoot
+ .querySelector("button[name='scramble']")
+ .addEventListener("click", () => this.scramble());
+
+ const patternLengthInputElement = shadowRoot.querySelector(".scrambler__pattern-length > input");
+ patternLengthInputElement.value = this.#numberOfMovesToGenerate;
+ patternLengthInputElement.addEventListener("input", event => {
+ try {
+ const integerValue = parseInt(event.target.value);
+ this.#numberOfMovesToGenerate = integerValue;
+ } catch (e) {
+ console.error("Non-integer value of pattern length field", e);
+ }
+ });
+
+ this.scramble();
+ }
+
+ attributeChangedCallback(name, oldValue, newValue) {
+ console.debug("RubiksCubeScrambler attribute changed", name, oldValue, newValue);
+ if (name === "count") {
+ try {
+ let newIntValue = parseInt(newValue);
+ this.#numberOfMovesToGenerate = newIntValue;
+ } catch (e) {
+ console.error("`count` attribute should have an integer value.", e);
+ }
+ }
+ }
+}
+
+window.customElements.define("rubiks-cube-scrambler", RubiksCubeScrambler);
diff --git a/content/blog/2024/rubiks-scrambler.md b/content/blog/2024/rubiks-scrambler.md
new file mode 100644
index 0000000..a0ac788
--- /dev/null
+++ b/content/blog/2024/rubiks-scrambler.md
@@ -0,0 +1,30 @@
+---
+title: Rubik's Cube Scrambler
+date: 2024-11-13T15:34:22-08:00
+tags:
+ - Tech
+ - Puzzles
+ - Rubik's Cube
+ - HTML
+ - JavaScript
+ - CSS
+ - Web Components
+---
+
+Here's a silly thing I made while I was home sick today. It's a widget that
+produces a randomized pattern of [moves][rmoves] to scramble a 3×3 [Rubik's
+Cube][rcube].
+
+
+ {{< rubiks/scrambler >}}
+
+
+This thing is a [Web Component][wc]. The interactive logic lives inside a custom
+[HTMLElement][htmlelement], and the content and styling are specified inside a
+[``][template] element.
+
+[rcube]: https://www.rubiks.com
+[rmoves]: https://jperm.net/3x3/moves
+[wc]: https://developer.mozilla.org/en-US/docs/Web/API/Web_components
+[htmlelement]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement
+[template]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template
diff --git a/layouts/partials/base/body-extras.html b/layouts/partials/base/body-extras.html
new file mode 100644
index 0000000..0adf8d7
--- /dev/null
+++ b/layouts/partials/base/body-extras.html
@@ -0,0 +1,4 @@
+{{ if .HasShortcode "rubiks/scrambler" }}
+ {{ partial "rubiks/scrambler-template.html" . }}
+ {{ partial "resource_builders/script.html" (dict "resource" "scripts/rubiks/scrambler.js") }}
+{{ end }}
diff --git a/layouts/partials/rubiks/scrambler-template.html b/layouts/partials/rubiks/scrambler-template.html
new file mode 100644
index 0000000..56909e1
--- /dev/null
+++ b/layouts/partials/rubiks/scrambler-template.html
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/layouts/shortcodes/rubiks/scrambler.html b/layouts/shortcodes/rubiks/scrambler.html
new file mode 100644
index 0000000..4d56a75
--- /dev/null
+++ b/layouts/shortcodes/rubiks/scrambler.html
@@ -0,0 +1 @@
+
diff --git a/themes/resource-builders b/themes/resource-builders
index b9aa2d4..73e3cec 160000
--- a/themes/resource-builders
+++ b/themes/resource-builders
@@ -1 +1 @@
-Subproject commit b9aa2d4201d986b841ff8abf97eb72b5492fa8de
+Subproject commit 73e3cec7117f3a061554f7a9799285a97a284a22
diff --git a/themes/termlite b/themes/termlite
index ac168d7..c4821da 160000
--- a/themes/termlite
+++ b/themes/termlite
@@ -1 +1 @@
-Subproject commit ac168d7143d0437a837549a6cf96902e30409dac
+Subproject commit c4821daa6e080cc16f339884d022c243b163c0dc