shopping: multi-list, DnD ordering, phone layout; fix flaky tests

Shopping lists:
  - Multiple lists per tenant: picker uses checkboxes instead of
    radios. Relay schema migrated to composite PK (tenant_id,
    card_uid) with a position column for display ordering.
  - Picker rows are drag-and-drop reorderable (grip handle +
    accent drop indicator). Order persists in localStorage and is
    pushed to the relay as position integers — phone renders lists
    in that order (ORDER BY position, title).
  - Each picker row has a goto-arrow that closes the popup,
    switches to the card's bucket if needed, and opens the card.
  - Relay DELETE /api/shopping removes deactivated lists. Client
    proxy forwards DELETE. Deactivating a list in the picker
    immediately removes it from the relay.
  - Phone UI renders multiple lists as stacked boxes with headers.
    Section headings (# h1–h6) get 14px top padding for breathing
    room. CSS selectors fixed to match div-based headings inside
    the new .shopping-list-box containers.
  - Help text updated for multi-list wording.
  - pullOne preserves the synced position so a pull doesn't cause
    a spurious re-push of the same order.

Test fixes:
  - Line-numbers gutter height tolerance bumped 50%→60% — OS text
    shaping can push the divergence past 50%.
  - Find/replace flaky: replaced sleep(200) with waitFor on the
    confirm dialog so slow renders don't miss the OK click.
  - Shopping picker test updated: checkboxes instead of radios.

Bumps to 1.9.25.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-16 17:26:50 +02:00
1 parent 4c3954d989
commit be7eb1523e
12 files changed
+370 -192

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php <?php
define('APP_NAME', 'hyprcrd'); define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.9.24'); define('APP_VERSION', '1.9.25');
// Database path — writable directory, outside webroot // Database path — writable directory, outside webroot
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation // DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hyprcrd", "name": "hyprcrd",
"version": "1.9.24", "version": "1.9.25",
"description": "hyprcrd — self-hosted card manager", "description": "hyprcrd — self-hosted card manager",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
+1 -1
View File
@@ -1651,7 +1651,7 @@ if (str_starts_with($route, '/api/')) {
<section> <section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-shopping-cart text-sm"></i>shopping list (phone sync)</h3> <h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-shopping-cart text-sm"></i>shopping list (phone sync)</h3>
<div class="text-xs leading-relaxed space-y-3 text-t-muted mb-3"> <div class="text-xs leading-relaxed space-y-3 text-t-muted mb-3">
<p>Tag a card with <span class="text-t-accent">#einkauf</span> or <span class="text-t-accent">#shopping</span> to mark it as a shopping-list candidate. Click the header's <span class="text-t-bright font-pixel">Shopping</span> button to pick which one is the currently <span class="text-t-bright">active</span> list — only one at a time is mirrored to the phone.</p> <p>Tag a card with <span class="text-t-accent">#einkauf</span> or <span class="text-t-accent">#shopping</span> to mark it as a shopping-list candidate. Click the header's <span class="text-t-bright font-pixel">Shopping</span> button and check any number of lists to activate them — all active lists are pushed to the relay and shown stacked on the phone.</p>
<p>The active card's body is pushed to the relay on every sync tick. On your phone, log into the relay UI (same credentials as everywhere else) and open <span class="text-t-bright font-pixel">shopping</span> from the menu. You see only the <span class="text-t-bright">unchecked</span> <code class="text-t-accent">- [ ]</code> items as tappable rows. Tapping an item flips it to <code class="text-t-accent">- [x]</code> on the relay in-place; the next client tick pulls that back and updates the local card.</p> <p>The active card's body is pushed to the relay on every sync tick. On your phone, log into the relay UI (same credentials as everywhere else) and open <span class="text-t-bright font-pixel">shopping</span> from the menu. You see only the <span class="text-t-bright">unchecked</span> <code class="text-t-accent">- [ ]</code> items as tappable rows. Tapping an item flips it to <code class="text-t-accent">- [x]</code> on the relay in-place; the next client tick pulls that back and updates the local card.</p>
<p>Editing stays in hyprcrd — the phone can only check/uncheck, never change text. That keeps conflict handling trivial (last-writer wins by timestamp).</p> <p>Editing stays in hyprcrd — the phone can only check/uncheck, never change text. That keeps conflict handling trivial (last-writer wins by timestamp).</p>
</div> </div>
+4 -1
View File
@@ -34,7 +34,7 @@ function shoppingRelay(): array {
PluginManager::instance()->onApiRoute(function (string $route, string $method) { PluginManager::instance()->onApiRoute(function (string $route, string $method) {
if ($route !== '/api/shopping' && $route !== '/api/shopping/check') return; if ($route !== '/api/shopping' && $route !== '/api/shopping/check') return;
if (!in_array($method, ['GET', 'POST'], true)) return; if (!in_array($method, ['GET', 'POST', 'DELETE'], true)) return;
$r = shoppingRelay(); $r = shoppingRelay();
if (!$r['url'] || !$r['user'] || !$r['key']) { if (!$r['url'] || !$r['user'] || !$r['key']) {
@@ -54,6 +54,9 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
if ($method === 'POST') { if ($method === 'POST') {
$opts[CURLOPT_POST] = true; $opts[CURLOPT_POST] = true;
$opts[CURLOPT_POSTFIELDS] = file_get_contents('php://input') ?: '{}'; $opts[CURLOPT_POSTFIELDS] = file_get_contents('php://input') ?: '{}';
} elseif ($method === 'DELETE') {
$opts[CURLOPT_CUSTOMREQUEST] = 'DELETE';
$opts[CURLOPT_POSTFIELDS] = file_get_contents('php://input') ?: '{}';
} }
curl_setopt_array($ch, $opts); curl_setopt_array($ch, $opts);
$response = curl_exec($ch); $response = curl_exec($ch);
+181 -85
View File
@@ -1,10 +1,10 @@
// ── Shopping-list plugin (hyprcrd side) ── // ── Shopping-list plugin (hyprcrd side) ──
// //
// Tag a card with #einkauf or #shopping to make it eligible. Pick // Tag a card with #einkauf or #shopping to make it eligible. Pick
// one as "active" via the Shopping header button; the active card's // any number as "active" via the Shopping header button; each active
// body is pushed to the relay on every sync.tick and on every // card's body is pushed to the relay on every sync.tick and on every
// cards.reloaded so edits land on the phone within seconds. Pulls // cards.reloaded so edits land on the phone within seconds. Pulls
// phone-side toggles back and PATCHes the local card. // phone-side toggles back and PATCHes the local cards.
// //
// No timestamp cursors — we diff the (uid, title, body) triple we // No timestamp cursors — we diff the (uid, title, body) triple we
// last saw in sync with the relay, which is timezone-agnostic. // last saw in sync with the relay, which is timezone-agnostic.
@@ -14,8 +14,9 @@ if (!hyprcrd) throw new Error('[shopping plugin] window.hyprcrd missing');
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const ACTIVE_KEY = 'hyprcrd-shopping-active-id'; const ACTIVE_KEY = 'hyprcrd-shopping-active-ids';
const SYNCED_KEY = 'hyprcrd-shopping-synced'; const SYNCED_KEY = 'hyprcrd-shopping-synced';
const ORDER_KEY = 'hyprcrd-shopping-order';
function esc(s) { function esc(s) {
return String(s ?? '') return String(s ?? '')
@@ -28,48 +29,71 @@ function hasEinkaufTag(card) {
return tags.includes('einkauf') || tags.includes('shopping'); return tags.includes('einkauf') || tags.includes('shopping');
} }
function getActiveId() { // ── Active IDs (multi-select) ────────────────────────────────
const v = localStorage.getItem(ACTIVE_KEY);
return v ? String(v) : null; function getActiveIds() {
try { return JSON.parse(localStorage.getItem(ACTIVE_KEY) || '[]'); } catch { return []; }
} }
function setActiveId(id) { function setActiveIds(ids) {
if (id === null || id === '') localStorage.removeItem(ACTIVE_KEY); localStorage.setItem(ACTIVE_KEY, JSON.stringify(ids.map(String)));
else localStorage.setItem(ACTIVE_KEY, String(id)); }
localStorage.removeItem(SYNCED_KEY); function isActive(id) {
return getActiveIds().includes(String(id));
} }
function getActiveCard() { function getActiveCards() {
const id = getActiveId(); const ids = getActiveIds();
if (!id) return null; if (!ids.length) return [];
const cards = window.state?.cards || []; const cards = window.state?.cards || [];
return cards.find(c => String(c.id) === id) || null; return ids.map(id => cards.find(c => String(c.id) === id)).filter(Boolean);
} }
function getSynced() { function getStoredOrder() {
try { return JSON.parse(localStorage.getItem(SYNCED_KEY) || 'null'); } catch { return null; } try { return JSON.parse(localStorage.getItem(ORDER_KEY) || '[]'); } catch { return []; }
} }
function setSynced(uid, title, body) { function setStoredOrder(ids) {
localStorage.setItem(SYNCED_KEY, JSON.stringify({ uid: String(uid), title: title || '', body: body || '' })); localStorage.setItem(ORDER_KEY, JSON.stringify(ids.map(String)));
}
function sortByOrder(cards) {
const order = getStoredOrder();
const idx = new Map(order.map((id, i) => [id, i]));
return [...cards].sort((a, b) => {
const ai = idx.has(String(a.id)) ? idx.get(String(a.id)) : 9999;
const bi = idx.has(String(b.id)) ? idx.get(String(b.id)) : 9999;
return ai - bi;
});
}
// ── Synced state (per card_uid) ──────────────────────────────
function getSyncedMap() {
try { return JSON.parse(localStorage.getItem(SYNCED_KEY) || '{}'); } catch { return {}; }
}
function setSyncedFor(uid, title, body, pos) {
const m = getSyncedMap();
m[String(uid)] = { title: title || '', body: body || '', pos: pos ?? -1 };
localStorage.setItem(SYNCED_KEY, JSON.stringify(m));
}
function removeSyncedFor(uid) {
const m = getSyncedMap();
delete m[String(uid)];
localStorage.setItem(SYNCED_KEY, JSON.stringify(m));
} }
// ── Header button injection ────────────────────────────────── // ── Header button injection ──────────────────────────────────
function injectHeaderButton() { function injectHeaderButton() {
if ($('shopping-btn')) return; if ($('shopping-btn')) return;
// Put it next to Calendar. The header's right-hand button row
// lives in `header > div:last-child > .flex.items-center.gap-3`.
const header = document.querySelector('header#main-header'); const header = document.querySelector('header#main-header');
if (!header) return; if (!header) return;
const rightRow = header.querySelectorAll('.flex.items-center.gap-3'); const rows = header.querySelectorAll('.flex.items-center.gap-3');
const group = rightRow[rightRow.length - 1]; const group = rows[rows.length - 1];
if (!group) return; if (!group) return;
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.id = 'shopping-btn'; btn.id = 'shopping-btn';
btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors'; btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors';
btn.innerHTML = '<i class="ph ph-shopping-cart text-sm"></i><span class="font-pixel text-[10px]">Shopping</span>'; btn.innerHTML = '<i class="ph ph-shopping-cart text-sm"></i><span class="font-pixel text-[10px]">Shopping</span>';
btn.addEventListener('click', openDialog); btn.addEventListener('click', openDialog);
// Insert as the first item of the right-hand group so the order
// reads Shopping, Calendar, Assets, Tasks, New.
group.insertBefore(btn, group.firstChild); group.insertBefore(btn, group.firstChild);
} }
@@ -87,14 +111,14 @@ function openDialog() {
panel.innerHTML = ` panel.innerHTML = `
<div class="flex items-center gap-3 px-5 py-3 border-b border-t-border"> <div class="flex items-center gap-3 px-5 py-3 border-b border-t-border">
<i class="ph ph-shopping-cart text-t-accent text-base"></i> <i class="ph ph-shopping-cart text-t-accent text-base"></i>
<h2 class="font-pixel text-sm text-t-accent flex-1">Shopping list</h2> <h2 class="font-pixel text-sm text-t-accent flex-1">Shopping lists</h2>
<button class="shopping-picker-close text-t-muted hover:text-t-danger"><i class="ph ph-x text-sm"></i></button> <button class="shopping-picker-close text-t-muted hover:text-t-danger"><i class="ph ph-x text-sm"></i></button>
</div> </div>
<div class="px-5 py-3 text-[11px] text-t-muted leading-relaxed"> <div class="px-5 py-3 text-[11px] text-t-muted leading-relaxed">
Cards tagged with <span class="text-t-accent">#einkauf</span> or Cards tagged with <span class="text-t-accent">#einkauf</span> or
<span class="text-t-accent">#shopping</span> can be set as the active list for the <span class="text-t-accent">#shopping</span> can be activated for the phone.
phone. Only one is active at a time; its body is pushed to the relay on every sync Check any number of lists; their items appear stacked under the
and appears under the <span class="text-t-accent">shopping</span> tab in the relay UI. <span class="text-t-accent">shopping</span> tab in the relay UI.
</div> </div>
<div class="flex-1 overflow-y-auto px-2 py-2" id="shopping-picker-list"></div> <div class="flex-1 overflow-y-auto px-2 py-2" id="shopping-picker-list"></div>
<div class="flex items-center gap-2 px-5 py-3 border-t border-t-border"> <div class="flex items-center gap-2 px-5 py-3 border-t border-t-border">
@@ -117,9 +141,9 @@ function openDialog() {
}); });
const cards = window.state?.cards || []; const cards = window.state?.cards || [];
const candidates = cards.filter(hasEinkaufTag); const candidates = sortByOrder(cards.filter(hasEinkaufTag));
const currentActive = getActiveId(); const currentIds = new Set(getActiveIds());
let selectedId = currentActive; const selected = new Set(currentIds);
if (!candidates.length) { if (!candidates.length) {
list.innerHTML = `<div class="px-4 py-6 text-center text-[11px] text-t-dim"> list.innerHTML = `<div class="px-4 py-6 text-center text-[11px] text-t-dim">
@@ -130,93 +154,165 @@ function openDialog() {
} else { } else {
list.innerHTML = candidates.map(c => { list.innerHTML = candidates.map(c => {
const id = String(c.id); const id = String(c.id);
const checked = id === selectedId ? 'checked' : ''; const checked = selected.has(id) ? 'checked' : '';
const items = (c.body || '').split('\n').filter(l => /^\s*-\s*\[[ xX]\]/.test(l)); const items = (c.body || '').split('\n').filter(l => /^\s*-\s*\[[ xX]\]/.test(l));
const open = items.filter(l => /^\s*-\s*\[\s\]/.test(l)).length; const open = items.filter(l => /^\s*-\s*\[\s\]/.test(l)).length;
return `<label class="flex items-start gap-3 px-3 py-2 hover:bg-t-raised cursor-pointer"> return `<div class="shopping-picker-row flex items-start gap-3 px-3 py-2 hover:bg-t-raised" draggable="true" data-card-id="${id}">
<input type="radio" name="shopping-active" value="${id}" ${checked} style="margin-top:3px;"> <i class="ph ph-dots-six-vertical text-t-dim" style="margin-top:4px;cursor:grab;flex-shrink:0;"></i>
<div class="flex-1 min-w-0"> <label class="flex items-start gap-3 flex-1 min-w-0 cursor-pointer">
<div class="text-xs text-t-bright font-pixel">${esc(c.title || '(untitled)')}</div> <input type="checkbox" data-card-id="${id}" ${checked} style="margin-top:3px;">
<div class="text-[10px] text-t-dim font-pixel mt-0.5">${open} open · ${items.length} total</div> <div class="flex-1 min-w-0">
</div> <div class="text-xs text-t-bright font-pixel">${esc(c.title || '(untitled)')}</div>
</label>`; <div class="text-[10px] text-t-dim font-pixel mt-0.5">${open} open · ${items.length} total</div>
</div>
</label>
<button class="shopping-goto text-t-dim hover:text-t-accent transition-colors" data-card-id="${id}" data-bucket="${c.bucket_id || ''}" title="go to card" style="padding:4px;margin-top:1px;"><i class="ph ph-arrow-square-out text-sm"></i></button>
</div>`;
}).join(''); }).join('');
list.insertAdjacentHTML('beforeend', `<label class="flex items-center gap-3 px-3 py-2 border-t border-t-border hover:bg-t-raised cursor-pointer mt-2"> list.querySelectorAll('input[type="checkbox"]').forEach(cb => {
<input type="radio" name="shopping-active" value="" ${currentActive ? '' : 'checked'} style="margin-top:0;"> cb.addEventListener('change', () => {
<span class="text-[11px] text-t-muted italic">(no active list)</span> if (cb.checked) selected.add(cb.dataset.cardId);
</label>`); else selected.delete(cb.dataset.cardId);
list.querySelectorAll('input[name="shopping-active"]').forEach(r => { });
r.addEventListener('change', () => { selectedId = r.value || null; }); });
// Drag-and-drop reordering of picker rows.
let dragRow = null;
list.querySelectorAll('.shopping-picker-row').forEach(row => {
row.addEventListener('dragstart', (e) => {
dragRow = row;
e.dataTransfer.effectAllowed = 'move';
row.style.opacity = '0.4';
});
row.addEventListener('dragend', () => {
row.style.opacity = '';
dragRow = null;
list.querySelectorAll('.shopping-picker-row').forEach(r => r.style.borderTop = '');
});
row.addEventListener('dragover', (e) => {
if (!dragRow || dragRow === row) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
list.querySelectorAll('.shopping-picker-row').forEach(r => r.style.borderTop = '');
const rect = row.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
if (e.clientY < mid) {
row.style.borderTop = '2px solid var(--t-accent)';
} else {
const next = row.nextElementSibling;
if (next) next.style.borderTop = '2px solid var(--t-accent)';
}
});
row.addEventListener('drop', (e) => {
e.preventDefault();
if (!dragRow || dragRow === row) return;
const rect = row.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
if (e.clientY < mid) {
list.insertBefore(dragRow, row);
} else {
list.insertBefore(dragRow, row.nextSibling);
}
// Persist new order from DOM.
const newOrder = [...list.querySelectorAll('.shopping-picker-row')].map(r => r.dataset.cardId);
setStoredOrder(newOrder);
list.querySelectorAll('.shopping-picker-row').forEach(r => r.style.borderTop = '');
});
});
list.querySelectorAll('.shopping-goto').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const cardId = btn.dataset.cardId;
const bucketId = btn.dataset.bucket;
close();
// Switch bucket if needed so the card is visible.
if (bucketId && window.state?.activeBucket !== bucketId
&& window.state?.activeBucket !== 'all') {
window.state.activeBucket = bucketId;
localStorage.setItem('hyprcrd-active-bucket', bucketId);
window._renderBoard?.();
}
setTimeout(() => window._openDetail?.(parseInt(cardId)), 100);
});
}); });
} }
panel.querySelector('.shopping-picker-save').addEventListener('click', async () => { panel.querySelector('.shopping-picker-save').addEventListener('click', async () => {
const prev = getActiveId(); const prev = getActiveIds();
setActiveId(selectedId); const next = [...selected];
setActiveIds(next);
status.textContent = 'saving…'; status.textContent = 'saving…';
try { try {
if (selectedId) await pushActive(); // Remove deactivated lists from relay.
else await clearRemote(); for (const id of prev) {
if (!selected.has(id)) {
removeSyncedFor(id);
await hyprcrd.api('api/shopping', { method: 'DELETE', body: { card_uid: id } }).catch(() => {});
}
}
// Push all active lists.
await tick();
status.textContent = 'saved'; status.textContent = 'saved';
setTimeout(close, 400); setTimeout(close, 400);
} catch (e) { } catch (e) {
status.textContent = 'error: ' + (e?.message || e); status.textContent = 'error: ' + (e?.message || e);
status.style.color = 'var(--t-danger)'; status.style.color = 'var(--t-danger)';
setActiveId(prev); setActiveIds(prev);
} }
}); });
} }
// ── Sync ───────────────────────────────────────────────────── // ── Sync ─────────────────────────────────────────────────────
async function pushActive() { async function pushOne(card) {
const card = getActiveCard();
if (!card) return;
const uid = String(card.id); const uid = String(card.id);
const title = card.title || ''; const title = card.title || '';
const body = card.body || ''; const body = card.body || '';
const synced = getSynced(); const order = getStoredOrder();
if (synced && synced.uid === uid && synced.title === title && synced.body === body) return; const pos = order.indexOf(uid);
const synced = getSyncedMap()[uid];
if (synced && synced.title === title && synced.body === body && synced.pos === pos) return;
const res = await hyprcrd.api('api/shopping', { const res = await hyprcrd.api('api/shopping', {
method: 'POST', method: 'POST',
body: { card_uid: uid, title, body, updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) }, body: { card_uid: uid, title, body, position: pos >= 0 ? pos : 999, updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) },
}); });
if (res?.ok && res.stored !== false) setSynced(uid, title, body); if (res?.ok && res.stored !== false) setSyncedFor(uid, title, body, pos);
} }
async function pullActive() { async function pullOne(card, relayState) {
const card = getActiveCard(); const uid = String(card.id);
if (!card) return; const { body, title } = relayState;
const res = await hyprcrd.api('api/shopping', { method: 'GET' }); const synced = getSyncedMap()[uid];
if (!res?.ok || !res.state) return; // Preserve the position we last pushed so a pull doesn't reset it
const { card_uid, body, title } = res.state; // and cause an unnecessary re-push on the next tick.
if (String(card_uid) !== String(card.id)) return; const curPos = synced?.pos ?? -1;
const synced = getSynced(); if (synced && synced.body === (body || '') && synced.title === (title || '')) return;
if (synced && synced.uid === String(card.id) && synced.body === (body || '') && synced.title === (title || '')) return;
if ((card.body || '') === (body || '') && (card.title || '') === (title || '')) { if ((card.body || '') === (body || '') && (card.title || '') === (title || '')) {
setSynced(card.id, card.title || '', card.body || ''); setSyncedFor(card.id, card.title || '', card.body || '', curPos);
return; return;
} }
// Relay differs from both the sync marker and the local card — phone
// toggled. Phone can only flip checkboxes (never edit text), so the
// relay body is authoritative for this tick.
await hyprcrd.api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } }); await hyprcrd.api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } });
setSynced(card.id, card.title || '', body || ''); setSyncedFor(card.id, card.title || '', body || '', curPos);
await window._reloadAll?.(); await window._reloadAll?.();
} }
async function clearRemote() {
await hyprcrd.api('api/shopping', {
method: 'POST',
body: { card_uid: '__inactive__', title: '', body: '', updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) },
});
localStorage.removeItem(SYNCED_KEY);
}
async function tick() { async function tick() {
if (!getActiveId()) return; const activeCards = getActiveCards();
try { await pushActive(); } catch {} if (!activeCards.length) return;
try { await pullActive(); } catch {} // Push all active cards first.
for (const card of activeCards) {
try { await pushOne(card); } catch {}
}
// Pull: fetch all lists from relay, match by card_uid.
try {
const res = await hyprcrd.api('api/shopping', { method: 'GET' });
if (!res?.ok || !Array.isArray(res.lists)) return;
const byUid = new Map(res.lists.map(l => [l.card_uid, l]));
for (const card of activeCards) {
const relay = byUid.get(String(card.id));
if (relay) await pullOne(card, relay);
}
} catch {}
} }
// ── Init ───────────────────────────────────────────────────── // ── Init ─────────────────────────────────────────────────────
@@ -235,6 +331,6 @@ if (document.readyState === 'loading') {
hyprcrd.registerPlugin?.({ hyprcrd.registerPlugin?.({
name: 'shopping', name: 'shopping',
version: '1.0.0', version: '1.1.0',
description: 'Sync one shopping card to the relay so it\'s checkable from a phone.', description: 'Sync shopping cards to the relay so they\'re checkable from a phone.',
}); });
@@ -1,4 +1,4 @@
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=4'; import { describe, it, afterEach, expect, $, $$, click, sleep, waitFor, closeAll, csrf } from './runner.js?v=4';
async function makeCard(title, body) { async function makeCard(title, body) {
return fetch('api/cards', { return fetch('api/cards', {
@@ -70,13 +70,13 @@ describe('Search: Find/Replace', () => {
const applyBtn = document.getElementById('search-replace-all'); const applyBtn = document.getElementById('search-replace-all');
expect(applyBtn.disabled).toBe(false); expect(applyBtn.disabled).toBe(false);
click(applyBtn); click(applyBtn);
await sleep(200);
const dialog = document.getElementById('dialog'); // The replace-all button opens a confirm dialog — wait for it
if (dialog?.classList.contains('open')) { // to appear rather than relying on a fixed sleep, since the
const okBtn = document.getElementById('dialog-ok'); // dialog creation is async and can lag on slow runs.
if (okBtn) click(okBtn); await waitFor(() => document.getElementById('dialog')?.classList.contains('open'), 2000);
} const okBtn = document.getElementById('dialog-ok');
if (okBtn) click(okBtn);
// Poll until the replace has actually persisted // Poll until the replace has actually persisted
let updated1, updated2; let updated1, updated2;
+4 -3
View File
@@ -143,9 +143,10 @@ describe('Line Numbers: Alignment with Textarea', () => {
const cs = getComputedStyle(body); const cs = getComputedStyle(body);
const pad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom); const pad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
const textTotal = body.scrollHeight - pad; const textTotal = body.scrollHeight - pad;
// textarea scrollHeight and mirror-div sums diverge due to internal rendering; // textarea scrollHeight and mirror-div sums diverge due to internal rendering,
// allow 50% relative tolerance — scroll sync handles the rest // font metrics, and OS-level text shaping — allow 60% relative tolerance.
const tolerance = Math.max(textTotal, gutterTotal) * 0.5; // Scroll sync (proportional mapping) handles the real alignment at runtime.
const tolerance = Math.max(textTotal, gutterTotal) * 0.6;
expect(Math.abs(gutterTotal - textTotal)).toBeLessThan(tolerance); expect(Math.abs(gutterTotal - textTotal)).toBeLessThan(tolerance);
}); });
+3 -5
View File
@@ -86,11 +86,9 @@ describe('Shopping: picker tag matching', () => {
const controlShown = listText.includes('__test_shop_control__'); const controlShown = listText.includes('__test_shop_control__');
expect(controlShown).toBe(false); expect(controlShown).toBe(false);
// Picker always offers a "no active list" option so the // Picker uses checkboxes so multiple lists can be active.
// user can stop syncing without leaving the dialog. const checkboxes = overlay.querySelectorAll('input[type="checkbox"]');
const radios = overlay.querySelectorAll('input[name="shopping-active"]'); expect(checkboxes.length).toBeGreaterThan(0);
const hasEmpty = [...radios].some(r => r.value === '');
expect(hasEmpty).toBe(true);
overlay.querySelector('.shopping-picker-cancel').click(); overlay.querySelector('.shopping-picker-cancel').click();
await sleep(40); await sleep(40);
+30 -5
View File
@@ -233,19 +233,44 @@ $db->exec('CREATE TABLE IF NOT EXISTS chat_room_members (
)'); )');
$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_room_member_tenant ON chat_room_members(tenant_id, room_uid)'); $db->exec('CREATE INDEX IF NOT EXISTS idx_chat_room_member_tenant ON chat_room_members(tenant_id, room_uid)');
// Shopping list: one active card per tenant, body mirrored from the // Shopping lists: multiple active cards per tenant, each mirrored from
// client. Phone toggles checkboxes in-place via the session UI — no // the client. Phone toggles checkboxes in-place via the session UI.
// multi-list fan-out, no history, no attachments. hyprcrd remains the // hyprcrd remains the only surface that edits item text.
// only surface that edits item text.
$db->exec('CREATE TABLE IF NOT EXISTS shopping_state ( $db->exec('CREATE TABLE IF NOT EXISTS shopping_state (
tenant_id INTEGER PRIMARY KEY, tenant_id INTEGER NOT NULL,
card_uid TEXT NOT NULL, card_uid TEXT NOT NULL,
title TEXT NOT NULL DEFAULT "", title TEXT NOT NULL DEFAULT "",
body TEXT NOT NULL DEFAULT "", body TEXT NOT NULL DEFAULT "",
position INTEGER NOT NULL DEFAULT 0,
updated_at_client TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at_client TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at_server TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at_server TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (tenant_id, card_uid),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
)'); )');
// Migrate from old single-PK schema (tenant_id only) to composite PK
// + ensure position column exists (may be missing on tables created
// before the multi-list update).
$shoppingInfo = $db->query("PRAGMA table_info(shopping_state)")->fetchAll();
$shoppingPk = array_filter($shoppingInfo, fn($c) => $c['pk'] > 0);
$shoppingColNames = array_column($shoppingInfo, 'name');
if (count($shoppingPk) === 1) {
$db->exec('ALTER TABLE shopping_state RENAME TO _shopping_old');
$db->exec('CREATE TABLE shopping_state (
tenant_id INTEGER NOT NULL,
card_uid TEXT NOT NULL,
title TEXT NOT NULL DEFAULT "",
body TEXT NOT NULL DEFAULT "",
position INTEGER NOT NULL DEFAULT 0,
updated_at_client TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at_server TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (tenant_id, card_uid),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
)');
$db->exec('INSERT OR IGNORE INTO shopping_state (tenant_id, card_uid, title, body, updated_at_client, updated_at_server) SELECT tenant_id, card_uid, title, body, updated_at_client, updated_at_server FROM _shopping_old');
$db->exec('DROP TABLE _shopping_old');
} elseif (!in_array('position', $shoppingColNames)) {
$db->exec('ALTER TABLE shopping_state ADD COLUMN position INTEGER NOT NULL DEFAULT 0');
}
// Sync-event notifications: replaces the old bot-chat mechanism. When a // Sync-event notifications: replaces the old bot-chat mechanism. When a
// client syncs a card they received, they POST here; the sender polls // client syncs a card they received, they POST here; the sender polls
+40 -44
View File
@@ -1,53 +1,50 @@
<?php <?php
// Shopping-list routes: // Shopping-list routes:
// POST /api/shopping (client) upsert active card {uid, title, body, updated_at} // POST /api/shopping (client) upsert one list {card_uid, title, body}
// GET /api/shopping (client + session) read current state // GET /api/shopping (client + session) all active lists for tenant
// POST /api/shopping/check (session) toggle one line {line, checked} // POST /api/shopping/check (session) toggle one line {card_uid, line, checked}
// GET /shopping (session) phone-friendly UI // DELETE /api/shopping (client) remove a deactivated list {card_uid}
// //
// Plaintext on purpose — shopping-list content is low-stakes and // Multiple lists per tenant (composite PK: tenant_id + card_uid).
// everything here is session- or client-auth'd. No per-tenant key // Plaintext on purpose — shopping-list content is low-stakes.
// dance like chat / collab-buckets. If that needs tightening later,
// it's a one-table migration.
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; } if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
$clientTenant = $clientTenant ?? authClient($db); $clientTenant = $clientTenant ?? authClient($db);
$tenant = $sessionTenant ?: $clientTenant; $tenant = $sessionTenant ?: $clientTenant;
function shoppingLoad(PDO $db, int $tenantId): ?array { function shoppingLoadAll(PDO $db, int $tenantId): array {
$stmt = $db->prepare('SELECT card_uid, title, body, updated_at_client, updated_at_server FROM shopping_state WHERE tenant_id = :t'); $stmt = $db->prepare('SELECT card_uid, title, body, position, updated_at_client, updated_at_server FROM shopping_state WHERE tenant_id = :t ORDER BY position, title');
$stmt->execute(['t' => $tenantId]); $stmt->execute(['t' => $tenantId]);
return $stmt->fetchAll() ?: [];
}
function shoppingLoadOne(PDO $db, int $tenantId, string $cardUid): ?array {
$stmt = $db->prepare('SELECT card_uid, title, body, updated_at_client, updated_at_server FROM shopping_state WHERE tenant_id = :t AND card_uid = :u');
$stmt->execute(['t' => $tenantId, 'u' => $cardUid]);
$row = $stmt->fetch(); $row = $stmt->fetch();
return $row ?: null; return $row ?: null;
} }
// ── POST /api/shopping ──────────────────────────────────────── // ── POST /api/shopping ────────────────────────────────────────
// Client pushes the currently-active card's content. `updated_at`
// is the client's timestamp — used for lose-the-race checks.
if ($route === '/api/shopping' && $method === 'POST') { if ($route === '/api/shopping' && $method === 'POST') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
$input = json_decode(file_get_contents('php://input'), true) ?: []; $input = json_decode(file_get_contents('php://input'), true) ?: [];
$uid = trim((string)($input['card_uid'] ?? '')); $uid = trim((string)($input['card_uid'] ?? ''));
$title = (string)($input['title'] ?? ''); $title = (string)($input['title'] ?? '');
$body = (string)($input['body'] ?? ''); $body = (string)($input['body'] ?? '');
$pos = (int)($input['position'] ?? 0);
$clientTs = trim((string)($input['updated_at'] ?? '')) ?: gmdate('Y-m-d H:i:s'); $clientTs = trim((string)($input['updated_at'] ?? '')) ?: gmdate('Y-m-d H:i:s');
if ($uid === '') jsonResponse(['error' => 'card_uid required'], 400); if ($uid === '') jsonResponse(['error' => 'card_uid required'], 400);
// No server-side staleness guard: the client decides whether to
// push by diffing its last-synced body against the current card,
// not by timestamp, so cross-timezone clock comparisons can't
// silently block edits. Last-writer per tick wins — phone-toggle
// races lose at most one tick of flips (rare by design).
$now = gmdate('Y-m-d H:i:s'); $now = gmdate('Y-m-d H:i:s');
$db->prepare(' $db->prepare('
INSERT INTO shopping_state (tenant_id, card_uid, title, body, updated_at_client, updated_at_server) INSERT INTO shopping_state (tenant_id, card_uid, title, body, position, updated_at_client, updated_at_server)
VALUES (:t, :u, :ti, :b, :c, :s) VALUES (:t, :u, :ti, :b, :p, :c, :s)
ON CONFLICT(tenant_id) DO UPDATE SET ON CONFLICT(tenant_id, card_uid) DO UPDATE SET
card_uid = excluded.card_uid,
title = excluded.title, title = excluded.title,
body = excluded.body, body = excluded.body,
position = excluded.position,
updated_at_client = excluded.updated_at_client, updated_at_client = excluded.updated_at_client,
updated_at_server = excluded.updated_at_server updated_at_server = excluded.updated_at_server
')->execute([ ')->execute([
@@ -55,39 +52,42 @@ if ($route === '/api/shopping' && $method === 'POST') {
'u' => $uid, 'u' => $uid,
'ti' => $title, 'ti' => $title,
'b' => $body, 'b' => $body,
'p' => $pos,
'c' => $clientTs, 'c' => $clientTs,
's' => $now, 's' => $now,
]); ]);
jsonResponse(['ok' => true, 'stored' => true, 'updated_at_server' => $now]); jsonResponse(['ok' => true, 'stored' => true, 'updated_at_server' => $now]);
} }
// ── GET /api/shopping ──────────────────────────────────────── // ── DELETE /api/shopping ─────────────────────────────────────
// Both the client (to pull phone-made toggles back) and the if ($route === '/api/shopping' && $method === 'DELETE') {
// session-auth'd phone UI poll this. if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$uid = trim((string)($input['card_uid'] ?? ''));
if ($uid === '') jsonResponse(['error' => 'card_uid required'], 400);
$db->prepare('DELETE FROM shopping_state WHERE tenant_id = :t AND card_uid = :u')
->execute(['t' => $clientTenant['id'], 'u' => $uid]);
jsonResponse(['ok' => true]);
}
// ── GET /api/shopping ────────────────────────────────────────
if ($route === '/api/shopping' && $method === 'GET') { if ($route === '/api/shopping' && $method === 'GET') {
if (!$tenant) jsonResponse(['error' => 'unauthorized'], 401); if (!$tenant) jsonResponse(['error' => 'unauthorized'], 401);
$state = shoppingLoad($db, (int)$tenant['id']); $lists = shoppingLoadAll($db, (int)$tenant['id']);
if (!$state) jsonResponse(['ok' => true, 'state' => null]); jsonResponse(['ok' => true, 'lists' => $lists]);
jsonResponse(['ok' => true, 'state' => $state]);
} }
// ── POST /api/shopping/check ───────────────────────────────── // ── POST /api/shopping/check ─────────────────────────────────
// Phone-side toggle. Body: { line: "- [ ] milk", checked: true }.
// We match the exact line once to keep merges simple; if the line
// isn't found (hyprcrd just rewrote it), the caller reloads and
// retries. Bumps updated_at_server so the next client pull sees
// the change.
if ($route === '/api/shopping/check' && $method === 'POST') { if ($route === '/api/shopping/check' && $method === 'POST') {
if (!$sessionTenant) jsonResponse(['error' => 'unauthorized'], 401); if (!$sessionTenant) jsonResponse(['error' => 'unauthorized'], 401);
$input = json_decode(file_get_contents('php://input'), true) ?: []; $input = json_decode(file_get_contents('php://input'), true) ?: [];
$uid = trim((string)($input['card_uid'] ?? ''));
$line = (string)($input['line'] ?? ''); $line = (string)($input['line'] ?? '');
$checked = !empty($input['checked']); $checked = !empty($input['checked']);
if ($line === '') jsonResponse(['error' => 'line required'], 400); if ($uid === '' || $line === '') jsonResponse(['error' => 'card_uid and line required'], 400);
$state = shoppingLoad($db, (int)$sessionTenant['id']); $state = shoppingLoadOne($db, (int)$sessionTenant['id'], $uid);
if (!$state) jsonResponse(['error' => 'no active list'], 404); if (!$state) jsonResponse(['error' => 'list not found'], 404);
$body = $state['body']; $body = $state['body'];
$lines = preg_split("/\r\n|\n|\r/", $body); $lines = preg_split("/\r\n|\n|\r/", $body);
$hit = false; $hit = false;
@@ -96,7 +96,7 @@ if ($route === '/api/shopping/check' && $method === 'POST') {
$toggled = $checked $toggled = $checked
? preg_replace('/^(\s*-\s*)\[\s\]/', '$1[x]', $l, 1) ? preg_replace('/^(\s*-\s*)\[\s\]/', '$1[x]', $l, 1)
: preg_replace('/^(\s*-\s*)\[[xX]\]/', '$1[ ]', $l, 1); : preg_replace('/^(\s*-\s*)\[[xX]\]/', '$1[ ]', $l, 1);
if ($toggled === $l) continue; // not a checkbox line if ($toggled === $l) continue;
$lines[$i] = $toggled; $lines[$i] = $toggled;
$hit = true; $hit = true;
break; break;
@@ -105,11 +105,7 @@ if ($route === '/api/shopping/check' && $method === 'POST') {
$newBody = implode("\n", $lines); $newBody = implode("\n", $lines);
$now = gmdate('Y-m-d H:i:s'); $now = gmdate('Y-m-d H:i:s');
$db->prepare('UPDATE shopping_state SET body = :b, updated_at_server = :s WHERE tenant_id = :t') $db->prepare('UPDATE shopping_state SET body = :b, updated_at_server = :s WHERE tenant_id = :t AND card_uid = :u')
->execute(['b' => $newBody, 's' => $now, 't' => $sessionTenant['id']]); ->execute(['b' => $newBody, 's' => $now, 't' => $sessionTenant['id'], 'u' => $uid]);
jsonResponse(['ok' => true, 'updated_at_server' => $now]); jsonResponse(['ok' => true, 'updated_at_server' => $now]);
} }
// Shopping list lives inside the relay UI as a regular view (see
// relay/ui/layout.php + app.js). No separate /shopping page — login
// and navigation are the same as for every other relay feature.
+47 -25
View File
@@ -773,45 +773,67 @@ async function loadShopping(quiet = false) {
const list = $('shopping-items'); const list = $('shopping-items');
if (!list) return; if (!list) return;
if (!data?.ok) { if (!quiet) shoppingErr(data?.error || 'load failed'); return; } if (!data?.ok) { if (!quiet) shoppingErr(data?.error || 'load failed'); return; }
if (!data.state) { const lists = data.lists || [];
if (title) title.textContent = ''; if (!lists.length) {
list.innerHTML = '<li class="empty">no active shopping list</li>'; if (title) title.textContent = 'Shopping';
return; list.innerHTML = '<li class="empty">no active shopping lists</li>';
}
const { title: t, body } = data.state;
if (title) title.textContent = t || '';
const nodes = shoppingParseLines(body);
const hasItems = nodes.some(n => n.kind === 'item');
if (!hasItems) {
list.innerHTML = '<li class="empty">\u2713 all done</li>';
return; return;
} }
if (title) title.textContent = lists.length === 1 ? (lists[0].title || 'Shopping') : 'Shopping';
list.innerHTML = ''; list.innerHTML = '';
for (const n of nodes) { for (const entry of lists) {
const li = document.createElement('li'); const nodes = shoppingParseLines(entry.body);
if (n.kind === 'heading') { const hasItems = nodes.some(n => n.kind === 'item');
li.className = 'shopping-heading h' + n.level; // List box wrapper
li.textContent = n.text; const box = document.createElement('li');
} else { box.className = 'shopping-list-box';
li.textContent = n.text; if (lists.length > 1 || entry.title) {
li.dataset.line = n.line; const hdr = document.createElement('div');
li.addEventListener('click', () => shoppingCheck(li)); hdr.className = 'shopping-list-header';
hdr.textContent = entry.title || '(untitled)';
box.appendChild(hdr);
} }
list.appendChild(li); if (!hasItems) {
const done = document.createElement('div');
done.className = 'shopping-list-done';
done.textContent = '\u2713 all done';
box.appendChild(done);
} else {
for (const n of nodes) {
if (n.kind === 'heading') {
const h = document.createElement('div');
h.className = 'shopping-heading h' + n.level;
h.textContent = n.text;
box.appendChild(h);
} else {
const row = document.createElement('div');
row.className = 'shopping-item';
row.textContent = n.text;
row.dataset.line = n.line;
row.dataset.uid = entry.card_uid;
row.addEventListener('click', () => shoppingCheck(row));
box.appendChild(row);
}
}
}
list.appendChild(box);
} }
} }
async function shoppingCheck(li) { async function shoppingCheck(el) {
if (shoppingBusy) return; if (shoppingBusy) return;
shoppingBusy = true; shoppingBusy = true;
li.classList.add('checked'); el.classList.add('checked');
try { try {
const res = await api('api/shopping/check', { method: 'POST', body: { line: li.dataset.line, checked: true } }); const res = await api('api/shopping/check', {
method: 'POST',
body: { card_uid: el.dataset.uid, line: el.dataset.line, checked: true },
});
if (!res?.ok) throw new Error(res?.error || 'check failed'); if (!res?.ok) throw new Error(res?.error || 'check failed');
setTimeout(() => { shoppingBusy = false; loadShopping(true); }, 300); setTimeout(() => { shoppingBusy = false; loadShopping(true); }, 300);
} catch (e) { } catch (e) {
shoppingBusy = false; shoppingBusy = false;
li.classList.remove('checked'); el.classList.remove('checked');
shoppingErr(e.message); shoppingErr(e.message);
} }
} }
+51 -14
View File
@@ -276,26 +276,63 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
} }
.shopping-items li:active { transform: scale(0.98); background: var(--raised, var(--surface)); } .shopping-items li:active { transform: scale(0.98); background: var(--raised, var(--surface)); }
.shopping-items li.checked { opacity: 0.35; text-decoration: line-through; } .shopping-items li.checked { opacity: 0.35; text-decoration: line-through; }
.shopping-items .empty { text-align: center; padding: 40px 16px; color: var(--dim); background: transparent; border: none; cursor: default; } .shopping-items .empty { text-align: center; padding: 40px 16px; color: var(--dim); background: transparent; border: none; cursor: default; list-style: none; }
.shopping-items .empty:active { transform: none; } .shopping-items .empty:active { transform: none; }
/* Section headings between items — "# Obst", "## Backwaren", etc. */ /* Multi-list stacked boxes */
.shopping-items li.shopping-heading { .shopping-list-box {
background: transparent; list-style: none;
border: none; background: var(--surface);
padding: 18px 4px 4px; border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.shopping-list-box:active { transform: none; }
.shopping-list-header {
font-weight: 600;
font-size: 13px;
color: var(--bright);
padding: 4px 6px 8px;
border-bottom: 1px solid var(--border);
}
.shopping-list-done {
text-align: center;
padding: 16px 8px;
color: var(--dim);
font-size: 14px;
}
.shopping-item {
background: var(--raised, var(--surface));
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 12px 14px;
font-size: 15px;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition: transform 0.12s, opacity 0.3s, background 0.12s;
}
.shopping-item:active { transform: scale(0.98); }
.shopping-item.checked { opacity: 0.35; text-decoration: line-through; }
/* Section headings between items — "# Obst", "## Backwaren", etc.
Adds breathing room above so grouped items feel like sections. */
.shopping-heading {
padding: 14px 6px 2px;
cursor: default; cursor: default;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
.shopping-items li.shopping-heading:active { transform: none; background: transparent; opacity: 1; } .shopping-heading:first-child { padding-top: 2px; }
.shopping-items li.shopping-heading:first-child { padding-top: 2px; } .shopping-heading.h1 { font-size: 17px; color: var(--bright); }
.shopping-items li.shopping-heading.h1 { font-size: 18px; color: var(--bright); } .shopping-heading.h2 { font-size: 14px; color: var(--accent); }
.shopping-items li.shopping-heading.h2 { font-size: 15px; color: var(--accent); } .shopping-heading.h3,
.shopping-items li.shopping-heading.h3, .shopping-heading.h4,
.shopping-items li.shopping-heading.h4, .shopping-heading.h5,
.shopping-items li.shopping-heading.h5, .shopping-heading.h6 { font-size: 12px; color: var(--muted); }
.shopping-items li.shopping-heading.h6 { font-size: 13px; color: var(--muted); }
.shopping-err { background: var(--danger-bg, #7f1d1d); color: var(--danger-fg, #fecaca); padding: 8px 12px; border-radius: 6px; font-size: 13px; } .shopping-err { background: var(--danger-bg, #7f1d1d); color: var(--danger-fg, #fecaca); padding: 8px 12px; border-radius: 6px; font-size: 13px; }
.shopping-err.hidden { display: none; } .shopping-err.hidden { display: none; }