LEAD POST · UH-01Created by GROK’it for Alchemy of Grace.

FALSE TIME COMMANDS.
TRUE TIME REVEALS.
NOW TIME IS REAL.

A clock that counts for you can start to command you: it says how late, how old, which week, what counts. The Real Clock at Alchemy of Grace was built the other way. It starts with observation: the Moon you can see, the Sun’s place in its yearly circuit, the equinoxes and solstices, and what you notice and make of it.

Underneath there is arithmetic. We keep it, show all of it, and test it, because anyone should be able to check it. But we label it for what it is: CALCULATION / TRANSLATION. It is not the observation, not the experience, not the interpretation, and not cosmology. The numbers are only for comparison, and they never overwrite what you saw.

Observation first. Calculation may describe. Calculation does not dictate cosmology. Alchemy of Grace remains cosmologically open: the page claims nothing about what moves, what turns, how far anything is, or how the heavens are built.

Return to the living clock at Alchemy of Grace →

The calculation

Published formulas turn what you can see in the sky into numbers you can check.

Explanation

CALCULATION / TRANSLATION ≠ OBSERVATION ≠ EXPERIENCE ≠ INTERPRETATION ≠ COSMOLOGY. What follows is conventional astronomical arithmetic, used to describe what is observed. Words in the source such as geocentric, ecliptic or sidereal name coordinate systems the algorithm uses. They are not Alchemy of Grace doctrine, and they do not state that anything spins or orbits, that the Sun or Moon sit at any distance, or that any structure of the heavens is established here.

Moon phase and illumination
The Moon’s angular separation from the Sun; illumination = (1 − cos separation)/2. The principal phases are named only within about half a day of the instant; otherwise crescent (under half lit) or gibbous (over half lit), waxing or waning.
New and Full Moon
A mean time plus periodic terms, converted to universal time.
Sun’s position on its yearly circuit
An angle from 0° to 360°. 0° marks the March equinox, 90° the June solstice, 180° the September equinox, 270° the December solstice. The angle marks position within the observed yearly circuit.
Equinox, solstice
A mean instant plus periodic terms.
Day of the year
Calendar arithmetic on the UTC date. No calendar of our own.
“About N Moons”
Whole elapsed days from a birth date, divided by 29.530588853 and rounded. A translation, never a governing time. The birth Moon is reconstructed at noon UTC and marked uncertain when its named phase changes within half a day.
Sources · math · precision

Every calculation is a published formula (Jean Meeus, Astronomical Algorithms, 2nd ed.): Moon phase angle (ch. 48), New and Full Moon with 25 periodic terms and 14 further corrections (ch. 49), the Sun’s low-accuracy position (ch. 25), equinoxes and solstices with 24 periodic terms (ch. 27), converted from dynamical to universal time with the Espenak–Meeus ΔT polynomials. Run on the visitor’s own device. Nothing is fetched.

Precision, stated: Moon phase times and equinox and solstice times about two minutes; illuminated fraction about one percentage point. Direct observation always outranks the numbers.

Reproduce a value: from the AOG repository, node -e "import('./front/src/clock.js').then(c=>console.log(c.moonPhase(new Date('2026-11-09T13:40:00Z'))))".

The code

The whole calculation is one file. You can read it.

Explanation

The files Alchemy of Grace ships, byte for byte, each with its sha256. The calculation is pure functions of a date: no network, no storage, no personal data.

clock.js · the whole calculation

front/src/clock.js · 13318 bytes · sha256 4df8045977da78b52f0759f29377e9589bc84433beeff628c43b37876738926a

The Worker imports this file, and visitors’ browsers load the same bytes at /_a/clock.js.

// AOG REAL CLOCK. Observational astronomy only. Nothing here is a calendar of our own invention.
//
// What this file does: from a moment in time it calculates the Moon's phase and illumination, the Sun's position along its
// observed yearly circuit, and when the next Moon phases, equinoxes and solstices fall. These are CALCULATION / TRANSLATION:
// conventional astronomical mathematics used to describe what is observed. OBSERVATION FIRST. CALCULATION MAY DESCRIBE.
// CALCULATION DOES NOT DICTATE COSMOLOGY: nothing here states what moves, what turns, how far anything is, or how the heavens are built. Method: Jean Meeus, "Astronomical Algorithms" (2nd ed., 1998):
//   ch. 25 (Sun, low accuracy) · ch. 27 (equinoxes and solstices) · ch. 48 (Moon phase angle) · ch. 49 (Moon phases).
// What it does NOT do: invent lunar hours, minutes or seconds; count years of its own; divide the year into 13 x 28;
// name seasons of its own. "About N Moons" is a translation of elapsed days, never a governing time.
// Accuracy (see test/clock.test.mjs): Moon phase times within about 2 minutes, equinox/solstice times within about
// 2 minutes (2000-2050), illuminated fraction within about 1 percentage point. Direct observation always outranks it.
// No network, no storage, no personal data: pure functions of a Date. Served as-is at /_a/clock.js.

const RAD = Math.PI / 180;
const SYNODIC = 29.530588853; // mean synodic month, days
const sin = (d) => Math.sin(d * RAD);
const cos = (d) => Math.cos(d * RAD);
const mod = (x, m) => ((x % m) + m) % m;

