diff --git a/README.md b/README.md
index 603e294..c6543bf 100644
--- a/README.md
+++ b/README.md
@@ -17,8 +17,8 @@ across sites.
|---|---|
| `weirsoe-theme.css` | Tokens (`--color-*`) + base (glow, selection, transitions) + `.wt-card/.wt-btn/.wt-input/.wt-tag` + `.prose` + theme-toggle styles |
| `weirsoe-theme.js` | Persisted dark⇄light toggle + a no-flash snippet |
+| `weirsoe-ui.js` | Drag-and-drop sortable lists (`[data-wt-sortable]` + `data-order-url`, clean drag image) + copy-to-clipboard (`[data-copy-link]`) |
| `adapters/bulma.css` | Maps Bulma classes → tokens (for Bulma sites) |
-| `adapters/bootstrap.css` | Maps Bootstrap 5 classes → tokens (for Bootstrap sites) |
| `examples/demo.html` | Standalone preview |
## Quick start (vanilla)
@@ -52,21 +52,6 @@ Load the adapter **after** Bulma and **after** `weirsoe-theme.css`:
Your existing `.box`, `.button`, `.input`, `.tag`, `.navbar`… are now dark and
card-styled. `wish.weircon.dk` (Christmas-Community fork) consumes it this way.
-## Theming an existing Bootstrap site
-
-Load the adapter **after** Bootstrap and **after** `weirsoe-theme.css`, and mirror
-`data-theme` onto Bootstrap’s `data-bs-theme` on `` so the two stay in sync:
-
-```html
-
-
-
-```
-
-Your existing `.card`, `.btn`, `.form-control`, `.alert`, `.table`, `.navbar`,
-`.badge`… become dark and card-styled. `email-manager.weircon.dk` consumes it
-this way.
-
## Tokens
`--color-bg --color-surface --color-surface-2 --color-surface-3 --color-border
diff --git a/weirsoe-theme.css b/weirsoe-theme.css
index 54b4841..413f122 100644
--- a/weirsoe-theme.css
+++ b/weirsoe-theme.css
@@ -161,6 +161,17 @@ body::before {
.wt-card--link:hover .wt-card-title, a.wt-card--hover:hover .wt-card-title { color: var(--color-accent); }
.wt-card-desc { margin-top: 0.5rem; font-size: 0.875rem; color: var(--color-muted); }
+/* Drag-and-drop sortable lists (paired with weirsoe-ui.js). Mark the container
+ with [data-wt-sortable] + data-order-url; children need [data-item-id]; add a
+ .drag-handle as the grab affordance. */
+.drag-handle {
+ cursor: grab; color: var(--color-muted);
+ margin-right: 0.4rem; font-size: 1.2rem; line-height: 1; user-select: none;
+}
+.drag-handle:active { cursor: grabbing; }
+[data-wt-sortable] > [data-item-id] { cursor: default; }
+.wt-dragging { opacity: 0.45; }
+
/* Pill chip / tag-link (e.g. blog tags) */
.wt-chip {
display: inline-flex; align-items: center; border-radius: 999px;
diff --git a/weirsoe-ui.js b/weirsoe-ui.js
new file mode 100644
index 0000000..b262e75
--- /dev/null
+++ b/weirsoe-ui.js
@@ -0,0 +1,178 @@
+/* weirsoe-ui.js — small, dependency-free UI behaviours for weirsoe-theme.
+ *
+ * • Sortable lists: mark a container with [data-wt-sortable] and a
+ * data-order-url; its [data-item-id] children become drag-reorderable, and
+ * on drop the new order is POSTed as urlencoded `order[]=…` to data-order-url.
+ * Add a .drag-handle inside each child as the grab affordance. A clean, solid
+ * drag image is used so translucent/blurred card surfaces don't smear.
+ *
+ *
+ *
+ * • Copy to clipboard: any [data-copy-link] button copies its attribute value
+ * and shows brief "Kopieret ✓" feedback.
+ */
+(function () {
+ function afterElement(container, y) {
+ var els = Array.prototype.slice.call(
+ container.querySelectorAll('[data-item-id]:not(.wt-dragging)')
+ );
+ var best = { offset: -Infinity, el: null };
+ els.forEach(function (child) {
+ var box = child.getBoundingClientRect();
+ var offset = y - box.top - box.height / 2;
+ if (offset < 0 && offset > best.offset) best = { offset: offset, el: child };
+ });
+ return best.el;
+ }
+
+ function cleanDragImage(el, e) {
+ // A solid clone as the drag image — avoids the browser snapshotting the
+ // card's translucent/blurred surface + page glow (which looks smeared).
+ try {
+ var g = el.cloneNode(true);
+ g.classList.remove('wt-dragging');
+ var cs = getComputedStyle(el);
+ var surface = getComputedStyle(document.documentElement)
+ .getPropertyValue('--color-surface').trim();
+ g.style.position = 'fixed';
+ g.style.top = '-9999px';
+ g.style.left = '-9999px';
+ g.style.width = el.offsetWidth + 'px';
+ g.style.margin = '0';
+ g.style.opacity = '1';
+ g.style.boxShadow = 'none';
+ g.style.backdropFilter = 'none';
+ g.style.webkitBackdropFilter = 'none';
+ g.style.background =
+ cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)'
+ ? cs.backgroundColor
+ : surface || '#121a27';
+ document.body.appendChild(g);
+ e.dataTransfer.setDragImage(g, 16, 16);
+ setTimeout(function () { if (g.parentNode) g.parentNode.removeChild(g); }, 0);
+ } catch (err) { /* setDragImage unsupported — fall back to default ghost */ }
+ }
+
+ function persist(container) {
+ var url = container.getAttribute('data-order-url');
+ if (!url) return;
+ var ids = Array.prototype.slice
+ .call(container.querySelectorAll('[data-item-id]'))
+ .map(function (el) { return el.getAttribute('data-item-id'); });
+ var body = ids.map(function (id) { return 'order[]=' + encodeURIComponent(id); }).join('&');
+ fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: body,
+ credentials: 'same-origin',
+ }).catch(function () { /* non-fatal */ });
+ }
+
+ function initSortable(container) {
+ // ---- desktop: native HTML5 drag-and-drop ----
+ var dragEl = null;
+ container.querySelectorAll('[data-item-id]').forEach(function (el) {
+ el.setAttribute('draggable', 'true');
+ el.addEventListener('dragstart', function (e) {
+ dragEl = el;
+ el.classList.add('wt-dragging');
+ e.dataTransfer.effectAllowed = 'move';
+ cleanDragImage(el, e);
+ });
+ el.addEventListener('dragend', function () {
+ el.classList.remove('wt-dragging');
+ dragEl = null;
+ persist(container);
+ });
+ });
+ container.addEventListener('dragover', function (e) {
+ e.preventDefault();
+ if (!dragEl) return;
+ var after = afterElement(container, e.clientY);
+ if (after == null) container.appendChild(dragEl);
+ else container.insertBefore(dragEl, after);
+ });
+
+ // ---- mobile: long-press to lift, drag to reorder, edge auto-scroll ----
+ // (native DnD doesn't fire on touch). Hold ~220ms to lift; a scroll-intent
+ // move before that cancels the lift so the page still scrolls normally.
+ var holdTimer = null, lifted = false, touchEl = null, startY = 0, scrollSpeed = 0, raf = null;
+
+ function startScroll() {
+ if (raf) return;
+ var tick = function () {
+ if (scrollSpeed !== 0) window.scrollBy(0, scrollSpeed);
+ raf = requestAnimationFrame(tick);
+ };
+ raf = requestAnimationFrame(tick);
+ }
+ function stopScroll() { if (raf) { cancelAnimationFrame(raf); raf = null; } scrollSpeed = 0; }
+ function endTouch() {
+ if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
+ if (lifted && touchEl) { touchEl.classList.remove('wt-dragging'); persist(container); }
+ lifted = false; touchEl = null; stopScroll();
+ }
+
+ container.querySelectorAll('[data-item-id]').forEach(function (el) {
+ el.addEventListener('touchstart', function (e) {
+ if (e.touches.length !== 1) return;
+ // don't hijack taps on interactive controls
+ if (e.target.closest('a, button, input, select, textarea, summary, label')) return;
+ startY = e.touches[0].clientY;
+ holdTimer = setTimeout(function () {
+ lifted = true; touchEl = el; el.classList.add('wt-dragging');
+ if (navigator.vibrate) navigator.vibrate(12);
+ startScroll();
+ }, 220);
+ }, { passive: true });
+ });
+
+ container.addEventListener('touchmove', function (e) {
+ if (!lifted) {
+ if (holdTimer && Math.abs(e.touches[0].clientY - startY) > 12) {
+ clearTimeout(holdTimer); holdTimer = null; // moved first → it's a scroll
+ }
+ return;
+ }
+ if (!touchEl) return;
+ e.preventDefault(); // we own the gesture; stop the page from scrolling under us
+ var y = e.touches[0].clientY;
+ var after = afterElement(container, y);
+ if (after == null) container.appendChild(touchEl);
+ else container.insertBefore(touchEl, after);
+ var edge = 90, vh = window.innerHeight;
+ if (y < edge) scrollSpeed = -Math.ceil((edge - y) / 5);
+ else if (y > vh - edge) scrollSpeed = Math.ceil((y - (vh - edge)) / 5);
+ else scrollSpeed = 0;
+ }, { passive: false });
+
+ container.addEventListener('touchend', endTouch);
+ container.addEventListener('touchcancel', endTouch);
+ }
+
+ function wire() {
+ document.querySelectorAll('[data-wt-sortable]').forEach(initSortable);
+ }
+
+ document.addEventListener('click', function (e) {
+ var btn = e.target.closest && e.target.closest('[data-copy-link]');
+ if (!btn) return;
+ e.preventDefault();
+ var text = btn.getAttribute('data-copy-link');
+ var done = function () {
+ var old = btn.textContent;
+ btn.textContent = 'Kopieret ✓';
+ setTimeout(function () { btn.textContent = old; }, 1600);
+ };
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(text).then(done, function () { window.prompt('Kopiér linket:', text); });
+ } else {
+ window.prompt('Kopiér linket:', text);
+ }
+ });
+
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
+ else wire();
+})();