dev/sessions/20260206-reveal-js-slideshows.md

Torey Heinz committed Feb 17, 2026
commit 1ae96d5d6588c6c1136ada2a82cffd6e8539838e
Showing 4 changed files with 1828 additions and 0 deletions
dev/sessions/20260206-reveal-js-slideshows.md +62 -0
@@ @@ -0,0 +1,62 @@
+ # Milestone: Reveal.js Slideshow Conversion
+
+ **Date:** 2026-02-06
+ **Overview:** Converted LiveBook markdown outlines into Reveal.js HTML presentations for the GR Web Dev Meetup talk "Elixir in the Real World."
+
+ ## Features Implemented
+
+ ### 1. Shared Stylesheet (`slides/shared.css`)
+ - Extracted all CSS into a reusable stylesheet (442 lines)
+ - Elixir purple (`#6e44ff`) accent, light theme for projector readability
+ - Component classes: entity cards, company cards, comparison grids, stats rows, flow diagrams, architecture diagrams, tables, callout boxes, pattern grids, resource lists, key-value lists
+ - Utility classes: `.muted`, `.small`, `.smaller`, `.small-code`, `.tiny-code`
+ - Section divider styling with purple gradient background
+
+ ### 2. Intro Slideshow (`slides/01-intro.html`)
+ - 15 slides covering: title, about me, Vianet entities, agenda, Part 1 (Why Elixir), Erlang history, BEAM, Elixir (2012), who's using it, I/O bound insight, language comparison, Vianet impact ($10k savings), code taste (2 slides), transition to LiveBook
+ - 30 fragment animations for progressive reveal
+ - Speaker notes on every slide
+ - Code highlighting with line-number stepping for Elixir blocks
+ - CDN-based Reveal.js 5.1.0 (unpkg) — no build tools needed
+
+ ### 3. Real-World Code Slideshow (`slides/03-real-world.html`)
+ - 32 slides, 50 fragments, 32 speaker notes
+ - **Section 3.1 — Email Marketing:** Architecture diagram, supervision tree, rate limiter GenServer (token bucket), comparison table (Redis vs Node vs DB vs GenServer), mailer worker `with` chain, `else` block walkthrough, scheduler streaming (500k records, constant memory)
+ - **Section 3.2 — Domain Registry:** EPP challenge, `with` chain connection, binary pattern matching (`<<length::32-big>>`), connection pool GenServer, health check pattern matching (5 clauses, zero if/else), OrbitFour supervision tree
+ - **Section 3.3 — Internal Admin:** Philosophy (separate admin app), multi-DB architecture (5 databases), LiveView forms with `assign_async`, real-time presence tracking, async charts with `push_event`, flow explanation
+ - **Summary:** 6-card pattern grid mapping patterns across all 3 systems
+ - **Wrap-up:** Recap, pitch, getting started resources, thank you/Q&A
+
+ ## Technical Decisions
+
+ | Decision | Rationale |
+ |----------|-----------|
+ | CDN-based Reveal.js (unpkg) | No build tools, just open in browser — keeps talk prep simple |
+ | Light theme (white background) | Better for projectors and presentation venues |
+ | Shared CSS file vs inline styles | Both slideshows use identical styling; extracted after building 01-intro |
+ | `small-code` / `tiny-code` classes | Dense code slides (production examples) need smaller font sizes to fit |
+ | Section dividers with zoom transitions | Visual break between major topics; purple gradient reinforces branding |
+ | Line-number highlighting on code blocks | Guides audience attention during code walkthroughs |
+ | `02-the-language.livemd` stays as LiveBook | Interactive code demos require live execution — LiveBook is the right tool |
+
+ ## Code Changes
+
+ | File | Action | Size |
+ |------|--------|------|
+ | `slides/shared.css` | Created | 442 lines, 7.3KB |
+ | `slides/01-intro.html` | Created, then refactored (inline CSS → shared.css) | 19KB |
+ | `slides/03-real-world.html` | Created | 42KB |
+
+ ## Key Patterns
+
+ - **Reveal.js CDN boilerplate:** 4 CSS links (reset, reveal, theme, highlight) + shared.css, 4 JS scripts (reveal, highlight, notes, markdown), standard `Reveal.initialize()` config
+ - **Slide structure:** `<section>` per slide, `class="fragment"` for animations, `<aside class="notes">` for speaker notes, `class="section-divider"` + `data-transition="zoom"` for section breaks
+ - **Code blocks:** `<pre class="small-code"><code class="language-elixir" data-trim data-line-numbers="...">` for highlighted Elixir with stepping
+
+ ## Next Steps
+
+ - [ ] Review both slideshows in browser, adjust timing and content density
+ - [ ] Consider adding slide numbers / progress bar configuration
+ - [ ] Potentially add a title card or navigation between the 3 parts (01-intro → LiveBook → 03-real-world)
+ - [ ] Practice run to verify speaker notes flow naturally
+ - [ ] Check code highlighting on Elixir-specific syntax (pipe operator, pattern matching, binary syntax)
slides/01-intro.html +387 -0
@@ @@ -0,0 +1,387 @@
+ <!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Elixir in the Real World — GR Web Dev Meetup</title>
+
+ <link rel="stylesheet" href="https://unpkg.com/reveal.js@5.1.0/dist/reset.css" />
+ <link rel="stylesheet" href="https://unpkg.com/reveal.js@5.1.0/dist/reveal.css" />
+ <link rel="stylesheet" href="https://unpkg.com/reveal.js@5.1.0/dist/theme/white.css" id="theme" />
+ <link rel="stylesheet" href="https://unpkg.com/reveal.js@5.1.0/plugin/highlight/monokai.css" />
+ <link rel="stylesheet" href="shared.css" />
+ </head>
+ <body>
+ <div class="reveal">
+ <div class="slides">
+
+ <!-- SLIDE 1: Title -->
+ <section data-transition="fade">
+ <h1>Elixir in the Real World</h1>
+ <p class="subtitle">A Practical Introduction</p>
+ <p class="meta">
+ <strong>Torey Heinz</strong><br />
+ GR Web Dev Meetup &mdash; February 23, 2026
+ </p>
+
+ <aside class="notes">
+ Welcome everyone! Tonight we're going to look at Elixir — not as an academic exercise, but as a practical tool for building real web applications. I'll share why I made the switch and what it looks like in production.
+ </aside>
+ </section>
+
+ <!-- SLIDE 2: About Me -->
+ <section>
+ <h2>About Me</h2>
+ <p><strong>Torey Heinz</strong></p>
+ <ul>
+ <li class="fragment">Full-stack developer &mdash; 15+ years building web applications</li>
+ <li class="fragment"><strong>Vianet Management</strong> &mdash; <a href="https://www.vianetmanagement.com/">vianetmanagement.com</a></li>
+ <li class="fragment"><strong>Shopflow</strong> &mdash; Founder &mdash; Software for small manufacturing</li>
+ <li class="fragment">15+ years of Ruby on Rails &rarr; caught the Elixir bug &rarr; haven't looked back</li>
+ </ul>
+
+ <aside class="notes">
+ Quick intro. I've been doing web dev for over 15 years, mostly Rails. I work at Vianet Management here in West Michigan, and I also founded Shopflow for small manufacturing businesses. My path to Elixir was through Rails — I loved Ruby but wanted something better for concurrency.
+ </aside>
+ </section>
+
+ <!-- SLIDE 3: Vianet Entities -->
+ <section>
+ <h2>Vianet Management</h2>
+ <p style="font-size: 0.7em; color: #888; margin-bottom: 10px;">
+ Huge thanks to Vianet for fully embracing Elixir and giving us the freedom to adopt it across our stack.
+ </p>
+ <div class="entity-grid">
+ <div class="entity-card fragment">
+ <h4>The Factory</h4>
+ <p>Coworking space in Grand Rapids</p>
+ </div>
+ <div class="entity-card fragment">
+ <h4>Puppies.com</h4>
+ <p>Online puppy marketplace</p>
+ </div>
+ <div class="entity-card fragment">
+ <h4>Roommates.com</h4>
+ <p>Roommate &amp; room rental matching &mdash; 22+ years</p>
+ </div>
+ <div class="entity-card fragment">
+ <h4>OrbitFour</h4>
+ <p>Domain registrar &mdash; no upselling, no hidden fees</p>
+ </div>
+ <div class="entity-card wide fragment">
+ <h4>Reputable Rooms</h4>
+ <p>Verified room &amp; roommate search</p>
+ </div>
+ </div>
+
+ <aside class="notes">
+ Vianet Management runs several businesses, all powered by Elixir in production. The Factory is a coworking space right here in GR. Puppies.com and Roommates.com are marketplaces. OrbitFour is our domain registrar — straightforward, no upselling. And Reputable Rooms is our newest product for verified room rentals. Everything you see today runs in production because Vianet was willing to invest in Elixir.
+ </aside>
+ </section>
+
+ <!-- SLIDE 4: What We're Covering -->
+ <section>
+ <h2>What We're Covering Today</h2>
+ <p style="color: #888;">Three parts, about 50 minutes:</p>
+ <ol>
+ <li class="fragment"><strong>Why Elixir?</strong> &mdash; Where it comes from and why it matters</li>
+ <li class="fragment"><strong>The Language</strong> &mdash; Pattern matching, processes, and LiveView <em>(live demos!)</em></li>
+ <li class="fragment"><strong>Real-World Code</strong> &mdash; Production systems from my work at Vianet</li>
+ </ol>
+ <p class="fragment" style="margin-top: 1em; color: #888;">Then we'll open it up for Q&amp;A.</p>
+
+ <aside class="notes">
+ Here's the roadmap. First, why Elixir exists. Then we'll do live coding in LiveBook to explore the language. Finally, real production code from Vianet. We'll save time for questions at the end.
+ </aside>
+ </section>
+
+ <!-- SLIDE 5: Section Divider — Part 1 -->
+ <section class="section-divider" data-transition="zoom">
+ <h2>Part 1: Why Elixir?</h2>
+ <p>Where it comes from and why you should care</p>
+
+ <aside class="notes">
+ Before we look at any code, let's answer the question: why does this language exist, and why should you care?
+ </aside>
+ </section>
+
+ <!-- SLIDE 6: Erlang -->
+ <section>
+ <h2>Erlang <span style="font-weight: 400; color: #888;">(1986)</span></h2>
+ <p>Built by <strong>Ericsson</strong> for telecom switches &mdash; systems that literally <strong>could not go down</strong>.</p>
+ <p class="fragment" style="color: #888; font-size: 0.75em;">Think about what a phone network requires:</p>
+ <ul>
+ <li class="fragment">Millions of simultaneous calls</li>
+ <li class="fragment">A single dropped call can't crash the entire system</li>
+ <li class="fragment">Update software <strong>without</strong> hanging up on everyone</li>
+ <li class="fragment">99.9999999% uptime &mdash; <em>~31ms of downtime per year</em></li>
+ </ul>
+ <p class="fragment" style="margin-top: 0.8em; font-size: 0.75em; color: #888;">
+ Battle-tested for <strong>40 years</strong> in phone networks, banking, and messaging.
+ </p>
+
+ <aside class="notes">
+ Erlang was created by Ericsson in 1986 for telecom switches. These are systems that absolutely cannot fail. The nine-nines uptime — that's 31 milliseconds of downtime per year. This isn't theoretical, this is what they achieved in production phone networks. That's the foundation Elixir is built on.
+ </aside>
+ </section>
+
+ <!-- SLIDE 7: The BEAM -->
+ <section>
+ <h2>The BEAM</h2>
+ <p>Erlang's virtual machine &mdash; designed from scratch for concurrent, networked applications.</p>
+ <ul>
+ <li class="fragment"><strong>Millions of lightweight processes</strong> &mdash; not OS threads, much cheaper</li>
+ <li class="fragment"><strong>Preemptive scheduling</strong> &mdash; no single process can hog the CPU</li>
+ <li class="fragment"><strong>Per-process garbage collection</strong> &mdash; no stop-the-world pauses</li>
+ <li class="fragment"><strong>Built-in distribution</strong> &mdash; processes communicate across machines</li>
+ </ul>
+ <p class="fragment" style="margin-top: 1em; font-style: italic; color: #888;">
+ Arguably the best runtime ever built for this problem.<br/>
+ It just had a developer experience problem...
+ </p>
+
+ <aside class="notes">
+ The BEAM is the runtime that actually runs the code, and it's fundamentally different from the JVM or Node. Lightweight processes cost about 2KB each, and you can run millions of them. The scheduler is preemptive — no process can block others. And garbage collection is per-process, so you never get those stop-the-world pauses. The catch? Erlang syntax is... an acquired taste. That's where Elixir comes in.
+ </aside>
+ </section>
+
+ <!-- SLIDE 8: Elixir -->
+ <section>
+ <h2>Elixir <span style="font-weight: 400; color: #888;">(2012)</span></h2>
+ <p><strong>Jos&eacute; Valim</strong> &mdash; Ruby on Rails core team member &mdash; asked:</p>
+ <blockquote class="fragment">
+ &ldquo;What if we took the best runtime in the world and made it a joy to write?&rdquo;
+ </blockquote>
+ <ul>
+ <li class="fragment"><strong>Modern syntax</strong> &mdash; inspired by Ruby, approachable for web devs</li>
+ <li class="fragment"><strong>Excellent tooling</strong> &mdash; Mix, Hex, ExUnit</li>
+ <li class="fragment"><strong>Metaprogramming</strong> &mdash; macros that extend the language cleanly</li>
+ <li class="fragment"><strong>Full Erlang access</strong> &mdash; 40 years of battle-tested libraries</li>
+ </ul>
+ <p class="fragment" style="margin-top: 0.8em; font-size: 0.8em; color: #555;">
+ Elixir doesn't replace Erlang. It <strong>runs on</strong> Erlang's BEAM.
+ </p>
+
+ <aside class="notes">
+ José Valim was on the Rails core team. He saw the power of the BEAM but wanted better ergonomics. Elixir gives you all of Erlang's superpowers — concurrency, fault tolerance, distribution — with a syntax that feels like Ruby. And you get the entire Erlang ecosystem for free.
+ </aside>
+ </section>
+
+ <!-- SLIDE 9: Who's Using It -->
+ <section>
+ <h2>Who's Using Elixir?</h2>
+ <p style="font-size: 0.75em; color: #888;">Companies choose Elixir when <strong>reliability and scale actually matter</strong>.</p>
+
+ <div class="company-grid">
+ <div class="company-card fragment">
+ <h4>Discord</h4>
+ <p>5+ million concurrent users<br/>Real-time messaging at massive scale</p>
+ </div>
+ <div class="company-card fragment">
+ <h4>Pinterest</h4>
+ <p>Notification delivery system<br/>Billions of events processed</p>
+ </div>
+ <div class="company-card fragment">
+ <h4>Pepsi</h4>
+ <p>Real-time manufacturing &amp; logistics<br/>Monitoring production lines across facilities</p>
+ </div>
+ <div class="company-card highlight fragment">
+ <h4>The Famous Phoenix Demo</h4>
+ <p style="font-size: 0.75em !important; color: var(--elixir-purple) !important; font-weight: 600;">
+ 2 million simultaneous WebSocket connections on a single server.
+ </p>
+ </div>
+ </div>
+
+ <aside class="notes">
+ This isn't a hobby language. Discord handles 5 million concurrent users with Elixir. Pinterest processes billions of notification events. Pepsi runs real-time manufacturing monitoring. And the famous Phoenix benchmark: 2 million simultaneous WebSocket connections on ONE server. That's the BEAM in action. And of course, Vianet Management, right here in West Michigan.
+ </aside>
+ </section>
+
+ <!-- SLIDE 10: Why It Matters — I/O bound insight -->
+ <section data-auto-animate>
+ <h2>Why It Matters for Web Apps</h2>
+ <p>The key insight: <strong>Web applications are fundamentally I/O bound</strong></p>
+
+ <div class="flow-diagram" data-id="flow">
+ Request arrives
+ |
+ [ Parse ] &larr; microseconds
+ |
+ <span class="waiting">[ Query DB ]</span> &larr; <span class="waiting">WAITING (ms to seconds)</span>
+ |
+ [ Process ] &larr; microseconds
+ |
+ <span class="waiting">[ Call API ]</span> &larr; <span class="waiting">WAITING (ms to seconds)</span>
+ |
+ [ Render ] &larr; microseconds
+ |
+ Response sent
+ </div>
+
+ <p class="fragment" style="margin-top: 0.5em; font-size: 0.8em;">
+ Your app spends most of its time <strong>waiting</strong> &mdash; on databases, APIs, file systems.
+ </p>
+
+ <aside class="notes">
+ Here's the insight that made Elixir click for me. Think about what your web app actually does. The computation — parsing, processing, rendering — takes microseconds. But the I/O — database queries, API calls — takes milliseconds to seconds. Your app spends almost all its time waiting. So the question is: how well does your runtime handle waiting?
+ </aside>
+ </section>
+
+ <!-- SLIDE 11: Language Comparison -->
+ <section>
+ <h2>How Languages Handle This</h2>
+
+ <div class="compare-grid">
+ <div class="compare-item fragment">
+ <h4>Node.js &mdash; Event Loop</h4>
+ <p>Great for I/O, but one long computation blocks everything. No true parallelism for CPU work.</p>
+ </div>
+ <div class="compare-item fragment">
+ <h4>Ruby / Python &mdash; GIL</h4>
+ <p>Global Interpreter Lock limits concurrency. Threads are expensive (~1MB). Compensate with multiple processes.</p>
+ </div>
+ <div class="compare-item fragment">
+ <h4>Java / Go &mdash; Better Primitives</h4>
+ <p>Goroutines &amp; virtual threads are lightweight. But no built-in fault tolerance or supervision.</p>
+ </div>
+ <div class="compare-item elixir fragment">
+ <h4>Elixir / BEAM &mdash; Purpose-Built</h4>
+ <p>~2KB processes (millions possible). Preemptive scheduling. Built-in fault tolerance. All I/O non-blocking by default &mdash; no async/await needed.</p>
+ </div>
+ </div>
+
+ <aside class="notes">
+ Node uses a single-threaded event loop — great for I/O but one heavy computation blocks everything. Ruby and Python have the GIL limiting true concurrency. Java and Go have better concurrency primitives, but no built-in supervision. Elixir was purpose-built for exactly this problem. Lightweight processes, preemptive scheduling, fault tolerance baked in, and all I/O is non-blocking by default. You don't need async/await — everything just works concurrently.
+ </aside>
+ </section>
+
+ <!-- SLIDE 12: Real Impact at Vianet -->
+ <section>
+ <h2>Real Impact at Vianet</h2>
+ <p style="font-size: 0.8em; color: #888;">
+ Migrated from <strong>Ruby on Rails on AWS</strong> &rarr; <strong>Elixir on Render</strong>
+ </p>
+
+ <div class="stats-row">
+ <div class="stats-item fragment">
+ <div class="stat">&darr;</div>
+ <div class="stat-label">Faster response times</div>
+ </div>
+ <div class="stats-item fragment">
+ <div class="stat">&darr;</div>
+ <div class="stat-label">Fewer resources needed</div>
+ </div>
+ <div class="stats-item fragment">
+ <div class="stat">$10k</div>
+ <div class="stat-label">/month infrastructure savings</div>
+ </div>
+ </div>
+
+ <p class="fragment" style="font-size: 0.75em; color: #555; max-width: 700px; margin: 0.5em auto;">
+ This isn't because Rails is bad &mdash; I built my career on Rails and I still respect it deeply.
+ It's because <strong>Elixir's concurrency model is a natural fit for what web apps actually do.</strong>
+ </p>
+
+ <aside class="notes">
+ Real numbers from Vianet. We migrated from Rails on AWS to Elixir on Render. Faster response times because the BEAM handles concurrent requests naturally. Fewer resources because one Elixir instance does the work of several Rails workers. And ten thousand dollars a month in infrastructure savings. That's not a typo. This isn't Rails bashing — I love Rails. It's that Elixir is built for exactly what web apps do: handle lots of concurrent I/O.
+ </aside>
+ </section>
+
+ <!-- SLIDE 13: A Quick Taste — Code -->
+ <section>
+ <h2>A Quick Taste</h2>
+ <p style="font-size: 0.75em; color: #888;">If you've written Ruby or JavaScript, this should feel familiar.</p>
+
+ <pre><code class="language-elixir" data-trim data-line-numbers="1-5|7-8">
+ # Variables, strings, basic data types
+ name = "GR Web Dev"
+ year = 2026
+ attendees = ["Alice", "Bob", "Charlie"]
+
+ IO.puts("Welcome to #{name}, #{year}!")
+ IO.puts("We have #{length(attendees)} people here tonight")
+ </code></pre>
+
+ <pre class="fragment"><code class="language-elixir" data-trim data-line-numbers="1-2|4-7">
+ languages = ["JavaScript", "Ruby", "Python", "Elixir", "Go"]
+
+ # The pipe operator |> chains functions (like Unix pipes)
+ languages
+ |> Enum.filter(fn lang -> String.length(lang) > 4 end)
+ |> Enum.sort()
+ |> Enum.join(", ")
+ </code></pre>
+
+ <aside class="notes">
+ Before we jump into LiveBook, here's what Elixir looks like. First block: variables, strings, string interpolation — feels just like Ruby. Second block: the pipe operator. This is one of my favorite things about Elixir. Data flows left to right, top to bottom, just like you'd read English. It's like Unix pipes for your code.
+ </aside>
+ </section>
+
+ <!-- SLIDE 13b: More Code -->
+ <section>
+ <h2>A Quick Taste</h2>
+
+ <pre><code class="language-elixir" data-trim data-line-numbers>
+ # Maps (like JavaScript objects or Ruby hashes)
+ talk = %{
+ title: "Elixir in the Real World",
+ speaker: "Torey Heinz",
+ meetup: "GR Web Dev",
+ duration_minutes: 50
+ }
+ </code></pre>
+
+ <pre class="fragment"><code class="language-elixir" data-trim data-line-numbers>
+ # Pattern matching + guard clauses
+ defmodule Math do
+ def factorial(0), do: 1
+ def factorial(n) when n > 0, do: n * factorial(n - 1)
+ end
+
+ Math.factorial(10) # => 3628800
+ </code></pre>
+
+ <p class="fragment" style="font-size: 0.75em; color: #555;">
+ That's already using <strong>pattern matching</strong> and <strong>guard clauses</strong> &mdash;
+ two features we'll explore in depth next.
+ </p>
+
+ <aside class="notes">
+ Maps are like JavaScript objects or Ruby hashes. And this factorial function — notice we define it twice with different arguments. The BEAM tries them in order. factorial(0) returns 1, and factorial(n) does the recursion. That's pattern matching, and it's one of Elixir's superpowers. We'll dig deep into this in the LiveBook section.
+ </aside>
+ </section>
+
+ <!-- SLIDE 14: Let's Dive In -->
+ <section class="section-divider" data-transition="zoom">
+ <h2>Let's Dive In</h2>
+ <p>Now that you know <strong>why</strong> Elixir exists and <strong>who</strong> is using it,<br/>let's look at <strong>what makes it different</strong>.</p>
+ <p style="margin-top: 1em; font-size: 0.9em;">
+ Next up: <strong>The Language</strong> &mdash; pattern matching, processes, and LiveView
+ </p>
+
+ <aside class="notes">
+ Alright, that's the "why." Now let's switch over to LiveBook and get hands-on with the language. We'll look at pattern matching, processes, supervision, and LiveView — all with live, runnable code.
+ </aside>
+ </section>
+
+ </div>
+ </div>
+
+ <script src="https://unpkg.com/reveal.js@5.1.0/dist/reveal.js"></script>
+ <script src="https://unpkg.com/reveal.js@5.1.0/plugin/highlight/highlight.js"></script>
+ <script src="https://unpkg.com/reveal.js@5.1.0/plugin/notes/notes.js"></script>
+ <script src="https://unpkg.com/reveal.js@5.1.0/plugin/markdown/markdown.js"></script>
+ <script>
+ Reveal.initialize({
+ hash: true,
+ transition: 'slide',
+ slideNumber: true,
+ plugins: [RevealHighlight, RevealNotes, RevealMarkdown],
+ highlight: {
+ highlightOnLoad: true
+ }
+ });
+ </script>
+ </body>
+ </html>
slides/03-real-world.html +937 -0
@@ @@ -0,0 +1,937 @@
+ <!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Part 3: Real-World Code — Elixir in the Real World</title>
+
+ <link rel="stylesheet" href="https://unpkg.com/reveal.js@5.1.0/dist/reset.css" />
+ <link rel="stylesheet" href="https://unpkg.com/reveal.js@5.1.0/dist/reveal.css" />
+ <link rel="stylesheet" href="https://unpkg.com/reveal.js@5.1.0/dist/theme/white.css" id="theme" />
+ <link rel="stylesheet" href="https://unpkg.com/reveal.js@5.1.0/plugin/highlight/monokai.css" />
+ <link rel="stylesheet" href="shared.css" />
+ </head>
+ <body>
+ <div class="reveal">
+ <div class="slides">
+
+ <!-- =============================================
+ SECTION: Intro
+ ============================================= -->
+
+ <section class="section-divider" data-transition="zoom">
+ <h2>Part 3: Real-World Code</h2>
+ <p>From concepts to production</p>
+
+ <aside class="notes">
+ Everything we just covered — pattern matching, processes, supervision, LiveView — these aren't academic concepts. They're the foundation of production systems running real businesses right now. Let's walk through three real systems built at Vianet Management.
+ </aside>
+ </section>
+
+ <section>
+ <h2>Three Production Systems</h2>
+ <p class="muted">Same patterns, different domains</p>
+ <ol>
+ <li class="fragment"><strong>Email Marketing</strong> &mdash; 500k+ personalized emails through AWS SES</li>
+ <li class="fragment"><strong>Domain Registry</strong> &mdash; TCP connections to Verisign</li>
+ <li class="fragment"><strong>Internal Admin</strong> &mdash; Multi-site LiveView dashboard</li>
+ </ol>
+
+ <aside class="notes">
+ We'll look at three systems. Each one showcases different aspects of what we learned — GenServers, supervision, pattern matching, and LiveView — all in production.
+ </aside>
+ </section>
+
+ <!-- =============================================
+ SECTION 3.1: Email Marketing
+ ============================================= -->
+
+ <section class="section-divider" data-transition="zoom">
+ <h2>3.1 &mdash; Email Marketing</h2>
+ <p>500,000+ personalized emails through AWS SES</p>
+
+ <aside class="notes">
+ First system: our email marketing platform. This sends half a million personalized emails through AWS SES.
+ </aside>
+ </section>
+
+ <section>
+ <h2>The Challenge</h2>
+ <p>Send <strong>500,000+ personalized marketing emails</strong> through AWS SES.</p>
+ <div class="callout fragment">
+ <p><strong>The catch:</strong> If you exceed your rate limit, AWS doesn't tell you &mdash; they <strong>silently drop your emails</strong>. No error, no bounce, no notification.</p>
+ </div>
+ <ul>
+ <li class="fragment">Rate limiter that <strong>never</strong> exceeds the AWS SES sending rate</li>
+ <li class="fragment">Schedule and personalize hundreds of thousands of emails</li>
+ <li class="fragment">Handle bounces, complaints, and delivery notifications</li>
+ <li class="fragment">Run concurrently without blocking the web app</li>
+ </ul>
+
+ <aside class="notes">
+ The key constraint: AWS SES silently drops emails if you exceed your rate limit. No error, nothing. So we need a rate limiter that's rock-solid, plus the ability to schedule and personalize half a million emails without blocking the web app.
+ </aside>
+ </section>
+
+ <section>
+ <h2>The Architecture</h2>
+ <div class="arch-diagram">
+ Application Supervisor
+ ┌──────────┬──────────────┬───────────┐
+ │ Phoenix │ EmailRate │ Oban │
+ │ Endpoint │ Limiter │ (Job Queue│)
+ │ │ (GenServer) │ │
+ └──────────┘──────────────┘───────────┘
+ ▲ │
+ │ ┌────┴────┐
+ │ │Scheduler│
+ │ │ Worker │
+ │ └────┬────┘
+ │ │
+ │ ┌─────────┴────────┐
+ │ │ 2,000 at a time │
+ │ └─────────┬────────┘
+ │ │
+ │ ┌─────────┴────────┐
+ └─────│ Mailer Workers │
+ │ (one per email) │
+ └──────────────────┘</div>
+ <ol style="font-size: 0.65em; margin-top: 15px;">
+ <li class="fragment"><strong>Scheduler Worker</strong> queries DB, creates email jobs &mdash; 2,000 at a time</li>
+ <li class="fragment"><strong>Mailer Worker</strong> handles one email: validate, personalize, rate-check, send</li>
+ <li class="fragment"><strong>Rate Limiter</strong> (GenServer) ensures we never exceed the AWS SES limit</li>
+ </ol>
+
+ <aside class="notes">
+ Here's the architecture. A scheduler worker pulls member IDs from the database in chunks of 2,000 and creates individual email jobs. Each mailer worker handles one email — validate, personalize, check the rate limiter, send. The rate limiter is a GenServer that enforces the AWS SES sending rate. All supervised.
+ </aside>
+ </section>
+
+ <section>
+ <h2>The Supervision Tree</h2>
+ <p class="muted small">The actual <code>application.ex</code> &mdash; entry point that starts every process</p>
+
+ <pre class="small-code"><code class="language-elixir" data-trim data-line-numbers="4-14|8|11|12">
+ defmodule Marketing.Application do
+ use Application
+
+ def start(_type, _args) do
+ children = [
+ MarketingWeb.Telemetry,
+ Marketing.Repo, # Database connection pool
+ Marketing.PuppiesRepo, # Multi-database support
+ Marketing.ReputableRoomsRepo,
+ Marketing.RoommatesRepo,
+ Marketing.NuzzleRepo,
+ {Phoenix.PubSub, name: Marketing.PubSub},
+ {Finch, name: Marketing.Finch}, # HTTP client
+ MarketingWeb.Endpoint, # Web server
+ Marketing.EmailRateLimiter, # ← Our rate limiter process
+ {Oban, Application.fetch_env!(:marketing, Oban)} # Job queue
+ ]
+
+ opts = [strategy: :one_for_one, name: Marketing.Supervisor]
+ Supervisor.start_link(children, opts)
+ end
+ end
+ </code></pre>
+
+ <p class="fragment" style="font-size: 0.7em; color: #555;">
+ In <strong>Rails</strong>: Puma + Sidekiq + Redis + custom daemon.<br/>
+ In <strong>Elixir</strong>: one unified supervision tree.
+ </p>
+
+ <aside class="notes">
+ This is the actual application.ex. Every process in this list is supervised. If the rate limiter crashes, the supervisor restarts it. If the web server crashes, same thing. In Rails, you'd need separate processes — Puma, Sidekiq, Redis, maybe a custom daemon. In Elixir, it's one unified tree.
+ </aside>
+ </section>
+
+ <section>
+ <h2>The Rate Limiter</h2>
+ <p class="muted small">A GenServer using a token bucket algorithm</p>
+
+ <pre class="small-code"><code class="language-elixir" data-trim data-line-numbers="1-5|8|11-13|15-27">
+ defmodule Marketing.EmailRateLimiter do
+ use GenServer
+
+ defstruct tokens: 0.0, capacity: 50.0, rate: 25.0,
+ daily_remaining: 500_000, last_refill_ms: nil
+
+ # Public API — other code just calls this
+ def ready?, do: GenServer.call(__MODULE__, :acquire)
+
+ def init(_opts) do
+ state = %__MODULE__{last_refill_ms: now_ms()}
+ Process.send_after(self(), :refill, 1_000)
+ {:ok, state}
+ end
+
+ # Synchronous check: can we send right now?
+ def handle_call(:acquire, _from, state) do
+ state = refill(state)
+
+ cond do
+ state.daily_remaining <= 1_000 ->
+ {:reply, {:not_ready, :exceeded_daily}, state}
+ state.tokens >= 1.0 ->
+ {:reply, {:ok, :token},
+ %{state | tokens: state.tokens - 1.0,
+ daily_remaining: state.daily_remaining - 1}}
+ true ->
+ {:reply, {:not_ready, :exceeded_rate}, state}
+ end
+ end
+ end
+ </code></pre>
+
+ <aside class="notes">
+ This is the real rate limiter. It's a GenServer with a token bucket algorithm. The public API is just `ready?` — other code calls that. Internally, it tracks tokens and daily quota. Tokens refill over time. When a worker asks ready?, it checks tokens and daily quota. Thread-safe by design — GenServer processes messages one at a time.
+ </aside>
+ </section>
+
+ <section>
+ <h2>Why GenServer?</h2>
+ <table>
+ <thead>
+ <tr>
+ <th>Approach</th>
+ <th>Problem</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr class="fragment">
+ <td>Redis counter (Rails)</td>
+ <td>Network round-trip for every email, eventual consistency</td>
+ </tr>
+ <tr class="fragment">
+ <td>In-memory variable (Node.js)</td>
+ <td>Race conditions under concurrent access</td>
+ </tr>
+ <tr class="fragment">
+ <td>Database counter</td>
+ <td>Way too slow at 500k emails</td>
+ </tr>
+ <tr class="fragment highlight-row">
+ <td><strong>GenServer (Elixir)</strong></td>
+ <td><strong>In-process, atomic, zero network overhead</strong></td>
+ </tr>
+ </tbody>
+ </table>
+
+ <aside class="notes">
+ Why a GenServer instead of alternatives? Redis adds a network round-trip for every single email. An in-memory variable in Node has race conditions. A database counter is way too slow at this scale. A GenServer is in-process, atomic, and has zero network overhead. Messages are processed one at a time, so there are no race conditions by design.
+ </aside>
+ </section>
+
+ <section>
+ <h2>The Mailer Worker</h2>
+ <p class="muted small">Pattern matching handles every possible failure</p>
+
+ <pre class="small-code"><code class="language-elixir" data-trim data-line-numbers="3-11|12-19">
+ def perform(%Oban.Job{args: args} = job) do
+ campaign_id = Map.get(args, "campaign_id")
+ member_id = Map.get(args, "member_id")
+
+ with {:ok, member} <- get_member_with_site(member_id),
+ {:ok, _} <- validate_email(member.email),
+ {:ok, campaign} <- get_campaign_with_site(campaign_id),
+ {:ok, campaign_email} <- reserve_campaign_email(campaign, member),
+ {:ok} <- ensure_campaign_email_not_sent(campaign_email),
+ {:ok, _} <- EmailRateLimiter.ready?() do
+ send_campaign_email(campaign, member, live_mode)
+ else
+ {:not_ready, :exceeded_rate} -> {:snooze, 1}
+ {:not_ready, :exceeded_daily} -> {:snooze, min(backoff(job), 3_600)}
+ {:already_sent} -> {:discard, :already_sent}
+ {:invalid_email, reason} -> {:discard, {:invalid_email, reason}}
+ {:error, %{code: "InvalidParameterValue"} = result} ->
+ {:discard, {:invalid_param, result}}
+ {:error, result} -> {:error, result}
+ end
+ end
+ </code></pre>
+
+ <aside class="notes">
+ Each email goes through a pipeline with the `with` chain. Get the member, validate the email, get the campaign, reserve the record, check it hasn't been sent, check the rate limiter. If everything passes, send. The else block reads like a specification: rate exceeded? Snooze 1 second. Daily limit? Backoff up to an hour. Already sent? Discard. Every outcome is handled. No silent failures.
+ </aside>
+ </section>
+
+ <section>
+ <h2>Reading the <code>else</code> Block</h2>
+ <ul class="kv-list">
+ <li class="fragment">
+ <span class="kv-key">Rate exceeded?</span>
+ <span class="kv-val">&rarr; Snooze 1 second, try again</span>
+ </li>
+ <li class="fragment">
+ <span class="kv-key">Daily limit hit?</span>
+ <span class="kv-val">&rarr; Snooze with exponential backoff, up to 1 hour</span>
+ </li>
+ <li class="fragment">
+ <span class="kv-key">Already sent?</span>
+ <span class="kv-val">&rarr; Discard (don't double-send)</span>
+ </li>
+ <li class="fragment">
+ <span class="kv-key">Invalid email?</span>
+ <span class="kv-val">&rarr; Discard (don't retry what won't work)</span>
+ </li>
+ <li class="fragment">
+ <span class="kv-key">AWS rejected?</span>
+ <span class="kv-val">&rarr; Discard</span>
+ </li>
+ <li class="fragment">
+ <span class="kv-key">Any other error?</span>
+ <span class="kv-val">&rarr; Retry (Oban handles retry logic)</span>
+ </li>
+ </ul>
+ <p class="fragment" style="font-size: 0.8em; margin-top: 0.5em;">Every possible outcome handled. <strong>No silent failures. No lost emails.</strong></p>
+
+ <aside class="notes">
+ Let's read that else block like a specification. Rate exceeded — snooze 1 second. Daily limit — backoff up to an hour. Already sent — discard, don't double-send. Invalid email — discard, don't retry what won't work. AWS rejected parameters — discard. Anything else — retry with Oban's built-in retry logic. Every outcome is covered.
+ </aside>
+ </section>
+
+ <section>
+ <h2>Scheduling 500k Jobs</h2>
+ <p class="muted small">Stream records in chunks &mdash; constant memory usage</p>
+
+ <pre><code class="language-elixir" data-trim data-line-numbers="5-8|11-15">
+ defmodule Marketing.CampaignSchedulerWorker do
+ use Oban.Worker, queue: :default, max_attempts: 1
+ @chunk_size 2_000
+
+ def perform(%Oban.Job{args: args}) do
+ campaign = Campaigns.get_campaign_with_site(args["campaign_id"])
+
+ Repo.transaction(fn ->
+ Repo.stream(campaign_member_ids_query(campaign), max_rows: @chunk_size)
+ |> Stream.chunk_every(@chunk_size)
+ |> Enum.each(&schedule_batch(&1, campaign, args["live_mode"]))
+ end, timeout: :infinity)
+ end
+
+ defp schedule_batch(member_ids, campaign, live_mode) do
+ Enum.map(member_ids, fn member_id ->
+ %{campaign_id: campaign.id, member_id: member_id, live_mode: live_mode}
+ |> CampaignMailerWorker.new(scheduled_at: campaign.scheduled_at)
+ end)
+ |> Oban.insert_all()
+ end
+ end
+ </code></pre>
+
+ <p class="fragment" style="font-size: 0.75em; color: #555;">
+ <code>Repo.stream</code> + <code>Stream.chunk_every</code> = process 500k records using <strong>constant memory</strong>.
+ </p>
+
+ <aside class="notes">
+ You can't load 500k records into memory at once. Repo.stream gives us a lazy stream from the database. Stream.chunk_every breaks it into chunks of 2,000. Each chunk becomes 2,000 individual email jobs inserted into Oban. Constant memory usage regardless of how many records we process.
+ </aside>
+ </section>
+
+ <!-- =============================================
+ SECTION 3.2: Domain Registry
+ ============================================= -->
+
+ <section class="section-divider" data-transition="zoom">
+ <h2>3.2 &mdash; Domain Registry</h2>
+ <p>TCP connections to Verisign</p>
+
+ <aside class="notes">
+ Second system: OrbitFour, our domain registrar. This one communicates directly with Verisign over persistent TCP connections.
+ </aside>
+ </section>
+
+ <section>
+ <h2>The Challenge</h2>
+ <p>Communicate directly with <strong>Verisign's EPP</strong> (Extensible Provisioning Protocol) to register <code>.com</code> and <code>.net</code> domains.</p>
+ <ul>
+ <li class="fragment"><strong>Persistent TCP/TLS connections</strong> to Verisign's servers</li>
+ <li class="fragment"><strong>XML over binary framing</strong> &mdash; 4-byte length header + XML payload (RFC 5734)</li>
+ <li class="fragment"><strong>Connection pooling</strong> &mdash; maintain authenticated sessions, reuse efficiently</li>
+ <li class="fragment"><strong>Automatic recovery</strong> &mdash; reconnect without manual intervention</li>
+ </ul>
+
+ <aside class="notes">
+ OrbitFour needs persistent TCP/TLS connections to Verisign. The protocol is XML over binary framing — a 4-byte length header followed by XML. We need connection pooling for authenticated sessions and automatic recovery when connections drop.
+ </aside>
+ </section>
+
+ <section>
+ <h2>The Connection</h2>
+ <p class="muted small"><code>with</code> chains &mdash; same pattern as the email worker</p>
+
+ <pre><code class="language-elixir" data-trim data-line-numbers="3-6|8-11">
+ def connect(host, port, username, password, opts \\ []) do
+ with {:ok, socket_info} <- establish_ssl_connection(host, port, opts, timeout),
+ {:ok, greeting} <- read_greeting(socket_info, timeout),
+ :ok <- validate_greeting(greeting),
+ {:ok, session_id} <- perform_login(socket_info, username, password, timeout) do
+ {:ok, %__MODULE__{
+ socket: socket_info, host: host, port: port,
+ logged_in: true, session_id: session_id, greeting: greeting
+ }}
+ else
+ {:error, reason} = error ->
+ Logger.error("EPP connection failed to #{host}:#{port} - #{inspect(reason)}")
+ error
+ end
+ end
+ </code></pre>
+
+ <p class="fragment" style="font-size: 0.75em; color: #555;">
+ Four steps: connect &rarr; read greeting &rarr; validate &rarr; login.<br/>
+ If any step fails, it short-circuits. Clean, linear, <strong>no nesting</strong>.
+ </p>
+
+ <aside class="notes">
+ Same `with` pattern we saw in the email worker. Four steps: establish SSL connection, read the greeting, validate it, perform login. If any step fails, it short-circuits with an error. No callback hell, no nested if-statements.
+ </aside>
+ </section>
+
+ <section>
+ <h2>Binary Pattern Matching</h2>
+ <p class="muted small">Something you <strong>can't easily do</strong> in most languages</p>
+
+ <pre><code class="language-elixir" data-trim data-line-numbers="3-5|7-8">
+ # Sending: 4-byte length header (big-endian) + XML payload
+ def send_frame(socket_info, xml_data) do
+ xml_bytes = :unicode.characters_to_binary(xml_data, :unicode, :utf8)
+ total_length = byte_size(xml_bytes) + 4
+ frame = <<total_length::32-big, xml_bytes::binary>>
+
+ send_data(socket_info, frame)
+ end
+
+ # Receiving: parse the 4-byte header, then read that many bytes
+ def receive_frame(socket_info, timeout) do
+ with {:ok, <<length::32-big>>} <- recv_data(socket_info, 4, timeout) do
+ data_length = length - 4
+ recv_data(socket_info, data_length, timeout)
+ end
+ end
+ </code></pre>
+
+ <div class="callout fragment">
+ <p><code>&lt;&lt;total_length::32-big&gt;&gt;</code> &mdash; "4 bytes, big-endian integer."<br/>
+ In JS/Ruby, you'd manually slice buffers and call <code>readUInt32BE</code>.<br/>
+ In Elixir, it's just <strong>pattern matching</strong>.</p>
+ </div>
+
+ <aside class="notes">
+ This is one of my favorite Elixir features. Binary pattern matching. That double-angle-bracket syntax creates and matches binary data at the bit level. To send: build a 4-byte big-endian length header followed by XML. To receive: pattern match on exactly 4 bytes, extract the length as a big-endian integer, then read that many bytes. In JavaScript or Ruby, you'd be manually slicing buffers. Here, it's just pattern matching.
+ </aside>
+ </section>
+
+ <section>
+ <h2>Connection Pool</h2>
+ <p class="muted small">Long-lived TCP sessions managed as supervised processes</p>
+
+ <pre class="small-code"><code class="language-elixir" data-trim data-line-numbers="4-6|8-22">
+ defmodule OrbitFour.Epp.ConnectionPool do
+ use GenServer
+
+ @pool_size 2 # Connections per registry
+ @max_overflow 3 # Extra connections under load
+ @max_connection_age 4 * 60 * 60 * 1000 # 4 hours
+
+ def with_connection(registrar_module, fun, opts \\ []) do
+ timeout = Keyword.get(opts, :timeout, 30_000)
+ try do
+ GenServer.call(__MODULE__, {:checkout, registrar_module, timeout}, timeout + 5_000)
+ |> case do
+ {:ok, conn} ->
+ try do
+ result = fun.(conn)
+ GenServer.cast(__MODULE__, {:checkin, registrar_module, conn, :ok})
+ result
+ rescue
+ error ->
+ GenServer.cast(__MODULE__, {:checkin, registrar_module, conn, :error})
+ reraise error, __STACKTRACE__
+ end
+ {:error, :pool_exhausted} ->
+ {:error, "Connection pool is busy. Please try again."}
+ end
+ catch
+ :exit, {:timeout, _} ->
+ {:error, "Registry is taking too long to respond."}
+ end
+ end
+ end
+ </code></pre>
+
+ <aside class="notes">
+ The connection pool is a GenServer managing long-lived TCP sessions. The public API is with_connection — checkout a connection, run your function, return it. If your function succeeds, the connection goes back to the pool. If it raises, we mark it as errored and the pool creates a fresh one. If the pool is exhausted, we return a user-friendly error. All supervised.
+ </aside>
+ </section>
+
+ <section>
+ <h2>Health Checks</h2>
+ <p class="muted small">Pattern matching handles every possible socket state</p>
+
+ <pre><code class="language-elixir" data-trim data-line-numbers="1-2|4-5|7-12|14-19|21-22">
+ # No socket — unhealthy
+ defp connection_healthy?(%Connection{socket: nil}), do: false
+
+ # Not logged in — unhealthy
+ defp connection_healthy?(%Connection{logged_in: false}), do: false
+
+ # SSL socket — check with the SSL module
+ defp connection_healthy?(%Connection{socket: {:ssl, socket}}) do
+ case :ssl.connection_information(socket) do
+ {:ok, _info} -> true
+ _error -> false
+ end
+ end
+
+ # TCP socket — check with the inet module
+ defp connection_healthy?(%Connection{socket: {:tcp, socket}}) do
+ case :inet.peername(socket) do
+ {:ok, _} -> true
+ {:error, _} -> false
+ end
+ end
+
+ # Unknown socket type — unhealthy
+ defp connection_healthy?(_conn), do: false
+ </code></pre>
+
+ <p class="fragment" style="font-size: 0.8em;">
+ <strong>Five function clauses, zero if/else.</strong> Each handles exactly one case.
+ </p>
+
+ <aside class="notes">
+ Look at how pattern matching handles health checks. Five function clauses, zero if/else statements. No socket? Unhealthy. Not logged in? Unhealthy. SSL socket? Check with the SSL module. TCP socket? Check with inet. Unknown type? Unhealthy. Each clause handles exactly one case. The compiler ensures you haven't missed anything.
+ </aside>
+ </section>
+
+ <section>
+ <h2>OrbitFour Supervision Tree</h2>
+ <p class="muted small">One supervisor managing everything</p>
+
+ <pre class="small-code"><code class="language-elixir" data-trim data-line-numbers="5-14">
+ defmodule OrbitFour.Application do
+ use Application
+
+ def start(_type, _args) do
+ children = [
+ OrbitFourWeb.Telemetry,
+ OrbitFour.Repo, # Database
+ {Phoenix.PubSub, name: OrbitFour.PubSub},
+ {Finch, name: OrbitFour.Finch}, # HTTP client
+ {Oban, ...}, # Job queue
+ OrbitFour.Epp.ConnectionPool, # ← TCP connection pool
+ OrbitFour.Epp.HealthCache, # ← Periodic health checks
+ OrbitFour.Billing.ExchangeRates, # ← Currency rates cache
+ OrbitFour.Rdap.Cache, # ← WHOIS data cache
+ OrbitFourWeb.Endpoint # Web server
+ ]
+
+ opts = [strategy: :one_for_one, name: OrbitFour.Supervisor]
+ Supervisor.start_link(children, opts)
+ end
+ end
+ </code></pre>
+
+ <p class="fragment" style="font-size: 0.7em; color: #555;">
+ DB, web server, TCP pool, health monitors, caches, job queue &mdash;<br/>
+ all supervised. If the connection pool crashes, the supervisor restarts it.<br/>
+ <strong>No pager alerts. No manual restarts.</strong>
+ </p>
+
+ <aside class="notes">
+ Here's OrbitFour's full supervision tree. Database, web server, TCP connection pool, health monitors, exchange rate caches, WHOIS caches, job queue — all in one tree. If the connection pool crashes from a bad TCP state or network blip, the supervisor restarts it. New connections are established automatically. No pager alerts, no manual intervention.
+ </aside>
+ </section>
+
+ <!-- =============================================
+ SECTION 3.3: Internal Admin
+ ============================================= -->
+
+ <section class="section-divider" data-transition="zoom">
+ <h2>3.3 &mdash; Internal Admin</h2>
+ <p>Multi-site LiveView dashboard</p>
+
+ <aside class="notes">
+ Third system: our internal admin app. This is a LiveView dashboard that connects to all of Vianet's properties from a single place.
+ </aside>
+ </section>
+
+ <section>
+ <h2>The Philosophy</h2>
+ <p class="muted small">Instead of building admin features <em>into</em> each app...</p>
+ <ul>
+ <li class="fragment"><strong>Customer apps stay focused</strong> &mdash; no admin routes, no admin UI, no admin auth</li>
+ <li class="fragment"><strong>Admin app iterates fast</strong> &mdash; internal tool, no public-facing risk</li>
+ <li class="fragment"><strong>One dashboard</strong> &mdash; manage all sites from a single place</li>
+ <li class="fragment"><strong>LiveView</strong> &mdash; real-time updates, interactive tables, presence tracking</li>
+ </ul>
+
+ <aside class="notes">
+ The philosophy: instead of polluting each customer-facing app with admin features, we built a separate admin app that connects to all of them. Customer apps stay clean. The admin app can iterate fast since it's internal. One dashboard to manage everything. And LiveView gives us real-time updates for free.
+ </aside>
+ </section>
+
+ <section>
+ <h2>Multi-Database Architecture</h2>
+ <p class="muted small">Connects to <strong>five databases</strong> simultaneously</p>
+
+ <pre class="small-code"><code class="language-elixir" data-trim data-line-numbers="5-16">
+ defmodule VianetAdmin.Application do
+ use Application
+
+ def start(_type, _args) do
+ children = [
+ VianetAdminWeb.Telemetry,
+ VianetAdmin.Repo, # Admin's own database
+ VianetAdmin.PuppiesRepo, # Puppies.com database
+ VianetAdmin.RRRepo, # ReputableRooms database
+ VianetAdmin.RoommatesRepo, # Roommates database
+ VianetAdmin.GeocodeRepo, # Geocoding database
+ VianetAdmin.ConnectionMonitor,
+ {Phoenix.PubSub, name: VianetAdmin.PubSub},
+ {Finch, name: VianetAdmin.Finch},
+ VianetAdmin.Presence, # ← Track which admins are online
+ VianetAdminWeb.Endpoint,
+ VianetAdmin.Scheduler,
+ {Oban, ...},
+ {Cachex, [:cache]} # In-memory cache
+ ]
+
+ opts = [strategy: :one_for_one, name: VianetAdmin.Supervisor]
+ Supervisor.start_link(children, opts)
+ end
+ end
+ </code></pre>
+
+ <p class="fragment" style="font-size: 0.7em; color: #555;">
+ Each <code>Repo</code> is a supervised connection pool to a different PostgreSQL database.
+ </p>
+
+ <aside class="notes">
+ The admin app's supervision tree connects to five databases simultaneously. Each Repo is a supervised connection pool to a different PostgreSQL database. Plus presence tracking, a scheduler, job queue, and in-memory cache. All in one tree.
+ </aside>
+ </section>
+
+ <section>
+ <h2>LiveView: Real-Time Admin Forms</h2>
+ <p class="muted small">Loading data from three databases asynchronously</p>
+
+ <pre class="small-code"><code class="language-elixir" data-trim data-line-numbers="4-7|8-16">
+ def mount(params, _session, socket) do
+ {form, action} =
+ if Map.has_key?(params, "id") do
+ admin = Accounts.get_admin!(params["id"])
+ {Admin.update_changeset(admin, %{}) |> to_form(), :edit}
+ else
+ {Admin.create_changeset(%Admin{}, %{}) |> to_form(), :new}
+ end
+
+ socket =
+ socket
+ |> assign(form: form, action: action)
+ |> assign_async(
+ [:puppies_admins, :roommates_admins, :reputable_rooms_admins],
+ fn ->
+ {:ok, %{
+ puppies_admins: Puppies.Admins.get_active_admins(),
+ roommates_admins: Roommates.Admins.get_admins(),
+ reputable_rooms_admins: ReputableRooms.Admins.get_admins()
+ }}
+ end
+ )
+
+ {:ok, socket}
+ end
+ </code></pre>
+
+ <p class="fragment" style="font-size: 0.7em; color: #555;">
+ <code>assign_async</code> loads admin lists from 3 databases <strong>concurrently</strong> &mdash; loading states come for free.
+ </p>
+
+ <aside class="notes">
+ Here's a real LiveView form. It loads data from three different databases asynchronously using assign_async. The loading states are automatic. Pattern matching determines if we're creating or editing. The form submission is just an Elixir function, not a REST endpoint.
+ </aside>
+ </section>
+
+ <section>
+ <h2>LiveView: Real-Time Presence</h2>
+ <p class="muted small">Multiple admins viewing the same record can see each other</p>
+
+ <pre><code class="language-elixir" data-trim data-line-numbers="1-5|8-13|15-18">
+ # Track this admin's presence
+ Presence.track(self(), "admin:presence", admin.id, %{
+ initials: Utilities.initials(admin),
+ current_user: params["id"]
+ })
+ Phoenix.PubSub.subscribe(PubSub, "admin:presence")
+
+ # Handle presence changes — who joined, who left
+ def handle_info(
+ %Phoenix.Socket.Broadcast{event: "presence_diff", payload: diff},
+ socket) do
+ {:noreply,
+ socket
+ |> handle_leaves(diff.leaves)
+ |> handle_joins(diff.joins)}
+ end
+
+ defp handle_joins(socket, joins) do
+ Enum.reduce(joins, socket, fn {admin, %{metas: [meta | _]}}, socket ->
+ assign(socket, :admins, Map.put(socket.assigns.admins, admin, meta))
+ end)
+ end
+ </code></pre>
+
+ <p class="fragment" style="font-size: 0.8em;">
+ <strong>No WebSocket code. No JavaScript. No polling.</strong>
+ </p>
+
+ <aside class="notes">
+ Real-time presence. When multiple admins look at the same user record, they can see each other. Phoenix Presence handles distributed presence tracking across all connected clients. No WebSocket code, no JavaScript, no polling. It just works.
+ </aside>
+ </section>
+
+ <section>
+ <h2>LiveView: Async Charts</h2>
+ <p class="muted small">Data loads asynchronously, pushes chart updates to the browser</p>
+
+ <pre class="small-code"><code class="language-elixir" data-trim data-line-numbers="5-7|10-17|20-24">
+ def mount(_params, _session, socket) do
+ socket =
+ socket
+ |> assign(stats: AsyncResult.loading())
+ |> assign_async(:site_stats, fn ->
+ {:ok, %{site_stats: Stats.latest_by_site("roommates")}}
+ end)
+ |> start_async(:stats, fn -> Stats.load_stats("havers", "7") end)
+
+ {:ok, socket}
+ end
+
+ # Async task completes → update the chart
+ def handle_async(:stats, {:ok, fetched_stats}, socket) do
+ data = %{stats: fetched_stats, title: "Listings created per hour"}
+ socket =
+ socket
+ |> assign(stats: AsyncResult.ok(socket.assigns.stats, fetched_stats))
+ {:noreply, push_event(socket, "graph", data)}
+ end
+
+ # User changes filter → reload asynchronously
+ def handle_event("update-stats", %{"table" => table, "range" => range}, socket) do
+ socket =
+ socket
+ |> assign(table: table, range: range, stats: AsyncResult.loading())
+ |> start_async(:stats, fn -> Stats.load_stats(table, range) end)
+ {:noreply, socket}
+ end
+ </code></pre>
+
+ <aside class="notes">
+ Stats dashboard with async charts. Page loads with a spinner. When the async task completes, handle_async fires and pushes chart data to the browser. User changes a filter — new async task starts, spinner appears, chart updates when data arrives. No REST API, no fetch calls, no loading state management library. All built into LiveView.
+ </aside>
+ </section>
+
+ <section>
+ <h2>The Flow</h2>
+ <ol>
+ <li class="fragment">Page loads &rarr; shows loading spinner (<code>AsyncResult.loading()</code>)</li>
+ <li class="fragment">Async task completes &rarr; <code>handle_async</code> fires, data flows to chart via <code>push_event</code></li>
+ <li class="fragment">User changes filter &rarr; new async task starts, spinner reappears</li>
+ <li class="fragment">New data arrives &rarr; chart updates in real-time</li>
+ </ol>
+ <p class="fragment" style="font-size: 0.8em; margin-top: 1em; color: #555;">
+ <strong>No REST API. No <code>fetch()</code>. No loading state management library.</strong><br/>
+ All handled by LiveView's built-in async primitives.
+ </p>
+
+ <aside class="notes">
+ Let me walk through the flow explicitly. Page loads, shows a spinner. Async task completes, data flows to the chart. User changes a filter, new task starts, spinner appears. New data arrives, chart updates. No REST API, no fetch, no state management library. It's all built into LiveView.
+ </aside>
+ </section>
+
+ <!-- =============================================
+ SECTION: Summary
+ ============================================= -->
+
+ <section>
+ <h2>What We've Seen</h2>
+ <p class="muted small">Three production systems, same patterns</p>
+
+ <div class="pattern-grid">
+ <div class="pattern-card fragment">
+ <h4>Supervision</h4>
+ <p><strong>Email:</strong> Rate limiter + job queue<br/>
+ <strong>Domain:</strong> Connection pool + health cache<br/>
+ <strong>Admin:</strong> Multi-DB + presence + cache</p>
+ </div>
+ <div class="pattern-card fragment">
+ <h4>GenServer</h4>
+ <p><strong>Email:</strong> Token bucket rate limiter<br/>
+ <strong>Domain:</strong> TCP connection pool<br/>
+ <strong>Admin:</strong> Connection monitor</p>
+ </div>
+ <div class="pattern-card fragment">
+ <h4>Pattern Matching</h4>
+ <p><strong>Email:</strong> Error handling in <code>with</code><br/>
+ <strong>Domain:</strong> Socket type dispatch<br/>
+ <strong>Admin:</strong> Form actions, presence diffs</p>
+ </div>
+ <div class="pattern-card fragment">
+ <h4><code>with</code> Chains</h4>
+ <p><strong>Email:</strong> 6-step pipeline<br/>
+ <strong>Domain:</strong> Connect &rarr; greet &rarr; login<br/>
+ <strong>Admin:</strong> Form submit &rarr; redirect</p>
+ </div>
+ <div class="pattern-card fragment">
+ <h4>Concurrency</h4>
+ <p><strong>Email:</strong> 500k jobs in parallel<br/>
+ <strong>Domain:</strong> Pooled TCP connections<br/>
+ <strong>Admin:</strong> Async from 5 databases</p>
+ </div>
+ <div class="pattern-card fragment">
+ <h4>LiveView</h4>
+ <p><strong>Email:</strong> Campaign management<br/>
+ <strong>Domain:</strong> Domain search &amp; registration<br/>
+ <strong>Admin:</strong> Real-time dashboards + presence</p>
+ </div>
+ </div>
+
+ <aside class="notes">
+ Three production systems, same patterns everywhere. Supervision, GenServer, pattern matching, with chains, concurrency, LiveView. These aren't separate tools bolted together. They're all built into the language and framework. One supervision tree. One deployment. One codebase.
+ </aside>
+ </section>
+
+ <section>
+ <h2>One Unified Platform</h2>
+ <p style="font-size: 0.85em; color: #555; max-width: 700px; margin: 0.5em auto;">
+ These aren't separate tools bolted together. They're all
+ <strong>built into the language and framework</strong>.
+ </p>
+ <div class="stats-row fragment">
+ <div class="stats-item">
+ <div class="stat" style="font-size: 1.8em !important;">1</div>
+ <div class="stat-label">Supervision tree</div>
+ </div>
+ <div class="stats-item">
+ <div class="stat" style="font-size: 1.8em !important;">1</div>
+ <div class="stat-label">Deployment</div>
+ </div>
+ <div class="stats-item">
+ <div class="stat" style="font-size: 1.8em !important;">1</div>
+ <div class="stat-label">Codebase</div>
+ </div>
+ </div>
+
+ <aside class="notes">
+ One supervision tree. One deployment. One codebase. That's the power of building on the BEAM.
+ </aside>
+ </section>
+
+ <!-- =============================================
+ SECTION: Wrap-up
+ ============================================= -->
+
+ <section class="section-divider" data-transition="zoom">
+ <h2>Wrap-up</h2>
+
+ <aside class="notes">
+ Let's wrap up.
+ </aside>
+ </section>
+
+ <section>
+ <h2>What We Covered Today</h2>
+ <ol>
+ <li class="fragment"><strong>Why Elixir exists</strong> &mdash; Erlang's 40-year legacy of reliability, made accessible</li>
+ <li class="fragment"><strong>Pattern matching</strong> &mdash; clearer code where every case is explicit</li>
+ <li class="fragment"><strong>Processes &amp; supervision</strong> &mdash; concurrency without locks, fault tolerance without fear</li>
+ <li class="fragment"><strong>LiveView</strong> &mdash; real-time web apps without JavaScript complexity</li>
+ <li class="fragment"><strong>Real production systems</strong> &mdash; 500k emails, TCP protocols, multi-site admin tools</li>
+ </ol>
+
+ <aside class="notes">
+ Quick recap: why Elixir exists, pattern matching for clarity, processes and supervision for concurrency and fault tolerance, LiveView for real-time web apps, and three production systems showing it all in action.
+ </aside>
+ </section>
+
+ <section>
+ <h2>The Pitch</h2>
+ <p style="font-size: 0.85em; color: #555; margin-bottom: 0.8em;">
+ Elixir isn't just "another language." It changes <strong>how you think</strong> about building web apps:
+ </p>
+ <ul>
+ <li class="fragment">State lives in <strong>processes</strong>, not shared memory</li>
+ <li class="fragment">Errors are handled <strong>by design</strong>, not by hope</li>
+ <li class="fragment">Concurrency is the <strong>default</strong>, not an afterthought</li>
+ <li class="fragment">Real-time features come <strong>for free</strong></li>
+ </ul>
+
+ <aside class="notes">
+ Elixir isn't just another language to learn. It fundamentally changes how you think about building software. State in processes, not shared memory. Errors handled by design. Concurrency as a default. Real-time for free. It's a different paradigm, and once it clicks, it's hard to go back.
+ </aside>
+ </section>
+
+ <section>
+ <h2>Getting Started</h2>
+ <ul class="resource-list">
+ <li>
+ <a href="https://elixir-lang.org">elixir-lang.org</a>
+ <span class="resource-desc">&mdash; the official guides are excellent</span>
+ </li>
+ <li>
+ <a href="https://livebook.dev">livebook.dev</a>
+ <span class="resource-desc">&mdash; install it tonight, start experimenting</span>
+ </li>
+ <li>
+ <a href="https://phoenixframework.org">phoenixframework.org</a>
+ <span class="resource-desc">&mdash; the web framework</span>
+ </li>
+ <li>
+ <a href="https://elixirforum.com">elixirforum.com</a>
+ <span class="resource-desc">&mdash; one of the friendliest communities in tech</span>
+ </li>
+ <li>
+ <a href="https://elixirschool.com">elixirschool.com</a>
+ <span class="resource-desc">&mdash; free lessons, great for self-paced learning</span>
+ </li>
+ </ul>
+
+ <aside class="notes">
+ If you want to get started: elixir-lang.org has excellent official guides. Install Livebook tonight and start experimenting. Phoenix is the web framework. The Elixir Forum is genuinely one of the friendliest communities in tech. And Elixir School has free, self-paced lessons.
+ </aside>
+ </section>
+
+ <section>
+ <h2>Thank You</h2>
+ <p><strong>Torey Heinz</strong></p>
+ <p class="muted">Vianet Management / Shopflow</p>
+ <p style="margin-top: 1.5em; font-size: 1.2em; font-style: italic;">Questions?</p>
+
+ <aside class="notes">
+ Thank you! I'm happy to take questions. If you want to chat more about Elixir, catch me after — I could talk about this stuff all day.
+ </aside>
+ </section>
+
+ </div>
+ </div>
+
+ <script src="https://unpkg.com/reveal.js@5.1.0/dist/reveal.js"></script>
+ <script src="https://unpkg.com/reveal.js@5.1.0/plugin/highlight/highlight.js"></script>
+ <script src="https://unpkg.com/reveal.js@5.1.0/plugin/notes/notes.js"></script>
+ <script src="https://unpkg.com/reveal.js@5.1.0/plugin/markdown/markdown.js"></script>
+ <script>
+ Reveal.initialize({
+ hash: true,
+ transition: 'slide',
+ slideNumber: true,
+ plugins: [RevealHighlight, RevealNotes, RevealMarkdown],
+ highlight: {
+ highlightOnLoad: true
+ }
+ });
+ </script>
+ </body>
+ </html>
slides/shared.css +442 -0
@@ @@ -0,0 +1,442 @@
+ /* ============================================================
+ Reveal.js — Shared Theme for "Elixir in the Real World"
+ ============================================================ */
+
+ :root {
+ --elixir-purple: #6e44ff;
+ --elixir-purple-light: #9b59b6;
+ --elixir-dark: #1a1a2e;
+ --accent-gray: #444;
+ }
+
+ /* ----- Typography ----- */
+
+ .reveal {
+ font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
+ }
+
+ .reveal h1, .reveal h2, .reveal h3 {
+ color: var(--elixir-purple);
+ text-transform: none;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ }
+
+ .reveal h1 { font-size: 2.4em; }
+ .reveal h2 { font-size: 1.8em; }
+ .reveal h3 { font-size: 1.3em; }
+
+ .reveal p, .reveal li {
+ color: #333;
+ font-size: 0.85em;
+ line-height: 1.5;
+ }
+
+ .reveal strong { color: var(--elixir-purple); }
+
+ .reveal blockquote {
+ background: #f4f0ff;
+ border-left: 5px solid var(--elixir-purple);
+ padding: 20px 30px;
+ font-style: italic;
+ color: #333;
+ width: 85%;
+ margin: 0.5em auto;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.08);
+ border-radius: 4px;
+ }
+
+ /* ----- Code ----- */
+
+ .reveal pre {
+ width: 90%;
+ font-size: 0.55em;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+ border-radius: 8px;
+ }
+
+ .reveal code {
+ font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
+ }
+
+ /* ----- Section Dividers ----- */
+
+ .reveal .section-divider {
+ background: linear-gradient(135deg, var(--elixir-purple), var(--elixir-purple-light));
+ color: white !important;
+ }
+ .reveal .section-divider h2,
+ .reveal .section-divider p,
+ .reveal .section-divider strong {
+ color: white !important;
+ }
+
+ /* ----- Utility Classes ----- */
+
+ .reveal .subtitle {
+ color: var(--accent-gray);
+ font-size: 1.2em;
+ margin-top: 0.3em;
+ }
+
+ .reveal .meta {
+ color: #888;
+ font-size: 0.7em;
+ margin-top: 1.5em;
+ }
+
+ .reveal .muted {
+ color: #888;
+ }
+
+ .reveal .small {
+ font-size: 0.75em;
+ }
+
+ .reveal .smaller {
+ font-size: 0.7em;
+ }
+
+ /* ----- Entity / Card Grids ----- */
+
+ .entity-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 20px;
+ margin-top: 30px;
+ width: 90%;
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ .entity-card {
+ background: #fafafa;
+ border: 2px solid #e8e0ff;
+ border-radius: 12px;
+ padding: 20px 24px;
+ text-align: left;
+ transition: transform 0.2s, box-shadow 0.2s;
+ }
+
+ .entity-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 16px rgba(110, 68, 255, 0.15);
+ }
+
+ .entity-card h4 {
+ margin: 0 0 4px 0;
+ font-size: 0.9em;
+ color: var(--elixir-purple);
+ }
+
+ .entity-card p {
+ margin: 0;
+ font-size: 0.65em !important;
+ color: #666;
+ }
+
+ .entity-card.wide {
+ grid-column: 1 / -1;
+ max-width: 50%;
+ justify-self: center;
+ }
+
+ /* ----- Company Cards ----- */
+
+ .company-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 20px;
+ margin-top: 20px;
+ width: 90%;
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ .company-card {
+ background: #fafafa;
+ border: 2px solid #e8e0ff;
+ border-radius: 12px;
+ padding: 20px;
+ text-align: left;
+ }
+
+ .company-card h4 {
+ margin: 0 0 8px 0;
+ font-size: 0.85em;
+ color: var(--elixir-purple);
+ }
+
+ .company-card p {
+ margin: 0;
+ font-size: 0.6em !important;
+ color: #666;
+ }
+
+ .company-card.highlight {
+ border-color: var(--elixir-purple);
+ background: #f4f0ff;
+ grid-column: 1 / -1;
+ }
+
+ /* ----- Comparison Grid ----- */
+
+ .compare-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+ margin-top: 20px;
+ width: 90%;
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ .compare-item {
+ background: #fafafa;
+ border-radius: 10px;
+ padding: 18px 22px;
+ text-align: left;
+ border: 2px solid #eee;
+ }
+
+ .compare-item h4 {
+ margin: 0 0 8px 0;
+ font-size: 0.8em;
+ color: #555;
+ }
+
+ .compare-item p {
+ margin: 0;
+ font-size: 0.6em !important;
+ color: #777;
+ }
+
+ .compare-item.elixir {
+ border-color: var(--elixir-purple);
+ background: #f4f0ff;
+ }
+
+ .compare-item.elixir h4 {
+ color: var(--elixir-purple);
+ }
+
+ .compare-item.elixir p {
+ color: #444;
+ }
+
+ /* ----- Stats ----- */
+
+ .stat {
+ font-size: 2.5em !important;
+ font-weight: 800;
+ color: var(--elixir-purple) !important;
+ margin: 0.2em 0 !important;
+ }
+
+ .stat-label {
+ font-size: 0.8em !important;
+ color: #888 !important;
+ }
+
+ .stats-row {
+ display: flex;
+ justify-content: center;
+ gap: 60px;
+ margin: 30px 0;
+ }
+
+ .stats-item {
+ text-align: center;
+ }
+
+ /* ----- ASCII Flow Diagrams ----- */
+
+ .flow-diagram {
+ font-family: "JetBrains Mono", "Fira Code", monospace;
+ font-size: 0.48em !important;
+ line-height: 1.4;
+ color: #555 !important;
+ text-align: left;
+ display: inline-block;
+ background: #f8f8f8;
+ padding: 20px 30px;
+ border-radius: 8px;
+ }
+
+ .flow-diagram .waiting {
+ color: #e74c3c;
+ font-weight: bold;
+ }
+
+ /* ----- Architecture Diagrams ----- */
+
+ .arch-diagram {
+ font-family: "JetBrains Mono", "Fira Code", monospace;
+ font-size: 0.42em !important;
+ line-height: 1.3;
+ color: #555 !important;
+ text-align: left;
+ display: inline-block;
+ background: #f8f8f8;
+ padding: 20px 30px;
+ border-radius: 8px;
+ white-space: pre;
+ }
+
+ /* ----- Tables ----- */
+
+ .reveal table {
+ margin: 20px auto;
+ border-collapse: collapse;
+ font-size: 0.6em;
+ }
+
+ .reveal table th {
+ background: var(--elixir-purple);
+ color: white;
+ padding: 10px 16px;
+ font-weight: 600;
+ text-align: left;
+ }
+
+ .reveal table td {
+ padding: 8px 16px;
+ border-bottom: 1px solid #eee;
+ color: #444;
+ }
+
+ .reveal table tr:nth-child(even) td {
+ background: #f9f7ff;
+ }
+
+ .reveal table tr.highlight-row td {
+ background: #f4f0ff;
+ font-weight: 600;
+ color: var(--elixir-purple);
+ }
+
+ /* ----- Key-Value List (for patterns/summaries) ----- */
+
+ .kv-list {
+ list-style: none;
+ padding: 0;
+ margin: 15px auto;
+ width: 85%;
+ }
+
+ .kv-list li {
+ display: flex;
+ align-items: baseline;
+ gap: 12px;
+ margin-bottom: 8px;
+ font-size: 0.7em !important;
+ }
+
+ .kv-list .kv-key {
+ flex-shrink: 0;
+ font-weight: 600;
+ color: var(--elixir-purple);
+ min-width: 140px;
+ text-align: right;
+ }
+
+ .kv-list .kv-val {
+ color: #555;
+ }
+
+ /* ----- Callout Box ----- */
+
+ .callout {
+ background: #f4f0ff;
+ border: 2px solid var(--elixir-purple);
+ border-radius: 10px;
+ padding: 20px 28px;
+ margin: 20px auto;
+ width: 80%;
+ text-align: left;
+ }
+
+ .callout p {
+ margin: 0;
+ font-size: 0.75em !important;
+ color: #444;
+ }
+
+ /* ----- Slide Padding ----- */
+
+ .reveal .slides section {
+ padding: 40px;
+ }
+
+ /* ----- Smaller code for dense slides ----- */
+
+ .reveal pre.small-code {
+ font-size: 0.45em;
+ }
+
+ .reveal pre.tiny-code {
+ font-size: 0.40em;
+ }
+
+ /* ----- Pattern summary cards ----- */
+
+ .pattern-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 14px;
+ margin-top: 20px;
+ width: 95%;
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ .pattern-card {
+ background: #fafafa;
+ border: 2px solid #e8e0ff;
+ border-radius: 10px;
+ padding: 14px 16px;
+ text-align: left;
+ }
+
+ .pattern-card h4 {
+ margin: 0 0 6px 0;
+ font-size: 0.7em;
+ color: var(--elixir-purple);
+ }
+
+ .pattern-card p {
+ margin: 0;
+ font-size: 0.55em !important;
+ color: #666;
+ line-height: 1.4;
+ }
+
+ /* ----- Resource links ----- */
+
+ .resource-list {
+ list-style: none;
+ padding: 0;
+ text-align: left;
+ width: 70%;
+ margin: 20px auto;
+ }
+
+ .resource-list li {
+ margin-bottom: 12px;
+ font-size: 0.8em !important;
+ }
+
+ .resource-list a {
+ color: var(--elixir-purple);
+ text-decoration: none;
+ font-weight: 600;
+ }
+
+ .resource-list a:hover {
+ text-decoration: underline;
+ }
+
+ .resource-list .resource-desc {
+ color: #888;
+ font-size: 0.85em;
+ }