export const jdFromMs = (ms) => ms / 86400000 + 2440587.5;
export const msFromJd = (jd) => (jd - 2440587.5) * 86400000;
const tOf = (jd) => (jd - 2451545) / 36525;

// Delta T (TT - UT) in seconds, Espenak and Meeus polynomials; only needed to turn event times into UT.
export function deltaT(year) {
  const y = year;
  if (y >= 2005 && y < 2050) { const t = y - 2000; return 62.92 + 0.32217 * t + 0.005589 * t * t; }
  if (y >= 2050 && y <= 2150) return -20 + 32 * ((y - 1820) / 100) ** 2 - 0.5628 * (2150 - y);
  if (y >= 1986 && y < 2005) { const t = y - 2000; return 63.86 + 0.3345 * t - 0.060374 * t ** 2 + 0.0017275 * t ** 3 + 0.000651814 * t ** 4 + 0.00002373599 * t ** 5; }
  if (y >= 1961 && y < 1986) { const t = y - 1975; return 45.45 + 1.067 * t - t * t / 260 - t ** 3 / 718; }
  if (y >= 1941 && y < 1961) { const t = y - 1950; return 29.07 + 0.407 * t - t * t / 233 + t ** 3 / 2547; }
  return 69; // outside 1941-2150: a rough value; event times there carry a larger, unstated error
}
const yearOf = (jd) => 2000 + (jd - 2451545) / 365.2425;

// ── Moon phase right now (ch. 48): elongation of the Moon from the Sun, illuminated fraction, waxing or waning ──
export const PHASES = ['NEW MOON', 'WAXING CRESCENT', 'FIRST QUARTER', 'WAXING GIBBOUS', 'FULL MOON', 'WANING GIBBOUS', 'LAST QUARTER', 'WANING CRESCENT'];
export function moonPhase(date) {
  const jd = jdFromMs(date.getTime()); const T = tOf(jd);
  const D = 297.8501921 + 445267.1114034 * T - 0.0018819 * T * T + T ** 3 / 545868 - T ** 4 / 113065000;
  const M = 357.5291092 + 35999.0502909 * T - 0.0001536 * T * T + T ** 3 / 24490000;
  const Mp = 134.9633964 + 477198.8675055 * T + 0.0087414 * T * T + T ** 3 / 69699 - T ** 4 / 14712000;
  const elong = mod(D + 6.289 * sin(Mp) - 2.100 * sin(M) + 1.274 * sin(2 * D - Mp) + 0.658 * sin(2 * D) + 0.214 * sin(2 * Mp) + 0.110 * sin(D), 360);
  const fraction = (1 - cos(elong)) / 2;
  // Quarters and new/full are instants: named only within 6 degrees (about half a day) of them. Otherwise the Moon is a
  // crescent (under half lit) or gibbous (over half lit), waxing or waning.
  const near = (c) => Math.abs(mod(elong - c + 180, 360) - 180) <= 6;
  const index = near(0) ? 0 : near(90) ? 2 : near(180) ? 4 : near(270) ? 6 : elong < 90 ? 1 : elong < 180 ? 3 : elong < 270 ? 5 : 7;
  return { elongation: elong, fraction, waxing: elong < 180, name: PHASES[index], index };
}

// SVG path (r = 50, centred 0,0) for the lit part of the Moon, drawn as seen from the northern hemisphere.
export function moonPath(fraction, waxing, r = 50) {
  const rx = Math.abs(1 - 2 * fraction) * r;
  if (fraction < 0.01) return '';
  if (fraction > 0.99) return `M0 ${-r}A${r} ${r} 0 1 1 0 ${r}A${r} ${r} 0 1 1 0 ${-r}Z`;
  const d = `M0 ${-r}A${r} ${r} 0 0 ${waxing ? 1 : 0} 0 ${r}A${rx.toFixed(2)} ${r} 0 0 ${(fraction > 0.5) === waxing ? 1 : 0} 0 ${-r}Z`;
  return d;
}

