update ui and add new audio components

This commit is contained in:
2026-03-14 00:45:15 -07:00
parent aa3f23c92c
commit 843c2fe96f
13 changed files with 2458 additions and 155 deletions

22
app.js
View File

@@ -6,6 +6,8 @@ const { ensureTarotSection } = window.TarotSectionUi || {};
const { ensurePlanetSection } = window.PlanetSectionUi || {};
const { ensureCyclesSection } = window.CyclesSectionUi || {};
const { ensureElementsSection } = window.ElementsSectionUi || {};
const { ensureAudioCircleSection } = window.AudioCircleUi || {};
const { ensureAudioNotesSection } = window.AudioNotesUi || {};
const { ensureIChingSection } = window.IChingSectionUi || {};
const { ensureKabbalahSection } = window.KabbalahSectionUi || {};
const { ensureCubeSection } = window.CubeSectionUi || {};
@@ -35,6 +37,8 @@ const calendarEl = document.getElementById("calendar");
const timelineSectionEl = document.getElementById("timeline-section");
const calendarSectionEl = document.getElementById("calendar-section");
const holidaySectionEl = document.getElementById("holiday-section");
const audioCircleSectionEl = document.getElementById("audio-circle-section");
const audioNotesSectionEl = document.getElementById("audio-notes-section");
const tarotSectionEl = document.getElementById("tarot-section");
const tarotHouseSectionEl = document.getElementById("tarot-house-section");
const astronomySectionEl = document.getElementById("astronomy-section");
@@ -59,6 +63,9 @@ const openCalendarEl = document.getElementById("open-calendar");
const openCalendarTimelineEl = document.getElementById("open-calendar-timeline");
const openCalendarMonthsEl = document.getElementById("open-calendar-months");
const openHolidaysEl = document.getElementById("open-holidays");
const openAudioEl = document.getElementById("open-audio");
const openAudioCircleEl = document.getElementById("open-audio-circle");
const openAudioNotesEl = document.getElementById("open-audio-notes");
const openTarotEl = document.getElementById("open-tarot");
const openTarotHouseEl = document.getElementById("open-tarot-house");
const openAstronomyEl = document.getElementById("open-astronomy");
@@ -400,6 +407,8 @@ sectionStateUi.init?.({
timelineSectionEl,
calendarSectionEl,
holidaySectionEl,
audioCircleSectionEl,
audioNotesSectionEl,
tarotSectionEl,
tarotHouseSectionEl,
astronomySectionEl,
@@ -424,6 +433,9 @@ sectionStateUi.init?.({
openCalendarTimelineEl,
openCalendarMonthsEl,
openHolidaysEl,
openAudioEl,
openAudioCircleEl,
openAudioNotesEl,
openTarotEl,
openTarotHouseEl,
openAstronomyEl,
@@ -461,7 +473,9 @@ sectionStateUi.init?.({
ensureCalendarSection,
ensureHolidaySection,
ensureNatalPanel,
ensureNumbersSection
ensureNumbersSection,
ensureAudioCircleSection,
ensureAudioNotesSection
}
});
@@ -520,6 +534,9 @@ navigationUi.init?.({
openCalendarTimelineEl,
openCalendarMonthsEl,
openHolidaysEl,
openAudioEl,
openAudioCircleEl,
openAudioNotesEl,
openTarotEl,
openTarotHouseEl,
openAstronomyEl,
@@ -552,7 +569,8 @@ navigationUi.init?.({
ensureAlphabetTextSection,
ensureZodiacSection,
ensureGodsSection,
ensureCalendarSection
ensureCalendarSection,
ensureAudioCircleSection
}
});

View File

@@ -5,6 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Stellarium NOW Wrapper</title>
<style>
:root {
--sky-embed-width: 100%;
--sky-embed-height: 106%;
--sky-embed-left: 0%;
--sky-embed-top: -2%;
--sky-brand-mask-width: 0%;
}
html,
body {
width: 100%;
@@ -32,14 +40,45 @@
#sky-embed {
position: absolute;
inset: 0;
width: 100%;
height: 106%;
top: -2%;
left: var(--sky-embed-left);
top: var(--sky-embed-top);
width: var(--sky-embed-width);
height: var(--sky-embed-height);
border: 0;
background: #000;
}
#sky-brand-mask {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: var(--sky-brand-mask-width);
pointer-events: none;
background: linear-gradient(90deg, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.96) 58%, rgba(0, 0, 0, 0) 100%);
z-index: 3;
}
@media (max-width: 760px) {
:root {
--sky-embed-width: 122%;
--sky-embed-height: 112%;
--sky-embed-left: -10%;
--sky-embed-top: -3%;
--sky-brand-mask-width: 12%;
}
}
@media (max-width: 480px) {
:root {
--sky-embed-width: 126%;
--sky-embed-height: 116%;
--sky-embed-left: -12%;
--sky-embed-top: -4%;
--sky-brand-mask-width: 15%;
}
}
#sky-shell::before {
content: "";
position: absolute;
@@ -76,6 +115,7 @@
<body>
<div id="sky-shell">
<iframe id="sky-embed" title="Decorative sky background" scrolling="no" allow="geolocation"></iframe>
<div id="sky-brand-mask" aria-hidden="true"></div>
</div>
<script>
(function () {

View File

@@ -137,6 +137,10 @@
background: #3f3f46;
}
@media (max-width: 900px) {
body.topbar-menu-open {
overflow: hidden;
}
.topbar {
position: sticky;
top: 0;
@@ -156,9 +160,34 @@
min-height: 38px;
margin-left: auto;
}
.topbar.is-menu-open {
flex-wrap: nowrap;
justify-content: flex-end;
border-bottom-color: transparent;
}
.topbar.is-menu-open .topbar-home-button {
display: none;
}
.topbar-actions {
max-height: calc(100svh - 88px);
}
.topbar.is-menu-open .topbar-actions {
position: absolute;
top: calc(100% - 1px);
left: 0;
right: 0;
width: auto;
min-height: calc(100svh - 58px);
max-height: calc(100svh - 58px);
margin: 0;
padding: 14px 12px calc(18px + env(safe-area-inset-bottom, 0px));
border-radius: 0 0 18px 18px;
border-left: 0;
border-right: 0;
border-bottom: 0;
box-shadow: 0 22px 44px rgba(0, 0, 0, 0.44);
overflow-y: auto;
}
.settings-trigger {
padding: 10px 12px;
}
@@ -175,6 +204,12 @@
border-radius: 14px;
max-height: calc(100svh - 78px);
}
.topbar.is-menu-open .topbar-actions {
min-height: calc(100svh - 52px);
max-height: calc(100svh - 52px);
padding: 12px 10px calc(18px + env(safe-area-inset-bottom, 0px));
border-radius: 0;
}
.settings-trigger {
font-size: 13px;
}
@@ -3338,7 +3373,7 @@
}
.alpha-text-controls--heading {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
padding: 14px;
border: 1px solid #2f2f39;
border-radius: 14px;
@@ -3398,6 +3433,14 @@
gap: 4px;
}
.alpha-text-control[hidden] {
display: none !important;
}
.alpha-text-control--toggle {
align-content: start;
}
.alpha-text-control > span {
color: #a1a1aa;
font-size: 11px;
@@ -3416,6 +3459,11 @@
font-size: 13px;
}
.alpha-text-compare-toggle {
min-height: 42px;
justify-content: center;
}
.alpha-text-search-input {
width: 100%;
min-height: 42px;
@@ -3482,6 +3530,10 @@
.alpha-text-heading-tools {
grid-template-columns: 1fr;
}
.alpha-text-reader-compare {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
@@ -3572,6 +3624,17 @@
gap: 0;
}
.alpha-text-reader-compare {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
min-width: 0;
}
.alpha-text-reader-card--compare {
min-width: 0;
}
.alpha-text-reader-navigation {
display: flex;
gap: 8px;
@@ -4210,6 +4273,409 @@
}
#elements-section[hidden] { display: none; }
/* ── Audio Notes section ─────────────────────────────────────────────── */
#audio-notes-section {
height: calc(100vh - 61px);
background: #18181b;
box-sizing: border-box;
overflow: hidden;
}
#audio-notes-section[hidden] { display: none; }
#audio-circle-section {
height: calc(100vh - 61px);
background: #18181b;
box-sizing: border-box;
overflow: hidden;
}
#audio-circle-section[hidden] { display: none; }
.audio-circle-layout {
height: 100%;
display: grid;
grid-template-columns: minmax(340px, 1.1fr) minmax(320px, 0.9fr);
min-height: 0;
background: #18181b;
}
.audio-circle-stage-panel {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 0;
min-height: 0;
border-right: 1px solid #27272a;
overflow: auto;
}
.audio-circle-intro {
padding: 10px 12px 0;
color: #a1a1aa;
font-size: 12px;
line-height: 1.5;
}
.audio-circle-stage {
position: relative;
min-height: 620px;
padding: 24px;
box-sizing: border-box;
background:
radial-gradient(circle at center, rgba(99, 102, 241, 0.08), transparent 46%),
linear-gradient(180deg, rgba(15, 15, 23, 0.98), rgba(10, 10, 16, 0.98));
}
.audio-circle-shell {
position: relative;
width: min(78vw, 560px);
height: min(78vw, 560px);
min-width: 280px;
min-height: 280px;
max-width: 100%;
max-height: 100%;
margin: 0 auto;
border-radius: 50%;
border: 1px solid rgba(99, 102, 241, 0.3);
background:
radial-gradient(circle at center, rgba(12, 12, 18, 0.98) 0, rgba(12, 12, 18, 0.98) 24%, rgba(39, 39, 42, 0.24) 24.4%, rgba(39, 39, 42, 0.24) 54%, rgba(15, 15, 23, 0.98) 54.4%, rgba(15, 15, 23, 0.98) 100%);
box-shadow: inset 0 0 0 1px rgba(165, 180, 252, 0.08), 0 24px 50px rgba(0, 0, 0, 0.35);
}
.audio-circle-center {
position: absolute;
left: 50%;
top: 50%;
width: 34%;
height: 34%;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 1px solid rgba(99, 102, 241, 0.24);
display: grid;
place-items: center;
text-align: center;
padding: 14px;
box-sizing: border-box;
background: linear-gradient(180deg, rgba(24, 24, 38, 0.98), rgba(10, 10, 16, 0.98));
}
.audio-circle-center-label {
color: #f4f4f5;
font-size: clamp(24px, 4vw, 36px);
font-weight: 700;
line-height: 1;
}
.audio-circle-center-sub {
color: #a1a1aa;
font-size: 11px;
line-height: 1.35;
margin-top: 6px;
}
.audio-circle-key {
position: absolute;
width: 88px;
min-height: 64px;
padding: 10px 8px;
border-radius: 16px;
border: 1px solid #3f3f46;
background: linear-gradient(180deg, rgba(24, 24, 27, 0.98), rgba(12, 12, 18, 0.98));
color: #f4f4f5;
cursor: pointer;
display: grid;
gap: 4px;
justify-items: center;
box-sizing: border-box;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.28);
transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
}
.audio-circle-key:hover {
transform: translate(-50%, -50%) scale(1.03);
border-color: #818cf8;
background: linear-gradient(180deg, rgba(49, 46, 129, 0.26), rgba(12, 12, 18, 0.98));
}
.audio-circle-key.is-selected,
.audio-circle-key[aria-pressed="true"] {
border-color: #a5b4fc;
background: linear-gradient(180deg, rgba(67, 56, 202, 0.46), rgba(18, 18, 30, 0.98));
box-shadow: 0 0 0 1px rgba(165, 180, 252, 0.22), 0 14px 24px rgba(0, 0, 0, 0.34);
}
.audio-circle-key-major {
font-size: 20px;
font-weight: 700;
line-height: 1;
}
.audio-circle-key-minor {
color: #c7d2fe;
font-size: 11px;
line-height: 1.2;
}
.audio-circle-key-meta {
color: #71717a;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
line-height: 1.1;
}
.audio-circle-detail-panel {
min-height: 0;
overflow: auto;
padding: 0 14px 14px;
box-sizing: border-box;
}
.audio-circle-detail-stack {
display: grid;
gap: 12px;
min-width: 0;
}
.audio-circle-chip-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.audio-circle-chip {
display: inline-flex;
align-items: center;
padding: 4px 9px;
border-radius: 999px;
border: 1px solid #3f3f46;
background: #18181b;
color: #d4d4d8;
font-size: 11px;
line-height: 1;
}
.audio-circle-chip--active {
border-color: #4338ca;
background: rgba(67, 56, 202, 0.18);
color: #c7d2fe;
}
.audio-circle-copy {
color: #d4d4d8;
font-size: 13px;
line-height: 1.58;
}
.audio-circle-nav-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
}
.audio-circle-nav-card {
display: grid;
gap: 6px;
padding: 10px;
border: 1px solid #2f2f39;
border-radius: 10px;
background: #0c0c12;
}
.audio-circle-nav-label {
color: #a1a1aa;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.audio-circle-nav-value {
color: #f4f4f5;
font-size: 16px;
font-weight: 700;
line-height: 1.2;
}
.audio-circle-nav-sub {
color: #c7d2fe;
font-size: 11px;
}
@media (max-width: 1040px) {
.audio-circle-layout {
grid-template-columns: minmax(0, 1fr);
}
.audio-circle-stage-panel {
border-right: 0;
border-bottom: 1px solid #27272a;
}
}
@media (max-width: 720px) {
.audio-circle-stage {
min-height: 500px;
padding: 18px 12px;
}
.audio-circle-shell {
width: min(92vw, 420px);
height: min(92vw, 420px);
}
.audio-circle-key {
width: 68px;
min-height: 52px;
padding: 8px 6px;
border-radius: 12px;
}
.audio-circle-key-major {
font-size: 16px;
}
.audio-circle-key-minor,
.audio-circle-key-meta {
font-size: 9px;
}
}
.audio-note-intro {
padding: 10px 12px 0;
color: #a1a1aa;
font-size: 12px;
line-height: 1.5;
}
.audio-note-detail-stack {
display: grid;
gap: 12px;
min-width: 0;
}
.audio-note-chip-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.audio-note-chip {
display: inline-flex;
align-items: center;
padding: 4px 9px;
border-radius: 999px;
border: 1px solid #3f3f46;
background: #18181b;
color: #d4d4d8;
font-size: 11px;
line-height: 1;
}
.audio-note-chip--natural {
border-color: #365314;
background: rgba(22, 101, 52, 0.16);
color: #bbf7d0;
}
.audio-note-chip--accidental {
border-color: #4338ca;
background: rgba(67, 56, 202, 0.18);
color: #c7d2fe;
}
.audio-note-copy {
color: #d4d4d8;
font-size: 13px;
line-height: 1.58;
}
.audio-note-formula {
color: #d4d4d8;
font-size: 13px;
line-height: 1.58;
}
.audio-note-playback-copy {
color: #d4d4d8;
font-size: 13px;
line-height: 1.58;
}
.audio-note-playback-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.audio-note-playback-btn {
min-height: 38px;
}
.audio-note-frequency-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 8px;
}
.audio-note-frequency-cell {
display: grid;
gap: 4px;
padding: 10px;
border: 1px solid #2f2f39;
border-radius: 10px;
background: #0c0c12;
}
.audio-note-frequency-cell--button {
width: 100%;
text-align: left;
cursor: pointer;
transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
}
.audio-note-frequency-cell--button:hover {
border-color: #6366f1;
background: #121224;
transform: translateY(-1px);
}
.audio-note-frequency-cell--button.is-playing,
.audio-note-frequency-cell--button[aria-pressed="true"] {
border-color: #a5b4fc;
background: linear-gradient(180deg, rgba(49, 46, 129, 0.42), rgba(12, 12, 18, 0.98));
box-shadow: 0 0 0 1px rgba(129, 140, 248, 0.26);
}
.audio-note-frequency-label {
color: #a1a1aa;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.audio-note-frequency-value {
color: #f4f4f5;
font-size: 15px;
font-weight: 700;
line-height: 1.2;
}
.audio-note-frequency-meta {
color: #71717a;
font-size: 11px;
}
.audio-note-frequency-status {
color: #c7d2fe;
font-size: 11px;
font-weight: 600;
}
@media (max-width: 720px) {
.audio-note-frequency-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.enoch-list-item {
width: 100%;
text-align: left;
@@ -4683,6 +5149,10 @@
}
#now-panel {
--now-square-size: min(85vmin, calc(100vw - 172px), calc(100svh - 92px));
--now-sky-width: max(calc(var(--now-square-size) * 3.25), calc(100% + 420px));
--now-sky-height: max(calc(var(--now-square-size) * 2.28), calc(100% + 250px));
--now-sky-top: 50.5%;
--now-sky-left: 44%;
position: relative;
overflow: hidden;
height: calc(100svh - 88px);
@@ -4701,10 +5171,10 @@
#now-sky-layer {
position: absolute;
width: max(calc(var(--now-square-size) * 3.25), calc(100% + 420px));
height: max(calc(var(--now-square-size) * 2.28), calc(100% + 250px));
top: 50.5%;
left: 44%;
width: var(--now-sky-width);
height: var(--now-sky-height);
top: var(--now-sky-top);
left: var(--now-sky-left);
transform: translate(-50%, -50%);
transform-origin: center center;
z-index: 0;
@@ -4786,6 +5256,34 @@
visibility: hidden;
pointer-events: none;
}
@media (max-width: 760px) {
#now-panel {
--now-square-size: min(88vw, calc(100svh - 150px), 560px);
--now-sky-width: max(198vw, calc(100% + 140px));
--now-sky-height: max(126svh, calc(100% + 120px));
--now-sky-top: 50.5%;
--now-sky-left: 46%;
padding: 12px clamp(12px, 4vw, 24px) 16px;
}
#now-sky-layer {
filter: saturate(1.02);
}
.now-panel-controls {
top: 12px;
right: 12px;
}
}
@media (max-width: 480px) {
#now-panel {
--now-square-size: min(90vw, calc(100svh - 158px), 480px);
--now-sky-width: max(214vw, calc(100% + 96px));
--now-sky-height: max(132svh, calc(100% + 96px));
--now-sky-left: 45%;
}
}
.now-section {
position: relative;
z-index: 1;
@@ -4952,16 +5450,36 @@
}
.now-stats-planets {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px 12px;
font-size: clamp(15px, 1.9vmin, 17px);
line-height: 1.3;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 14px;
font-size: clamp(12px, 1.6vmin, 14px);
line-height: 1.24;
color: #e2e8f0;
align-content: start;
align-items: start;
min-height: 0;
overflow: visible;
}
.now-stats-planet {
min-width: 0;
display: grid;
gap: 2px;
align-content: start;
text-align: left;
}
.now-stats-planet-sign {
font-size: 0.84em;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: inherit;
}
.now-stats-planet-list {
display: grid;
gap: 2px;
min-width: 0;
}
.now-stats-planet-entry {
white-space: normal;
overflow: visible;
text-overflow: clip;
@@ -5386,7 +5904,7 @@
width: min(64%, 122px);
}
.now-stats-planets {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.planet-layout,
.tarot-layout {
@@ -5507,6 +6025,7 @@
}
.now-stats-planets {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 5px 10px;
}
}
@@ -5532,7 +6051,8 @@
font-size: 10px;
}
.now-stats-planets {
grid-template-columns: minmax(0, 1fr);
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px 8px;
}
}

