update ui webp export

This commit is contained in:
2026-03-12 21:01:32 -07:00
parent 9da3ccf678
commit aa3f23c92c
10 changed files with 741 additions and 240 deletions

5
app.js
View File

@@ -36,6 +36,7 @@ const timelineSectionEl = document.getElementById("timeline-section");
const calendarSectionEl = document.getElementById("calendar-section"); const calendarSectionEl = document.getElementById("calendar-section");
const holidaySectionEl = document.getElementById("holiday-section"); const holidaySectionEl = document.getElementById("holiday-section");
const tarotSectionEl = document.getElementById("tarot-section"); const tarotSectionEl = document.getElementById("tarot-section");
const tarotHouseSectionEl = document.getElementById("tarot-house-section");
const astronomySectionEl = document.getElementById("astronomy-section"); const astronomySectionEl = document.getElementById("astronomy-section");
const natalSectionEl = document.getElementById("natal-section"); const natalSectionEl = document.getElementById("natal-section");
const planetSectionEl = document.getElementById("planet-section"); const planetSectionEl = document.getElementById("planet-section");
@@ -59,6 +60,7 @@ const openCalendarTimelineEl = document.getElementById("open-calendar-timeline")
const openCalendarMonthsEl = document.getElementById("open-calendar-months"); const openCalendarMonthsEl = document.getElementById("open-calendar-months");
const openHolidaysEl = document.getElementById("open-holidays"); const openHolidaysEl = document.getElementById("open-holidays");
const openTarotEl = document.getElementById("open-tarot"); const openTarotEl = document.getElementById("open-tarot");
const openTarotHouseEl = document.getElementById("open-tarot-house");
const openAstronomyEl = document.getElementById("open-astronomy"); const openAstronomyEl = document.getElementById("open-astronomy");
const openPlanetsEl = document.getElementById("open-planets"); const openPlanetsEl = document.getElementById("open-planets");
const openCyclesEl = document.getElementById("open-cycles"); const openCyclesEl = document.getElementById("open-cycles");
@@ -399,6 +401,7 @@ sectionStateUi.init?.({
calendarSectionEl, calendarSectionEl,
holidaySectionEl, holidaySectionEl,
tarotSectionEl, tarotSectionEl,
tarotHouseSectionEl,
astronomySectionEl, astronomySectionEl,
natalSectionEl, natalSectionEl,
planetSectionEl, planetSectionEl,
@@ -422,6 +425,7 @@ sectionStateUi.init?.({
openCalendarMonthsEl, openCalendarMonthsEl,
openHolidaysEl, openHolidaysEl,
openTarotEl, openTarotEl,
openTarotHouseEl,
openAstronomyEl, openAstronomyEl,
openPlanetsEl, openPlanetsEl,
openCyclesEl, openCyclesEl,
@@ -517,6 +521,7 @@ navigationUi.init?.({
openCalendarMonthsEl, openCalendarMonthsEl,
openHolidaysEl, openHolidaysEl,
openTarotEl, openTarotEl,
openTarotHouseEl,
openAstronomyEl, openAstronomyEl,
openPlanetsEl, openPlanetsEl,
openCyclesEl, openCyclesEl,

View File

@@ -18,6 +18,7 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
flex-wrap: wrap;
border-bottom: 1px solid #27272a; border-bottom: 1px solid #27272a;
background: #18181b; background: #18181b;
min-width: 0; min-width: 0;
@@ -43,7 +44,9 @@
color: #fbbf24; color: #fbbf24;
} }
.topbar-menu-toggle { .topbar-menu-toggle {
display: none; display: inline-flex;
align-items: center;
justify-content: center;
padding: 7px 12px; padding: 7px 12px;
border-radius: 999px; border-radius: 999px;
border: 1px solid #3f3f46; border: 1px solid #3f3f46;
@@ -59,55 +62,53 @@
background: #3f3f46; background: #3f3f46;
} }
.topbar-actions { .topbar-actions {
display: flex; display: none;
align-items: center; flex: 1 0 100%;
gap: 8px; width: 100%;
flex: 1 1 auto; padding: 12px;
min-width: 0; margin: 4px 0 0;
justify-content: flex-start; border: 1px solid #2f2f39;
overflow-x: auto; border-radius: 16px;
overflow-y: hidden; background: linear-gradient(180deg, rgba(24, 24, 34, 0.98), rgba(12, 12, 18, 0.98));
padding-bottom: 132px; box-shadow: 0 18px 40px rgba(0, 0, 0, 0.34);
margin-bottom: -130px; gap: 10px;
pointer-events: none; max-height: calc(100svh - 88px);
overflow-y: auto;
}
.topbar.is-menu-open .topbar-actions {
display: grid;
grid-template-columns: 1fr;
} }
.topbar-actions > * { .topbar-actions > * {
pointer-events: auto; width: 100%;
}
.topbar-actions::-webkit-scrollbar {
height: 6px;
} }
.topbar-dropdown { .topbar-dropdown {
position: relative; display: grid;
display: inline-flex; width: 100%;
align-items: center;
} }
.topbar-dropdown-menu { .topbar-dropdown-menu {
position: absolute; position: static;
top: 100%; min-width: 0;
left: 0; width: 100%;
min-width: 140px;
display: none; display: none;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 4px; gap: 4px;
padding: 6px; padding: 6px;
border-radius: 10px; margin-top: 6px;
border: 1px solid #3f3f46; border-radius: 12px;
background: #18181b; border: 1px solid #31313d;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.3); background: rgba(10, 10, 16, 0.92);
box-shadow: none;
z-index: 40; z-index: 40;
} }
.topbar-dropdown.is-open .topbar-dropdown-menu { .topbar-dropdown.is-open .topbar-dropdown-menu {
display: grid; display: grid;
} }
.topbar-dropdown:hover .topbar-dropdown-menu,
.topbar-dropdown:focus-within .topbar-dropdown-menu {
display: grid;
}
.topbar-sub-trigger { .topbar-sub-trigger {
width: 100%; width: 100%;
text-align: left; text-align: left;
font-size: 13px; font-size: 13px;
min-height: 40px;
padding: 6px 10px; padding: 6px 10px;
} }
.topbar-sub-trigger.is-active { .topbar-sub-trigger.is-active {
@@ -115,6 +116,12 @@
border-color: #52525b; border-color: #52525b;
} }
.settings-trigger { .settings-trigger {
width: 100%;
min-height: 42px;
display: flex;
align-items: center;
justify-content: flex-start;
text-align: left;
padding: 7px 12px; padding: 7px 12px;
border-radius: 8px; border-radius: 8px;
border: 1px solid #3f3f46; border: 1px solid #3f3f46;
@@ -146,70 +153,15 @@
min-height: 38px; min-height: 38px;
} }
.topbar-menu-toggle { .topbar-menu-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px; min-height: 38px;
margin-left: auto; margin-left: auto;
} }
.topbar-actions { .topbar-actions {
display: none;
flex: 1 0 100%;
width: 100%;
padding: 12px;
margin: 4px 0 0;
border: 1px solid #2f2f39;
border-radius: 16px;
background: linear-gradient(180deg, rgba(24, 24, 34, 0.98), rgba(12, 12, 18, 0.98));
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.34);
overflow: visible;
pointer-events: auto;
gap: 10px;
max-height: calc(100svh - 88px); max-height: calc(100svh - 88px);
overflow-y: auto;
}
.topbar.is-menu-open .topbar-actions {
display: grid;
grid-template-columns: 1fr;
}
.topbar-actions > * {
width: 100%;
}
.topbar-dropdown {
display: grid;
width: 100%;
}
.topbar-dropdown-menu {
position: static;
min-width: 0;
width: 100%;
margin-top: 6px;
padding: 6px;
border-radius: 12px;
border-color: #31313d;
background: rgba(10, 10, 16, 0.92);
box-shadow: none;
}
.topbar-dropdown:hover .topbar-dropdown-menu,
.topbar-dropdown:focus-within .topbar-dropdown-menu {
display: none;
}
.topbar-dropdown.is-open .topbar-dropdown-menu {
display: grid;
} }
.settings-trigger { .settings-trigger {
width: 100%;
min-height: 42px;
display: flex;
align-items: center;
justify-content: flex-start;
text-align: left;
padding: 10px 12px; padding: 10px 12px;
} }
.topbar-sub-trigger {
min-height: 40px;
font-size: 13px;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.topbar { .topbar {
@@ -366,9 +318,18 @@
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; overflow: hidden;
} }
#tarot-house-section {
height: calc(100vh - 61px);
background: #18181b;
box-sizing: border-box;
overflow: hidden;
}
#tarot-section[hidden] { #tarot-section[hidden] {
display: none; display: none;
} }
#tarot-house-section[hidden] {
display: none;
}
#planet-section[hidden] { #planet-section[hidden] {
display: none; display: none;
} }
@@ -1172,22 +1133,11 @@
box-sizing: border-box; box-sizing: border-box;
} }
#tarot-browse-view.is-house-focus { #tarot-house-view.is-house-focus {
grid-template-rows: minmax(0, 1fr); padding: 18px;
} }
#tarot-browse-view.is-house-focus .tarot-section-house-top { #tarot-house-view.is-house-focus .tarot-house-layout {
max-height: none;
height: 100%;
padding: 14px;
overflow: auto;
}
#tarot-browse-view.is-house-focus .tarot-layout {
display: none;
}
#tarot-browse-view.is-house-focus .tarot-house-layout {
--tarot-house-card-gap: clamp(4px, 0.6vw, 8px); --tarot-house-card-gap: clamp(4px, 0.6vw, 8px);
--tarot-house-row-gap: clamp(6px, 0.9vw, 10px); --tarot-house-row-gap: clamp(6px, 0.9vw, 10px);
--tarot-house-section-gap: clamp(12px, 1.4vw, 16px); --tarot-house-section-gap: clamp(12px, 1.4vw, 16px);
@@ -1197,7 +1147,7 @@
align-content: start; align-content: start;
} }
#tarot-browse-view.is-house-focus .tarot-house-trumps { #tarot-house-view.is-house-focus .tarot-house-trumps {
overflow-x: hidden; overflow-x: hidden;
} }
@@ -1802,6 +1752,10 @@
display: block; display: block;
} }
.cube-export-btn[hidden] {
display: none;
}
.cube-rotation-controls { .cube-rotation-controls {
display: grid; display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr)); grid-template-columns: repeat(6, minmax(0, 1fr));
@@ -2285,6 +2239,33 @@
margin: 0; margin: 0;
} }
.kab-export-btn {
margin-left: auto;
padding: 5px 10px;
border: 1px solid #3f3f46;
border-radius: 6px;
background: #18181b;
color: #d4d4d8;
cursor: pointer;
font-size: 12px;
line-height: 1.2;
}
.kab-export-btn:hover:not(:disabled) {
background: #27272a;
border-color: #52525b;
color: #f4f4f5;
}
.kab-export-btn:disabled {
opacity: 0.65;
cursor: progress;
}
.kab-export-btn[hidden] {
display: none;
}
.kab-detail-panel { .kab-detail-panel {
min-width: 0; min-width: 0;
overflow: auto; overflow: auto;
@@ -2783,7 +2764,7 @@
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: minmax(0, 1fr);
grid-row: 1 / -1; grid-row: 1 / -1;
} }
@@ -2791,6 +2772,28 @@
display: none !important; display: none !important;
} }
#tarot-house-view {
min-height: 0;
height: 100%;
overflow: auto;
padding: 14px;
box-sizing: border-box;
background: #151520;
}
#tarot-house-view .tarot-section-house-top {
max-height: none;
height: auto;
padding: 0;
border-bottom: none;
overflow: visible;
background: transparent;
}
#tarot-house-view .tarot-house-card {
gap: 14px;
}
/* ── Tarot Spread View ─────────────────────────────── */ /* ── Tarot Spread View ─────────────────────────────── */
#tarot-spread-view { #tarot-spread-view {
display: flex; display: flex;