// ── Moon phase events (ch. 49): true times of new and full Moon, UT ──
const NEW_TERMS = [[-0.40720, 'Mp'], [0.17241, 'M', 1], [0.01608, '2Mp'], [0.01039, '2F'], [0.00739, 'Mp-M', 1], [-0.00514, 'Mp+M', 1], [0.00208, '2M', 2], [-0.00111, 'Mp-2F'], [-0.00057, 'Mp+2F'], [0.00056, '2Mp+M', 1], [-0.00042, '3Mp'], [0.00042, 'M+2F', 1], [0.00038, 'M-2F', 1], [-0.00024, '2Mp-M', 1], [-0.00017, 'O'], [-0.00007, 'Mp+2M'], [0.00004, '2Mp-2F'], [0.00004, '3M'], [0.00003, 'Mp+M-2F'], [0.00003, '2Mp+2F'], [-0.00003, 'Mp+M+2F'], [0.00003, 'Mp-M+2F'], [-0.00002, 'Mp-M-2F'], [-0.00002, '3Mp+M'], [0.00002, '4Mp']];
const FULL_TERMS = NEW_TERMS.map((t, i) => [[-0.40614, 0.17302, 0.01614, 0.01043, 0.00734, -0.00515, 0.00209][i] ?? t[0], ...t.slice(1)]);
const ARG = { 'Mp': (a) => a.Mp, 'M': (a) => a.M, '2Mp': (a) => 2 * a.Mp, '2F': (a) => 2 * a.F, 'Mp-M': (a) => a.Mp - a.M, 'Mp+M': (a) => a.Mp + a.M, '2M': (a) => 2 * a.M, 'Mp-2F': (a) => a.Mp - 2 * a.F, 'Mp+2F': (a) => a.Mp + 2 * a.F, '2Mp+M': (a) => 2 * a.Mp + a.M, '3Mp': (a) => 3 * a.Mp, 'M+2F': (a) => a.M + 2 * a.F, 'M-2F': (a) => a.M - 2 * a.F, '2Mp-M': (a) => 2 * a.Mp - a.M, 'O': (a) => a.O, 'Mp+2M': (a) => a.Mp + 2 * a.M, '2Mp-2F': (a) => 2 * a.Mp - 2 * a.F, '3M': (a) => 3 * a.M, 'Mp+M-2F': (a) => a.Mp + a.M - 2 * a.F, '2Mp+2F': (a) => 2 * a.Mp + 2 * a.F, 'Mp+M+2F': (a) => a.Mp + a.M + 2 * a.F, 'Mp-M+2F': (a) => a.Mp - a.M + 2 * a.F, 'Mp-M-2F': (a) => a.Mp - a.M - 2 * a.F, '3Mp+M': (a) => 3 * a.Mp + a.M, '4Mp': (a) => 4 * a.Mp };
// k: integer = new Moon, integer + 0.5 = full Moon (k = 0 is the new Moon of 2000 January 6). Returns JDE (dynamical time).
export function phaseJDE(k) {
  const T = k / 1236.85;
  const jde = 2451550.09766 + 29.530588861 * k + 0.00015437 * T * T - 0.000000150 * T ** 3 + 0.00000000073 * T ** 4;
  const E = 1 - 0.002516 * T - 0.0000074 * T * T;
  const a = {
    M: 2.5534 + 29.10535670 * k - 0.0000014 * T * T - 0.00000011 * T ** 3,
    Mp: 201.5643 + 385.81693528 * k + 0.0107582 * T * T + 0.00001238 * T ** 3 - 0.000000058 * T ** 4,
    F: 160.7108 + 390.67050284 * k - 0.0016118 * T * T - 0.00000227 * T ** 3 + 0.000000011 * T ** 4,
    O: 124.7746 - 1.56375588 * k + 0.0020672 * T * T + 0.00000215 * T ** 3,
  };
  const full = Math.abs(k - Math.round(k)) > 0.25;
  let s = 0;
  for (const [c, key, e = 0] of (full ? FULL_TERMS : NEW_TERMS)) s += c * (e === 0 ? 1 : E ** e) * sin(ARG[key](a));
  const A = [[299.77, 0.107408, 0.000325], [251.88, 0.016321, 0.000165], [251.83, 26.651886, 0.000164], [349.42, 36.412478, 0.000126], [84.66, 18.206239, 0.00011], [141.74, 53.303771, 0.000062], [207.14, 2.453732, 0.00006], [154.84, 7.306860, 0.000056], [34.52, 27.261239, 0.000047], [207.19, 0.121824, 0.000042], [291.34, 1.844379, 0.00004], [161.72, 24.198154, 0.000037], [239.56, 25.513099, 0.000035], [331.55, 3.592518, 0.000023]];
  let p = 0;
  A.forEach(([a0, a1, c], i) => { p += c * sin(a0 + a1 * k - (i === 0 ? 0.009173 * T * T : 0)); });
  return jde + s + p;
}
export const phaseDate = (k) => { const jde = phaseJDE(k); return new Date(msFromJd(jde - deltaT(yearOf(jde)) / 86400)); };

// Previous and next new / full Moon around a moment.
export function moonEvents(date) {
  const k0 = Math.floor((yearOf(jdFromMs(date.getTime())) - 2000) * 12.3685) - 2;
  const list = [];
  for (let i = 0; i < 8; i++) { const k = k0 + i / 2; list.push({ kind: Number.isInteger(k) ? 'NEW MOON' : 'FULL MOON', at: phaseDate(k) }); }
  const t = date.getTime();
  const next = (kind) => list.find((e) => e.kind === kind && e.at.getTime() > t);
  const prev = (kind) => [...list].reverse().find((e) => e.kind === kind && e.at.getTime() <= t);
  return { prevNew: prev('NEW MOON'), nextNew: next('NEW MOON'), prevFull: prev('FULL MOON'), nextFull: next('FULL MOON') };
}

// ── Sun (ch. 25, low accuracy): position along the observed yearly circuit, as an angle (conventionally "ecliptic longitude"). 0 = March equinox, 90 = June solstice, 180, 270. A calculation, not a claim about what moves. ──
export function sunLongitude(date) {
  const T = tOf(jdFromMs(date.getTime()));
  const L0 = 280.46646 + 36000.76983 * T + 0.0003032 * T * T;
  const M = 357.52911 + 35999.05029 * T - 0.0001537 * T * T;
  const C = (1.914602 - 0.004817 * T - 0.000014 * T * T) * sin(M) + (0.019993 - 0.000101 * T) * sin(2 * M) + 0.000289 * sin(3 * M);
  const O = 125.04 - 1934.136 * T;
  return mod(L0 + C - 0.00569 - 0.00478 * sin(O), 360);
}

