Pick magic signs from an Astronomy sign (moon phase, planet, constellation, element, color), cast a spell, and get back a real astronomy reading: science interpretation, tonight's sky events, and an observation challenge. A second sign, Ocean, ships too, mostly to prove the architecture holds up.
Everything lives in magic.html — one file, no build step, no
dependencies to install. That's a deliberate choice for a Telegram mini-app:
Telegram just needs a URL that serves HTML, so one file is the easiest
possible thing to host and keep working.
Open magic.html directly in a browser. It runs standalone outside
Telegram — the Telegram SDK script is loaded defensively
(window.Telegram?.WebApp), so it just no-ops if it's not present.
For real use, serve it over HTTPS (GitHub Pages, Vercel, Netlify, Cloudflare Pages, or any static host all work) rather than opening the file directly — Telegram requires HTTPS, and geolocation prompts generally need a real origin too.
- Host
magic.htmlsomewhere public over HTTPS. - Message @BotFather on Telegram →
/newapp(or/newbotfirst if you don't have a bot yet, then/newappon it). - Give it a name and icon, and paste your hosted URL when asked.
- BotFather gives you a
t.me/YourBot/YourApplink — that's the mini-app.
The moon phase, planet positions, sunrise/sunset, and rise/set times are computed from actual orbital mechanics in the browser — no API key, no backend, works offline. Specifically:
- Moon phase — from the real angular separation between the Sun and Moon (not a fixed 29.5-day cycle guess), cross-checked against live almanac data to within ~1–3% illumination.
- Planets — NASA/JPL's published low-precision Keplerian elements (valid 1800–2050), solved for position, then converted to altitude/azimuth for your location. Validated two ways: physical limits (Mercury/Venus elongation never exceeds their real ~28°/47° maximums across a 400-day scan) and a live cross-check against real-world "what's visible tonight" reporting — it correctly reproduced which planets were up, in the right part of the sky, at the right time of night.
- Meteor showers — a fixed calendar of the 11 major annual showers, cross-checked against the current AMS/IMO calendar.
- Sunrise/sunset — the standard NOAA solar-position formula.
- Tides (Ocean sign) — real spring/neap tide regime, derived from the actual Sun–Moon angle. It won't give you a precise tide time for a specific beach — that needs local harmonic tide-station data this app doesn't have — so it intentionally doesn't pretend to.
This is naked-eye, "which way do I look" precision, not arcsecond ephemeris — that's the right level for a stargazing app, and it's honest about the difference (see the footer disclaimer in the app itself).
The file has three layers, in this order:
- Core (
TelegramBridge,MagicRegistry,SpellGenerator,el()) — knows nothing about astronomy, oceans, or anything else. It just renders whatever categories a registered domain declares, and generates spell names from whateversigilfragments a symbol supplies. - A domain's data + engine — e.g.
AstronomyData/AstronomyEngine. Pure calculation, no UI, no magic framing. - A domain's registration — e.g.
registerAstronomyDomain(). This is the only layer that knows both the real-world subject and the "magic" framing. It defines the symbols and two functions, then callsMagicRegistry.register(...).
The Ocean domain in the file is a working example of step 3, built to
prove the contract — it even reuses AstronomyEngine.getMoonPhase() for
genuine spring/neap tide logic, without touching a single line of the
Astronomy domain's own code.
You only ever need to write one thing: an object with categories and two
functions, registered once. Nothing in Core or in other domains changes.
MagicRegistry.register({
id: 'weather',
name: 'Weather',
icon: '⛈',
categories: [
{
id: 'sky', name: 'Sky', maxSelect: 1,
symbols: [
{ id: 'clear', name: 'Clear', glyph: '☀',
sigil: { epithet: 'Open', root: 'Vault', power: 'Clarity' } },
{ id: 'storm', name: 'Storm', glyph: '⛈',
sigil: { epithet: 'Roaring', root: 'Tempest', power: 'Upheaval' } }
// ...
]
}
// more categories: wind, precipitation, whatever fits
],
// Required: return { scienceText, skyEvents: [{glyph, label, value}, ...] }
computeInsight(selection, ctx) {
// ctx = { date, lat, lon, hemisphere }
// call a real weather API here, or your own calculation
return { scienceText: '...', skyEvents: [...] };
},
// Required: return one actionable string
generateChallenge(selection, insight, ctx) {
return 'Step outside and ...';
}
});Every symbol needs a sigil: { epithet, root, power } — three words the
generic SpellGenerator mixes into a name. That's the entire integration
surface. The category picker, the sigil-circle drawing, the domain switcher
tab, and the result cards all pick this up automatically because they're
written against the registry, not against "astronomy" by name.
| Section | What it is |
|---|---|
<style> |
Design tokens + component CSS |
TelegramBridge |
Telegram WebApp SDK wrapper (haptics, theming) |
MagicRegistry |
The domain plug-in system |
SpellGenerator |
Domain-agnostic spell name generator |
AstronomyData / AstronomyEngine |
Orbital mechanics, validated |
registerAstronomyDomain() |
Astronomy's symbols + content |
registerOceanDomain() |
Ocean's symbols + content (the demo sign) |
App |
DOM rendering + event wiring, generic over any domain |
If this grows past one file, the natural next step is to split each of
those into its own module (core/, domains/astronomy/,
domains/ocean/) — the boundaries above are already exactly where the
<script> tags would become import/export module files.