StudyPages

A desktop app that turns any topic into a rich, interactive study page


Turning any topic into a proper lesson

When you ask an AI to teach you something, you usually get a wall of text. StudyPages was my attempt at something better: you type in any topic, and it builds you a full, scrollable, visual study page, with headings, callouts, tables, charts, timelines, quizzes, code blocks, and embedded videos. Not a chat log, an actual lesson you can save and come back to.

It’s a Windows desktop app, and it’s one of the more “real software” projects I’ve made, the kind with a proper UI, a backend, saved files, and a packaged installer.

A quick note of honesty: the code for this one is a bit of a mess right now and I haven’t cleaned it up yet, so this write-up is about the shape of the thing rather than a line-by-line tour. I’ll flesh it out once I’ve tidied it.

How it works

The flow, end to end, looks like this:

  1. You open the app (built with CustomTkinter, a themed desktop UI toolkit) and type a topic into the front page.
  2. It kicks off the request on a background thread, showing a loading animation so the window never freezes, and sends your topic plus a big system prompt to a Cloudflare Worker.
  3. That Worker is the brain-behind-the-curtain. It proxies the request to an AI model (Gemini) and hands back a structured response. Crucially, the AI is told to reply as JSON, a list of typed “blocks,” not as prose.
  4. The app takes that JSON and renders each block as its own styled card, walking the list and drawing a heading, a chart, a quiz, a table, whatever each block says it is.
  5. Lessons get saved to disk as JSON so you can reopen them later without regenerating.

The part I’m proud of: the block system

The heart of StudyPages is that the AI doesn’t write me a page, it writes me a structured description of a page, and my app decides how to draw it. Every block has a type (heading, callout, chart, mcq, timeline, code, and so on), and each type has its own renderer function registered in a big lookup table.

So the model returns something like this, and my app decides what it looks like:

{ "blocks": [
    { "type": "heading",   "text": "The Solar System" },
    { "type": "callout",   "style": "info", "text": "Jupiter is 2.5x all the others combined." },
    { "type": "chart",     "chart_type": "bar", "data": [...] },
    { "type": "mcq",       "question": "Which planet is largest?",
                           "options": ["Earth", "Jupiter"], "answer": 1 }
] }

Every block type just maps to a render function in one lookup table:

BLOCK_RENDERERS = {
    'paragraph': render_text,
    'keypoints': render_keypoints,
    'example':   render_example,
    'mcq':       render_mcq,
    'callout':   render_callout,
    'timeline':  render_timeline,
    ...
}

The nice thing about that design is how cleanly it grows: to add a brand-new kind of card I write one render function, register it here, and describe it in the system prompt so the model knows it’s allowed to emit it. It’s basically a little plugin system where the “plugins” are card types. That separation — the model proposes structured content, the app owns how it looks — is the idea I’m most happy with, and it means a bad response degrades into a plain paragraph instead of a broken screen.

The lesson: LLM output is messy

The thing nobody warns you about is that models rarely hand back clean JSON. They wrap it in explanation, or code fences, or a <reasoning> block, or a friendly sentence before the actual data. If you just try to parse the raw response, it blows up constantly.

So a surprising amount of the client is a robust JSON extractor. It runs a small gauntlet on whatever comes back:

# 1. strip a <reasoning>...</reasoning> block if the model "thought out loud"
# 2. unwrap ```json ... ``` fences
# 3. scan for the FIRST BALANCED {...} object, counting braces,
#    instead of regexing for the first '{' and the last '}'
# 4. json.loads that, and only that

Step 3 is the one that matters. The obvious approach — grab everything between the first { and the last } — breaks the moment the model writes a friendly sentence containing a brace, or nests an object, or trails off mid-thought. Counting braces to find a genuinely balanced object is boring, and it’s the reason the app stopped crashing.

The lesson generalises: never trust an LLM to obey a format perfectly. Ask for structure, then parse defensively like you’re reading input from a stranger — because you are.

I also reused a pattern here that I lean on everywhere now: the API key lives on the Cloudflare Worker, never inside the app itself. Same reasoning as in PromptEnhance, you never ship a secret to code that runs on someone else’s machine.

Where it stands

StudyPages works. You can type a topic, watch it generate a genuinely nice-looking lesson, and save it. It’s also got rough edges I’m still sanding down, a few block types aren’t fully finished and some polish is missing, which is exactly why the code needs a cleanup pass before I’d call it done.

Even in its half-tidy state, it’s the project that taught me how to build a real desktop app: a threaded UI that stays responsive, a clean way to turn structured AI output into visuals, saving and loading user data, and packaging the whole thing into something you can actually double-click and run.

Share: LinkedIn