// ── Equinoxes and solstices (ch. 27) ──
const SEASON_MEAN = [
  [2451623.80984, 365242.37404, 0.05169, -0.00411, -0.00057],
  [2451716.56767, 365241.62603, 0.00325, 0.00888, -0.00030],
  [2451810.21715, 365242.01767, -0.11575, 0.00337, 0.00078],
  [2451900.05952, 365242.74049, -0.06223, -0.00823, 0.00032],
];
const SEASON_TERMS = [[485, 324.96, 1934.136], [203, 337.23, 32964.467], [199, 342.08, 20.186], [182, 27.85, 445267.112], [156, 73.14, 45036.886], [136, 171.52, 22518.443], [77, 222.54, 65928.934], [74, 296.72, 3034.906], [70, 243.58, 9037.513], [58, 119.81, 33718.147], [52, 297.17, 150.678], [50, 21.02, 2281.226], [45, 247.54, 29929.562], [44, 325.15, 31555.956], [29, 60.93, 4443.417], [18, 155.12, 67555.328], [17, 288.79, 4562.452], [16, 198.04, 62894.029], [14, 199.76, 31436.921], [12, 95.39, 14577.848], [12, 287.11, 31931.756], [12, 320.81, 34777.259], [9, 227.73, 1222.114], [8, 15.45, 16859.074]];
export const SEASON_NAMES = ['MARCH EQUINOX', 'JUNE SOLSTICE', 'SEPTEMBER EQUINOX', 'DECEMBER SOLSTICE'];
export function seasonDate(year, n) { // n: 0 March equinox, 1 June solstice, 2 September equinox, 3 December solstice
  const Y = (year - 2000) / 1000; const [a, b, c, d, e] = SEASON_MEAN[n];
  const jde0 = a + b * Y + c * Y ** 2 + d * Y ** 3 + e * Y ** 4;
  const T = (jde0 - 2451545) / 36525;
  const W = 35999.373 * T - 2.47;
  const dl = 1 + 0.0334 * cos(W) + 0.0007 * cos(2 * W);
  let S = 0; for (const [A, B, C] of SEASON_TERMS) S += A * cos(B + C * T);
  const jde = jde0 + 0.00001 * S / dl;
  return new Date(msFromJd(jde - deltaT(year) / 86400));
}
export function seasonEvents(date) {
  const y = date.getUTCFullYear(); const all = [];
  for (let yy = y - 1; yy <= y + 1; yy++) for (let n = 0; n < 4; n++) all.push({ kind: SEASON_NAMES[n], at: seasonDate(yy, n) });
  all.sort((p, q) => p.at - q.at);
  const t = date.getTime();
  return { prev: [...all].reverse().find((e) => e.at.getTime() <= t), next: all.find((e) => e.at.getTime() > t) };
}

// ── Everything NOW needs, in one call ──
export function now(date = new Date()) {
  const moon = moonPhase(date); const ev = moonEvents(date);
  const age = ev.prevNew ? (date - ev.prevNew.at) / 86400000 : null;
  const lon = sunLongitude(date); const se = seasonEvents(date);
  return { at: date, moon: { ...moon, ageDays: age, prevNew: ev.prevNew.at, nextNew: ev.nextNew.at, prevFull: ev.prevFull.at, nextFull: ev.nextFull.at },
    sun: { longitude: lon, since: se.prev, until: se.next },
    earth: { dayOfYear: Math.floor((Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) - Date.UTC(date.getUTCFullYear(), 0, 0)) / 86400000), circuitDegrees: lon } };
}

// ── The Record: Day Zero is a person's birth, once. Local to the device; never sent anywhere. ──
const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
export function parseDayZero(iso) {
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ''));
  if (!m) return null;
  const y = +m[1], mo = +m[2], d = +m[3]; const t = new Date(Date.UTC(y, mo - 1, d));
  if (t.getUTCFullYear() !== y || t.getUTCMonth() !== mo - 1 || t.getUTCDate() !== d || y < 1800) return null;
  return t;
}
export const formatDayZero = (iso) => { const t = parseDayZero(iso); return t ? `${MONTHS[t.getUTCMonth()]} ${String(t.getUTCDate()).padStart(2, '0')} ${t.getUTCFullYear()}` : null; };
// The birth Moon is RECONSTRUCTED from the date alone (noon UTC; the hour and place of birth are not known here).
// If the Moon's named phase changes within 12 hours of that noon, `uncertain` is true and the name must be softened.
export function dayZeroRecord(iso, at = new Date()) {
  const z = parseDayZero(iso); if (!z) return null;
  const noon = new Date(z.getTime() + 12 * 3600000);
  const b = moonPhase(noon);
  const uncertain = moonPhase(new Date(noon.getTime() - 12 * 3600000)).index !== b.index || moonPhase(new Date(noon.getTime() + 12 * 3600000)).index !== b.index;
  const today = Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate());
  const days = Math.round((today - z.getTime()) / 86400000);
  return { label: `DAY ZERO · ${formatDayZero(iso)}`, birthPhase: b.name, birthFraction: b.fraction, reconstructed: true, uncertain, days, moons: days >= 0 ? Math.round(days / SYNODIC) : null, isToday: days >= 0 && at.getUTCMonth() === z.getUTCMonth() && at.getUTCDate() === z.getUTCDate() };
}
now.js · how the page uses it

