Compare commits

...

2 Commits

Author SHA1 Message Date
Disco DeDisco
5aaff6240b palette tooltip: contextual description per lock state; earned date below Unlocked in green — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
- _palettes_for_user: shoptalk→description; "available by default" / "explore to unlock" / "recognized via {Title}"; unlocked_date key carries earned_at ISO for Note-unlocked
- template: data-shoptalk→data-description; data-unlocked-date now holds ISO datetime (was literal "Default")
- dashboard.js: lockText drops "— Default"; dateLine renders "Apr 22, 2026 · 3:05 AM" after Unlocked line
- _palette-picker.scss: tt-shoptalk→tt-description in display:block list; tt-date added
- _tooltips.scss: .tt-date mirrors .tt-expiry w. --priGn colour

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:59:10 -04:00
Disco DeDisco
c78ecb61bf natus wheel: unified PRV/NXT cycle merging planets & angles; drop degree from classic element contribs; tt-planet-sym larger; tt-sign-type italic — TDD
- _chartItems merges _planetItems + _angleItems sorted by degree desc;
  _stepCycle dispatches to _activatePlanet or _activateAngle via unified list
- T15g/h/i: angle↔planet boundary navigation & wrap; T9n/T9w updated for merged cycle
- classic element contrib rows: removed @ deg° (pdata/inDeg lookup dropped)
- .tt-planet-sym 1.2→1.8rem; .tt-house-of/.tt-house-type 0.6em→0.7rem;
  .tt-sign-type added alongside .tt-house-type selector, font-style: italic

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:42:47 -04:00
10 changed files with 208 additions and 51 deletions

View File

