A living almanac of seasons, soil, and the quiet knowledge that used to be common. Rust axum with minijinja and Vite.
agriculturealmanacaxumfolk-knowledgegardeningminijinjarustseasonalvite
1// almanac.js
2//
3// the surface of the page. handles color, motion, and navigation.
4// all content is assembled on the server now.
5
6(function () {
7 'use strict';
8
9 var ANIM_WORD_DELAY = 18;
10 var ANIM_LIST_DELAY = 60;
11
12
13 // --- daylight cycle ---
14 // shifts the palette based on time of day
15
16 var TIMES = [
17 { name: 'night', start: 0, end: 5 },
18 { name: 'dawn', start: 5, end: 8 },
19 { name: 'morning', start: 8, end: 12 },
20 { name: 'afternoon', start: 12, end: 17 },
21 { name: 'evening', start: 17, end: 21 },
22 { name: 'night', start: 21, end: 24 }
23 ];
24
25 function getTimeOfDay(date) {
26 var h = date.getHours();
27 for (var i = 0; i < TIMES.length; i++) {
28 if (h >= TIMES[i].start && h < TIMES[i].end) return TIMES[i].name;
29 }
30 return 'night';
31 }
32
33 function getSeasonName(date) {
34 var m = date.getMonth() + 1;
35 var d = date.getDate();
36 var ranges = [
37 ['winter', 1,1, 2,29],
38 ['early-spring', 3,1, 4,15],
39 ['late-spring', 4,16, 5,31],
40 ['early-summer', 6,1, 6,30],
41 ['midsummer', 7,1, 8,31],
42 ['early-fall', 9,1, 10,31],
43 ['late-fall', 11,1, 11,30],
44 ['winter', 12,1, 12,31]
45 ];
46 for (var i = 0; i < ranges.length; i++) {
47 var r = ranges[i];
48 var afterStart = m > r[1] || (m === r[1] && d >= r[2]);
49 var beforeEnd = m < r[3] || (m === r[3] && d <= r[4]);
50 if (afterStart && beforeEnd) return r[0];
51 }
52 return 'winter';
53 }
54
55 // palette per time of day. text stays in the bone/cream range so
56 // contrast holds; the ember "heading" role drives the accent (drop
57 // cap, roman numerals, links, mood markers) and stays warm-gold
58 // even at night so editorial elements don't disappear.
59 var CYCLES = {
60 night: {
61 bg: '#0a0806', text: '#dcd0b8', accent: '#86a07a', heading: '#d2a070',
62 glow: 'rgba(30,25,20,0.08)', under1: 'rgba(15,12,10,0.8)',
63 under2: 'rgba(20,18,15,0.6)', under3: 'rgba(10,10,15,0.5)'
64 },
65 dawn: {
66 bg: '#13100b', text: '#e0d4be', accent: '#92a880', heading: '#e3b27a',
67 glow: 'rgba(180,120,60,0.06)', under1: 'rgba(40,25,15,0.6)',
68 under2: 'rgba(50,30,18,0.4)', under3: 'rgba(30,20,12,0.5)'
69 },
70 morning: {
71 bg: '#14100c', text: '#e2d8c2', accent: '#92a880', heading: '#d8aa78',
72 glow: 'rgba(140,100,50,0.04)', under1: 'rgba(26,23,20,0.7)',
73 under2: 'rgba(42,36,32,0.5)', under3: 'rgba(26,23,20,0.4)'
74 },
75 afternoon: {
76 bg: '#15110e', text: '#dccfba', accent: '#88a07a', heading: '#d3a06c',
77 glow: 'rgba(160,100,40,0.05)', under1: 'rgba(35,28,20,0.6)',
78 under2: 'rgba(30,25,18,0.5)', under3: 'rgba(40,30,20,0.4)'
79 },
80 evening: {
81 bg: '#120f0b', text: '#dac9b4', accent: '#82987a', heading: '#dfa478',
82 glow: 'rgba(180,100,40,0.08)', under1: 'rgba(45,25,12,0.7)',
83 under2: 'rgba(35,18,10,0.6)', under3: 'rgba(25,15,8,0.5)'
84 }
85 };
86
87 var SEASON_COLORS = {
88 'winter': { bg: '#07080b', tint: '40,50,80' },
89 'early-spring': { bg: '#0a0c08', tint: '50,70,35' },
90 'late-spring': { bg: '#0b0d07', tint: '55,80,30' },
91 'early-summer': { bg: '#0d0b07', tint: '80,65,20' },
92 'midsummer': { bg: '#0e0a06', tint: '90,60,15' },
93 'early-fall': { bg: '#0d0906', tint: '85,45,20' },
94 'late-fall': { bg: '#0b0908', tint: '65,40,30' }
95 };
96
97 var TIME_LIGHTS = {
98 night:
99 'linear-gradient(to bottom, rgba(100,120,180,0.15) 0%, rgba(100,120,180,0.05) 30%, transparent 70%)',
100 dawn:
101 'linear-gradient(to bottom, rgba(220,150,70,0.22) 0%, rgba(200,120,50,0.06) 40%, transparent 80%)',
102 morning:
103 'linear-gradient(to bottom, rgba(240,210,140,0.16) 0%, rgba(240,210,140,0.04) 40%, transparent 80%)',
104 afternoon:
105 'linear-gradient(to bottom, rgba(240,200,110,0.18) 0%, rgba(240,200,110,0.05) 40%, transparent 80%)',
106 evening:
107 'linear-gradient(to bottom, rgba(200,90,30,0.24) 0%, rgba(180,70,20,0.06) 40%, transparent 80%)'
108 };
109
110 function applyDaylightCycle(time, seasonName) {
111 var c = CYCLES[time] || CYCLES.morning;
112 var s = SEASON_COLORS[seasonName] || SEASON_COLORS['early-spring'];
113 var r = document.documentElement;
114
115 r.style.setProperty('--earth', s.bg);
116 r.style.setProperty('--bone', c.text);
117 r.style.setProperty('--sprout', c.accent);
118 r.style.setProperty('--ember', c.heading);
119 r.style.setProperty('--glow', c.glow);
120 r.style.setProperty('--under1', c.under1);
121 r.style.setProperty('--under2', c.under2);
122 r.style.setProperty('--under3', c.under3);
123
124 var sky = document.getElementById('sky-layer');
125 if (sky) {
126 sky.style.background =
127 'radial-gradient(ellipse at 30% 15%, rgba(' + s.tint + ',0.25) 0%, transparent 55%), ' +
128 'radial-gradient(ellipse at 70% 80%, rgba(' + s.tint + ',0.12) 0%, transparent 55%), ' +
129 'radial-gradient(ellipse at 50% 50%, rgba(' + s.tint + ',0.08) 0%, transparent 70%)';
130 }
131
132 var timeEl = document.getElementById('time-layer');
133 if (timeEl) {
134 timeEl.style.background = TIME_LIGHTS[time] || TIME_LIGHTS.morning;
135 }
136 }
137
138
139 // --- word reveal ---
140
141 function revealWords(root) {
142 if (!root) return;
143 var elements = root.querySelectorAll('h1, h2, p, li, blockquote');
144 var allItems = [];
145
146 elements.forEach(function (el) {
147 if (el.tagName === 'LI') {
148 allItems.push(el);
149 return;
150 }
151
152 var nodes = [];
153 el.childNodes.forEach(function (node) {
154 if (node.nodeType === 3) {
155 node.textContent.split(/(\s+)/).forEach(function (w) {
156 if (/^\s*$/.test(w)) {
157 nodes.push(document.createTextNode(w));
158 } else {
159 var span = document.createElement('span');
160 span.className = 'word';
161 span.textContent = w;
162 nodes.push(span);
163 allItems.push(span);
164 }
165 });
166 } else if (node.nodeType === 1) {
167 var wrapper = document.createElement(node.tagName.toLowerCase());
168 for (var a = 0; a < node.attributes.length; a++) {
169 wrapper.setAttribute(node.attributes[a].name, node.attributes[a].value);
170 }
171 var innerText = node.textContent || '';
172 innerText.split(/(\s+)/).forEach(function (w) {
173 if (/^\s*$/.test(w)) {
174 wrapper.appendChild(document.createTextNode(w));
175 } else {
176 var span = document.createElement('span');
177 span.className = 'word';
178 span.textContent = w;
179 wrapper.appendChild(span);
180 allItems.push(span);
181 }
182 });
183 nodes.push(wrapper);
184 }
185 });
186 el.textContent = '';
187 nodes.forEach(function (n) { el.appendChild(n); });
188 });
189
190 // cap total cascade so the last item lands within ~1s of the
191 // first. budget includes both word and LI advances; with ~300
192 // items the per-item step shrinks below 5ms which still reads
193 // as a sweep rather than a pop.
194 var ANIM_TOTAL_MS = 900;
195 var n = allItems.length;
196 var step = n > 1 ? ANIM_TOTAL_MS / (n - 1) : 0;
197 var nextDelay = 0;
198 allItems.forEach(function (item) {
199 item.style.animationDelay = nextDelay + 'ms';
200 nextDelay += step;
201 });
202 }
203
204
205 // --- main ---
206
207 var currentSeasonOverride = null;
208 var naturalSeason = null;
209
210 var dom = {};
211 function cacheDOM() {
212 dom.dateLine = document.querySelector('.date-line');
213 dom.seasonName = document.querySelector('.season-name');
214 dom.seasonNote = document.querySelector('.season-note');
215 dom.haikuBlock = document.querySelector('.haiku-block blockquote');
216 dom.sections = document.querySelector('.sections');
217 dom.footerStatus = document.querySelector('.footer-status');
218 dom.readout = document.querySelector('.readout');
219 dom.footer = document.querySelector('footer');
220 dom.seasonsNav = document.querySelector('.seasons-nav');
221 }
222
223 function loadContent(seasonOverride) {
224 var fadeTargets = ['.season-block', '.haiku-block', '.sections', 'footer'];
225 fadeTargets.forEach(function (sel) {
226 var el = document.querySelector(sel);
227 if (el) el.style.opacity = '0';
228 });
229
230 var url = '/api/content' + (seasonOverride ? '?season=' + encodeURIComponent(seasonOverride) : '');
231 currentSeasonOverride = seasonOverride;
232
233 fetch(url)
234 .then(function (r) { return r.json(); })
235 .then(function (data) {
236 dom.dateLine.textContent = data.date_line;
237 dom.seasonName.textContent = data.season_name;
238 dom.seasonNote.innerHTML = data.season_note;
239 dom.haikuBlock.innerHTML = data.haiku_html;
240 dom.sections.innerHTML = data.sections_html;
241 dom.footerStatus.textContent = data.footer_text;
242 dom.seasonsNav.innerHTML = data.season_nav_html;
243
244 document.body.setAttribute('data-season', data.season_key);
245 document.body.setAttribute('data-time', data.time_key);
246
247 // background cycle follows real clock; season tint follows content
248 applyDaylightCycle(getTimeOfDay(new Date()), data.season_key);
249
250 fadeTargets.forEach(function (sel) {
251 var el = document.querySelector(sel);
252 if (el) el.style.opacity = '1';
253 });
254
255 revealWords(dom.readout);
256 revealWords(dom.footer);
257 bindNavClicks();
258 })
259 .catch(function () {
260 dom.sections.innerHTML = '<p style="color:var(--ash);font-style:italic;">the pages could not be found. try again in a moment.</p>';
261 fadeTargets.forEach(function (sel) {
262 var el = document.querySelector(sel);
263 if (el) el.style.opacity = '1';
264 });
265 });
266 }
267
268 function bindNavClicks() {
269 dom.seasonsNav.querySelectorAll('a[data-season]').forEach(function (a) {
270 a.addEventListener('click', function (e) {
271 e.preventDefault();
272 var season = a.getAttribute('data-season');
273 window.scrollTo({ top: 0, behavior: 'smooth' });
274 loadContent(season === naturalSeason ? null : season);
275 });
276 });
277 }
278
279
280 // --- init ---
281
282 cacheDOM();
283
284 var now = new Date();
285 naturalSeason = getSeasonName(now);
286
287 var initSeason = document.body.getAttribute('data-season') || naturalSeason;
288 var initTime = document.body.getAttribute('data-time') || getTimeOfDay(now);
289 applyDaylightCycle(initTime, initSeason);
290
291 revealWords(dom.readout);
292 revealWords(dom.footer);
293 bindNavClicks();
294
295 // refresh on day rollover (sun, moon, and the day-seeded picks all change
296 // at midnight, not just at season boundaries); keep palette in sync with
297 // the clock
298 var currentDay = now.toDateString();
299 setInterval(function () {
300 var check = new Date();
301 var newSeason = getSeasonName(check);
302 if (newSeason !== naturalSeason) {
303 naturalSeason = newSeason;
304 }
305 if (check.toDateString() !== currentDay) {
306 currentDay = check.toDateString();
307 loadContent(currentSeasonOverride);
308 return;
309 }
310 applyDaylightCycle(getTimeOfDay(check), document.body.getAttribute('data-season') || newSeason);
311 }, 60000);
312
313 // unregister the retired service worker so returning visitors stop
314 // serving the page from a stale cache
315 if ('serviceWorker' in navigator) {
316 navigator.serviceWorker.getRegistrations().then(function (regs) {
317 regs.forEach(function (r) { r.unregister(); });
318 });
319 }
320
321})();