View File

@@ -5,7 +5,6 @@
const DETAIL_COLLAPSE_STORAGE_PREFIX = "tarot-detail-collapsed:v2:"; const DETAIL_COLLAPSE_STORAGE_PREFIX = "tarot-detail-collapsed:v2:";
const DEFAULT_DATASET_ENTRY_COLLAPSED = false; const DEFAULT_DATASET_ENTRY_COLLAPSED = false;
const DEFAULT_DATASET_DETAIL_COLLAPSED = true; const DEFAULT_DATASET_DETAIL_COLLAPSED = true;
const MOBILE_TOPBAR_MEDIA_QUERY = "(max-width: 900px)";
const sidebarControllers = new WeakMap(); const sidebarControllers = new WeakMap();
const detailControllers = new WeakMap(); const detailControllers = new WeakMap();
const AUTO_COLLAPSE_ENTRY_SELECTOR = [ const AUTO_COLLAPSE_ENTRY_SELECTOR = [
@@ -354,10 +353,6 @@
}; };
} }
function isMobileTopbarViewport() {
return window.matchMedia(MOBILE_TOPBAR_MEDIA_QUERY).matches;
}
function setTopbarMenuOpen(isOpen) { function setTopbarMenuOpen(isOpen) {
const { topbarEl, menuToggleEl } = getTopbarElements(); const { topbarEl, menuToggleEl } = getTopbarElements();
if (!(topbarEl instanceof HTMLElement) || !(menuToggleEl instanceof HTMLButtonElement)) { if (!(topbarEl instanceof HTMLElement) || !(menuToggleEl instanceof HTMLButtonElement)) {
@@ -407,18 +402,13 @@
if (!isDropdownTrigger || isMenuItem) { if (!isDropdownTrigger || isMenuItem) {
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
if (isMobileTopbarViewport()) { setTopbarMenuOpen(false);
setTopbarMenuOpen(false);
}
}); });
} }
}); });
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
const clickTarget = event.target; const clickTarget = event.target;
if (!isMobileTopbarViewport()) {
return;
}
if (clickTarget instanceof Node && topbarEl.contains(clickTarget)) { if (clickTarget instanceof Node && topbarEl.contains(clickTarget)) {
return; return;
} }
@@ -426,12 +416,6 @@
setTopbarMenuOpen(false); setTopbarMenuOpen(false);
}); });
window.addEventListener("resize", () => {
if (!isMobileTopbarViewport()) {
setTopbarMenuOpen(false);
}
});
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
if (event.key === "Escape") { if (event.key === "Escape") {
setTopbarMenuOpen(false); setTopbarMenuOpen(false);
@@ -463,20 +447,6 @@
setTopbarDropdownOpen(dropdownEl, false); setTopbarDropdownOpen(dropdownEl, false);
dropdownEl.addEventListener("mouseenter", () => {
if (isMobileTopbarViewport()) {
return;
}
setTopbarDropdownOpen(dropdownEl, true);
});
dropdownEl.addEventListener("mouseleave", () => {
if (isMobileTopbarViewport()) {
return;
}
setTopbarDropdownOpen(dropdownEl, false);
});
dropdownEl.addEventListener("focusout", (event) => { dropdownEl.addEventListener("focusout", (event) => {
const nextTarget = event.relatedTarget; const nextTarget = event.relatedTarget;
if (!(nextTarget instanceof Node) || !dropdownEl.contains(nextTarget)) { if (!(nextTarget instanceof Node) || !dropdownEl.contains(nextTarget)) {

View File

@@ -16,9 +16,20 @@
selectedConnectorId: null, selectedConnectorId: null,
selectedWallId: null, selectedWallId: null,
selectedEdgeId: null, selectedEdgeId: null,
focusMode: false focusMode: false,
exportInProgress: false,
exportFormat: ""
}; };
const CUBE_EXPORT_FORMATS = {
webp: {
mimeType: "image/webp",
extension: "webp",
quality: 0.98
}
};
const CUBE_EXPORT_BACKGROUND = "#111118";
const CUBE_VERTICES = [ const CUBE_VERTICES = [
[-1, -1, -1], [-1, -1, -1],
[1, -1, -1], [1, -1, -1],
@@ -124,6 +135,7 @@
const cubeChassisUi = window.CubeChassisUi || {}; const cubeChassisUi = window.CubeChassisUi || {};
const cubeMathHelpers = window.CubeMathHelpers || {}; const cubeMathHelpers = window.CubeMathHelpers || {};
const cubeSelectionHelpers = window.CubeSelectionHelpers || {}; const cubeSelectionHelpers = window.CubeSelectionHelpers || {};
let webpExportSupported = null;
function getElements() { function getElements() {
return { return {
@@ -136,6 +148,7 @@
rotateDownEl: document.getElementById("cube-rotate-down"), rotateDownEl: document.getElementById("cube-rotate-down"),
rotateResetEl: document.getElementById("cube-rotate-reset"), rotateResetEl: document.getElementById("cube-rotate-reset"),
focusToggleEl: document.getElementById("cube-focus-toggle"), focusToggleEl: document.getElementById("cube-focus-toggle"),
exportWebpEl: document.getElementById("cube-export-webp"),
markerModeEl: document.getElementById("cube-marker-mode"), markerModeEl: document.getElementById("cube-marker-mode"),
connectorToggleEl: document.getElementById("cube-connector-toggle"), connectorToggleEl: document.getElementById("cube-connector-toggle"),
primalToggleEl: document.getElementById("cube-primal-toggle"), primalToggleEl: document.getElementById("cube-primal-toggle"),
@@ -344,6 +357,233 @@
} }
} }
function isExportFormatSupported(format) {
const exportFormat = CUBE_EXPORT_FORMATS[format];
if (!exportFormat) {
return false;
}
if (format === "webp" && typeof webpExportSupported === "boolean") {
return webpExportSupported;
}
const probeCanvas = document.createElement("canvas");
const dataUrl = probeCanvas.toDataURL(exportFormat.mimeType);
const isSupported = dataUrl.startsWith(`data:${exportFormat.mimeType}`);
if (format === "webp") {
webpExportSupported = isSupported;
}
return isSupported;
}
function syncExportControls(elements) {
if (!(elements?.exportWebpEl instanceof HTMLButtonElement)) {
return;
}
const supportsWebp = isExportFormatSupported("webp");
elements.exportWebpEl.hidden = !supportsWebp;
elements.exportWebpEl.disabled = Boolean(state.exportInProgress) || !supportsWebp;
elements.exportWebpEl.textContent = state.exportInProgress && state.exportFormat === "webp"
? "Exporting..."
: "Export WebP";
if (supportsWebp) {
elements.exportWebpEl.title = "Download the current cube view as a WebP image.";
}
}
function copyComputedStyles(sourceEl, targetEl) {
if (!(sourceEl instanceof Element) || !(targetEl instanceof Element)) {
return;
}
const computedStyle = window.getComputedStyle(sourceEl);
Array.from(computedStyle).forEach((propertyName) => {
targetEl.style.setProperty(
propertyName,
computedStyle.getPropertyValue(propertyName),
computedStyle.getPropertyPriority(propertyName)
);
});
targetEl.style.setProperty("animation", "none");
targetEl.style.setProperty("transition", "none");
}
function inlineSvgStyles(sourceNode, targetNode) {
if (!(sourceNode instanceof Element) || !(targetNode instanceof Element)) {
return;
}
copyComputedStyles(sourceNode, targetNode);
const sourceChildren = Array.from(sourceNode.children);
const targetChildren = Array.from(targetNode.children);
const childCount = Math.min(sourceChildren.length, targetChildren.length);
for (let index = 0; index < childCount; index += 1) {
inlineSvgStyles(sourceChildren[index], targetChildren[index]);
}
}
function absolutizeSvgImageLinks(svgEl) {
if (!(svgEl instanceof SVGSVGElement)) {
return;
}
svgEl.querySelectorAll("image").forEach((imageEl) => {
const href = imageEl.getAttribute("href")
|| imageEl.getAttributeNS("http://www.w3.org/1999/xlink", "href");
if (!href) {
return;
}
const absoluteHref = new URL(href, document.baseURI).href;
imageEl.setAttribute("href", absoluteHref);
imageEl.setAttributeNS("http://www.w3.org/1999/xlink", "href", absoluteHref);
});
}
function prepareSvgMarkupForExport(svgEl) {
if (!(svgEl instanceof SVGSVGElement)) {
throw new Error("Cube view is not ready to export yet.");
}
const bounds = svgEl.getBoundingClientRect();
const viewBox = svgEl.viewBox?.baseVal || null;
const width = Math.max(
240,
Math.round(bounds.width),
Number.isFinite(viewBox?.width) ? Math.round(viewBox.width) : 0
);
const height = Math.max(
220,
Math.round(bounds.height),
Number.isFinite(viewBox?.height) ? Math.round(viewBox.height) : 0
);
const clone = svgEl.cloneNode(true);
if (!(clone instanceof SVGSVGElement)) {
throw new Error("Cube export could not clone the current SVG view.");
}
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
clone.setAttribute("width", String(width));
clone.setAttribute("height", String(height));
clone.setAttribute("preserveAspectRatio", "xMidYMid meet");
inlineSvgStyles(svgEl, clone);
absolutizeSvgImageLinks(clone);
const backgroundRect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
backgroundRect.setAttribute("x", "0");
backgroundRect.setAttribute("y", "0");
backgroundRect.setAttribute("width", "100%");
backgroundRect.setAttribute("height", "100%");
backgroundRect.setAttribute("fill", CUBE_EXPORT_BACKGROUND);
backgroundRect.setAttribute("pointer-events", "none");
clone.insertBefore(backgroundRect, clone.firstChild);
return {
width,
height,
markup: new XMLSerializer().serializeToString(clone)
};
}
function loadSvgImage(markup) {
return new Promise((resolve, reject) => {
const svgBlob = new Blob([markup], { type: "image/svg+xml;charset=utf-8" });
const svgUrl = URL.createObjectURL(svgBlob);
const image = new Image();
image.decoding = "async";
image.onload = () => {
URL.revokeObjectURL(svgUrl);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(svgUrl);
reject(new Error("Cube export renderer could not load the current SVG view."));
};
image.src = svgUrl;
});
}
function canvasToBlobByFormat(canvas, format) {
const exportFormat = CUBE_EXPORT_FORMATS[format];
if (!exportFormat) {
return Promise.reject(new Error("Unsupported export format."));
}
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error("Canvas export failed."));
}, exportFormat.mimeType, exportFormat.quality);
});
}
async function exportCubeView(format = "webp") {
const exportFormat = CUBE_EXPORT_FORMATS[format];
if (!exportFormat || state.exportInProgress) {
return;
}
const elements = getElements();
const svgEl = elements.viewContainerEl?.querySelector("svg.cube-svg");
if (!(svgEl instanceof SVGSVGElement)) {
window.alert("Cube view is not ready to export yet.");
return;
}
state.exportInProgress = true;
state.exportFormat = format;
syncExportControls(elements);
try {
const { width, height, markup } = prepareSvgMarkupForExport(svgEl);
const image = await loadSvgImage(markup);
const scale = Math.max(2, Math.min(4, Number(window.devicePixelRatio) || 1));
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.ceil(width * scale));
canvas.height = Math.max(1, Math.ceil(height * scale));
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Canvas context is unavailable.");
}
context.scale(scale, scale);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
context.fillStyle = CUBE_EXPORT_BACKGROUND;
context.fillRect(0, 0, width, height);
context.drawImage(image, 0, 0, width, height);
const blob = await canvasToBlobByFormat(canvas, format);
const blobUrl = URL.createObjectURL(blob);
const downloadLink = document.createElement("a");
const stamp = new Date().toISOString().slice(0, 10);
downloadLink.href = blobUrl;
downloadLink.download = `cube-of-space-${stamp}.${exportFormat.extension}`;
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
} catch (error) {
window.alert(error instanceof Error ? error.message : "Unable to export the current cube view.");
} finally {
state.exportInProgress = false;
state.exportFormat = "";
syncExportControls(getElements());
}
}
function bindRotationControls(elements) { function bindRotationControls(elements) {
if (state.controlsBound) { if (state.controlsBound) {
return; return;
@@ -358,6 +598,9 @@
state.focusMode = !state.focusMode; state.focusMode = !state.focusMode;
syncFocusControls(getElements()); syncFocusControls(getElements());
}); });
elements.exportWebpEl?.addEventListener("click", () => {
void exportCubeView("webp");
});
elements.markerModeEl?.addEventListener("change", (event) => { elements.markerModeEl?.addEventListener("change", (event) => {
const nextMode = normalizeId(event?.target?.value); const nextMode = normalizeId(event?.target?.value);
@@ -695,6 +938,7 @@
function render(elements) { function render(elements) {
syncFocusControls(elements); syncFocusControls(elements);
syncExportControls(elements);
if (elements?.markerModeEl) { if (elements?.markerModeEl) {
elements.markerModeEl.value = state.markerDisplayMode; elements.markerModeEl.value = state.markerDisplayMode;

View File

@@ -58,11 +58,22 @@
showPathNumbers: true, showPathNumbers: true,
showPathTarotCards: false, showPathTarotCards: false,
selectedSephiraNumber: null, selectedSephiraNumber: null,
selectedPathNumber: null selectedPathNumber: null,
exportInProgress: false,
exportFormat: ""
}; };
const TREE_EXPORT_FORMATS = {
webp: {
mimeType: "image/webp",
extension: "webp",
quality: 0.98
}
};
const TREE_EXPORT_BACKGROUND = "#02030a";
const kabbalahDetailUi = window.KabbalahDetailUi || {}; const kabbalahDetailUi = window.KabbalahDetailUi || {};
const kabbalahViewsUi = window.KabbalahViewsUi || {}; const kabbalahViewsUi = window.KabbalahViewsUi || {};
let webpExportSupported = null;
if ( if (
typeof kabbalahViewsUi.renderTree !== "function" typeof kabbalahViewsUi.renderTree !== "function"
@@ -259,6 +270,7 @@
pathLetterToggleEl: document.getElementById("kab-path-letter-toggle"), pathLetterToggleEl: document.getElementById("kab-path-letter-toggle"),
pathNumberToggleEl: document.getElementById("kab-path-number-toggle"), pathNumberToggleEl: document.getElementById("kab-path-number-toggle"),
pathTarotToggleEl: document.getElementById("kab-path-tarot-toggle"), pathTarotToggleEl: document.getElementById("kab-path-tarot-toggle"),
treeExportWebpEl: document.getElementById("kab-tree-export-webp"),
roseCrossContainerEl: document.getElementById("kab-rose-cross-container"), roseCrossContainerEl: document.getElementById("kab-rose-cross-container"),
roseDetailNameEl: document.getElementById("kab-rose-detail-name"), roseDetailNameEl: document.getElementById("kab-rose-detail-name"),
roseDetailSubEl: document.getElementById("kab-rose-detail-sub"), roseDetailSubEl: document.getElementById("kab-rose-detail-sub"),
@@ -476,6 +488,233 @@
kabbalahViewsUi.renderTree(getViewRenderContext(elements)); kabbalahViewsUi.renderTree(getViewRenderContext(elements));
} }
function isExportFormatSupported(format) {
const exportFormat = TREE_EXPORT_FORMATS[format];
if (!exportFormat) {
return false;
}
if (format === "webp" && typeof webpExportSupported === "boolean") {
return webpExportSupported;
}
const probeCanvas = document.createElement("canvas");
const dataUrl = probeCanvas.toDataURL(exportFormat.mimeType);
const isSupported = dataUrl.startsWith(`data:${exportFormat.mimeType}`);
if (format === "webp") {
webpExportSupported = isSupported;
}
return isSupported;
}
function syncExportControls(elements) {
if (!(elements?.treeExportWebpEl instanceof HTMLButtonElement)) {
return;
}
const supportsWebp = isExportFormatSupported("webp");
elements.treeExportWebpEl.hidden = !supportsWebp;
elements.treeExportWebpEl.disabled = Boolean(state.exportInProgress) || !supportsWebp;
elements.treeExportWebpEl.textContent = state.exportInProgress && state.exportFormat === "webp"
? "Exporting..."
: "Export WebP";
if (supportsWebp) {
elements.treeExportWebpEl.title = "Download the current Tree of Life view as a WebP image.";
}
}
function copyComputedStyles(sourceEl, targetEl) {
if (!(sourceEl instanceof Element) || !(targetEl instanceof Element)) {
return;
}
const computedStyle = window.getComputedStyle(sourceEl);
Array.from(computedStyle).forEach((propertyName) => {
targetEl.style.setProperty(
propertyName,
computedStyle.getPropertyValue(propertyName),
computedStyle.getPropertyPriority(propertyName)
);
});
targetEl.style.setProperty("animation", "none");
targetEl.style.setProperty("transition", "none");
}
function inlineSvgStyles(sourceNode, targetNode) {
if (!(sourceNode instanceof Element) || !(targetNode instanceof Element)) {
return;
}
copyComputedStyles(sourceNode, targetNode);
const sourceChildren = Array.from(sourceNode.children);
const targetChildren = Array.from(targetNode.children);
const childCount = Math.min(sourceChildren.length, targetChildren.length);
for (let index = 0; index < childCount; index += 1) {
inlineSvgStyles(sourceChildren[index], targetChildren[index]);
}
}
function absolutizeSvgImageLinks(svgEl) {
if (!(svgEl instanceof SVGSVGElement)) {
return;
}
svgEl.querySelectorAll("image").forEach((imageEl) => {
const href = imageEl.getAttribute("href")
|| imageEl.getAttributeNS("http://www.w3.org/1999/xlink", "href");
if (!href) {
return;
}
const absoluteHref = new URL(href, document.baseURI).href;
imageEl.setAttribute("href", absoluteHref);
imageEl.setAttributeNS("http://www.w3.org/1999/xlink", "href", absoluteHref);
});
}
function prepareSvgMarkupForExport(svgEl) {
if (!(svgEl instanceof SVGSVGElement)) {
throw new Error("Tree view is not ready to export yet.");
}
const bounds = svgEl.getBoundingClientRect();
const viewBox = svgEl.viewBox?.baseVal || null;
const width = Math.max(
240,
Math.round(bounds.width),
Number.isFinite(viewBox?.width) ? Math.round(viewBox.width) : 0
);
const height = Math.max(
470,
Math.round(bounds.height),
Number.isFinite(viewBox?.height) ? Math.round(viewBox.height) : 0
);
const clone = svgEl.cloneNode(true);
if (!(clone instanceof SVGSVGElement)) {
throw new Error("Tree export could not clone the current SVG view.");
}
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
clone.setAttribute("width", String(width));
clone.setAttribute("height", String(height));
clone.setAttribute("preserveAspectRatio", "xMidYMid meet");
inlineSvgStyles(svgEl, clone);
absolutizeSvgImageLinks(clone);
const backgroundRect = document.createElementNS(NS, "rect");
backgroundRect.setAttribute("x", "0");
backgroundRect.setAttribute("y", "0");
backgroundRect.setAttribute("width", "100%");
backgroundRect.setAttribute("height", "100%");
backgroundRect.setAttribute("fill", TREE_EXPORT_BACKGROUND);
backgroundRect.setAttribute("pointer-events", "none");
clone.insertBefore(backgroundRect, clone.firstChild);
return {
width,
height,
markup: new XMLSerializer().serializeToString(clone)
};
}
function loadSvgImage(markup) {
return new Promise((resolve, reject) => {
const svgBlob = new Blob([markup], { type: "image/svg+xml;charset=utf-8" });
const svgUrl = URL.createObjectURL(svgBlob);
const image = new Image();
image.decoding = "async";
image.onload = () => {
URL.revokeObjectURL(svgUrl);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(svgUrl);
reject(new Error("Tree export renderer could not load the current SVG view."));
};
image.src = svgUrl;
});
}
function canvasToBlobByFormat(canvas, format) {
const exportFormat = TREE_EXPORT_FORMATS[format];
if (!exportFormat) {
return Promise.reject(new Error("Unsupported export format."));
}
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error("Canvas export failed."));
}, exportFormat.mimeType, exportFormat.quality);
});
}
async function exportTreeView(format = "webp") {
const exportFormat = TREE_EXPORT_FORMATS[format];
if (!exportFormat || state.exportInProgress) {
return;
}
const elements = getElements();
const svgEl = elements.treeContainerEl?.querySelector("svg.kab-svg");
if (!(svgEl instanceof SVGSVGElement)) {
window.alert("Tree view is not ready to export yet.");
return;
}
state.exportInProgress = true;
state.exportFormat = format;
syncExportControls(elements);
try {
const { width, height, markup } = prepareSvgMarkupForExport(svgEl);
const image = await loadSvgImage(markup);
const scale = Math.max(2, Math.min(4, Number(window.devicePixelRatio) || 1));
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.ceil(width * scale));
canvas.height = Math.max(1, Math.ceil(height * scale));
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Canvas context is unavailable.");
}
context.scale(scale, scale);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
context.fillStyle = TREE_EXPORT_BACKGROUND;
context.fillRect(0, 0, width, height);
context.drawImage(image, 0, 0, width, height);
const blob = await canvasToBlobByFormat(canvas, format);
const blobUrl = URL.createObjectURL(blob);
const downloadLink = document.createElement("a");
const stamp = new Date().toISOString().slice(0, 10);
downloadLink.href = blobUrl;
downloadLink.download = `tree-of-life-${stamp}.${exportFormat.extension}`;
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
} catch (error) {
window.alert(error instanceof Error ? error.message : "Unable to export the current Tree of Life view.");
} finally {
state.exportInProgress = false;
state.exportFormat = "";
syncExportControls(getElements());
}
}
function renderCurrentSelection(elements) { function renderCurrentSelection(elements) {
if (!state.tree) { if (!state.tree) {
return; return;
@@ -538,6 +777,14 @@
bindPathDisplayToggle(elements.pathNumberToggleEl, "showPathNumbers"); bindPathDisplayToggle(elements.pathNumberToggleEl, "showPathNumbers");
bindPathDisplayToggle(elements.pathTarotToggleEl, "showPathTarotCards"); bindPathDisplayToggle(elements.pathTarotToggleEl, "showPathTarotCards");
syncExportControls(elements);
if (elements.treeExportWebpEl && !elements.treeExportWebpEl.dataset.bound) {
elements.treeExportWebpEl.addEventListener("click", () => {
void exportTreeView("webp");
});
elements.treeExportWebpEl.dataset.bound = "true";
}
renderTree(elements); renderTree(elements);
renderCurrentSelection(elements); renderCurrentSelection(elements);
renderRoseCross(elements); renderRoseCross(elements);

View File

@@ -46,6 +46,10 @@
} }
}); });
bindClick(elements.openTarotHouseEl, () => {
setActiveSection(getActiveSection() === "tarot-house" ? "home" : "tarot-house");
});
bindClick(elements.openAstronomyEl, () => { bindClick(elements.openAstronomyEl, () => {
setActiveSection(getActiveSection() === "astronomy" ? "home" : "astronomy"); setActiveSection(getActiveSection() === "astronomy" ? "home" : "astronomy");
}); });