@@ -33,17 +33,26 @@ const bindPaletteSwatches = () => {
function showTooltip(swatch) {
if (!portal) return;
const label = swatch.dataset.label || '';
const locked = swatch.dataset.locked === 'true';
const date = swatch.dataset.unlockedDate || '';
const shoptalk = swatch.dataset.shoptalk || '';
const lockIcon = locked ? 'fa-lock' : 'fa-lock-open';
const lockText = locked ? 'Locked' : `Unlocked${date}`.trim();
const label = swatch.dataset.label || '';
const locked = swatch.dataset.locked === 'true';
const description = swatch.dataset.description || '';
const unlockedDate = swatch.dataset.unlockedDate || '';
const lockIcon = locked ? 'fa-lock' : 'fa-lock-open';
const lockText = locked ? 'Locked' : 'Unlocked';
let dateLine = '';
if (unlockedDate) {
const dt = new Date(unlockedDate);
const dateStr = dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const timeStr = dt.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
dateLine = `<p class="tt-date">${dateStr} · ${timeStr}</p>`;
}
portal.innerHTML = `
<h4 class="tt-title">${label}</h4>
${shoptalk ? `<p class="tt-shoptalk"><em>${shoptalk}</em></p>` : ''}
<p class="tt-lock"><i class="fa-solid ${lockIcon}"></i> ${lockText}</p>`;
${description ? `<p class="tt-description"><em>${description}</em></p>` : ''}
<p class="tt-lock"><i class="fa-solid ${lockIcon}"></i> ${lockText}</p>
${dateLine}`;
const rect = swatch.getBoundingClientRect();
portal.style.display = 'block';

View File

@@ -358,7 +358,7 @@ class NotePaletteContextTest(TestCase):
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertFalse(bardo["locked"])
def test_note_palette_shoptalk_contains_note_title(self):
def test_note_palette_description_contains_note_title(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
@@ -366,7 +366,41 @@ class NotePaletteContextTest(TestCase):
response = self.client.get("/")
palettes = response.context["palettes"]
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertIn("Stargazer", bardo["shoptalk"])
self.assertIn("Stargazer", bardo["description"])
def test_default_palette_description_is_available_by_default(self):
response = self.client.get("/")
palettes = response.context["palettes"]
default = next(p for p in palettes if p["name"] == "palette-default")
self.assertEqual(default["description"], "available by default")
def test_locked_palette_description_is_explore_to_unlock(self):
response = self.client.get("/")
palettes = response.context["palettes"]
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertEqual(bardo["description"], "explore to unlock")
def test_note_palette_description_is_recognized_via(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
response = self.client.get("/")
palettes = response.context["palettes"]
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertEqual(bardo["description"], "recognized via Stargazer")
def test_note_palette_entry_includes_unlocked_date_iso(self):
earned = timezone.now()
Note.objects.create(
user=self.user, slug="stargazer", earned_at=earned,
palette="palette-bardo",
)
response = self.client.get("/")
palettes = response.context["palettes"]
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertIn("unlocked_date", bardo)
self.assertEqual(bardo["unlocked_date"], earned.isoformat())
def test_note_without_palette_field_keeps_swatch_locked(self):
Note.objects.create(

View File

@@ -51,7 +51,10 @@ PALETTES = _PALETTE_DEFS
def _palettes_for_user(user):
if not (user and user.is_authenticated):
return [dict(p, shoptalk="Placeholder") for p in _PALETTE_DEFS]
return [
dict(p, description="available by default" if not p["locked"] else "explore to unlock")
for p in _PALETTE_DEFS
]
granted = {
r.palette: r
for r in Note.objects.filter(user=user, palette__isnull=False).exclude(palette="")
@@ -63,9 +66,12 @@ def _palettes_for_user(user):
if r and p["locked"]:
entry["locked"] = False
title = _NOTE_TITLES.get(r.slug, r.slug.capitalize())
entry["shoptalk"] = f"{title} · {r.earned_at.strftime('%b %d, %Y').replace(' 0', ' ')}"
entry["description"] = f"recognized via {title}"
entry["unlocked_date"] = r.earned_at.isoformat()
elif not p["locked"]:
entry["description"] = "available by default"
else:
entry["shoptalk"] = "Placeholder"
entry["description"] = "explore to unlock"
result.append(entry)
return result

View File

@@ -159,11 +159,12 @@ const NatusWheel = (() => {
let _activeRing = null; // 'planets' | 'elements' | 'signs' | 'houses' | 'angles' | null
let _activeIdx = null; // index within the active ring's sorted list
let _planetItems = []; // [{name, degree}] sorted by ecliptic degree ascending
let _planetItems = []; // [{name, degree}] sorted by ecliptic degree descending
let _elementItems = []; // [{key}] in ELEMENT_ORDER
let _signItems = []; // [{name, symbol, element}] in SIGNS order
let _houseItems = []; // [{num, label}] houses 112
let _angleItems = []; // [{name, label, house}] — ASC and MC
let _angleItems = []; // [{name, deg}] — ASC and MC
let _chartItems = []; // [{type, name, deg}] planets+angles merged, degree desc
// Tooltip DOM refs — set by _injectTooltipControls() on each draw().
let _tooltipEl = null;
@@ -321,6 +322,10 @@ const NatusWheel = (() => {
{ name: 'ASC', deg: data.houses.asc },
{ name: 'MC', deg: data.houses.mc },
];
_chartItems = [
..._planetItems.map(p => ({ type: 'planet', name: p.name, deg: p.degree })),
..._angleItems.map(a => ({ type: 'angle', name: a.name, deg: a.deg })),
].sort((a, b) => b.deg - a.deg);
}
/** Clear all active-lock classes and reset cycle state. */
@@ -640,9 +645,7 @@ const NatusWheel = (() => {
if (contribs.length) {
bodyHtml += '<div class="tt-el-contribs">';
contribs.forEach(c => {
const pdata = (_currentData.planets || {})[c.planet] || {};
const inDeg = pdata.degree !== undefined ? _inSignDeg(pdata.degree).toFixed(1) : '?';
bodyHtml += `<div class="tt-asp-row">${_pSym(c.planet)} @ ${inDeg}° ${_signIcon(c.sign)} +1</div>`;
bodyHtml += `<div class="tt-asp-row">${_pSym(c.planet)} <span class="tt-dim">in</span> ${_signIcon(c.sign)} +1</div>`;
});
bodyHtml += '</div>';
} else {
@@ -666,7 +669,7 @@ const NatusWheel = (() => {
});
} else {
const psyms = st.planets.map(p => _pSym(p.planet)).join(' ');
bodyHtml += `<div class="tt-asp-row tt-el-planet-row">${_signIcon(st.sign)} : ${psyms}</div>`;
bodyHtml += `<div class="tt-asp-row tt-el-planet-row"><span class="tt-dim">in</span> ${_signIcon(st.sign)} : ${psyms}</div>`;
}
});
bodyHtml += '</div>';
@@ -697,7 +700,7 @@ const NatusWheel = (() => {
pd.signs.forEach(sign => {
const planets = bySign[sign] || [];
const psyms = planets.map(p => _pSym(p.planet)).join(' ');
bodyHtml += `<div class="tt-asp-row tt-el-planet-row">${_signIcon(sign)} : ${psyms}</div>`;
bodyHtml += `<div class="tt-asp-row tt-el-planet-row"><span class="tt-dim">in</span> ${_signIcon(sign)} : ${psyms}</div>`;
});
});
bodyHtml += '</div>';
@@ -771,7 +774,7 @@ const NatusWheel = (() => {
`<span class="tt-sign-icon-wrap tt-sign-icon-wrap--${elKey}">${iconSvg}</span>` +
`</div>` +
`<div class="tt-sign-meta">` +
`<span>${modality} ${sign.element}</span>` +
`<span class="tt-sign-type">${modality} ${sign.element}</span>` +
vecImg +
`</div>` +
`<div class="tt-sign-section-header">Planets</div>` +
@@ -831,9 +834,18 @@ const NatusWheel = (() => {
/** Advance the active ring by +1 (NXT) or -1 (PRV). */
function _stepCycle(dir) {
if (_activeRing === 'planets') {
_activeIdx = (_activeIdx + dir + _planetItems.length) % _planetItems.length;
_activatePlanet(_activeIdx);
if (_activeRing === 'planets' || _activeRing === 'angles') {
const currentName = _activeRing === 'planets'
? _planetItems[_activeIdx].name
: _activeIdx; // angles use name string as _activeIdx
const pos = _chartItems.findIndex(c => c.name === currentName);
if (pos === -1) return;
const next = _chartItems[(pos + dir + _chartItems.length) % _chartItems.length];
if (next.type === 'planet') {
_activatePlanet(_planetItems.findIndex(p => p.name === next.name));
} else {
_activateAngle(next.name);
}
} else if (_activeRing === 'elements') {
_activeIdx = (_activeIdx + dir + _elementItems.length) % _elementItems.length;
_activateElement(_activeIdx);

View File

@@ -232,8 +232,10 @@ describe("NatusWheel — tick lines, raise, and cycle navigation", () => {
});
// ── T9n ── PRV cycles counterclockwise (to higher ecliptic degree) ────────
// CONJUNCTION_CHART merged sorted desc: ASC(180)→Mars(132)→MC(90)→Sun(66.7)→Venus(63.3)
// PRV from Sun (pos 3) → MC (pos 2, 90°) — angles and planets share the cycle.
it("T9n: clicking PRV from Sun shows Mars (previous planet counterclockwise = higher degree)", () => {
it("T9n: clicking PRV from Sun shows MC (next higher ecliptic degree in merged cycle)", () => {
const sun = svgEl2.querySelector("[data-planet='Sun']");
expect(sun).not.toBeNull("expected [data-planet='Sun']");
@@ -242,24 +244,25 @@ describe("NatusWheel — tick lines, raise, and cycle navigation", () => {
const prvBtn = tooltipEl.querySelector(".nw-tt-prv");
prvBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.textContent).toContain("Mars");
const mars = svgEl2.querySelector("[data-planet='Mars']");
expect(mars.classList.contains("nw-planet--active")).toBe(true);
expect(tooltipEl.textContent).toContain("Midheaven");
const mc = svgEl2.querySelector("[data-angle='MC']");
expect(mc.classList.contains("nw-angle--active")).toBe(true);
expect(sun.classList.contains("nw-planet--active")).toBe(false);
});
// ── T9w ── NXT wraps clockwise from the last (lowest-degree) planet ───────
// ── T9w ── NXT wraps clockwise from the lowest-degree item ───────────────
// Venus(63.3°) is lowest; NXT wraps to ASC(180°) — the highest-degree item.
it("T9w: cycling NXT from Venus (lowest degree) wraps clockwise to Mars (highest degree)", () => {
// Venus is idx 2 (lowest degree = furthest clockwise); NXT wraps to idx 0 = Mars
it("T9w: cycling NXT from Venus (lowest degree) wraps clockwise to ASC (highest degree)", () => {
const venus = svgEl2.querySelector("[data-planet='Venus']");
venus.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const nxtBtn = tooltipEl.querySelector(".nw-tt-nxt");
nxtBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.textContent).toContain("Mars");
const mars = svgEl2.querySelector("[data-planet='Mars']");
expect(mars.classList.contains("nw-planet--active")).toBe(true);
expect(tooltipEl.textContent).toContain("Ascendant");
const asc = svgEl2.querySelector("[data-angle='ASC']");
expect(asc.classList.contains("nw-angle--active")).toBe(true);
});
});
@@ -952,4 +955,47 @@ describe("NatusWheel — angle (ASC/MC) click tooltips", () => {
const bodyHtml = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(bodyHtml).toContain("ASC");
});
// T15g — NXT from a planet steps into an angle when angle is next by degree
// ANGLE_CHART sorted descending: Mars(188)→Moon(97)→MC(90)→Sun(8)→ASC(0)
// Moon is idx 1; NXT steps to MC (idx 2).
it("T15g: clicking NXT from Moon (97°) activates MC (90°, next clockwise)", () => {
const moonGroup = svgEl.querySelector("[data-planet='Moon']");
expect(moonGroup).not.toBeNull("expected [data-planet='Moon']");
moonGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const nxtBtn = tooltipEl.querySelector(".nw-tt-nxt");
nxtBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.textContent).toContain("Midheaven");
const mcGroup = svgEl.querySelector("[data-angle='MC']");
expect(mcGroup.classList.contains("nw-angle--active")).toBe(true);
expect(moonGroup.classList.contains("nw-planet--active")).toBe(false);
});
// T15h — PRV from an angle steps back into a planet
it("T15h: clicking PRV from MC (90°) activates Moon (97°, previous counterclockwise)", () => {
const mcGroup = svgEl.querySelector("[data-angle='MC']");
mcGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const prvBtn = tooltipEl.querySelector(".nw-tt-prv");
prvBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const moonGroup = svgEl.querySelector("[data-planet='Moon']");
expect(moonGroup.classList.contains("nw-planet--active")).toBe(true);
expect(mcGroup.classList.contains("nw-angle--active")).toBe(false);
});
// T15i — NXT from ASC (lowest degree, 0°) wraps to Mars (highest degree, 188°)
it("T15i: NXT from ASC (0°, lowest) wraps clockwise to Mars (188°, highest)", () => {
const ascGroup = svgEl.querySelector("[data-angle='ASC']");
ascGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const nxtBtn = tooltipEl.querySelector(".nw-tt-nxt");
nxtBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.textContent).toContain("Mars");
const marsGroup = svgEl.querySelector("[data-planet='Mars']");
expect(marsGroup.classList.contains("nw-planet--active")).toBe(true);
});
});

View File

@@ -535,7 +535,7 @@ body[class*="-light"] {
margin-bottom: 0.2rem;
}
.tt-planet-sym {
font-size: 1.2rem;
font-size: 1.8rem;
opacity: 0.85;
}
.tt-angle-sym {
@@ -608,7 +608,7 @@ body[class*="-light"] {
margin-bottom: 0.3rem;
}
.tt-house-of {
font-size: 0.6em;
font-size: 0.7rem;
font-weight: 700;
margin-right: 0.15em;
opacity: 0.9;
@@ -619,12 +619,14 @@ body[class*="-light"] {
opacity: 1;
flex-shrink: 0;
}
.tt-house-type {
.tt-house-type,
.tt-sign-type {
display: block;
font-size: 0.6em;
font-size: 0.7rem;
font-weight: 400;
opacity: 0.7;
margin-top: 0.1em;
font-style: italic;
}
.tt-house-planets {
display: flex;

View File

@@ -80,7 +80,8 @@
#id_tooltip_portal {
// Override .tt { display: none } — portal content is shown/hidden by JS
.tt-title,
.tt-shoptalk,
.tt-description,
.tt-date,
.tt-lock {
display: block;
}

View File

@@ -10,6 +10,7 @@
.tt-description { padding: 0.125rem; font-size: 0.75rem; }
.tt-shoptalk { font-size: 0.75rem; opacity: 0.75; }
.tt-expiry { font-size: 1rem; color: rgba(var(--priRd), 1); }
.tt-date { font-size: 1rem; color: rgba(var(--priGn), 1); }
}
.token-tooltip,

View File

@@ -232,8 +232,10 @@ describe("NatusWheel — tick lines, raise, and cycle navigation", () => {
});
// ── T9n ── PRV cycles counterclockwise (to higher ecliptic degree) ────────
// CONJUNCTION_CHART merged sorted desc: ASC(180)→Mars(132)→MC(90)→Sun(66.7)→Venus(63.3)
// PRV from Sun (pos 3) → MC (pos 2, 90°) — angles and planets share the cycle.
it("T9n: clicking PRV from Sun shows Mars (previous planet counterclockwise = higher degree)", () => {
it("T9n: clicking PRV from Sun shows MC (next higher ecliptic degree in merged cycle)", () => {
const sun = svgEl2.querySelector("[data-planet='Sun']");
expect(sun).not.toBeNull("expected [data-planet='Sun']");
@@ -242,24 +244,25 @@ describe("NatusWheel — tick lines, raise, and cycle navigation", () => {
const prvBtn = tooltipEl.querySelector(".nw-tt-prv");
prvBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.textContent).toContain("Mars");
const mars = svgEl2.querySelector("[data-planet='Mars']");
expect(mars.classList.contains("nw-planet--active")).toBe(true);
expect(tooltipEl.textContent).toContain("Midheaven");
const mc = svgEl2.querySelector("[data-angle='MC']");
expect(mc.classList.contains("nw-angle--active")).toBe(true);
expect(sun.classList.contains("nw-planet--active")).toBe(false);
});
// ── T9w ── NXT wraps clockwise from the last (lowest-degree) planet ───────
// ── T9w ── NXT wraps clockwise from the lowest-degree item ───────────────
// Venus(63.3°) is lowest; NXT wraps to ASC(180°) — the highest-degree item.
it("T9w: cycling NXT from Venus (lowest degree) wraps clockwise to Mars (highest degree)", () => {
// Venus is idx 2 (lowest degree = furthest clockwise); NXT wraps to idx 0 = Mars
it("T9w: cycling NXT from Venus (lowest degree) wraps clockwise to ASC (highest degree)", () => {
const venus = svgEl2.querySelector("[data-planet='Venus']");
venus.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const nxtBtn = tooltipEl.querySelector(".nw-tt-nxt");
nxtBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.textContent).toContain("Mars");
const mars = svgEl2.querySelector("[data-planet='Mars']");
expect(mars.classList.contains("nw-planet--active")).toBe(true);
expect(tooltipEl.textContent).toContain("Ascendant");
const asc = svgEl2.querySelector("[data-angle='ASC']");
expect(asc.classList.contains("nw-angle--active")).toBe(true);
});
});
@@ -952,4 +955,47 @@ describe("NatusWheel — angle (ASC/MC) click tooltips", () => {
const bodyHtml = tooltipEl.querySelector(".nw-tt-body").innerHTML;
expect(bodyHtml).toContain("ASC");
});
// T15g — NXT from a planet steps into an angle when angle is next by degree
// ANGLE_CHART sorted descending: Mars(188)→Moon(97)→MC(90)→Sun(8)→ASC(0)
// Moon is idx 1; NXT steps to MC (idx 2).
it("T15g: clicking NXT from Moon (97°) activates MC (90°, next clockwise)", () => {
const moonGroup = svgEl.querySelector("[data-planet='Moon']");
expect(moonGroup).not.toBeNull("expected [data-planet='Moon']");
moonGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const nxtBtn = tooltipEl.querySelector(".nw-tt-nxt");
nxtBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.textContent).toContain("Midheaven");
const mcGroup = svgEl.querySelector("[data-angle='MC']");
expect(mcGroup.classList.contains("nw-angle--active")).toBe(true);
expect(moonGroup.classList.contains("nw-planet--active")).toBe(false);
});
// T15h — PRV from an angle steps back into a planet
it("T15h: clicking PRV from MC (90°) activates Moon (97°, previous counterclockwise)", () => {
const mcGroup = svgEl.querySelector("[data-angle='MC']");
mcGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const prvBtn = tooltipEl.querySelector(".nw-tt-prv");
prvBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const moonGroup = svgEl.querySelector("[data-planet='Moon']");
expect(moonGroup.classList.contains("nw-planet--active")).toBe(true);
expect(mcGroup.classList.contains("nw-angle--active")).toBe(false);
});
// T15i — NXT from ASC (lowest degree, 0°) wraps to Mars (highest degree, 188°)
it("T15i: NXT from ASC (0°, lowest) wraps clockwise to Mars (188°, highest)", () => {
const ascGroup = svgEl.querySelector("[data-angle='ASC']");
ascGroup.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const nxtBtn = tooltipEl.querySelector(".nw-tt-nxt");
nxtBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(tooltipEl.textContent).toContain("Mars");
const marsGroup = svgEl.querySelector("[data-planet='Mars']");
expect(marsGroup.classList.contains("nw-planet--active")).toBe(true);
});
});

View File

@@ -12,8 +12,8 @@
data-palette="{{ palette.name }}"
data-label="{{ palette.label }}"
data-locked="{{ palette.locked|yesno:'true,false' }}"
data-unlocked-date="{% if not palette.locked %}Default{% endif %}"
data-shoptalk="{{ palette.shoptalk }}"
data-unlocked-date="{{ palette.unlocked_date|default:'' }}"
data-description="{{ palette.description }}"
>
{% if not palette.locked %}
<button type="button" class="btn btn-confirm palette-ok" hidden>OK</button>