View File

@@ -6,10 +6,15 @@
const state = {
initialized: false,
catalog: null,
selectedSourceGroupId: "",
selectedSourceId: "",
selectedSourceIdByGroup: {},
compareSourceIdByGroup: {},
compareModeByGroup: {},
selectedWorkId: "",
selectedSectionId: "",
currentPassage: null,
comparePassage: null,
lexiconEntry: null,
lexiconRequestId: 0,
lexiconOccurrenceResults: null,
@@ -35,6 +40,12 @@
let globalSearchInputEl;
let localSearchFormEl;
let localSearchInputEl;
let translationSelectEl;
let translationControlEl;
let compareSelectEl;
let compareControlEl;
let compareToggleEl;
let compareToggleControlEl;
let workSelectEl;
let sectionSelectEl;
let detailHeadingEl;
@@ -63,6 +74,12 @@
globalSearchInputEl = document.getElementById("alpha-text-global-search-input");
localSearchFormEl = document.getElementById("alpha-text-local-search-form");
localSearchInputEl = document.getElementById("alpha-text-local-search-input");
translationSelectEl = document.getElementById("alpha-text-translation-select");
translationControlEl = translationSelectEl?.closest?.(".alpha-text-control") || null;
compareSelectEl = document.getElementById("alpha-text-compare-select");
compareControlEl = compareSelectEl?.closest?.(".alpha-text-control") || null;
compareToggleEl = document.getElementById("alpha-text-compare-toggle");
compareToggleControlEl = document.getElementById("alpha-text-compare-toggle-control");
workSelectEl = document.getElementById("alpha-text-work-select");
sectionSelectEl = document.getElementById("alpha-text-section-select");
detailHeadingEl = document.querySelector("#alphabet-text-section .alpha-text-detail-heading");
@@ -167,13 +184,128 @@
return Array.isArray(state.catalog?.sources) ? state.catalog.sources : [];
}
function getSourceGroupId(source) {
const metadata = getSourceMetadata(source);
return normalizeId(metadata.workKey || source?.id || source?.title);
}
function buildSourceGroups(sources) {
const groupsById = new Map();
(Array.isArray(sources) ? sources : []).forEach((source, index) => {
const groupId = getSourceGroupId(source) || `source-group-${index + 1}`;
if (!groupsById.has(groupId)) {
groupsById.set(groupId, {
id: groupId,
title: normalizeTextValue(source?.title) || normalizeTextValue(source?.shortTitle) || "Untitled Source",
order: index,
variants: []
});
}
groupsById.get(groupId).variants.push(source);
});
return [...groupsById.values()].sort((left, right) => left.order - right.order);
}
function getSourceGroups() {
return Array.isArray(state.catalog?.sourceGroups) ? state.catalog.sourceGroups : [];
}
function findById(entries, value) {
const needle = normalizeId(value);
return (Array.isArray(entries) ? entries : []).find((entry) => normalizeId(entry?.id) === needle) || null;
}
function getSelectedSourceGroup() {
return findById(getSourceGroups(), state.selectedSourceGroupId);
}
function getSourceVariants(group = getSelectedSourceGroup()) {
return Array.isArray(group?.variants) ? group.variants : [];
}
function getSourceForGroup(group = getSelectedSourceGroup(), sourceId = state.selectedSourceId) {
return findById(getSourceVariants(group), sourceId) || getSourceVariants(group)[0] || null;
}
function findSourceGroupBySourceId(sourceId) {
const needle = normalizeId(sourceId);
return getSourceGroups().find((group) => getSourceVariants(group).some((source) => normalizeId(source?.id) === needle)) || null;
}
function rememberSelectedSource(group, sourceId) {
const groupId = normalizeId(group?.id);
const normalizedSourceId = normalizeTextValue(sourceId);
if (!groupId || !normalizedSourceId) {
return;
}
state.selectedSourceIdByGroup[groupId] = normalizedSourceId;
}
function rememberCompareSource(group, sourceId) {
const groupId = normalizeId(group?.id);
const normalizedSourceId = normalizeTextValue(sourceId);
if (!groupId || !normalizedSourceId) {
return;
}
state.compareSourceIdByGroup[groupId] = normalizedSourceId;
}
function isCompareAvailable(group = getSelectedSourceGroup()) {
return getSourceVariants(group).length > 1;
}
function isCompareModeEnabled(group = getSelectedSourceGroup()) {
const groupId = normalizeId(group?.id);
return Boolean(groupId && state.compareModeByGroup[groupId] && isCompareAvailable(group));
}
function setCompareModeEnabled(group, isEnabled) {
const groupId = normalizeId(group?.id);
if (!groupId) {
return;
}
state.compareModeByGroup[groupId] = Boolean(isEnabled);
}
function getCompareCandidates(group = getSelectedSourceGroup()) {
const activeSourceId = normalizeId(state.selectedSourceId);
return getSourceVariants(group).filter((source) => normalizeId(source?.id) !== activeSourceId);
}
function getCompareSource(group = getSelectedSourceGroup()) {
const groupId = normalizeId(group?.id);
const candidates = getCompareCandidates(group);
const rememberedSourceId = groupId ? state.compareSourceIdByGroup[groupId] : "";
return findById(candidates, rememberedSourceId) || candidates[0] || null;
}
function syncCompareSelection(group = getSelectedSourceGroup()) {
const groupId = normalizeId(group?.id);
if (!groupId) {
return;
}
if (!isCompareAvailable(group)) {
delete state.compareSourceIdByGroup[groupId];
delete state.compareModeByGroup[groupId];
return;
}
const compareSource = getCompareSource(group);
if (compareSource?.id) {
rememberCompareSource(group, compareSource.id);
}
}
function getSelectedSource() {
return findById(getSources(), state.selectedSourceId);
return getSourceForGroup(getSelectedSourceGroup(), state.selectedSourceId)
|| findById(getSources(), state.selectedSourceId);
}
function getSelectedWork(source = getSelectedSource()) {
@@ -188,6 +320,114 @@
return String(value || "").trim();
}
function buildTranslationOptionLabel(source) {
const metadata = getSourceMetadata(source);
return normalizeTextValue(metadata.translator)
|| normalizeTextValue(metadata.versionLabel || metadata.version)
|| normalizeTextValue(source?.shortTitle)
|| normalizeTextValue(source?.title)
|| "Translation";
}
function getSourceMetadata(source) {
return source?.metadata && typeof source.metadata === "object" ? source.metadata : {};
}
function includesNormalizedText(container, value) {
const containerText = normalizeTextValue(container).toLowerCase();
const valueText = normalizeTextValue(value).toLowerCase();
return Boolean(containerText && valueText && containerText.includes(valueText));
}
function formatCountLabel(count, label) {
const normalizedCount = Number(count) || 0;
const baseLabel = normalizeTextValue(label) || "item";
if (normalizedCount === 1) {
return `${normalizedCount} ${baseLabel}`;
}
return `${normalizedCount} ${baseLabel.endsWith("s") ? baseLabel : `${baseLabel}s`}`;
}
function getSourceEditionLabel(source) {
const metadata = getSourceMetadata(source);
const version = normalizeTextValue(metadata.versionLabel || metadata.version);
const translator = normalizeTextValue(metadata.translator);
if (
version
&& translator
&& normalizeId(version) !== normalizeId(translator)
&& !includesNormalizedText(version, translator)
&& !includesNormalizedText(translator, version)
) {
return `${version} · ${translator}`;
}
return version || translator;
}
function buildSourceListMeta(source) {
const shortTitle = normalizeTextValue(source?.shortTitle);
const title = normalizeTextValue(source?.title);
const editionLabel = getSourceEditionLabel(source);
const parts = [];
if (shortTitle && normalizeId(shortTitle) !== normalizeId(title)) {
parts.push(shortTitle);
}
if (editionLabel && !parts.some((part) => includesNormalizedText(part, editionLabel) || includesNormalizedText(editionLabel, part))) {
parts.push(editionLabel);
}
parts.push(formatCountLabel(source?.stats?.workCount, source?.workLabel || "Work"));
parts.push(formatCountLabel(source?.stats?.sectionCount, source?.sectionLabel || "Section"));
return parts.join(" · ");
}
function buildSourceGroupListMeta(group) {
const activeSource = getSourceForGroup(group);
if (!group || getSourceVariants(group).length <= 1) {
return buildSourceListMeta(activeSource);
}
const translators = Array.from(new Set(
getSourceVariants(group)
.map((source) => normalizeTextValue(getSourceMetadata(source).translator))
.filter(Boolean)
));
const parts = [];
if (translators.length) {
parts.push(translators.join(" / "));
}
parts.push(formatCountLabel(getSourceVariants(group).length, "translation"));
parts.push(formatCountLabel(activeSource?.stats?.sectionCount, activeSource?.sectionLabel || "Section"));
return parts.join(" · ");
}
function buildSourceDetailSubtitle(source, work) {
const parts = [normalizeTextValue(source?.title) || "--"];
const editionLabel = getSourceEditionLabel(source);
const workTitle = normalizeTextValue(work?.title);
if (editionLabel) {
parts.push(editionLabel);
}
if (workTitle && normalizeId(workTitle) !== normalizeId(source?.title)) {
parts.push(workTitle);
}
return parts.join(" · ");
}
function buildCompareCardTitle(passage) {
const source = passage?.source || getSelectedSource();
const section = passage?.section || getSelectedSection(source, getSelectedWork(source));
return `${buildTranslationOptionLabel(source)} · ${section?.title || section?.label || "--"}`;
}
function extractVerseCountText(verse, source, displayPreferences, translationText = "") {
const mode = displayPreferences?.textMode || "translation";
const originalText = normalizeTextValue(verse?.originalText);
@@ -627,6 +867,28 @@
}
}
function syncSelectionForGroup(group = getSelectedSourceGroup()) {
const variants = getSourceVariants(group);
if (!variants.length) {
state.selectedSourceGroupId = "";
state.selectedSourceId = "";
state.selectedWorkId = "";
state.selectedSectionId = "";
return;
}
state.selectedSourceGroupId = group.id;
const rememberedSourceId = state.selectedSourceIdByGroup[normalizeId(group.id)] || "";
const source = findById(variants, state.selectedSourceId)
|| findById(variants, rememberedSourceId)
|| variants[0];
state.selectedSourceId = source?.id || "";
rememberSelectedSource(group, state.selectedSourceId);
syncSelectionForSource(source);
syncCompareSelection(group);
}
async function ensureCatalogLoaded(forceRefresh = false) {
if (!forceRefresh && state.catalog) {
return state.catalog;
@@ -637,11 +899,17 @@
? payload
: { meta: {}, sources: [], lexicons: [] };
if (!state.selectedSourceId) {
state.selectedSourceId = getSources()[0]?.id || "";
state.catalog.sourceGroups = buildSourceGroups(getSources());
if (!state.selectedSourceGroupId && state.selectedSourceId) {
state.selectedSourceGroupId = findSourceGroupBySourceId(state.selectedSourceId)?.id || "";
}
syncSelectionForSource(getSelectedSource());
if (!state.selectedSourceGroupId) {
state.selectedSourceGroupId = getSourceGroups()[0]?.id || "";
}
syncSelectionForGroup(getSelectedSourceGroup());
return state.catalog;
}
@@ -668,39 +936,39 @@
}
sourceListEl.replaceChildren();
const sources = getSources();
sources.forEach((source) => {
const sourceGroups = getSourceGroups();
sourceGroups.forEach((group) => {
const source = getSourceForGroup(group);
const button = document.createElement("button");
button.type = "button";
button.className = "planet-list-item alpha-text-source-btn";
button.dataset.sourceId = source.id;
button.dataset.sourceGroupId = group.id;
button.setAttribute("role", "option");
const isSelected = normalizeId(source.id) === normalizeId(state.selectedSourceId);
const isSelected = normalizeId(group.id) === normalizeId(state.selectedSourceGroupId);
button.classList.toggle("is-selected", isSelected);
button.setAttribute("aria-selected", isSelected ? "true" : "false");
const name = document.createElement("span");
name.className = "planet-list-name";
name.textContent = source.title;
name.textContent = group.title;
const meta = document.createElement("span");
meta.className = "alpha-text-source-meta";
const sectionLabel = source.sectionLabel || "Section";
meta.textContent = `${source.shortTitle || source.title} · ${source.stats?.workCount || 0} ${source.workLabel || "Works"} · ${source.stats?.sectionCount || 0} ${sectionLabel.toLowerCase()}s`;
meta.textContent = buildSourceGroupListMeta(group);
button.append(name, meta);
button.addEventListener("click", () => {
if (normalizeId(source.id) === normalizeId(state.selectedSourceId)) {
if (normalizeId(group.id) === normalizeId(state.selectedSourceGroupId)) {
showDetailOnlyMode();
return;
}
state.selectedSourceId = source.id;
state.selectedSourceGroupId = group.id;
state.currentPassage = null;
state.lexiconEntry = null;
state.highlightedVerseId = "";
syncSelectionForSource(getSelectedSource());
syncSelectionForGroup(group);
renderSourceList();
renderSelectors();
showDetailOnlyMode();
@@ -716,23 +984,56 @@
sourceListEl.appendChild(button);
});
if (!sources.length) {
if (!sourceGroups.length) {
sourceListEl.appendChild(createEmptyMessage("No text sources are available."));
}
if (sourceCountEl) {
sourceCountEl.textContent = `${sources.length} sources`;
sourceCountEl.textContent = `${sourceGroups.length} sources`;
}
}
function renderSelectors() {
const group = getSelectedSourceGroup();
const source = getSelectedSource();
const work = getSelectedWork(source);
const variants = getSourceVariants(group);
const compareCandidates = getCompareCandidates(group);
const compareSource = getCompareSource(group);
const compareEnabled = isCompareModeEnabled(group);
const works = Array.isArray(source?.works) ? source.works : [];
const sections = Array.isArray(work?.sections) ? work.sections : [];
fillSelect(translationSelectEl, variants, state.selectedSourceId, (entry) => buildTranslationOptionLabel(entry));
fillSelect(compareSelectEl, compareCandidates, compareSource?.id || "", (entry) => buildTranslationOptionLabel(entry));
fillSelect(workSelectEl, works, state.selectedWorkId, (entry) => `${entry.title} (${entry.sectionCount} ${String(source?.sectionLabel || "section").toLowerCase()}s)`);
fillSelect(sectionSelectEl, sections, state.selectedSectionId, (entry) => `${entry.label} · ${entry.verseCount} verses`);
if (translationSelectEl instanceof HTMLSelectElement) {
translationSelectEl.disabled = variants.length <= 1;
}
if (translationControlEl instanceof HTMLElement) {
translationControlEl.hidden = variants.length <= 1;
}
if (compareToggleEl instanceof HTMLButtonElement) {
compareToggleEl.textContent = compareEnabled ? "On" : "Off";
compareToggleEl.setAttribute("aria-pressed", compareEnabled ? "true" : "false");
compareToggleEl.classList.toggle("is-selected", compareEnabled);
}
if (compareToggleControlEl instanceof HTMLElement) {
compareToggleControlEl.hidden = !isCompareAvailable(group);
}
if (compareSelectEl instanceof HTMLSelectElement) {
compareSelectEl.disabled = !compareEnabled || compareCandidates.length === 0;
}
if (compareControlEl instanceof HTMLElement) {
compareControlEl.hidden = !compareEnabled || compareCandidates.length === 0;
}
}
function closeLexiconEntry() {
@@ -1044,9 +1345,14 @@
}
function createMetaGrid(passage) {
const sourceGroup = getSelectedSourceGroup();
const source = passage?.source || getSelectedSource();
const work = passage?.work || getSelectedWork(source);
const section = passage?.section || getSelectedSection(source, work);
const metadata = getSourceMetadata(source);
const version = normalizeTextValue(metadata.versionLabel || metadata.version);
const translator = normalizeTextValue(metadata.translator);
const compareSource = getCompareSource(sourceGroup);
const displayPreferences = getSourceDisplayPreferences(source, passage);
const metaGrid = document.createElement("div");
metaGrid.className = "alpha-text-meta-grid";
@@ -1055,6 +1361,10 @@
overviewCard.innerHTML += `
<dl class="alpha-dl">
<dt>Source</dt><dd>${source?.title || "--"}</dd>
${version ? `<dt>Version</dt><dd>${version}</dd>` : ""}
${translator ? `<dt>Translator</dt><dd>${translator}</dd>` : ""}
${getSourceVariants(sourceGroup).length > 1 ? `<dt>Translations</dt><dd>${getSourceVariants(sourceGroup).map((entry) => buildTranslationOptionLabel(entry)).join(" / ")}</dd>` : ""}
${isCompareModeEnabled(sourceGroup) && compareSource ? `<dt>Compare</dt><dd>${buildTranslationOptionLabel(compareSource)}</dd>` : ""}
<dt>Tradition</dt><dd>${source?.tradition || "--"}</dd>
<dt>Language</dt><dd>${source?.language || "--"}</dd>
<dt>Script</dt><dd>${source?.script || "--"}</dd>
@@ -1148,14 +1458,13 @@
return metaGrid;
}
function createPlainVerse(verse) {
const source = getSelectedSource();
const displayPreferences = getSourceDisplayPreferences(source, state.currentPassage);
function createPlainVerse(verse, source, displayPreferences, options = {}) {
const translationText = verse.text || "";
const verseCounts = getTextCounts(extractVerseCountText(verse, source, displayPreferences, translationText));
const isHighlighted = options.highlight !== false && isHighlightedVerse(verse);
const article = document.createElement("article");
article.className = "alpha-text-verse";
article.classList.toggle("is-highlighted", isHighlightedVerse(verse));
article.classList.toggle("is-highlighted", isHighlighted);
const head = document.createElement("div");
head.className = "alpha-text-verse-head";
@@ -1170,7 +1479,7 @@
head.append(reference, stats);
article.append(head);
appendVerseTextLines(article, verse, source, displayPreferences, translationText);
appendVerseTextLines(article, verse, source, displayPreferences, translationText, isHighlighted ? state.searchQuery : "");
return article;
}
@@ -1185,7 +1494,7 @@
return glossText || String(fallbackText || "").trim();
}
function appendVerseTextLines(target, verse, source, displayPreferences, translationText) {
function appendVerseTextLines(target, verse, source, displayPreferences, translationText, highlightQuery = "") {
if (!(target instanceof HTMLElement)) {
return;
}
@@ -1222,18 +1531,19 @@
lines.forEach((line) => {
const text = document.createElement("p");
text.className = `alpha-text-verse-text alpha-text-verse-text--${line.variant}`;
appendHighlightedText(text, line.text, isHighlightedVerse(verse) ? state.searchQuery : "");
appendHighlightedText(text, line.text, highlightQuery);
target.appendChild(text);
});
}
function createTokenVerse(verse, lexiconId, displayPreferences, source) {
function createTokenVerse(verse, lexiconId, displayPreferences, source, options = {}) {
const translationText = buildTokenTranslationText(verse?.tokens, verse?.text);
const verseCounts = getTextCounts(extractVerseCountText(verse, source, displayPreferences, translationText));
const isHighlighted = options.highlight !== false && isHighlightedVerse(verse);
const article = document.createElement("article");
article.className = "alpha-text-verse";
article.classList.toggle("alpha-text-verse--interlinear", Boolean(displayPreferences?.showInterlinear));
article.classList.toggle("is-highlighted", isHighlightedVerse(verse));
article.classList.toggle("is-highlighted", isHighlighted);
const head = document.createElement("div");
head.className = "alpha-text-verse-head";
@@ -1282,43 +1592,14 @@
head.append(reference, stats);
article.append(head);
appendVerseTextLines(article, verse, source, displayPreferences, translationText);
appendVerseTextLines(article, verse, source, displayPreferences, translationText, isHighlighted ? state.searchQuery : "");
if (displayPreferences?.showInterlinear) {
article.appendChild(tokenGrid);
}
return article;
}
function createReaderCard(passage) {
const source = passage?.source || getSelectedSource();
const displayPreferences = getSourceDisplayPreferences(source, passage);
const card = createCard(getPassageLocationLabel(passage));
card.classList.add("alpha-text-reader-card");
const reader = document.createElement("div");
reader.className = "alpha-text-reader";
if (passage?.errorMessage) {
reader.appendChild(createEmptyMessage(passage.errorMessage));
card.appendChild(reader);
return card;
}
const verses = Array.isArray(passage?.verses) ? passage.verses : [];
if (!verses.length) {
reader.appendChild(createEmptyMessage("No verses were found for this section."));
card.appendChild(reader);
return card;
}
verses.forEach((verse) => {
const verseEl = source?.features?.hasTokenAnnotations
? createTokenVerse(verse, source.features.lexiconIds?.[0] || "", displayPreferences, source)
: createPlainVerse(verse);
reader.appendChild(verseEl);
});
card.appendChild(reader);
function createReaderNavigation(passage) {
const navigation = document.createElement("div");
navigation.className = "alpha-text-reader-navigation";
@@ -1344,13 +1625,70 @@
navigation.appendChild(nextButton);
}
if (navigation.childElementCount) {
return navigation.childElementCount ? navigation : null;
}
function createReaderCard(passage, options = {}) {
const source = passage?.source || getSelectedSource();
const displayPreferences = getSourceDisplayPreferences(source, passage);
const card = createCard(options.title || getPassageLocationLabel(passage));
card.classList.add("alpha-text-reader-card");
if (options.compare) {
card.classList.add("alpha-text-reader-card--compare");
}
const reader = document.createElement("div");
reader.className = "alpha-text-reader";
if (passage?.errorMessage) {
reader.appendChild(createEmptyMessage(passage.errorMessage));
card.appendChild(reader);
return card;
}
const verses = Array.isArray(passage?.verses) ? passage.verses : [];
if (!verses.length) {
reader.appendChild(createEmptyMessage("No verses were found for this section."));
card.appendChild(reader);
return card;
}
verses.forEach((verse) => {
const verseEl = source?.features?.hasTokenAnnotations
? createTokenVerse(verse, source.features.lexiconIds?.[0] || "", displayPreferences, source, options)
: createPlainVerse(verse, source, displayPreferences, options);
reader.appendChild(verseEl);
});
card.appendChild(reader);
const navigation = options.showNavigation === false ? null : createReaderNavigation(passage);
if (navigation) {
card.appendChild(navigation);
}
return card;
}
function createCompareReaderGrid(primaryPassage, comparePassage) {
const wrapper = document.createElement("div");
wrapper.className = "alpha-text-reader-compare";
wrapper.appendChild(createReaderCard(primaryPassage, {
title: buildCompareCardTitle(primaryPassage),
showNavigation: false
}));
if (comparePassage) {
wrapper.appendChild(createReaderCard(comparePassage, {
title: buildCompareCardTitle(comparePassage),
compare: true,
highlight: false,
showNavigation: false
}));
}
return wrapper;
}
function createSearchCard() {
const hasSearchState = state.searchLoading || state.searchError || state.searchResults || state.searchQuery;
if (!hasSearchState) {
@@ -1443,6 +1781,7 @@
const source = getSelectedSource();
const work = getSelectedWork(source);
const section = getSelectedSection(source, work);
const compareEnabled = isCompareModeEnabled(getSelectedSourceGroup());
const globalSearchOnlyMode = isGlobalSearchOnlyMode();
setGlobalSearchHeadingMode(globalSearchOnlyMode);
@@ -1460,7 +1799,7 @@
if (detailSubEl) {
detailSubEl.textContent = globalSearchOnlyMode
? "All text sources"
: `${source.title} · ${work.title}`;
: buildSourceDetailSubtitle(source, work);
}
if (!detailBodyEl) {
return;
@@ -1487,38 +1826,99 @@
}
detailBodyEl.appendChild(createMetaGrid(state.currentPassage));
detailBodyEl.appendChild(createReaderCard(state.currentPassage));
if (compareEnabled && state.comparePassage) {
detailBodyEl.appendChild(createCompareReaderGrid(state.currentPassage, state.comparePassage));
const compareNavigation = createReaderNavigation(state.currentPassage);
if (compareNavigation) {
detailBodyEl.appendChild(compareNavigation);
}
} else {
detailBodyEl.appendChild(createReaderCard(state.currentPassage));
}
renderLexiconPopup();
}
function getComparableWork(source, work) {
const works = Array.isArray(source?.works) ? source.works : [];
return findById(works, work?.id)
|| works.find((entry) => normalizeId(entry?.title) === normalizeId(work?.title))
|| works[0]
|| null;
}
function getComparableSection(work, section) {
const sections = Array.isArray(work?.sections) ? work.sections : [];
return findById(sections, section?.id)
|| sections.find((entry) => Number(entry?.number || 0) === Number(section?.number || 0))
|| sections.find((entry) => normalizeId(entry?.title) === normalizeId(section?.title))
|| sections.find((entry) => normalizeId(entry?.label) === normalizeId(section?.label))
|| sections[0]
|| null;
}
function buildPassageLoadError(source, work, section, message) {
return {
source,
work,
section,
verses: [],
errorMessage: message
};
}
async function loadComparablePassage(compareSource, currentWork, currentSection) {
const compareWork = getComparableWork(compareSource, currentWork);
const compareSection = getComparableSection(compareWork, currentSection);
if (!compareWork || !compareSection) {
return buildPassageLoadError(compareSource, compareWork, compareSection, "Unable to align this comparison section.");
}
try {
return await dataService.loadTextSection?.(compareSource.id, compareWork.id, compareSection.id);
} catch (error) {
return buildPassageLoadError(compareSource, compareWork, compareSection, error?.message || "Unable to load the comparison translation.");
}
}
async function loadSelectedPassage() {
const source = getSelectedSource();
const work = getSelectedWork(source);
const section = getSelectedSection(source, work);
const compareSource = isCompareModeEnabled(getSelectedSourceGroup()) ? getCompareSource() : null;
if (!source || !work || !section) {
state.currentPassage = null;
state.comparePassage = null;
renderDetail();
return;
}
state.currentPassage = null;
state.comparePassage = null;
renderDetail();
try {
state.currentPassage = await dataService.loadTextSection?.(source.id, work.id, section.id);
renderDetail();
if (state.highlightedVerseId) {
requestAnimationFrame(scrollHighlightedVerseIntoView);
}
} catch (error) {
state.currentPassage = {
source,
work,
section,
verses: [],
errorMessage: error?.message || "Unable to load this section."
};
renderDetail();
const [primaryResult, compareResult] = await Promise.allSettled([
dataService.loadTextSection?.(source.id, work.id, section.id),
compareSource ? loadComparablePassage(compareSource, work, section) : Promise.resolve(null)
]);
if (primaryResult.status === "fulfilled") {
state.currentPassage = primaryResult.value;
} else {
state.currentPassage = buildPassageLoadError(source, work, section, primaryResult.reason?.message || "Unable to load this section.");
}
if (compareResult.status === "fulfilled") {
state.comparePassage = compareResult.value;
} else if (compareSource) {
const compareWork = getComparableWork(compareSource, work);
const compareSection = getComparableSection(compareWork, section);
state.comparePassage = buildPassageLoadError(compareSource, compareWork, compareSection, compareResult.reason?.message || "Unable to load the comparison translation.");
}
renderDetail();
if (state.highlightedVerseId) {
requestAnimationFrame(scrollHighlightedVerseIntoView);
}
}
@@ -1586,7 +1986,10 @@
return;
}
const sourceGroup = findSourceGroupBySourceId(result.sourceId);
state.selectedSourceGroupId = sourceGroup?.id || "";
state.selectedSourceId = result.sourceId;
rememberSelectedSource(sourceGroup, result.sourceId);
state.selectedWorkId = result.workId;
state.selectedSectionId = result.sectionId;
state.highlightedVerseId = result.verseId;
@@ -1641,12 +2044,58 @@
});
}
if (translationSelectEl instanceof HTMLSelectElement) {
translationSelectEl.addEventListener("change", () => {
const sourceGroup = getSelectedSourceGroup();
state.selectedSourceId = String(translationSelectEl.value || "");
rememberSelectedSource(sourceGroup, state.selectedSourceId);
syncSelectionForSource(getSelectedSource());
state.currentPassage = null;
state.comparePassage = null;
state.lexiconEntry = null;
state.highlightedVerseId = "";
syncCompareSelection(sourceGroup);
renderSourceList();
renderSelectors();
showDetailOnlyMode();
if (state.searchQuery && state.activeSearchScope === "source") {
void Promise.all([loadSelectedPassage(), runSearch("source")]);
return;
}
void loadSelectedPassage();
});
}
if (compareToggleEl instanceof HTMLButtonElement) {
compareToggleEl.addEventListener("click", () => {
const sourceGroup = getSelectedSourceGroup();
setCompareModeEnabled(sourceGroup, !isCompareModeEnabled(sourceGroup));
syncCompareSelection(sourceGroup);
state.comparePassage = null;
renderSelectors();
void loadSelectedPassage();
});
}
if (compareSelectEl instanceof HTMLSelectElement) {
compareSelectEl.addEventListener("change", () => {
const sourceGroup = getSelectedSourceGroup();
rememberCompareSource(sourceGroup, String(compareSelectEl.value || ""));
state.comparePassage = null;
renderSelectors();
void loadSelectedPassage();
});
}
if (workSelectEl) {
workSelectEl.addEventListener("change", () => {
state.selectedWorkId = String(workSelectEl.value || "");
const source = getSelectedSource();
syncSelectionForSource(source);
state.currentPassage = null;
state.comparePassage = null;
state.lexiconEntry = null;
state.highlightedVerseId = "";
renderSelectors();
@@ -1658,6 +2107,7 @@
sectionSelectEl.addEventListener("change", () => {
state.selectedSectionId = String(sectionSelectEl.value || "");
state.currentPassage = null;
state.comparePassage = null;
state.lexiconEntry = null;
state.highlightedVerseId = "";
void loadSelectedPassage();
@@ -1699,8 +2149,13 @@
function resetState() {
state.catalog = null;
state.currentPassage = null;
state.comparePassage = null;
state.lexiconEntry = null;
state.selectedSourceGroupId = "";
state.selectedSourceId = "";
state.selectedSourceIdByGroup = {};
state.compareSourceIdByGroup = {};
state.compareModeByGroup = {};
state.selectedWorkId = "";
state.selectedSectionId = "";
state.lexiconRequestId = 0;

408
app/ui-audio-circle.js Normal file
View File

@@ -0,0 +1,408 @@
(() => {
"use strict";
const CIRCLE_ENTRIES = [
{ id: "c", major: "C", relativeMinor: "Am", pitchClass: 0, frequencyLabel: "C4" },
{ id: "g", major: "G", relativeMinor: "Em", pitchClass: 7, frequencyLabel: "G4" },
{ id: "d", major: "D", relativeMinor: "Bm", pitchClass: 2, frequencyLabel: "D4" },
{ id: "a", major: "A", relativeMinor: "F#m", pitchClass: 9, frequencyLabel: "A4" },
{ id: "e", major: "E", relativeMinor: "C#m", pitchClass: 4, frequencyLabel: "E4" },
{ id: "b", major: "B", relativeMinor: "G#m", pitchClass: 11, frequencyLabel: "B4" },
{ id: "f-sharp-g-flat", major: "F#/Gb", relativeMinor: "D#m/Ebm", pitchClass: 6, frequencyLabel: "F#/Gb4" },
{ id: "d-flat", major: "Db", relativeMinor: "Bbm", pitchClass: 1, frequencyLabel: "Db4" },
{ id: "a-flat", major: "Ab", relativeMinor: "Fm", pitchClass: 8, frequencyLabel: "Ab4" },
{ id: "e-flat", major: "Eb", relativeMinor: "Cm", pitchClass: 3, frequencyLabel: "Eb4" },
{ id: "b-flat", major: "Bb", relativeMinor: "Gm", pitchClass: 10, frequencyLabel: "Bb4" },
{ id: "f", major: "F", relativeMinor: "Dm", pitchClass: 5, frequencyLabel: "F4" }
];
const state = {
initialized: false,
selectedId: "c",
audioContext: null,
oscillatorNode: null,
gainNode: null,
stopTimerId: 0,
activePlaybackKey: ""
};
function getElements() {
return {
stageEl: document.getElementById("audio-circle-stage"),
detailNameEl: document.getElementById("audio-circle-detail-name"),
detailSubEl: document.getElementById("audio-circle-detail-sub"),
detailSummaryEl: document.getElementById("audio-circle-detail-summary"),
detailBodyEl: document.getElementById("audio-circle-detail-body")
};
}
function getMidiNumber(pitchClass, octave) {
return ((octave + 1) * 12) + pitchClass;
}
function getFrequencyHz(pitchClass, octave) {
const midiNumber = getMidiNumber(pitchClass, octave);
return 440 * Math.pow(2, (midiNumber - 69) / 12);
}
function formatFrequencyHz(value) {
return `${value.toFixed(2)} Hz`;
}
function findEntryById(id) {
return CIRCLE_ENTRIES.find((entry) => entry.id === id) || null;
}
function getSelectedEntry() {
return findEntryById(state.selectedId) || CIRCLE_ENTRIES[0];
}
function getAudioContext() {
if (state.audioContext) {
return state.audioContext;
}
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
if (typeof AudioContextCtor !== "function") {
return null;
}
state.audioContext = new AudioContextCtor();
return state.audioContext;
}
function clearStopTimer() {
if (state.stopTimerId) {
window.clearTimeout(state.stopTimerId);
state.stopTimerId = 0;
}
}
function clearActivePlayback(shouldRender = true) {
state.oscillatorNode = null;
state.gainNode = null;
state.activePlaybackKey = "";
clearStopTimer();
if (shouldRender) {
render();
}
}
function stopPlayback(shouldRender = true) {
clearStopTimer();
const oscillatorNode = state.oscillatorNode;
const gainNode = state.gainNode;
const audioContext = state.audioContext;
state.oscillatorNode = null;
state.gainNode = null;
state.activePlaybackKey = "";
if (oscillatorNode && gainNode && audioContext) {
const now = audioContext.currentTime;
gainNode.gain.cancelScheduledValues(now);
gainNode.gain.setValueAtTime(Math.max(gainNode.gain.value, 0.0001), now);
gainNode.gain.exponentialRampToValueAtTime(0.0001, now + 0.06);
try {
oscillatorNode.stop(now + 0.07);
} catch (_error) {
}
}
if (shouldRender) {
render();
}
}
async function playFrequency(frequencyHz, playbackKey) {
const audioContext = getAudioContext();
if (!audioContext) {
return;
}
if (state.activePlaybackKey === playbackKey) {
stopPlayback(true);
return;
}
if (audioContext.state === "suspended") {
await audioContext.resume();
}
stopPlayback(false);
const oscillatorNode = audioContext.createOscillator();
const gainNode = audioContext.createGain();
const now = audioContext.currentTime;
oscillatorNode.type = "sine";
oscillatorNode.frequency.setValueAtTime(frequencyHz, now);
gainNode.gain.setValueAtTime(0.0001, now);
gainNode.gain.exponentialRampToValueAtTime(0.16, now + 0.03);
gainNode.gain.exponentialRampToValueAtTime(0.0001, now + 1.1);
oscillatorNode.connect(gainNode);
gainNode.connect(audioContext.destination);
state.oscillatorNode = oscillatorNode;
state.gainNode = gainNode;
state.activePlaybackKey = playbackKey;
oscillatorNode.onended = () => {
if (state.oscillatorNode === oscillatorNode || state.activePlaybackKey === playbackKey) {
clearActivePlayback(true);
}
};
oscillatorNode.start(now);
oscillatorNode.stop(now + 1.12);
state.stopTimerId = window.setTimeout(() => {
clearActivePlayback(true);
}, 1250);
render();
}
function createMetaCard(title) {
const card = document.createElement("div");
card.className = "planet-meta-card";
const heading = document.createElement("strong");
heading.textContent = title;
card.appendChild(heading);
return card;
}
function renderCircleStage(elements) {
if (!elements.stageEl) {
return;
}
const shell = document.createElement("div");
shell.className = "audio-circle-shell";
const center = document.createElement("div");
center.className = "audio-circle-center";
const selectedEntry = getSelectedEntry();
center.innerHTML = `
<div>
<div class="audio-circle-center-label">${selectedEntry.major}</div>
<div class="audio-circle-center-sub">Relative minor ${selectedEntry.relativeMinor}<br>Circle of Fifths</div>
</div>
`;
shell.appendChild(center);
const radius = 41;
CIRCLE_ENTRIES.forEach((entry, index) => {
const angleDegrees = -90 + (index * 30);
const angleRadians = (angleDegrees * Math.PI) / 180;
const x = 50 + (Math.cos(angleRadians) * radius);
const y = 50 + (Math.sin(angleRadians) * radius);
const playbackKey = `circle:${entry.id}`;
const isSelected = entry.id === state.selectedId;
const isPlaying = state.activePlaybackKey === playbackKey;
const button = document.createElement("button");
button.type = "button";
button.className = `audio-circle-key${isSelected || isPlaying ? " is-selected" : ""}`;
button.dataset.entryId = entry.id;
button.dataset.action = "play-entry";
button.dataset.playbackKey = playbackKey;
button.dataset.frequencyHz = String(getFrequencyHz(entry.pitchClass, 4));
button.style.left = `${x}%`;
button.style.top = `${y}%`;
button.style.transform = "translate(-50%, -50%)";
button.setAttribute("aria-pressed", isPlaying ? "true" : "false");
button.setAttribute("aria-label", `${isPlaying ? "Stop" : "Play"} ${entry.major}`);
const major = document.createElement("div");
major.className = "audio-circle-key-major";
major.textContent = entry.major;
const minor = document.createElement("div");
minor.className = "audio-circle-key-minor";
minor.textContent = entry.relativeMinor;
const meta = document.createElement("div");
meta.className = "audio-circle-key-meta";
meta.textContent = isPlaying ? "Playing" : "Tap";
button.append(major, minor, meta);
shell.appendChild(button);
});
elements.stageEl.replaceChildren(shell);
}
function renderDetail(elements) {
const entry = getSelectedEntry();
if (!elements.detailNameEl || !elements.detailSubEl || !elements.detailSummaryEl || !elements.detailBodyEl) {
return;
}
const entryIndex = CIRCLE_ENTRIES.findIndex((item) => item.id === entry.id);
const clockwiseEntry = CIRCLE_ENTRIES[(entryIndex + 1) % CIRCLE_ENTRIES.length];
const counterClockwiseEntry = CIRCLE_ENTRIES[(entryIndex - 1 + CIRCLE_ENTRIES.length) % CIRCLE_ENTRIES.length];
const frequencyHz = getFrequencyHz(entry.pitchClass, 4);
elements.detailNameEl.textContent = `${entry.major} Major`;
elements.detailSubEl.textContent = `${entry.relativeMinor} relative minor · tonic ${entry.frequencyLabel}`;
elements.detailSummaryEl.textContent = `${entry.major} sits between ${counterClockwiseEntry.major} and ${clockwiseEntry.major} on the Circle of Fifths.`;
const grid = document.createElement("div");
grid.className = "planet-meta-grid";
const overviewCard = createMetaCard("Key Overview");
overviewCard.innerHTML += `
<dl class="alpha-dl">
<dt>Major key</dt><dd>${entry.major}</dd>
<dt>Relative minor</dt><dd>${entry.relativeMinor}</dd>
<dt>Tonic pitch</dt><dd>${entry.frequencyLabel}</dd>
<dt>Tonic frequency</dt><dd>${formatFrequencyHz(frequencyHz)}</dd>
<dt>Clockwise neighbor</dt><dd>${clockwiseEntry.major}</dd>
<dt>Counter-clockwise neighbor</dt><dd>${counterClockwiseEntry.major}</dd>
</dl>
`;
const chipRow = document.createElement("div");
chipRow.className = "audio-circle-chip-row";
[entry.major, entry.relativeMinor, clockwiseEntry.major, counterClockwiseEntry.major].forEach((label, index) => {
const chip = document.createElement("span");
chip.className = `audio-circle-chip${index === 0 ? " audio-circle-chip--active" : ""}`;
chip.textContent = label;
chipRow.appendChild(chip);
});
overviewCard.appendChild(chipRow);
const explanationCard = createMetaCard("How To Read The Circle");
const explanation = document.createElement("div");
explanation.className = "audio-circle-copy";
explanation.textContent = "Moving clockwise adds one sharp and raises the tonic by a perfect fifth. Moving counter-clockwise adds one flat and returns by a perfect fourth. The inner minor labels show the relative minor that shares the same key signature.";
explanationCard.appendChild(explanation);
const playbackCard = createMetaCard("Tone Preview");
const playbackCopy = document.createElement("div");
playbackCopy.className = "audio-circle-copy";
playbackCopy.textContent = `Tap any key on the circle to hear its tonic. ${entry.major} is currently centered at ${formatFrequencyHz(frequencyHz)}.`;
const playbackActions = document.createElement("div");
playbackActions.className = "audio-note-playback-actions";
const playButton = document.createElement("button");
playButton.type = "button";
playButton.className = "alpha-nav-btn audio-note-playback-btn";
playButton.dataset.action = "play-entry";
playButton.dataset.entryId = entry.id;
playButton.dataset.playbackKey = `circle:${entry.id}`;
playButton.dataset.frequencyHz = String(frequencyHz);
playButton.textContent = `Play ${entry.major}`;
const stopButton = document.createElement("button");
stopButton.type = "button";
stopButton.className = "alpha-nav-btn alpha-nav-btn--ghost audio-note-playback-btn";
stopButton.dataset.action = "stop-playback";
stopButton.textContent = "Stop";
playbackActions.append(playButton, stopButton);
playbackCard.append(playbackCopy, playbackActions);
const navCard = createMetaCard("Neighbors");
const navGrid = document.createElement("div");
navGrid.className = "audio-circle-nav-grid";
[
{ label: "Counter-clockwise", entry: counterClockwiseEntry },
{ label: "Clockwise", entry: clockwiseEntry }
].forEach(({ label, entry: neighbor }) => {
const card = document.createElement("div");
card.className = "audio-circle-nav-card";
card.innerHTML = `
<div class="audio-circle-nav-label">${label}</div>
<div class="audio-circle-nav-value">${neighbor.major}</div>
<div class="audio-circle-nav-sub">Relative minor ${neighbor.relativeMinor}</div>
`;
navGrid.appendChild(card);
});
navCard.appendChild(navGrid);
grid.append(overviewCard, explanationCard, playbackCard, navCard);
elements.detailBodyEl.replaceChildren(grid);
}
function render() {
const elements = getElements();
renderCircleStage(elements);
renderDetail(elements);
}
function selectEntry(id, shouldRender = true) {
if (!findEntryById(id)) {
return;
}
state.selectedId = id;
if (shouldRender) {
render();
}
}
function bindEvents() {
const elements = getElements();
if (!elements.stageEl || !elements.detailBodyEl) {
return;
}
const clickHandler = (event) => {
const button = event.target instanceof Element ? event.target.closest("button[data-action]") : null;
if (!(button instanceof HTMLButtonElement)) {
return;
}
const action = button.dataset.action || "";
if (action === "stop-playback") {
stopPlayback(true);
return;
}
if (action === "play-entry") {
const entryId = String(button.dataset.entryId || "").trim();
const playbackKey = String(button.dataset.playbackKey || "").trim();
const frequencyHz = Number(button.dataset.frequencyHz);
if (!entryId || !playbackKey || !Number.isFinite(frequencyHz)) {
return;
}
selectEntry(entryId, false);
void playFrequency(frequencyHz, playbackKey);
}
};
elements.stageEl.addEventListener("click", clickHandler);
elements.detailBodyEl.addEventListener("click", clickHandler);
window.addEventListener("beforeunload", () => {
stopPlayback(false);
});
}
function init() {
if (state.initialized) {
render();
return;
}
bindEvents();
render();
state.initialized = true;
}
function ensureAudioCircleSection() {
init();
}
window.AudioCircleUi = {
...(window.AudioCircleUi || {}),
init,
ensureAudioCircleSection,
selectEntry
};
})();

671
app/ui-audio-notes.js Normal file
View File

@@ -0,0 +1,671 @@
(() => {
"use strict";
const OCTAVES = [0, 1, 2, 3, 4, 5, 6, 7, 8];
const SPEED_OF_SOUND_METERS_PER_SECOND = 343;
const INTERVALS_FROM_C = [
"Unison",
"Minor second",
"Major second",
"Minor third",
"Major third",
"Perfect fourth",
"Tritone",
"Perfect fifth",
"Minor sixth",
"Major sixth",
"Minor seventh",
"Major seventh"
];
const NOTE_DEFINITIONS = [
{
id: "c",
label: "C",
pitchClass: 0,
family: "Natural",
keyColor: "White key",
aliases: ["Do"],
description: "C is the anchor note for scientific pitch notation, and middle C is the common visual landmark many players use to orient the keyboard.",
usage: "In basic theory it often feels like a starting point because C major uses only natural notes."
},
{
id: "c-sharp-d-flat",
label: "C#/Db",
pitchClass: 1,
family: "Accidental",
keyColor: "Black key",
aliases: ["C sharp", "D flat"],
description: "C sharp and D flat are the same sounding pitch in equal temperament, sitting one semitone above C and one semitone below D.",
usage: "It is a common chromatic color tone that adds immediate tension or forward motion."
},
{
id: "d",
label: "D",
pitchClass: 2,
family: "Natural",
keyColor: "White key",
aliases: ["Re"],
description: "D is a whole step above C and often acts like a clear, open support tone in many scales and melodies.",
usage: "In C major it functions as the supertonic, which often points onward rather than feeling final."
},
{
id: "d-sharp-e-flat",
label: "D#/Eb",
pitchClass: 3,
family: "Accidental",
keyColor: "Black key",
aliases: ["D sharp", "E flat"],
description: "D sharp and E flat are enharmonic spellings of the same pitch class, positioned three semitones above C.",
usage: "It is common in minor colors and in flat-oriented keys such as E-flat major."
},
{
id: "e",
label: "E",
pitchClass: 4,
family: "Natural",
keyColor: "White key",
aliases: ["Mi"],
description: "E is the major third above C, so it strongly helps define the sound of major harmony.",
usage: "When E is present over C, the chord immediately feels brighter and more settled."
},
{
id: "f",
label: "F",
pitchClass: 5,
family: "Natural",
keyColor: "White key",
aliases: ["Fa"],
description: "F is the perfect fourth above C and often feels stable while still wanting to move or resolve in tonal music.",
usage: "It frequently appears as a structural tone in melodies and as the root of flat-side key centers."
},
{
id: "f-sharp-g-flat",
label: "F#/Gb",
pitchClass: 6,
family: "Accidental",
keyColor: "Black key",
aliases: ["F sharp", "G flat"],
description: "F sharp and G flat form the tritone above C, the octave's most evenly divided and tense pitch relationship.",
usage: "Because it sits at the midpoint of the octave, it is often used to create instability or dramatic pull."
},
{
id: "g",
label: "G",
pitchClass: 7,
family: "Natural",
keyColor: "White key",
aliases: ["Sol"],
description: "G is the perfect fifth above C and one of the most stable interval relationships in Western tonal music.",
usage: "It reinforces harmony strongly and often feels supportive, open, and structurally important."
},
{
id: "g-sharp-a-flat",
label: "G#/Ab",
pitchClass: 8,
family: "Accidental",
keyColor: "Black key",
aliases: ["G sharp", "A flat"],
description: "G sharp and A flat are enharmonic spellings used in different key contexts for the same sounding pitch.",
usage: "This pitch often acts as a vivid chromatic color that leans upward toward A or downward toward G."
},
{
id: "a",
label: "A",
pitchClass: 9,
family: "Natural",
keyColor: "White key",
aliases: ["La"],
description: "A is the modern concert-pitch reference note, with A4 standardized at 440 Hz for most contemporary instruments and tuning devices.",
usage: "It is the main calibration point for ensembles, tuners, and synthesized reference tones."
},
{
id: "a-sharp-b-flat",
label: "A#/Bb",
pitchClass: 10,
family: "Accidental",
keyColor: "Black key",
aliases: ["A sharp", "B flat"],
description: "A sharp and B flat are the same equal-tempered pitch, frequently spelled as B flat in ensemble and band-oriented music.",
usage: "It shows up often in brass, wind, and flat-key writing, where B-flat-centered harmony is common."
},
{
id: "b",
label: "B",
pitchClass: 11,
family: "Natural",
keyColor: "White key",
aliases: ["Ti"],
description: "B is the major seventh above C, so it often behaves like a leading tone that wants to resolve upward.",
usage: "That strong pull makes it useful for creating expectation and directional movement toward C."
}
];
const state = {
initialized: false,
entries: [],
filteredEntries: [],
selectedId: "",
searchQuery: "",
audioContext: null,
oscillatorNode: null,
gainNode: null,
stopTimerId: 0,
activePlaybackKey: ""
};
function getElements() {
return {
countEl: document.getElementById("audio-note-count"),
listEl: document.getElementById("audio-note-list"),
searchEl: document.getElementById("audio-note-search-input"),
searchClearEl: document.getElementById("audio-note-search-clear"),
detailNameEl: document.getElementById("audio-note-detail-name"),
detailSubEl: document.getElementById("audio-note-detail-sub"),
detailSummaryEl: document.getElementById("audio-note-detail-summary"),
detailBodyEl: document.getElementById("audio-note-detail-body")
};
}
function normalize(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9#]+/g, " ")
.replace(/\s+/g, " ");
}
function getMidiNumber(pitchClass, octave) {
return ((octave + 1) * 12) + pitchClass;
}
function getFrequencyHz(pitchClass, octave) {
const midiNumber = getMidiNumber(pitchClass, octave);
return 440 * Math.pow(2, (midiNumber - 69) / 12);
}
function formatFrequencyHz(value) {
if (value >= 100) {
return `${value.toFixed(2)} Hz`;
}
if (value >= 10) {
return `${value.toFixed(3)} Hz`;
}
return `${value.toFixed(4)} Hz`;
}
function formatMeters(value) {
if (value >= 1) {
return `${value.toFixed(2)} m`;
}
return `${value.toFixed(3)} m`;
}
function formatMilliseconds(value) {
return `${value.toFixed(2)} ms`;
}
function formatSignedSemitoneOffset(value) {
if (value === 0) {
return "Concert pitch reference";
}
const distance = Math.abs(value);
const direction = value > 0 ? "above" : "below";
return `${distance} semitone${distance === 1 ? "" : "s"} ${direction} A4`;
}
function buildEntries() {
return NOTE_DEFINITIONS.map((definition) => {
const referenceOctave = 4;
const referenceFrequencyHz = getFrequencyHz(definition.pitchClass, referenceOctave);
const referenceMidiNumber = getMidiNumber(definition.pitchClass, referenceOctave);
const octaveSeries = OCTAVES.map((octave) => {
const frequencyHz = getFrequencyHz(definition.pitchClass, octave);
return {
octave,
label: `${definition.label}${octave}`,
frequencyHz,
midiNumber: getMidiNumber(definition.pitchClass, octave)
};
});
return {
...definition,
intervalFromC: INTERVALS_FROM_C[definition.pitchClass],
referenceOctave,
referenceLabel: `${definition.label}${referenceOctave}`,
referenceFrequencyHz,
referenceMidiNumber,
wavelengthMeters: SPEED_OF_SOUND_METERS_PER_SECOND / referenceFrequencyHz,
periodMilliseconds: 1000 / referenceFrequencyHz,
semitoneOffsetFromA4: referenceMidiNumber - 69,
octaveSeries,
searchText: normalize([
definition.label,
definition.family,
definition.keyColor,
definition.description,
definition.usage,
INTERVALS_FROM_C[definition.pitchClass],
...(definition.aliases || [])
].join(" "))
};
});
}
function findEntryById(id) {
return state.entries.find((entry) => entry.id === id) || null;
}
function getAudioContext() {
if (state.audioContext) {
return state.audioContext;
}
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
if (typeof AudioContextCtor !== "function") {
return null;
}
state.audioContext = new AudioContextCtor();
return state.audioContext;
}
function clearStopTimer() {
if (state.stopTimerId) {
window.clearTimeout(state.stopTimerId);
state.stopTimerId = 0;
}
}
function clearActivePlayback(shouldRender = true) {
state.oscillatorNode = null;
state.gainNode = null;
state.activePlaybackKey = "";
clearStopTimer();
if (shouldRender) {
render();
}
}
function stopPlayback(shouldRender = true) {
clearStopTimer();
const oscillatorNode = state.oscillatorNode;
const gainNode = state.gainNode;
const audioContext = state.audioContext;
state.oscillatorNode = null;
state.gainNode = null;
state.activePlaybackKey = "";
if (oscillatorNode && gainNode && audioContext) {
const now = audioContext.currentTime;
gainNode.gain.cancelScheduledValues(now);
gainNode.gain.setValueAtTime(Math.max(gainNode.gain.value, 0.0001), now);
gainNode.gain.exponentialRampToValueAtTime(0.0001, now + 0.06);
try {
oscillatorNode.stop(now + 0.07);
} catch (_error) {
}
}
if (shouldRender) {
render();
}
}
async function playFrequency(frequencyHz, playbackKey) {
const audioContext = getAudioContext();
if (!audioContext) {
return;
}
if (state.activePlaybackKey === playbackKey) {
stopPlayback(true);
return;
}
if (audioContext.state === "suspended") {
await audioContext.resume();
}
stopPlayback(false);
const oscillatorNode = audioContext.createOscillator();
const gainNode = audioContext.createGain();
const now = audioContext.currentTime;
oscillatorNode.type = "sine";
oscillatorNode.frequency.setValueAtTime(frequencyHz, now);
gainNode.gain.setValueAtTime(0.0001, now);
gainNode.gain.exponentialRampToValueAtTime(0.16, now + 0.03);
gainNode.gain.exponentialRampToValueAtTime(0.0001, now + 1.1);
oscillatorNode.connect(gainNode);
gainNode.connect(audioContext.destination);
state.oscillatorNode = oscillatorNode;
state.gainNode = gainNode;
state.activePlaybackKey = playbackKey;
oscillatorNode.onended = () => {
if (state.oscillatorNode === oscillatorNode || state.activePlaybackKey === playbackKey) {
clearActivePlayback(true);
}
};
oscillatorNode.start(now);
oscillatorNode.stop(now + 1.12);
state.stopTimerId = window.setTimeout(() => {
clearActivePlayback(true);
}, 1250);
render();
}
function createMetaCard(title) {
const card = document.createElement("div");
card.className = "planet-meta-card";
const heading = document.createElement("strong");
heading.textContent = title;
card.appendChild(heading);
return card;
}
function createFrequencyGrid(entry) {
const grid = document.createElement("div");
grid.className = "audio-note-frequency-grid";
entry.octaveSeries.forEach((seriesEntry) => {
const playbackKey = `${entry.id}:${seriesEntry.octave}`;
const isPlaying = state.activePlaybackKey === playbackKey;
const cell = document.createElement("button");
cell.type = "button";
cell.className = `audio-note-frequency-cell audio-note-frequency-cell--button${isPlaying ? " is-playing" : ""}`;
cell.dataset.action = "play-frequency";
cell.dataset.playbackKey = playbackKey;
cell.dataset.frequencyHz = String(seriesEntry.frequencyHz);
cell.setAttribute("aria-pressed", isPlaying ? "true" : "false");
cell.setAttribute("aria-label", `${isPlaying ? "Stop" : "Play"} ${seriesEntry.label} at ${formatFrequencyHz(seriesEntry.frequencyHz)}`);
const label = document.createElement("div");
label.className = "audio-note-frequency-label";
label.textContent = seriesEntry.label;
const value = document.createElement("div");
value.className = "audio-note-frequency-value";
value.textContent = formatFrequencyHz(seriesEntry.frequencyHz);
const meta = document.createElement("div");
meta.className = "audio-note-frequency-meta";
meta.textContent = `MIDI ${seriesEntry.midiNumber}`;
const status = document.createElement("div");
status.className = "audio-note-frequency-status";
status.textContent = isPlaying ? "Playing now" : "Tap to hear";
cell.append(label, value, meta, status);
grid.appendChild(cell);
});
return grid;
}
function renderList(elements) {
if (!elements.listEl) {
return;
}
const fragment = document.createDocumentFragment();
state.filteredEntries.forEach((entry) => {
const button = document.createElement("button");
button.type = "button";
button.className = "planet-list-item audio-note-list-item";
button.dataset.noteId = entry.id;
button.setAttribute("role", "option");
const isSelected = entry.id === state.selectedId;
button.classList.toggle("is-selected", isSelected);
button.setAttribute("aria-selected", isSelected ? "true" : "false");
const name = document.createElement("span");
name.className = "planet-list-name";
name.textContent = entry.label;
const meta = document.createElement("span");
meta.className = "planet-list-meta";
meta.textContent = `${entry.referenceLabel} = ${formatFrequencyHz(entry.referenceFrequencyHz)} · ${entry.intervalFromC}`;
button.append(name, meta);
fragment.appendChild(button);
});
elements.listEl.replaceChildren(fragment);
if (!state.filteredEntries.length) {
const empty = document.createElement("div");
empty.className = "planet-text";
empty.style.padding = "16px";
empty.style.color = "#71717a";
empty.textContent = "No notes match your search.";
elements.listEl.appendChild(empty);
}
if (elements.countEl) {
elements.countEl.textContent = `${state.filteredEntries.length} notes`;
}
}
function renderDetail(elements) {
const entry = findEntryById(state.selectedId);
if (!elements.detailNameEl || !elements.detailSubEl || !elements.detailSummaryEl || !elements.detailBodyEl) {
return;
}
elements.detailBodyEl.replaceChildren();
if (!entry) {
elements.detailNameEl.textContent = "--";
elements.detailSubEl.textContent = "Select a note to explore";
elements.detailSummaryEl.textContent = "--";
return;
}
elements.detailNameEl.textContent = entry.label;
elements.detailSubEl.textContent = `${entry.family} note · ${entry.intervalFromC}`;
elements.detailSummaryEl.textContent = entry.description;
const grid = document.createElement("div");
grid.className = "planet-meta-grid";
const profileCard = createMetaCard("Note Profile");
profileCard.innerHTML += `
<dl class="alpha-dl">
<dt>Reference pitch</dt><dd>${entry.referenceLabel}</dd>
<dt>Reference frequency</dt><dd>${formatFrequencyHz(entry.referenceFrequencyHz)}</dd>
<dt>Pitch class</dt><dd>${entry.pitchClass}</dd>
<dt>Interval above C</dt><dd>${entry.intervalFromC}</dd>
<dt>Keyboard key</dt><dd>${entry.keyColor}</dd>
<dt>Relation to A4</dt><dd>${formatSignedSemitoneOffset(entry.semitoneOffsetFromA4)}</dd>
<dt>Wavelength</dt><dd>${formatMeters(entry.wavelengthMeters)}</dd>
<dt>Period</dt><dd>${formatMilliseconds(entry.periodMilliseconds)}</dd>
</dl>
`;
const aliasRow = document.createElement("div");
aliasRow.className = "audio-note-chip-row";
const familyChip = document.createElement("span");
familyChip.className = `audio-note-chip audio-note-chip--${entry.family.toLowerCase()}`;
familyChip.textContent = entry.family;
aliasRow.appendChild(familyChip);
(entry.aliases || []).forEach((alias) => {
const chip = document.createElement("span");
chip.className = "audio-note-chip";
chip.textContent = alias;
aliasRow.appendChild(chip);
});
profileCard.appendChild(aliasRow);
const explanationCard = createMetaCard("Explanation");
const explanation = document.createElement("div");
explanation.className = "audio-note-copy";
explanation.textContent = `${entry.description} ${entry.usage}`;
explanationCard.appendChild(explanation);
const playbackCard = createMetaCard("Tone Preview");
const playbackCopy = document.createElement("div");
playbackCopy.className = "audio-note-playback-copy";
playbackCopy.textContent = `Play ${entry.referenceLabel} as a quick sine-wave reference, or use any octave button below to hear that exact frequency.`;
const playbackActions = document.createElement("div");
playbackActions.className = "audio-note-playback-actions";
const playReferenceButton = document.createElement("button");
playReferenceButton.type = "button";
playReferenceButton.className = "alpha-nav-btn audio-note-playback-btn";
playReferenceButton.dataset.action = "play-frequency";
playReferenceButton.dataset.playbackKey = `${entry.id}:${entry.referenceOctave}`;
playReferenceButton.dataset.frequencyHz = String(entry.referenceFrequencyHz);
playReferenceButton.textContent = `Play ${entry.referenceLabel}`;
const stopButton = document.createElement("button");
stopButton.type = "button";
stopButton.className = "alpha-nav-btn alpha-nav-btn--ghost audio-note-playback-btn";
stopButton.dataset.action = "stop-playback";
stopButton.textContent = "Stop";
playbackActions.append(playReferenceButton, stopButton);
playbackCard.append(playbackCopy, playbackActions);
const frequencyCard = createMetaCard("Frequencies By Octave");
frequencyCard.appendChild(createFrequencyGrid(entry));
const formulaCard = createMetaCard("Equal-Temperament Rule");
const formula = document.createElement("div");
formula.className = "audio-note-formula";
formula.textContent = "Reference frequencies here use twelve-tone equal temperament with A4 = 440 Hz. Every octave doubles the frequency, and each semitone step multiplies the pitch by the twelfth root of 2.";
formulaCard.appendChild(formula);
grid.append(profileCard, explanationCard, playbackCard, frequencyCard, formulaCard);
elements.detailBodyEl.appendChild(grid);
}
function render() {
const elements = getElements();
renderList(elements);
renderDetail(elements);
}
function applyFilter() {
const query = normalize(state.searchQuery);
state.filteredEntries = state.entries.filter((entry) => !query || entry.searchText.includes(query));
if (!state.filteredEntries.some((entry) => entry.id === state.selectedId)) {
state.selectedId = state.filteredEntries[0]?.id || "";
}
render();
const elements = getElements();
if (elements.searchClearEl) {
elements.searchClearEl.disabled = !state.searchQuery;
}
}
function selectNote(id) {
if (!findEntryById(id)) {
return;
}
stopPlayback(false);
state.selectedId = id;
render();
}
function bindEvents() {
const elements = getElements();
if (!elements.listEl || !elements.searchEl || !elements.searchClearEl || !elements.detailBodyEl) {
return;
}
elements.listEl.addEventListener("click", (event) => {
const button = event.target instanceof Element ? event.target.closest("button[data-note-id]") : null;
if (!button) {
return;
}
selectNote(button.dataset.noteId || "");
});
elements.searchEl.addEventListener("input", () => {
state.searchQuery = elements.searchEl.value || "";
applyFilter();
});
elements.searchClearEl.addEventListener("click", () => {
state.searchQuery = "";
elements.searchEl.value = "";
applyFilter();
elements.searchEl.focus();
});
elements.detailBodyEl.addEventListener("click", (event) => {
const control = event.target instanceof Element ? event.target.closest("button[data-action]") : null;
if (!control) {
return;
}
const action = control.dataset.action || "";
if (action === "stop-playback") {
stopPlayback(true);
return;
}
if (action === "play-frequency") {
const frequencyHz = Number(control.dataset.frequencyHz);
const playbackKey = String(control.dataset.playbackKey || "").trim();
if (!Number.isFinite(frequencyHz) || !playbackKey) {
return;
}
void playFrequency(frequencyHz, playbackKey);
}
});
window.addEventListener("beforeunload", () => {
stopPlayback(false);
});
}
function init() {
if (state.initialized) {
render();
return;
}
state.entries = buildEntries();
state.filteredEntries = [...state.entries];
state.selectedId = state.entries[0]?.id || "";
bindEvents();
render();
state.initialized = true;
}
function ensureAudioNotesSection() {
init();
}
window.AudioNotesUi = {
...(window.AudioNotesUi || {}),
init,
ensureAudioNotesSection,
selectNote
};
})();

View File

@@ -140,6 +140,28 @@
return Boolean(target.closest(AUTO_COLLAPSE_ENTRY_SELECTOR));
}
function getAutoCollapseLayoutFromTarget(target) {
if (!(target instanceof Element)) {
return null;
}
if (target.closest(AUTO_COLLAPSE_IGNORE_SELECTOR)) {
return null;
}
const entry = target.closest(AUTO_COLLAPSE_ENTRY_SELECTOR);
if (!(entry instanceof Element)) {
return null;
}
const panel = entry.closest("aside.planet-list-panel, aside.tarot-list-panel, aside.kab-tree-panel");
if (!(panel instanceof HTMLElement)) {
return null;
}
return resolveLayoutTarget(panel);
}
function scheduleAutoCollapse(layout) {
if (!(layout instanceof HTMLElement)) {
return;
@@ -151,46 +173,35 @@
}
function initializeSidebarAutoCollapse() {
const layouts = document.querySelectorAll(".planet-layout, .tarot-layout, .kab-layout");
if (!document.body || document.body.dataset.sidebarAutoCollapseReady === "1") {
return;
}
layouts.forEach((layout) => {
document.body.dataset.sidebarAutoCollapseReady = "1";
document.addEventListener("click", (event) => {
const target = event.target instanceof Element ? event.target : null;
const layout = getAutoCollapseLayoutFromTarget(target);
if (!(layout instanceof HTMLElement)) {
return;
}
const panel = Array.from(layout.children).find((child) => (
child instanceof HTMLElement
&& child.matches("aside.planet-list-panel, aside.tarot-list-panel, aside.kab-tree-panel")
));
scheduleAutoCollapse(layout);
}, true);
if (!(panel instanceof HTMLElement) || panel.dataset.sidebarAutoCollapseReady === "1") {
document.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
panel.dataset.sidebarAutoCollapseReady = "1";
const target = event.target instanceof Element ? event.target : null;
const layout = getAutoCollapseLayoutFromTarget(target);
if (!(layout instanceof HTMLElement)) {
return;
}
panel.addEventListener("click", (event) => {
const target = event.target instanceof Element ? event.target : null;
if (!shouldAutoCollapseFromEvent(panel, target)) {
return;
}
scheduleAutoCollapse(layout);
});
panel.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
const target = event.target instanceof Element ? event.target : null;
if (!shouldAutoCollapseFromEvent(panel, target)) {
return;
}
scheduleAutoCollapse(layout);
});
});
scheduleAutoCollapse(layout);
}, true);
}
function initializeSidebarPopouts() {
@@ -361,6 +372,7 @@
const nextOpen = Boolean(isOpen);
topbarEl.classList.toggle("is-menu-open", nextOpen);
document.body.classList.toggle("topbar-menu-open", nextOpen);
menuToggleEl.setAttribute("aria-expanded", nextOpen ? "true" : "false");
menuToggleEl.textContent = nextOpen ? "Close" : "Menu";
menuToggleEl.setAttribute("aria-label", nextOpen ? "Close navigation menu" : "Open navigation menu");

View File

@@ -4,7 +4,7 @@
let config = {};
let lastNowSkyGeoKey = "";
let lastNowSkySourceUrl = "";
const NOW_SKY_WRAPPER_PATH = "app/stellarium-now-wrapper.html";
const NOW_SKY_WRAPPER_PATH = "app/stellarium-now-wrapper.html?v=20260314-now-sky-mobile-04";
const NOW_SKY_FOV_DEGREES = "220";
function getNowSkyLayerEl() {

View File

@@ -37,6 +37,20 @@
setActiveSection("home");
});
bindClick(elements.openAudioEl, () => {
const activeSection = getActiveSection();
const isAudioSectionActive = activeSection === "audio-notes" || activeSection === "audio-circle";
setActiveSection(isAudioSectionActive ? "home" : "audio-notes");
});
bindClick(elements.openAudioCircleEl, () => {
setActiveSection("audio-circle");
});
bindClick(elements.openAudioNotesEl, () => {
setActiveSection("audio-notes");
});
bindClick(elements.openTarotEl, () => {
if (getActiveSection() === "tarot") {
setActiveSection("home");

View File

@@ -24,6 +24,21 @@
{ id: "pluto", astronomyBody: "Pluto", fallbackName: "Pluto", fallbackSymbol: "♇︎" }
];
const NOW_PLANET_SIGN_LAYOUT = [
{ id: "aries", name: "Aries", symbol: "♈" },
{ id: "libra", name: "Libra", symbol: "♎" },
{ id: "taurus", name: "Taurus", symbol: "♉" },
{ id: "scorpio", name: "Scorpio", symbol: "♏" },
{ id: "gemini", name: "Gemini", symbol: "♊" },
{ id: "sagittarius", name: "Sagittarius", symbol: "♐" },
{ id: "cancer", name: "Cancer", symbol: "♋" },
{ id: "capricorn", name: "Capricorn", symbol: "♑" },
{ id: "leo", name: "Leo", symbol: "♌" },
{ id: "aquarius", name: "Aquarius", symbol: "♒" },
{ id: "virgo", name: "Virgo", symbol: "♍" },
{ id: "pisces", name: "Pisces", symbol: "♓" }
];
function resetNowLightboxZoom() {
if (!nowLightboxImageEl) {
return;
@@ -302,22 +317,94 @@
return positions;
}
function normalizeNowSignId(sign) {
if (!sign) {
return "";
}
const signId = String(sign?.id || sign).trim().toLowerCase();
if (signId) {
return signId;
}
return String(sign?.name || "").trim().toLowerCase();
}
function buildNowPlanetSignBuckets(planetPositions = []) {
const positionsBySign = new Map();
planetPositions.forEach((position) => {
const signId = normalizeNowSignId(position?.sign);
if (!signId) {
return;
}
if (!positionsBySign.has(signId)) {
positionsBySign.set(signId, []);
}
positionsBySign.get(signId).push(position);
});
return NOW_PLANET_SIGN_LAYOUT.map((signMeta) => ({
...signMeta,
positions: (positionsBySign.get(signMeta.id) || [])
.slice()
.sort((left, right) => Number(left?.degreeInSign || 0) - Number(right?.degreeInSign || 0))
})).filter((signBucket) => signBucket.positions.length > 0);
}
function formatNowPlanetEntry(position) {
const symbol = String(position?.symbol || "").trim();
const name = String(position?.name || "").trim() || "--";
const degree = Number(position?.degreeInSign);
const degreeLabel = Number.isFinite(degree) ? `${degree.toFixed(1)}°` : "--";
return `${symbol ? `${symbol} ` : ""}${name} ${degreeLabel}`.trim();
}
function renderNowPlanetPositions(containerEl, planetPositions = []) {
if (!containerEl) {
return;
}
containerEl.replaceChildren();
const positions = Array.isArray(planetPositions) ? planetPositions.filter(Boolean) : [];
if (!positions.length) {
containerEl.textContent = "--";
return;
}
buildNowPlanetSignBuckets(positions).forEach((signBucket) => {
const item = document.createElement("div");
item.className = "now-stats-planet";
const title = document.createElement("div");
title.className = "now-stats-planet-sign";
title.textContent = `${signBucket.symbol} ${signBucket.name}`;
item.appendChild(title);
const list = document.createElement("div");
list.className = "now-stats-planet-list";
signBucket.positions.forEach((position) => {
const entry = document.createElement("div");
entry.className = "now-stats-planet-entry";
entry.textContent = formatNowPlanetEntry(position);
list.appendChild(entry);
});
item.appendChild(list);
containerEl.appendChild(item);
});
}
function updateNowStats(referenceData, elements, now) {
const planetPositions = calculatePlanetPositions(referenceData, now);
if (elements.nowStatsPlanetsEl) {
elements.nowStatsPlanetsEl.replaceChildren();
if (!planetPositions.length) {
elements.nowStatsPlanetsEl.textContent = "--";
} else {
planetPositions.forEach((position) => {
const item = document.createElement("div");
item.className = "now-stats-planet";
item.textContent = position.label;
elements.nowStatsPlanetsEl.appendChild(item);
});
}
renderNowPlanetPositions(elements.nowStatsPlanetsEl, planetPositions);
}
if (elements.nowStatsSabianEl) {
@@ -524,6 +611,7 @@
formatCountdown,
getDisplayTarotName,
setNowCardImage,
renderNowPlanetPositions,
updateNowStats
};
})();

View File

@@ -19,6 +19,7 @@
|| typeof nowUiHelpers.getSignStartDate !== "function"
|| typeof nowUiHelpers.getDisplayTarotName !== "function"
|| typeof nowUiHelpers.setNowCardImage !== "function"
|| typeof nowUiHelpers.renderNowPlanetPositions !== "function"
|| typeof nowUiHelpers.updateNowStats !== "function"
) {
throw new Error("NowUiHelpers module must load before ui-now.js");
@@ -41,19 +42,10 @@
function renderNowStatsFromSnapshot(elements, stats) {
if (elements.nowStatsPlanetsEl) {
elements.nowStatsPlanetsEl.replaceChildren();
const planetPositions = Array.isArray(stats?.planetPositions) ? stats.planetPositions : [];
if (!planetPositions.length) {
elements.nowStatsPlanetsEl.textContent = "--";
} else {
planetPositions.forEach((position) => {
const item = document.createElement("div");
item.className = "now-stats-planet";
item.textContent = String(position?.label || "").trim() || "--";
elements.nowStatsPlanetsEl.appendChild(item);
});
}
nowUiHelpers.renderNowPlanetPositions(
elements.nowStatsPlanetsEl,
Array.isArray(stats?.planetPositions) ? stats.planetPositions : []
);
}
if (elements.nowStatsSabianEl) {

View File

@@ -6,6 +6,8 @@
"timeline",
"calendar",
"holidays",
"audio-circle",
"audio-notes",
"tarot",
"tarot-house",
"astronomy",
@@ -88,6 +90,9 @@
const isCalendarOpen = activeSection === "calendar";
const isHolidaysOpen = activeSection === "holidays";
const isCalendarMenuOpen = isTimelineOpen || isCalendarOpen || isHolidaysOpen;
const isAudioNotesOpen = activeSection === "audio-notes";
const isAudioCircleOpen = activeSection === "audio-circle";
const isAudioMenuOpen = isAudioNotesOpen || isAudioCircleOpen;
const isTarotOpen = activeSection === "tarot";
const isTarotHouseOpen = activeSection === "tarot-house";
const isTarotMenuOpen = isTarotOpen || isTarotHouseOpen;
@@ -115,6 +120,8 @@
setHidden(elements.timelineSectionEl, !isTimelineOpen);
setHidden(elements.calendarSectionEl, !isCalendarOpen);
setHidden(elements.holidaySectionEl, !isHolidaysOpen);
setHidden(elements.audioCircleSectionEl, !isAudioCircleOpen);
setHidden(elements.audioNotesSectionEl, !isAudioNotesOpen);
setHidden(elements.tarotSectionEl, !isTarotOpen);
setHidden(elements.tarotHouseSectionEl, !isTarotHouseOpen);
setHidden(elements.astronomySectionEl, !isAstronomyOpen);
@@ -141,6 +148,9 @@
toggleActive(elements.openCalendarTimelineEl, isTimelineOpen);
toggleActive(elements.openCalendarMonthsEl, isCalendarOpen);
toggleActive(elements.openHolidaysEl, isHolidaysOpen);
setPressed(elements.openAudioEl, isAudioMenuOpen);
toggleActive(elements.openAudioCircleEl, isAudioCircleOpen);
toggleActive(elements.openAudioNotesEl, isAudioNotesOpen);
setPressed(elements.openTarotEl, isTarotMenuOpen);
toggleActive(elements.openTarotHouseEl, isTarotHouseOpen);
config.tarotSpreadUi?.applyViewState?.();
@@ -181,6 +191,16 @@
return;
}
if (isAudioCircleOpen) {
ensure.ensureAudioCircleSection?.();
return;
}
if (isAudioNotesOpen) {
ensure.ensureAudioNotesSection?.();
return;
}
if (isTarotOpen) {
if (typeof config.tarotSpreadUi?.handleSectionActivated === "function") {
config.tarotSpreadUi.handleSectionActivated();

View File

@@ -16,7 +16,7 @@
<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/noto-naskh-arabic/arabic-400.css">
<link rel="stylesheet" href="app/styles.css?v=20260312-house-cube-01">
<link rel="stylesheet" href="app/styles.css?v=20260314-audio-circle-01">
</head>
<body>
<div class="topbar">
@@ -39,6 +39,13 @@
<button id="open-zodiac" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Zodiac</button>
</div>
</div>
<div class="topbar-dropdown" aria-label="Audio menu">
<button id="open-audio" class="settings-trigger" type="button" aria-pressed="false" aria-haspopup="menu" aria-controls="audio-subpages" aria-expanded="false">Audio ▾</button>
<div id="audio-subpages" class="topbar-dropdown-menu" role="menu" aria-label="Audio subpages">
<button id="open-audio-circle" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Circle</button>
<button id="open-audio-notes" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Notes</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">
@@ -455,6 +462,50 @@
</section>
</div>
</section>
<section id="audio-notes-section" hidden>
<div class="planet-layout">
<aside class="planet-list-panel">
<div class="planet-list-header">
<strong>Audio &gt; Notes</strong>
<span id="audio-note-count" class="planet-list-count">--</span>
</div>
<div class="audio-note-intro planet-text">Browse the 12 equal-tempered pitch classes with reference frequencies anchored to A4 = 440 Hz.</div>
<div class="dataset-search-wrap">
<input id="audio-note-search-input" class="dataset-search-input" type="search" placeholder="Search note names, aliases, intervals" aria-label="Search audio notes">
<button id="audio-note-search-clear" class="dataset-search-clear" type="button" aria-label="Clear audio note search" disabled>×</button>
</div>
<div id="audio-note-list" class="planet-card-list" role="listbox" aria-label="Audio notes"></div>
</aside>
<section class="planet-detail-panel" aria-live="polite">
<div class="planet-detail-heading">
<h2 id="audio-note-detail-name">--</h2>
<div id="audio-note-detail-sub" class="planet-detail-type">Select a note to explore</div>
<div id="audio-note-detail-summary" class="planet-detail-summary">--</div>
</div>
<div id="audio-note-detail-body" class="audio-note-detail-stack"></div>
</section>
</div>
</section>
<section id="audio-circle-section" hidden>
<div class="audio-circle-layout">
<aside class="audio-circle-stage-panel">
<div class="planet-list-header">
<strong>Audio &gt; Circle</strong>
<span class="planet-list-count">12 Keys</span>
</div>
<div class="audio-circle-intro planet-text">The Circle of Fifths orders keys by perfect fifth motion. Tap any key to hear its tonic and inspect its clockwise and counter-clockwise neighbors.</div>
<div id="audio-circle-stage" class="audio-circle-stage" aria-label="Circle of Fifths"></div>
</aside>
<section class="planet-detail-panel audio-circle-detail-panel" aria-live="polite">
<div class="planet-detail-heading">
<h2 id="audio-circle-detail-name">Circle of Fifths</h2>
<div id="audio-circle-detail-sub" class="planet-detail-type">Select a key to explore its tonic, relative minor, and neighbors</div>
<div id="audio-circle-detail-summary" class="planet-detail-summary">--</div>
</div>
<div id="audio-circle-detail-body" class="audio-circle-detail-stack"></div>
</section>
</div>
</section>
<section id="iching-section" hidden>
<div class="planet-layout">
<aside class="planet-list-panel">
@@ -737,6 +788,18 @@
</div>
<div class="alpha-text-heading-tools">
<div class="alpha-text-controls alpha-text-controls--heading">
<label class="alpha-text-control" for="alpha-text-translation-select" hidden>
<span>Translation</span>
<select id="alpha-text-translation-select" class="alpha-text-select" aria-label="Select text translation"></select>
</label>
<div id="alpha-text-compare-toggle-control" class="alpha-text-control alpha-text-control--toggle" hidden>
<span>Compare</span>
<button id="alpha-text-compare-toggle" class="alpha-nav-btn alpha-text-compare-toggle" type="button" aria-pressed="false">Off</button>
</div>
<label class="alpha-text-control" for="alpha-text-compare-select" hidden>
<span>Compare With</span>
<select id="alpha-text-compare-select" class="alpha-text-select" aria-label="Select comparison translation"></select>
</label>
<label class="alpha-text-control" for="alpha-text-work-select">
<span>Work</span>
<select id="alpha-text-work-select" class="alpha-text-select" aria-label="Select text work"></select>
@@ -965,8 +1028,8 @@
<script src="app/ui-tarot-lightbox.js?v=20260312-compare-zoom-01"></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-now-helpers.js"></script>
<script src="app/ui-now.js"></script>
<script src="app/ui-now-helpers.js?v=20260314-now-planets-grid-01"></script>
<script src="app/ui-now.js?v=20260314-now-planets-grid-01"></script>
<script src="app/ui-natal.js"></script>
<script src="app/tarot-database-builders.js"></script>
<script src="app/tarot-database-assembly.js"></script>
@@ -987,6 +1050,8 @@
<script src="app/ui-planets.js"></script>
<script src="app/ui-cycles.js"></script>
<script src="app/ui-elements.js"></script>
<script src="app/ui-audio-notes.js?v=20260314-audio-notes-02"></script>
<script src="app/ui-audio-circle.js?v=20260314-audio-circle-01"></script>
<script src="app/ui-iching-references.js"></script>
<script src="app/ui-iching.js"></script>
<script src="app/ui-rosicrucian-cross.js"></script>
@@ -1004,7 +1069,7 @@
<script src="app/ui-alphabet-detail.js?v=20260309-enochian-api"></script>
<script src="app/ui-alphabet-kabbalah.js"></script>
<script src="app/ui-alphabet.js?v=20260308b"></script>
<script src="app/ui-alphabet-text.js?v=20260312-source-search-01"></script>
<script src="app/ui-alphabet-text.js?v=20260314-text-compare-01"></script>
<script src="app/ui-zodiac-references.js"></script>
<script src="app/ui-zodiac.js"></script>
<script src="app/ui-quiz-bank-builtins-domains.js"></script>
@@ -1021,13 +1086,13 @@
<script src="app/ui-numbers.js"></script>
<script src="app/ui-tarot-spread.js"></script>
<script src="app/ui-settings.js?v=20260309-gate"></script>
<script src="app/ui-chrome.js?v=20260312-menu-unify-01"></script>
<script src="app/ui-navigation.js?v=20260312-house-cube-01"></script>
<script src="app/ui-chrome.js?v=20260314-mobile-menu-overlay-01"></script>
<script src="app/ui-navigation.js?v=20260314-audio-circle-01"></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-home-calendar.js"></script>
<script src="app/ui-section-state.js?v=20260312-house-cube-01"></script>
<script src="app/ui-section-state.js?v=20260314-audio-circle-01"></script>
<script src="app/app-runtime.js?v=20260309-gate"></script>
<script src="app.js?v=20260312-house-cube-01"></script>
<script src="app.js?v=20260314-audio-circle-01"></script>
</body>
</html>