front/static/now.js · 7714 bytes · sha256 8875e3cbe3c19cd15e912a1fe6c48b5240224aeb4afbaed5a748c353fdd16c71

The browser script of the Real Clock page: it draws the Moon, the Sun’s place on its circuit and the events, and handles the Record, a birth date kept only on your device and only after a consent card.

// AOG NOW: draws the Real Clock page in the browser and handles the Record (Day Zero).
// Everything here is local. No network calls. Day Zero is kept only if the person says "keep it" (consent shown first),
// under the key aog_dayzero_v1, which My Data lists, downloads and removes. A date arriving as #day-zero=YYYY-MM-DD is
// read here, never sent to a server (a fragment is not part of a request), and is removed from the address bar.
import * as C from './clock.js';

const KEY = 'aog_dayzero_v1';
const CONSENT = 'aog_consent_events_v1';
const $ = (s) => document.querySelector(s);
const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
const store = {
  get(k, d) { try { const v = localStorage.getItem(k); return v ? JSON.parse(v) : d; } catch (e) { return d; } },
  set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); return true; } catch (e) { return false; } },
  del(k) { try { localStorage.removeItem(k); } catch (e) { /* nothing */ } },
};
const local = (d) => { const t = new Date(d); return `${MONTHS[t.getMonth()]} ${String(t.getDate()).padStart(2, '0')} ${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')} ${(new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' }).formatToParts(t).find((p) => p.type === 'timeZoneName') || { value: '' }).value}`; };
const setText = (sel, v) => { const e = $(sel); if (e) e.textContent = v; };
const field = $('[data-field]');
const kick = () => { if (field && field.__fieldKick) field.__fieldKick(); };