View File

@@ -7,6 +7,7 @@
"calendar", "calendar",
"holidays", "holidays",
"tarot", "tarot",
"tarot-house",
"astronomy", "astronomy",
"planets", "planets",
"cycles", "cycles",
@@ -88,6 +89,8 @@
const isHolidaysOpen = activeSection === "holidays"; const isHolidaysOpen = activeSection === "holidays";
const isCalendarMenuOpen = isTimelineOpen || isCalendarOpen || isHolidaysOpen; const isCalendarMenuOpen = isTimelineOpen || isCalendarOpen || isHolidaysOpen;
const isTarotOpen = activeSection === "tarot"; const isTarotOpen = activeSection === "tarot";
const isTarotHouseOpen = activeSection === "tarot-house";
const isTarotMenuOpen = isTarotOpen || isTarotHouseOpen;
const isAstronomyOpen = activeSection === "astronomy"; const isAstronomyOpen = activeSection === "astronomy";
const isPlanetOpen = activeSection === "planets"; const isPlanetOpen = activeSection === "planets";
const isCyclesOpen = activeSection === "cycles"; const isCyclesOpen = activeSection === "cycles";
@@ -113,6 +116,7 @@
setHidden(elements.calendarSectionEl, !isCalendarOpen); setHidden(elements.calendarSectionEl, !isCalendarOpen);
setHidden(elements.holidaySectionEl, !isHolidaysOpen); setHidden(elements.holidaySectionEl, !isHolidaysOpen);
setHidden(elements.tarotSectionEl, !isTarotOpen); setHidden(elements.tarotSectionEl, !isTarotOpen);
setHidden(elements.tarotHouseSectionEl, !isTarotHouseOpen);
setHidden(elements.astronomySectionEl, !isAstronomyOpen); setHidden(elements.astronomySectionEl, !isAstronomyOpen);
setHidden(elements.planetSectionEl, !isPlanetOpen); setHidden(elements.planetSectionEl, !isPlanetOpen);
setHidden(elements.cyclesSectionEl, !isCyclesOpen); setHidden(elements.cyclesSectionEl, !isCyclesOpen);
@@ -137,7 +141,8 @@
toggleActive(elements.openCalendarTimelineEl, isTimelineOpen); toggleActive(elements.openCalendarTimelineEl, isTimelineOpen);
toggleActive(elements.openCalendarMonthsEl, isCalendarOpen); toggleActive(elements.openCalendarMonthsEl, isCalendarOpen);
toggleActive(elements.openHolidaysEl, isHolidaysOpen); toggleActive(elements.openHolidaysEl, isHolidaysOpen);
setPressed(elements.openTarotEl, isTarotOpen); setPressed(elements.openTarotEl, isTarotMenuOpen);
toggleActive(elements.openTarotHouseEl, isTarotHouseOpen);
config.tarotSpreadUi?.applyViewState?.(); config.tarotSpreadUi?.applyViewState?.();
setPressed(elements.openAstronomyEl, isAstronomyMenuOpen); setPressed(elements.openAstronomyEl, isAstronomyMenuOpen);
toggleActive(elements.openPlanetsEl, isPlanetOpen); toggleActive(elements.openPlanetsEl, isPlanetOpen);
@@ -185,6 +190,11 @@
return; return;
} }
if (isTarotHouseOpen) {
ensure.ensureTarotSection?.(referenceData, magickDataset);
return;
}
if (isPlanetOpen) { if (isPlanetOpen) {
ensure.ensurePlanetSection?.(referenceData, magickDataset); ensure.ensurePlanetSection?.(referenceData, magickDataset);
return; return;

View File

@@ -51,6 +51,7 @@
normalizeTarotCardLookupName: (value) => String(value || "").trim().toLowerCase(), normalizeTarotCardLookupName: (value) => String(value || "").trim().toLowerCase(),
selectCardById: () => {}, selectCardById: () => {},
openCardLightbox: () => {}, openCardLightbox: () => {},
shouldOpenCardLightboxOnSelect: () => false,
isHouseFocusMode: () => false, isHouseFocusMode: () => false,
getCards: () => [], getCards: () => [],
getSelectedCardId: () => "", getSelectedCardId: () => "",
@@ -730,8 +731,11 @@
} }
button.addEventListener("click", () => { button.addEventListener("click", () => {
const shouldOpenLightbox = Boolean(config.isHouseFocusMode?.())
|| Boolean(config.shouldOpenCardLightboxOnSelect?.(elements, card));
config.selectCardById(card.id, elements); config.selectCardById(card.id, elements);
if (config.isHouseFocusMode?.() === true && imageUrl) { if (shouldOpenLightbox && imageUrl) {
config.openCardLightbox?.( config.openCardLightbox?.(
imageUrl, imageUrl,
cardDisplayName || card.name || "Tarot card enlarged image", cardDisplayName || card.name || "Tarot card enlarged image",

View File

@@ -244,6 +244,8 @@
function getElements() { function getElements() {
return { return {
tarotSectionEl: document.getElementById("tarot-section"),
tarotHouseSectionEl: document.getElementById("tarot-house-section"),
tarotCardListEl: document.getElementById("tarot-card-list"), tarotCardListEl: document.getElementById("tarot-card-list"),
tarotSearchInputEl: document.getElementById("tarot-search-input"), tarotSearchInputEl: document.getElementById("tarot-search-input"),
tarotSearchClearEl: document.getElementById("tarot-search-clear"), tarotSearchClearEl: document.getElementById("tarot-search-clear"),
@@ -278,6 +280,7 @@
tarotKabPathEl: document.getElementById("tarot-kab-path"), tarotKabPathEl: document.getElementById("tarot-kab-path"),
tarotHouseOfCardsEl: document.getElementById("tarot-house-of-cards"), tarotHouseOfCardsEl: document.getElementById("tarot-house-of-cards"),
tarotBrowseViewEl: document.getElementById("tarot-browse-view"), tarotBrowseViewEl: document.getElementById("tarot-browse-view"),
tarotHouseViewEl: document.getElementById("tarot-house-view"),
tarotHouseTopCardsVisibleEl: document.getElementById("tarot-house-top-cards-visible"), tarotHouseTopCardsVisibleEl: document.getElementById("tarot-house-top-cards-visible"),
tarotHouseTopInfoHebrewEl: document.getElementById("tarot-house-top-info-hebrew"), tarotHouseTopInfoHebrewEl: document.getElementById("tarot-house-top-info-hebrew"),
tarotHouseTopInfoPlanetEl: document.getElementById("tarot-house-top-info-planet"), tarotHouseTopInfoPlanetEl: document.getElementById("tarot-house-top-info-planet"),
@@ -539,8 +542,8 @@
} }
function syncHouseControls(elements) { function syncHouseControls(elements) {
if (elements?.tarotBrowseViewEl) { if (elements?.tarotHouseViewEl) {
elements.tarotBrowseViewEl.classList.toggle("is-house-focus", Boolean(state.houseFocusMode)); elements.tarotHouseViewEl.classList.toggle("is-house-focus", Boolean(state.houseFocusMode));
} }
if (elements?.tarotHouseTopCardsVisibleEl) { if (elements?.tarotHouseTopCardsVisibleEl) {
@@ -838,6 +841,10 @@
} }
}); });
}, },
shouldOpenCardLightboxOnSelect: (latestElements) => Boolean(
latestElements?.tarotHouseSectionEl instanceof HTMLElement
&& latestElements.tarotHouseSectionEl.hidden === false
),
isHouseFocusMode: () => state.houseFocusMode, isHouseFocusMode: () => state.houseFocusMode,
getCards: () => state.cards, getCards: () => state.cards,
getSelectedCardId: () => state.selectedCardId, getSelectedCardId: () => state.selectedCardId,

View File

@@ -16,58 +16,59 @@
<link rel="stylesheet" href="node_modules/@fontsource/amiri/arabic-400.css"> <link rel="stylesheet" href="node_modules/@fontsource/amiri/arabic-400.css">
<link rel="stylesheet" href="node_modules/@fontsource/amiri/arabic-700.css"> <link rel="stylesheet" href="node_modules/@fontsource/amiri/arabic-700.css">
<link rel="stylesheet" href="node_modules/@fontsource/noto-naskh-arabic/arabic-400.css"> <link rel="stylesheet" href="node_modules/@fontsource/noto-naskh-arabic/arabic-400.css">
<link rel="stylesheet" href="app/styles.css?v=20260312-global-search-04"> <link rel="stylesheet" href="app/styles.css?v=20260312-house-cube-01">
</head> </head>
<body> <body>
<div class="topbar"> <div class="topbar">
<button id="open-home" class="topbar-home-button" type="button" aria-pressed="true">Tarot Time!</button> <button id="open-home" class="topbar-home-button" type="button" aria-pressed="true">Tarot Time!</button>
<button id="topbar-menu-toggle" class="topbar-menu-toggle" type="button" aria-expanded="false" aria-controls="topbar-actions" aria-label="Open navigation menu">Menu</button> <button id="topbar-menu-toggle" class="topbar-menu-toggle" type="button" aria-expanded="false" aria-controls="topbar-actions" aria-label="Open navigation menu">Menu</button>
<div id="topbar-actions" class="topbar-actions"> <div id="topbar-actions" class="topbar-actions">
<div class="topbar-dropdown" aria-label="Calendar menu"> <div class="topbar-dropdown" aria-label="Alphabet menu">
<button id="open-calendar" class="settings-trigger" type="button" aria-pressed="false" aria-haspopup="menu" aria-controls="calendar-subpages" aria-expanded="false">Calendar</button> <button id="open-alphabet" class="settings-trigger" type="button" aria-pressed="false" aria-haspopup="menu" aria-controls="alphabet-subpages" aria-expanded="false">Alphabet</button>
<div id="calendar-subpages" class="topbar-dropdown-menu" role="menu" aria-label="Calendar subpages"> <div id="alphabet-subpages" class="topbar-dropdown-menu" role="menu" aria-label="Alphabet subpages">
<button id="open-calendar-timeline" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Timeline</button> <button id="open-alphabet-letters" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Letter Page</button>
<button id="open-calendar-months" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Months</button> <button id="open-alphabet-text" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Text</button>
<button id="open-holidays" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Holidays</button>
</div>
</div>
<div class="topbar-dropdown" aria-label="Tarot menu">
<button id="open-tarot" class="settings-trigger" type="button" aria-pressed="false" aria-haspopup="menu" aria-controls="tarot-subpages" aria-expanded="false">Tarot ▾</button>
<div id="tarot-subpages" class="topbar-dropdown-menu" role="menu" aria-label="Tarot subpages">
<button id="open-tarot-cards" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Cards</button>
<button id="open-tarot-spread" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Draw Spread</button>
</div> </div>
</div> </div>
<div class="topbar-dropdown" aria-label="Astronomy menu"> <div class="topbar-dropdown" aria-label="Astronomy menu">
<button id="open-astronomy" class="settings-trigger" type="button" aria-pressed="false" aria-haspopup="menu" aria-controls="astronomy-subpages" aria-expanded="false">Astronomy ▾</button> <button id="open-astronomy" class="settings-trigger" type="button" aria-pressed="false" aria-haspopup="menu" aria-controls="astronomy-subpages" aria-expanded="false">Astronomy ▾</button>
<div id="astronomy-subpages" class="topbar-dropdown-menu" role="menu" aria-label="Astronomy subpages"> <div id="astronomy-subpages" class="topbar-dropdown-menu" role="menu" aria-label="Astronomy subpages">
<button id="open-planets" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Planet</button>
<button id="open-cycles" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Cycles</button> <button id="open-cycles" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Cycles</button>
<button id="open-zodiac" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Zodiac</button>
<button id="open-natal" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Natal Chart</button> <button id="open-natal" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Natal Chart</button>
<button id="open-planets" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Planet</button>
<button id="open-zodiac" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Zodiac</button>
</div>
</div>
<div class="topbar-dropdown" aria-label="Calendar menu">
<button id="open-calendar" class="settings-trigger" type="button" aria-pressed="false" aria-haspopup="menu" aria-controls="calendar-subpages" aria-expanded="false">Calendar ▾</button>
<div id="calendar-subpages" class="topbar-dropdown-menu" role="menu" aria-label="Calendar subpages">
<button id="open-holidays" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Holidays</button>
<button id="open-calendar-months" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Months</button>
<button id="open-calendar-timeline" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Timeline</button>
</div> </div>
</div> </div>
<button id="open-elements" class="settings-trigger" type="button" aria-pressed="false">Elements</button> <button id="open-elements" class="settings-trigger" type="button" aria-pressed="false">Elements</button>
<button id="open-enochian" class="settings-trigger" type="button" aria-pressed="false">Enochian</button>
<button id="open-gods" class="settings-trigger" type="button" aria-pressed="false">Gods</button>
<button id="open-iching" class="settings-trigger" type="button" aria-pressed="false">I Ching</button> <button id="open-iching" class="settings-trigger" type="button" aria-pressed="false">I Ching</button>
<div class="topbar-dropdown" aria-label="Kabbalah menu"> <div class="topbar-dropdown" aria-label="Kabbalah menu">
<button id="open-kabbalah" class="settings-trigger" type="button" aria-pressed="false" aria-haspopup="menu" aria-controls="kabbalah-subpages" aria-expanded="false">Kabbalah ▾</button> <button id="open-kabbalah" class="settings-trigger" type="button" aria-pressed="false" aria-haspopup="menu" aria-controls="kabbalah-subpages" aria-expanded="false">Kabbalah ▾</button>
<div id="kabbalah-subpages" class="topbar-dropdown-menu" role="menu" aria-label="Kabbalah subpages"> <div id="kabbalah-subpages" class="topbar-dropdown-menu" role="menu" aria-label="Kabbalah subpages">
<button id="open-kabbalah-tree" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Tree</button>
<button id="open-kabbalah-cube" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Cube</button> <button id="open-kabbalah-cube" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Cube</button>
</div> <button id="open-kabbalah-tree" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Tree</button>
</div>
<div class="topbar-dropdown" aria-label="Alphabet menu">
<button id="open-alphabet" class="settings-trigger" type="button" aria-pressed="false" aria-haspopup="menu" aria-controls="alphabet-subpages" aria-expanded="false">Alphabet ▾</button>
<div id="alphabet-subpages" class="topbar-dropdown-menu" role="menu" aria-label="Alphabet subpages">
<button id="open-alphabet-text" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Text</button>
<button id="open-alphabet-letters" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Letter Page</button>
</div> </div>
</div> </div>
<button id="open-numbers" class="settings-trigger" type="button" aria-pressed="false">Numbers</button> <button id="open-numbers" class="settings-trigger" type="button" aria-pressed="false">Numbers</button>
<button id="open-quiz" class="settings-trigger" type="button" aria-pressed="false">Quiz</button> <button id="open-quiz" class="settings-trigger" type="button" aria-pressed="false">Quiz</button>
<button id="open-gods" class="settings-trigger" type="button" aria-pressed="false">Gods</button>
<button id="open-enochian" class="settings-trigger" type="button" aria-pressed="false">Enochian</button>
<button id="open-settings" class="settings-trigger" type="button" aria-haspopup="dialog" aria-expanded="false">Settings</button> <button id="open-settings" class="settings-trigger" type="button" aria-haspopup="dialog" aria-expanded="false">Settings</button>
<div class="topbar-dropdown" aria-label="Tarot menu">
<button id="open-tarot" class="settings-trigger" type="button" aria-pressed="false" aria-haspopup="menu" aria-controls="tarot-subpages" aria-expanded="false">Tarot ▾</button>
<div id="tarot-subpages" class="topbar-dropdown-menu" role="menu" aria-label="Tarot subpages">
<button id="open-tarot-cards" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Cards</button>
<button id="open-tarot-spread" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Draw Spread</button>
<button id="open-tarot-house" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">House</button>
</div>
</div>
</div> </div>
</div> </div>
<div id="connection-gate" class="connection-gate" hidden> <div id="connection-gate" class="connection-gate" hidden>
@@ -203,73 +204,6 @@
</section> </section>
<section id="tarot-section" hidden> <section id="tarot-section" hidden>
<div id="tarot-browse-view"> <div id="tarot-browse-view">
<section class="tarot-misc-section tarot-section-house-top" aria-label="Tarot misc">
<div class="tarot-meta-card tarot-house-card">
<div class="tarot-house-card-head">
<strong>House of Cards</strong>
<div class="tarot-house-card-actions">
<label class="tarot-house-toggle" for="tarot-house-top-cards-visible">
<input id="tarot-house-top-cards-visible" type="checkbox" checked>
<span>Show Top Cards</span>
</label>
<fieldset class="tarot-house-filter-group">
<legend>Top Info</legend>
<label class="tarot-house-mini-toggle" for="tarot-house-top-info-hebrew">
<input id="tarot-house-top-info-hebrew" type="checkbox" checked>
<span>Hebrew</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-top-info-planet">
<input id="tarot-house-top-info-planet" type="checkbox" checked>
<span>Planet</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-top-info-zodiac">
<input id="tarot-house-top-info-zodiac" type="checkbox" checked>
<span>Zodiac</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-top-info-trump">
<input id="tarot-house-top-info-trump" type="checkbox" checked>
<span>Trump</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-top-info-path">
<input id="tarot-house-top-info-path" type="checkbox" checked>
<span>Path</span>
</label>
</fieldset>
<label class="tarot-house-toggle" for="tarot-house-bottom-cards-visible">
<input id="tarot-house-bottom-cards-visible" type="checkbox" checked>
<span>Show Bottom Cards</span>
</label>
<fieldset class="tarot-house-filter-group">
<legend>Bottom Info</legend>
<label class="tarot-house-mini-toggle" for="tarot-house-bottom-info-zodiac">
<input id="tarot-house-bottom-info-zodiac" type="checkbox" checked>
<span>Sign</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-bottom-info-decan">
<input id="tarot-house-bottom-info-decan" type="checkbox" checked>
<span>Decan</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-bottom-info-month">
<input id="tarot-house-bottom-info-month" type="checkbox" checked>
<span>Month</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-bottom-info-ruler">
<input id="tarot-house-bottom-info-ruler" type="checkbox" checked>
<span>Ruler</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-bottom-info-date">
<input id="tarot-house-bottom-info-date" type="checkbox">
<span>Date</span>
</label>
</fieldset>
<button id="tarot-house-focus-toggle" class="tarot-house-action-btn" type="button" aria-pressed="false">Focus House</button>
<button id="tarot-house-export" class="tarot-house-action-btn" type="button">Export PNG</button>
<button id="tarot-house-export-webp" class="tarot-house-action-btn" type="button">Export WebP</button>
</div>
</div>
<div id="tarot-house-of-cards" class="tarot-house-layout" aria-live="polite"></div>
</div>
</section>
<div class="tarot-layout"> <div class="tarot-layout">
<aside class="tarot-list-panel"> <aside class="tarot-list-panel">
<div class="tarot-list-header"> <div class="tarot-list-header">
@@ -365,6 +299,77 @@
<div id="tarot-spread-board" class="tarot-spread-board" aria-live="polite"></div> <div id="tarot-spread-board" class="tarot-spread-board" aria-live="polite"></div>
</div> </div>
</section> </section>
<section id="tarot-house-section" hidden>
<div id="tarot-house-view" class="tarot-house-view">
<section class="tarot-misc-section tarot-section-house-top tarot-section-house-page" aria-label="Tarot house of cards">
<div class="tarot-meta-card tarot-house-card">
<div class="tarot-house-card-head">
<strong>House of Cards</strong>
<div class="tarot-house-card-actions">
<label class="tarot-house-toggle" for="tarot-house-top-cards-visible">
<input id="tarot-house-top-cards-visible" type="checkbox" checked>
<span>Show Top Cards</span>
</label>
<fieldset class="tarot-house-filter-group">
<legend>Top Info</legend>
<label class="tarot-house-mini-toggle" for="tarot-house-top-info-hebrew">
<input id="tarot-house-top-info-hebrew" type="checkbox" checked>
<span>Hebrew</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-top-info-planet">
<input id="tarot-house-top-info-planet" type="checkbox" checked>
<span>Planet</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-top-info-zodiac">
<input id="tarot-house-top-info-zodiac" type="checkbox" checked>
<span>Zodiac</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-top-info-trump">
<input id="tarot-house-top-info-trump" type="checkbox" checked>
<span>Trump</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-top-info-path">
<input id="tarot-house-top-info-path" type="checkbox" checked>
<span>Path</span>
</label>
</fieldset>
<label class="tarot-house-toggle" for="tarot-house-bottom-cards-visible">
<input id="tarot-house-bottom-cards-visible" type="checkbox" checked>
<span>Show Bottom Cards</span>
</label>
<fieldset class="tarot-house-filter-group">
<legend>Bottom Info</legend>
<label class="tarot-house-mini-toggle" for="tarot-house-bottom-info-zodiac">
<input id="tarot-house-bottom-info-zodiac" type="checkbox" checked>
<span>Sign</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-bottom-info-decan">
<input id="tarot-house-bottom-info-decan" type="checkbox" checked>
<span>Decan</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-bottom-info-month">
<input id="tarot-house-bottom-info-month" type="checkbox" checked>
<span>Month</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-bottom-info-ruler">
<input id="tarot-house-bottom-info-ruler" type="checkbox" checked>
<span>Ruler</span>
</label>
<label class="tarot-house-mini-toggle" for="tarot-house-bottom-info-date">
<input id="tarot-house-bottom-info-date" type="checkbox">
<span>Date</span>
</label>
</fieldset>
<button id="tarot-house-focus-toggle" class="tarot-house-action-btn" type="button" aria-pressed="false">Focus House</button>
<button id="tarot-house-export" class="tarot-house-action-btn" type="button">Export PNG</button>
<button id="tarot-house-export-webp" class="tarot-house-action-btn" type="button">Export WebP</button>
</div>
</div>
<div id="tarot-house-of-cards" class="tarot-house-layout" aria-live="polite"></div>
</div>
</section>
</div>
</section>
<section id="planet-section" hidden> <section id="planet-section" hidden>
<div class="planet-layout"> <div class="planet-layout">
<aside class="planet-list-panel"> <aside class="planet-list-panel">
@@ -612,6 +617,7 @@
<input id="kab-path-tarot-toggle" type="checkbox"> <input id="kab-path-tarot-toggle" type="checkbox">
<span>Show Tarot cards on paths</span> <span>Show Tarot cards on paths</span>
</label> </label>
<button id="kab-tree-export-webp" class="kab-export-btn" type="button">Export WebP</button>
</div> </div>
<div id="kab-tree-container" class="kab-tree-container"></div> <div id="kab-tree-container" class="kab-tree-container"></div>
</aside> </aside>
@@ -770,6 +776,7 @@
<button id="cube-rotate-down" class="cube-rotation-btn" type="button" title="Rotate down"></button> <button id="cube-rotate-down" class="cube-rotation-btn" type="button" title="Rotate down"></button>
<button id="cube-rotate-reset" class="cube-rotation-btn" type="button" title="Reset rotation">Reset</button> <button id="cube-rotate-reset" class="cube-rotation-btn" type="button" title="Reset rotation">Reset</button>
<button id="cube-focus-toggle" class="cube-rotation-btn cube-focus-toggle" type="button" aria-pressed="false">Focus Cube</button> <button id="cube-focus-toggle" class="cube-rotation-btn cube-focus-toggle" type="button" aria-pressed="false">Focus Cube</button>
<button id="cube-export-webp" class="cube-rotation-btn cube-export-btn" type="button">Export WebP</button>
<div class="cube-marker-mode-control"> <div class="cube-marker-mode-control">
<label for="cube-marker-mode" class="cube-marker-mode-label">Marker display</label> <label for="cube-marker-mode" class="cube-marker-mode-label">Marker display</label>
<select id="cube-marker-mode" class="cube-marker-mode-select" aria-label="Cube marker display mode"> <select id="cube-marker-mode" class="cube-marker-mode-select" aria-label="Cube marker display mode">
@@ -956,7 +963,7 @@
<script src="app/calendar-events.js"></script> <script src="app/calendar-events.js"></script>
<script src="app/card-images.js?v=20260309-gate"></script> <script src="app/card-images.js?v=20260309-gate"></script>
<script src="app/ui-tarot-lightbox.js?v=20260312-compare-zoom-01"></script> <script src="app/ui-tarot-lightbox.js?v=20260312-compare-zoom-01"></script>
<script src="app/ui-tarot-house.js?v=20260307b"></script> <script src="app/ui-tarot-house.js?v=20260312-house-cube-01"></script>
<script src="app/ui-tarot-relations.js"></script> <script src="app/ui-tarot-relations.js"></script>
<script src="app/ui-now-helpers.js"></script> <script src="app/ui-now-helpers.js"></script>
<script src="app/ui-now.js"></script> <script src="app/ui-now.js"></script>
@@ -975,7 +982,7 @@
<script src="app/ui-tarot-card-derivations.js?v=20260307b"></script> <script src="app/ui-tarot-card-derivations.js?v=20260307b"></script>
<script src="app/ui-tarot-detail.js?v=20260307b"></script> <script src="app/ui-tarot-detail.js?v=20260307b"></script>
<script src="app/ui-tarot-relation-display.js?v=20260307b"></script> <script src="app/ui-tarot-relation-display.js?v=20260307b"></script>
<script src="app/ui-tarot.js?v=20260307b"></script> <script src="app/ui-tarot.js?v=20260312-house-cube-01"></script>
<script src="app/ui-planets-references.js"></script> <script src="app/ui-planets-references.js"></script>
<script src="app/ui-planets.js"></script> <script src="app/ui-planets.js"></script>
<script src="app/ui-cycles.js"></script> <script src="app/ui-cycles.js"></script>
@@ -985,12 +992,12 @@
<script src="app/ui-rosicrucian-cross.js"></script> <script src="app/ui-rosicrucian-cross.js"></script>
<script src="app/ui-kabbalah-detail.js"></script> <script src="app/ui-kabbalah-detail.js"></script>
<script src="app/ui-kabbalah-views.js"></script> <script src="app/ui-kabbalah-views.js"></script>
<script src="app/ui-kabbalah.js"></script> <script src="app/ui-kabbalah.js?v=20260312-tree-export-01"></script>
<script src="app/ui-cube-detail.js"></script> <script src="app/ui-cube-detail.js"></script>
<script src="app/ui-cube-chassis.js"></script> <script src="app/ui-cube-chassis.js"></script>
<script src="app/ui-cube-math.js"></script> <script src="app/ui-cube-math.js"></script>
<script src="app/ui-cube-selection.js"></script> <script src="app/ui-cube-selection.js"></script>
<script src="app/ui-cube.js?v=20260310-cube-focus-01"></script> <script src="app/ui-cube.js?v=20260312-house-cube-01"></script>
<script src="app/ui-alphabet-gematria.js?v=20260308b"></script> <script src="app/ui-alphabet-gematria.js?v=20260308b"></script>
<script src="app/ui-alphabet-browser.js?v=20260309-enochian-api"></script> <script src="app/ui-alphabet-browser.js?v=20260309-enochian-api"></script>
<script src="app/ui-alphabet-references.js"></script> <script src="app/ui-alphabet-references.js"></script>
@@ -1014,13 +1021,13 @@
<script src="app/ui-numbers.js"></script> <script src="app/ui-numbers.js"></script>
<script src="app/ui-tarot-spread.js"></script> <script src="app/ui-tarot-spread.js"></script>
<script src="app/ui-settings.js?v=20260309-gate"></script> <script src="app/ui-settings.js?v=20260309-gate"></script>
<script src="app/ui-chrome.js?v=20260312-panel-toggle-02"></script> <script src="app/ui-chrome.js?v=20260312-menu-unify-01"></script>
<script src="app/ui-navigation.js?v=20260309-alphabet-text-01"></script> <script src="app/ui-navigation.js?v=20260312-house-cube-01"></script>
<script src="app/ui-calendar-formatting.js?v=20260307b"></script> <script src="app/ui-calendar-formatting.js?v=20260307b"></script>
<script src="app/ui-calendar-visuals.js?v=20260307b"></script> <script src="app/ui-calendar-visuals.js?v=20260307b"></script>
<script src="app/ui-home-calendar.js"></script> <script src="app/ui-home-calendar.js"></script>
<script src="app/ui-section-state.js?v=20260309-alphabet-text-01"></script> <script src="app/ui-section-state.js?v=20260312-house-cube-01"></script>
<script src="app/app-runtime.js?v=20260309-gate"></script> <script src="app/app-runtime.js?v=20260309-gate"></script>
<script src="app.js?v=20260309-alphabet-text-01"></script> <script src="app.js?v=20260312-house-cube-01"></script>
</body> </body>
</html> </html>