function render() {
  const at = new Date(); const n = C.now(at); const m = n.moon; const pct = Math.round(m.fraction * 100);
  setText('[data-moon-name]', m.name); setText('[data-moon-pct]', `${pct}% LIT`); setText('[data-moon-dir]', m.index === 0 || m.index === 4 ? '' : ` · ${m.waxing ? 'WAXING' : 'WANING'}`);
  const nm = $('[data-newmoon]'); if (nm) nm.hidden = m.name !== 'NEW MOON';
  const path = $('[data-moon-path]'); if (path) { path.setAttribute('d', C.moonPath(m.fraction, m.waxing)); if (m.waxing) path.removeAttribute('transform'); else path.setAttribute('transform', 'scale(-1 1)'); }
  setText('[data-moon-age]', `${m.ageDays.toFixed(1)} days since the new Moon`);
  const ev = { prevNew: m.prevNew, nextNew: m.nextNew, nextFull: m.nextFull, nextNew2: m.nextNew, nextFull2: m.nextFull };
  Object.keys(ev).forEach((k) => setText(`[data-ev="${k}"]`, local(ev[k])));
  setText('[data-ev="sunSince"]', `${n.sun.since.kind} · ${local(n.sun.since.at)}`);
  setText('[data-ev="sunUntil"]', `${n.sun.until.kind} · ${local(n.sun.until.at)}`);
  setText('[data-ev="sunUntil2"]', local(n.sun.until.at));
  setText('[data-sun-line]', `position ${n.sun.longitude.toFixed(1)}° on the observed yearly circuit`); setText('[data-sun-lon]', `${n.sun.longitude.toFixed(2)}°`);
  setText('[data-earth-line]', `${Math.round(n.sun.longitude)}° within the observed yearly circuit`);
  const hhmm = `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`;
  const zone = (new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' }).formatToParts(at).find((p) => p.type === 'timeZoneName') || { value: '' }).value;
  setText('[data-local]', `${hhmm} ${zone}`); setText('[data-clock]', `${hhmm} ${zone}`);
  const clk = $('[data-clock]'); if (clk) clk.removeAttribute('aria-hidden');
  setText('[data-utc]', `${String(at.getUTCHours()).padStart(2, '0')}:${String(at.getUTCMinutes()).padStart(2, '0')} UTC`);
  setText('[data-doy]', String(n.earth.dayOfYear));
  const nextEv = m.nextNew < m.nextFull ? ['new Moon', m.nextNew] : ['full Moon', m.nextFull];
  setText('[data-events-line]', `next: ${nextEv[0]} in ${Math.round((nextEv[1] - at) / 86400000)} days`);
}

function record(iso, kept) {
  const box = $('[data-record]'); if (!box) return;
  const r = C.dayZeroRecord(iso, new Date()); box.textContent = '';
  if (!r) { box.textContent = 'That is not a date this clock can read.'; return; }
  const line = (cls, t) => { const p = document.createElement('p'); p.className = cls; p.textContent = t; box.appendChild(p); };
  line('l1', r.label);
  line('l2', `${r.uncertain ? 'BORN NEAR ' : 'BORN '}${r.birthPhase}`);
  if (r.moons !== null) line('l3', `ABOUT ${r.moons} MOONS`);
  const note = document.createElement('p'); note.className = 'mute';
  note.textContent = `Birth Moon reconstructed from the date alone (noon UTC; birth hour and place unknown)${r.uncertain ? ', and the Moon’s named phase changes within a day of that moment' : ''}. ${r.days >= 0 ? `${r.days.toLocaleString('en-US')} days ÷ 29.53 is about ${r.moons} Moons: a translation, not a clock.` : ''}`;
  box.appendChild(note);
  setText('[data-record-line]', `${r.label} · ${r.moons === null ? '' : `ABOUT ${r.moons} MOONS`}`);
  const d = $('[data-record]').closest('details'); if (d) d.open = true;
  if (field) { field.setAttribute('data-birthday', r.isToday ? '1' : '0'); kick(); }
  if (!kept) offer(iso, box);
  else { const b = document.createElement('button'); b.type = 'button'; b.className = 'btn quiet'; b.textContent = 'Remove from this device'; b.onclick = () => { store.del(KEY); box.textContent = ''; setText('[data-record-line]', 'your Day Zero, kept only on your device'); if (field) { field.setAttribute('data-birthday', '0'); kick(); } }; box.appendChild(b); }
}

function offer(iso, box) {
  const card = document.createElement('div'); card.className = 'consent'; card.setAttribute('role', 'group'); card.setAttribute('aria-label', 'Before anything is kept');
  const facts = [['What', `The date you entered: ${C.formatDayZero(iso)}.`], ['Why', 'So this page can show your Record when you come back.'], ['Who can see it', 'Only someone using this browser on this device. Nothing is sent to Alchemy of Grace or anyone else.'], ['How long', 'Until you remove it on My Data, or clear this browser’s site data.'], ['Required?', 'No.'], ['If you decline', 'Nothing is kept. It is shown only while this page stays open.']];
  const dl = document.createElement('dl'); facts.forEach(([k, v]) => { const dt = document.createElement('dt'); dt.textContent = k; const dd = document.createElement('dd'); dd.textContent = v; dl.append(dt, dd); });
  const intro = document.createElement('p'); intro.textContent = 'Before anything is kept, here is exactly what would happen.';
  const row = document.createElement('div'); row.className = 'row';
  const mk = (t, cls, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = cls; b.textContent = t; b.onclick = fn; row.appendChild(b); return b; };
  const log = (choice) => { if (choice === 'declined') return; const ev = store.get(CONSENT, []); ev.push({ what: KEY, choice, at: new Date().toISOString() }); store.set(CONSENT, ev); };
  mk('Keep it on this device', 'btn', () => { log('keep'); store.set(KEY, { dayZero: iso, at: new Date().toISOString() }); record(iso, true); });
  mk('Just for now', 'btn ghost', () => { log('session'); card.remove(); });
  mk('Don’t keep it', 'btn quiet', () => card.remove());
  card.append(intro, dl, row); box.appendChild(card);
}

render(); setInterval(render, 30000);
const form = $('[data-dz-form]');
if (form) form.addEventListener('submit', (e) => { e.preventDefault(); const v = form.querySelector('input').value; if (v) record(v, false); });
const kept = store.get(KEY, null);
const frag = /^#day-zero=(\d{4}-\d{2}-\d{2})$/.exec(location.hash);
if (frag) { history.replaceState(null, '', location.pathname + location.search); const i = form && form.querySelector('input'); if (i) i.value = frag[1]; record(frag[1], !!(kept && kept.dayZero === frag[1])); }
else if (kept && kept.dayZero) record(kept.dayZero, true);

The tests

Every claim here runs against published values.

Explanation

11 of 11 pass at build. This page was built by running them; the build refuses to run if any fails.

  1. Meeus example 49.a: new Moon of 1977 February 18 (k = -283), JDE 2443192.65118
  2. new and full Moon times match published values within 2 minutes
  3. equinoxes and solstices of 2026 match published times within 2 minutes
  4. the Sun is at longitude 0 at the March equinox, 180 at the September equinox
  5. lit fraction: ~0 at new Moon, ~1 at full Moon, 0.5 at the quarters; waxing then waning
  6. events bracket the moment, in order
  7. Day Zero record: one birth, a reconstructed Moon, about N Moons; rejects bad dates
  8. no invention: the clock source has no lunar hours, year count, 13x28 or invented seasons
  9. privacy and boundary: no personal date is served anywhere; /time is the Real Clock only; no calendar of our own; footer door is GROK’it, not renamed
  10. approved New Moon wording (Yosi, 2026-11-09) shows only at New Moon; no direction is claimed at the turn of the Moon
  11. cosmology-neutral copy (Yosi, 2026-11-09): the clock page and source assume no model of what moves, turns or how far; calculation is labelled
Test source · run it yourself

front/test/clock.test.mjs · 6772 bytes · sha256 629d38a5b0bf106ffc4841c9a483860d0718d669cd12d16077de636d0f889e7d

From the AOG repository: cd front && node --test test/clock.test.mjs.

// Real Clock tests: published reference values, structure, and the privacy and no-invention boundaries.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import * as C from '../src/clock.js';
import worker from '../src/worker.js';
import { makeEnv } from './harness.mjs';

const mins = (a, b) => Math.abs(a - b) / 60000;

test('Meeus example 49.a: new Moon of 1977 February 18 (k = -283), JDE 2443192.65118', () => {
  assert.ok(Math.abs(C.phaseJDE(-283) - 2443192.65118) < 0.00002);
});
test('new and full Moon times match published values within 2 minutes', () => {
  assert.ok(mins(C.phaseDate(0), new Date('2000-01-06T18:14:00Z')) < 2);      // new Moon, 2000 Jan 6 18:14 UT
  assert.ok(mins(C.phaseDate(0.5), new Date('2000-01-21T04:40:00Z')) < 2);    // full Moon, 2000 Jan 21 04:40 UT
});
test('equinoxes and solstices of 2026 match published times within 2 minutes', () => {
  const ref = ['2026-03-20T14:46:00Z', '2026-06-21T08:24:00Z', '2026-09-23T00:05:00Z', '2026-12-21T20:50:00Z'];
  ref.forEach((r, n) => assert.ok(mins(C.seasonDate(2026, n), new Date(r)) < 2, C.SEASON_NAMES[n]));
});
test('the Sun is at longitude 0 at the March equinox, 180 at the September equinox', () => {
  const d0 = Math.abs(((C.sunLongitude(C.seasonDate(2026, 0)) + 180) % 360) - 180);
  const d2 = Math.abs(C.sunLongitude(C.seasonDate(2026, 2)) - 180);
  assert.ok(d0 < 0.01 && d2 < 0.01);
});
test('lit fraction: ~0 at new Moon, ~1 at full Moon, 0.5 at the quarters; waxing then waning', () => {
  assert.ok(C.moonPhase(C.phaseDate(0)).fraction < 0.01);
  assert.ok(C.moonPhase(C.phaseDate(0.5)).fraction > 0.99);
  const q = C.moonPhase(new Date(C.phaseDate(0).getTime() + 7.38 * 86400000));
  assert.ok(Math.abs(q.fraction - 0.5) < 0.08 && q.waxing && q.name === 'FIRST QUARTER');
  assert.equal(C.moonPhase(new Date(C.phaseDate(0.5).getTime() + 3 * 86400000)).name, 'WANING GIBBOUS');
  assert.equal(C.moonPhase(new Date(C.phaseDate(1).getTime() - 3 * 86400000)).name, 'WANING CRESCENT');
});
test('events bracket the moment, in order', () => {
  const t = new Date('2030-05-17T10:00:00Z'); const e = C.moonEvents(t);
  assert.ok(e.prevNew.at <= t && e.nextNew.at > t && e.prevFull.at <= t && e.nextFull.at > t);
  assert.ok((e.nextNew.at - e.prevNew.at) / 86400000 > 29 && (e.nextNew.at - e.prevNew.at) / 86400000 < 30);
});
test('Day Zero record: one birth, a reconstructed Moon, about N Moons; rejects bad dates', () => {
  const r = C.dayZeroRecord('1970-01-01', new Date('2026-01-01T00:00:00Z'));
  assert.equal(r.label, 'DAY ZERO · JAN 01 1970'); assert.equal(r.reconstructed, true);
  assert.equal(r.days, 20454); assert.equal(r.moons, Math.round(20454 / 29.530588853));
  assert.equal(C.dayZeroRecord('1970-02-30'), null); assert.equal(C.dayZeroRecord('nonsense'), null);
  assert.equal(C.dayZeroRecord('2999-01-01', new Date('2026-01-01T00:00:00Z')).moons, null, 'future date: no count');
  assert.equal(C.dayZeroRecord('1970-01-01', new Date('2026-06-01T00:00:00Z')).isToday, false);
  assert.equal(C.dayZeroRecord('1970-01-01', new Date('2026-01-01T09:00:00Z')).isToday, true);
});
test('no invention: the clock source has no lunar hours, year count, 13x28 or invented seasons', () => {
  const src = readFileSync(new URL('../src/clock.js', import.meta.url), 'utf8').replace(/\/\/.*$/gm, '');
  assert.ok(!/lunar ?hour|lunarHour|13 ?[x*×] ?28|thirteen|season\s*=\s*22|22 seasons/i.test(src));
});
test('privacy and boundary: no personal date is served anywhere; /time is the Real Clock only; no calendar of our own; footer door is GROK’it, not renamed', async () => {
  const env = makeEnv(JSON.stringify({ v: 2, approved: true, updated: new Date().toISOString().slice(0, 10), fire: [{ lane: 'LISTEN', text: 'x' }] }));
  for (const p of ['/', '/time', '/fire', '/_a/clock.js', '/_a/now.js', '/_a/field.js', '/_a/field.css']) {
    const r = await worker.fetch(new Request('https://alchemyofgrace.org' + p), env);
    assert.equal(r.status, 200, p); const t = await r.text();
    assert.ok(!/1963/.test(t), 'no birth year in ' + p);
    assert.ok(!/crockett/i.test(t), 'GROK’it is not renamed publicly ' + p);
    assert.ok(!/\b(fetch|XMLHttpRequest|sendBeacon|WebSocket)\b\s*\(/.test(t), 'no network call in ' + p);
    if (p === '/time') { assert.match(t, /data-field="time"/); assert.match(t, /MOON/); assert.ok(!/13 ?[x×] ?28|thirteen cycles|religious calendar/i.test(t), 'no 13x28 on /time'); }
    if (p === '/' || p === '/fire') { assert.match(t, /GROK’it<\/b> · SEE HOW THIS WAS BUILT/); assert.match(t, /href="https:\/\/grokit\.liftthesparks\.com\/under-the-hood"/); }
  }
  const home = await (await worker.fetch(new Request('https://alchemyofgrace.org/'), env)).text();
  assert.match(home, /\/_a\/field\.js/); assert.match(home, /\/_a\/field\.css/);
});

test('approved New Moon wording (Yosi, 2026-11-09) shows only at New Moon; no direction is claimed at the turn of the Moon', async () => {
  const env = makeEnv(JSON.stringify({ v: 2, approved: true, updated: new Date().toISOString().slice(0, 10), fire: [{ lane: 'LISTEN', text: 'x' }] }));
  const html = await (await worker.fetch(new Request('https://alchemyofgrace.org/time'), env)).text();
  assert.match(html, /The New Moon invites rest and renewal\.<br>Living fires come first\./);
  const now = await import('../src/clock.js'); const dark = now.moonPhase(now.phaseDate(0)), full = now.moonPhase(now.phaseDate(0.5));
  assert.equal(dark.name, 'NEW MOON'); assert.equal(full.name, 'FULL MOON'); assert.ok(dark.index === 0 && full.index === 4);
});

test('cosmology-neutral copy (Yosi, 2026-11-09): the clock page and source assume no model of what moves, turns or how far; calculation is labelled', async () => {
  const env = makeEnv(JSON.stringify({ v: 2, approved: true, updated: new Date().toISOString().slice(0, 10), fire: [{ lane: 'LISTEN', text: 'x' }] }));
  const html = await (await worker.fetch(new Request('https://alchemyofgrace.org/time'), env)).text();
  const src = readFileSync(new URL('../src/clock.js', import.meta.url), 'utf8') + readFileSync(new URL('../static/now.js', import.meta.url), 'utf8');
  const model = /orbit|revolv|rotat|spins?\b|travell?ed|heliocentr|geocentr|around the sun|around it|earth.s (own|turning|motion)|astronomical unit|light-?minutes|solar system/i;
  assert.ok(!model.test(html.replace(/<script[\s\S]*?<\/script>/g, '')), 'no model-assuming words on /time');
  assert.ok(!model.test(src), 'no model-assuming words in the clock source');
  assert.match(html, /0° marks the March equinox\. The angle marks position within the observed yearly circuit\./);
  assert.match(html, /CALCULATION \/ TRANSLATION/);
  assert.ok(!/Earth has travelled|how far Earth/i.test(html));
});

The limits

Where the numbers stop, and what they never claim.

Explanation
  • A calculation is not an observation, an experience, an interpretation or a cosmology. The page never shows one as another, and never lets one overwrite another.
  • Checked against published values: a worked example from the method’s book, the New and Full Moons of January 2000, and the 2026 equinoxes and solstices. Not an almanac; not for navigation or for any practice that depends on a sighting.
  • The ΔT polynomial is for 1941 to 2150; outside that range event times carry an error that is not stated here.
  • There is no sunrise, sunset, rise or set: those need your place, and the page never asks for it. Eclipses are not calculated.
  • The Moon picture is drawn as seen from the northern hemisphere. It ignores wobble and your latitude.
  • A birth from a date alone is reconstructed at noon UTC and marked as approximate.
  • “About N Moons” is an approximate translation, not a count the clock depends on.
  • No lunar hours, minutes or seconds, no year count of its own, no 13 × 28 calendar, and no seasonal framework are built or implied.
  • Not yet checked on a real phone by the owner. Visual review is by the owner.

Privacy

Nothing leaves your device.

Explanation
  • No network request, cookie, analytics, third-party script or location request from the clock or its page script (tested).
  • A birth date is typed in, or carried in the part of a web address after “#”, which a browser never sends to a server. The page removes it from the address bar.
  • Nothing is kept without an affirmative choice: one key, after a consent card, removable from My Data.
  • The clock source itself has no storage of any kind.

The record

What changed, why, who, and how to undo it.

Technical record · UH-01
What changed
Revision 3. Progressive-depth navigation: a side rail, a slim edge rail on small screens, a memory bar beside each section, and three depths per section (answer, explanation, sources · math · code · tests) that collapse back to the simple surface. Revision 2: the page shows, byte for byte, the Real Clock that Alchemy of Grace actually ships, and states that calculation / translation is not observation, experience, interpretation or cosmology.
Why
Surface: simple. Depth: everything is here. Revision 1 described a candidate that was not what ships, while saying it was; anyone should be able to see, run and check the calculation that is really running, and see where it stops, without it ever becoming the clock or a cosmology.
Source
front/src/clock.js, front/static/now.js and front/test/clock.test.mjs in the AOG repository, shown on this page byte for byte with their sha256.
Status
PUBLISHED · read-only · tests passing at build (see The tests). Verification against the live host is recorded in the release record, not claimed here.
When
2026-11-09
Who / capacity
GROK’it builder session (AI-assisted drafter) for Alchemy of Grace. Publishing authority: Yosi.
Rollback
Restore the previous page version, or remove /under-the-hood and the one AOG link to it. Nothing else depends on this page.

Return to the living clock at Alchemy of Grace →