<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title>LiveView - Tag - Tracy Atteberry</title><link>https://tracyatteberry.com/tags/liveview/</link><description>LiveView - Tag - Tracy Atteberry</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><managingEditor>tracy@magicbydesign.com (Tracy Atteberry)</managingEditor><webMaster>tracy@magicbydesign.com (Tracy Atteberry)</webMaster><lastBuildDate>Fri, 27 Mar 2026 00:00:00 +0000</lastBuildDate><image><url>https://tracyatteberry.com/images/feed-icon.jpg</url><title>LiveView - Tag - Tracy Atteberry</title><link>https://tracyatteberry.com/tags/liveview/</link></image><atom:link href="https://tracyatteberry.com/tags/liveview/" rel="self" type="application/rss+xml"/><item><title>Cognitive debt: A personal story and practical advice</title><link>https://tracyatteberry.com/posts/cognitive_debt/</link><pubDate>Fri, 27 Mar 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/cognitive_debt/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/cognitive_debt/cognitive_debt.png" referrerpolicy="no-referrer">
            </div><p>A few days ago I built a tool called JoyConf: a real-time audience feedback
system that lets speakers see emoji reactions floating up in the corner of
their presentation while they&rsquo;re talking. It was a fun, simple idea and I was
pretty excited about it.</p>
<p>I built it in Elixir and Phoenix LiveView, which was a deliberate choice. I
mostly write Ruby these days, but this project felt like a good excuse to dig
into Elixir and LiveView. Learn something new, build something useful. Two birds,
one stone.</p>
<p>I drove the overall design and implementation planning, did active code review,
and contributed everywhere I could. But for the Elixir and LiveView specifics,
I leaned heavily on Claude. The syntax, architecture decisions, and debugging
were Claude&rsquo;s domain, because I simply didn&rsquo;t know enough yet to own them. The
tool worked and it <em>seemed</em> to work well. But when I got to the end and looked at
the codebase, I realized I didn&rsquo;t really understand the parts Claude had built.
I had reviewed the code as carefully as I could, but reviewing code in a language
you don&rsquo;t know, implementing an unfamiliar architecture, only gets you so far.
The understanding of those pieces had mostly stayed with Claude.</p>
<p>That&rsquo;s cognitive debt. And LLMs are very good at generating it.</p>
<h2 id="what-cognitive-debt-actually-is">What cognitive debt actually is</h2>
<p>Cognitive debt accumulates when you defer the thinking that should happen now.
It&rsquo;s different from technical debt, which is about the code itself (shortcuts
taken, tests skipped, abstractions that didn&rsquo;t quite work out). Cognitive debt is
about the reasoning that never happened. The mental model that never got built.
The decision that got made without being understood.</p>
<p>Like financial debt, it doesn&rsquo;t feel like much at first. You&rsquo;re moving fast,
things are working, you&rsquo;re shipping. The bill comes later, when you need to
debug something you can&rsquo;t reason about, extend a system you don&rsquo;t understand,
or explain a decision you never actually made. And to be clear, cognitive debt
has been around long before LLMs, LLMs just magnify the problem.</p>
<h2 id="llms-make-this-disturbingly-easy">LLMs make this disturbingly easy</h2>
<p>LLM-generated code is mostly right. That&rsquo;s what makes it dangerous.</p>
<p>If the code were obviously wrong, you&rsquo;d catch it. You&rsquo;d dig in, figure out what
went wrong, learn something in the process. But LLM output is usually plausible,
often correct, and just coherent enough that it passes the vibe check. You run
the tests. They pass. You move on. The mental model of how it works never gets
built, because you never needed it&hellip; until you do.</p>
<p>There&rsquo;s a specific failure mode worth naming here. Using an LLM to move faster
on things you understand is <em>leverage</em>. Using it to skip understanding altogether
is <em>debt</em>. Those feel identical in the short term. Both result in code getting
written. One leaves you with understanding you can build on; the other leaves you
with output you&rsquo;re stuck with.</p>
<p>And it catches everyone. Junior developers accept LLM output because they don&rsquo;t
know enough to question it. Senior developers accept it because they had a
hundred PRs today and the code looks fine, so they assume it is fine. Both skip
the reasoning step. The result is a codebase full of decisions nobody on the
team can actually defend.</p>
<h2 id="back-to-joyconf">Back to JoyConf</h2>
<p>When I realized I&rsquo;d built something I didn&rsquo;t fully understand, I asked Claude to
write me an explainer document. Not a summary, but an actual explanation of the
architecture, the key concepts, why certain decisions were made, how the pieces
fit together. Something I could read, learn from, and come back to later.</p>
<p>It wasn&rsquo;t a magic pill. I was starting from near zero with Elixir and LiveView,
so one document didn&rsquo;t make me an expert. But it meaningfully closed the gap. I
understood the code better than I did before. I had something to refer back to.
And I started to feel like the codebase was actually mine.</p>
<p>That experience shaped how I think about using LLMs for coding. The tool works
fine. How you engage with it makes all the difference.</p>
<h2 id="practical-ways-to-keep-the-debt-in-check">Practical ways to keep the debt in check</h2>
<p><strong>Ask for explanations before you accept the code.</strong> Don&rsquo;t just run it. Ask the
LLM to walk you through what it did and why. This takes an extra minute and
catches a surprising number of cases where the code is technically correct but
built on assumptions you don&rsquo;t share.</p>
<p><strong>Ask for an explainer document for bigger decisions.</strong> Architecture choices,
non-obvious patterns, anything you&rsquo;re going to need to live with for a while:
ask the LLM to write it up in plain language. Keep it in the repo. Future you
will thank present you.</p>
<p><strong>Use Simon Willison&rsquo;s &ldquo;showboat&rdquo; approach to document what was built.</strong> The
<a href="https://github.com/simonw/showboat" target="_blank" rel="noopener noreffer "><code>showboat</code></a> tool &ldquo;creates executable demo
documents that show and prove an agent&rsquo;s work.&rdquo; (kind of like a Jupyter
notebook, but just markdown). The LLM walks through its output with explanation
and context. It&rsquo;s a great way to produce living documentation that captures not
just what the code does, but why it was written that way. It&rsquo;s a great tool,
but not suitable for every use case.</p>
<p><strong>Read the LLM&rsquo;s thinking, especially when debugging.</strong> Many LLMs can expose
their reasoning process. When you&rsquo;re stuck on a bug or trying to understand a
decision, asking the LLM to think out loud before answering is one of the
fastest ways to build genuine understanding rather than just getting an answer.</p>
<p><strong>Write the tests yourself.</strong> Even if you let the LLM write the implementation,
writing the tests forces you to reason about the behavior you actually want.
It&rsquo;s one of the best ways to make sure the mental model gets built. Of course,
it takes more time and it&rsquo;s not always possible, like with JoyConf where I
didn&rsquo;t know enough about the Elixir environment to write effective tests. But
when you can, it&rsquo;s a great way to stay in the driver&rsquo;s seat.</p>
<p><strong>Slow down at decision points.</strong> LLMs are fast. That&rsquo;s the point. But speed
can accelerate debt. When you hit a fork in the road (an architectural choice, a
tradeoff, a &ldquo;there are a few ways to do this&rdquo; moment) pause and do the reasoning
yourself, even if you use the LLM to help you think it through.</p>
<h2 id="the-goal-isnt-to-use-llms-less">The goal isn&rsquo;t to use LLMs less</h2>
<p>LLMs are genuinely useful and I don&rsquo;t plan to stop using them. The goal is to
stay in the driver&rsquo;s seat mentally, using them for leverage rather than as a
substitute for thinking.</p>
<p>A healthy LLM workflow and a debt-generating one can look identical from the
outside. The difference shows up later, when you need to understand, maintain, or
extend what you built. If you finish each session understanding what you built
and why, you&rsquo;re using the tool well. If you don&rsquo;t, you&rsquo;re taking out a loan.</p>
<p>And like financial debt, cognitive debt is a lot easier to avoid than to pay off.</p>
<h2 id="references">References</h2>
<ul>
<li><a href="https://arxiv.org/abs/2506.08872" target="_blank" rel="noopener noreffer ">MIT study on cognitive debt</a></li>
<li><a href="https://github.com/simonw/showboat" target="_blank" rel="noopener noreffer ">Simon Willison&rsquo;s showboat tool</a></li>
<li><a href="https://github.com/grymoire7/joyconf" target="_blank" rel="noopener noreffer ">JoyConf repo</a></li>
<li><a href="https://tracyatteberry.com/posts/joyconf/" target="_blank" rel="noopener noreffer ">JoyConf story</a></li>
</ul>
]]></description></item><item><title>JoyConf</title><link>https://tracyatteberry.com/portfolio/joyconf/</link><pubDate>Wed, 25 Mar 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/portfolio/joyconf/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/joyconf/hero.png" referrerpolicy="no-referrer">
            </div><p>Building a presentation thingy.</p>
]]></description></item><item><title>JoyConf: a live emoji reaction app for presentations</title><link>https://tracyatteberry.com/posts/joyconf/</link><pubDate>Wed, 25 Mar 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/joyconf/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/joyconf/hero.png" referrerpolicy="no-referrer">
            </div><h1 id="i-built-a-live-emoji-reaction-app-for-my-upcoming-talk">I built a live emoji reaction app for my upcoming talk</h1>
<p>I have a talk coming up in a few weeks. It got rescheduled once, which gave me
extra time to prepare. The presentation itself is finished, so I used that time
to build an application that I think will make the talk more fun and engaging
for the audience.</p>
<p>I wanted to add some live audience interaction. The usual options, Slido,
Mentimeter, Poll Everywhere, are fine, but they&rsquo;re designed around Q&amp;A and
polls. What I actually wanted was simpler and more visual: live emoji reactions
that float up as an overlay on my slides while I&rsquo;m presenting. None of the
existing tools seem to do that. So I built my own, and I called it JoyConf.</p>
<p>It also gave me a reason to finally learn some Elixir.</p>
<hr>
<h2 id="what-it-does">What it does</h2>
<p>The flow is simple:</p>
<ol>
<li>You create a talk in the admin panel and get a QR code</li>
<li>You put the QR code on your title slide</li>
<li>Attendees scan it and land on a page with pre-defined set emoji buttons: ❤️ 😂 🙋🏻 👏 🤯, etc.</li>
<li>They tap a button, and the emoji floats up on their screen in real time</li>
<li>A Chrome extension running on your laptop picks up the same broadcast and
overlays the emoji directly in the lower right corner of your Google Slides presentation</li>
</ol>
<p>That&rsquo;s it. No accounts for attendees, no app to install, no configuration to
fiddle with. Scan, tap, react.</p>
<hr>
<h2 id="why-elixir-and-phoenix">Why Elixir and Phoenix</h2>
<p>I write Ruby day to day, so the syntax was familiar enough. But Elixir runs on
the BEAM, the Erlang virtual machine, which was built for soft real-time
systems with lots of concurrent connections. Phoenix LiveView lets you build
interactive, server-rendered UIs over WebSockets without writing much
JavaScript. And Phoenix PubSub gives you a message bus that lets any process
broadcast to any other, regardless of what type of process it is.</p>
<p>All three of those things are important for a system where dozens of phones are
sending events to a server that needs to fan them out to a slide presentation
and all the phones (for their own consolidated live view) in under a second.</p>
<p>LiveView is pretty magical, by the way. You define your UI as a function of
state, and Phoenix handles keeping the browser in sync. You get
WebSocket-backed interactivity without writing a single-page app.</p>
<hr>
<h2 id="how-it-works-under-the-hood">How it works under the hood</h2>
<p>There are three clients talking to one Phoenix server.</p>
<p><strong>Attendee phones</strong> connect via Phoenix LiveView, which manages the WebSocket
lifecycle automatically. When an attendee taps an emoji, it fires a <code>phx-click</code>
event to the server. The server checks a rate limiter (one reaction per session
every 3 seconds) and, if allowed, broadcasts the event to a PubSub topic keyed
by the talk slug: <code>&quot;reactions:my-talk&quot;</code>.</p>
<p><strong>The Chrome extension</strong> connects via a Phoenix Channel, a lower-level
WebSocket primitive. The extension can&rsquo;t use LiveView because it&rsquo;s not a web
page; it just needs to receive events. The <code>ReactionChannel</code> is subscribed to
the same PubSub topic, so when the attendee&rsquo;s broadcast lands, it gets
forwarded to the extension automatically.</p>
<p><strong>The admin browser</strong> is a standard LiveView protected by HTTP Basic Auth. You
create a talk, the server generates a slug from the title, and you get a QR
code pointing at the attendee URL.</p>
<p>The PubSub layer is what makes the architecture clean. <code>TalkLive</code> doesn&rsquo;t need
to know the Chrome extension exists. It just broadcasts on the topic, and
PubSub delivers it to everyone subscribed, whether that&rsquo;s a LiveView process, a
Channel process, or both.</p>
<p>Here&rsquo;s the entire broadcast path in code:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-elixir">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-elixir" data-lang="elixir"><span class="line"><span class="cl"><span class="kd">def</span> <span class="n">handle_event</span><span class="p">(</span><span class="s2">&#34;react&#34;</span><span class="p">,</span> <span class="p">%{</span><span class="s2">&#34;emoji&#34;</span> <span class="o">=&gt;</span> <span class="n">emoji</span><span class="p">},</span> <span class="n">socket</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="nc">RateLimiter</span><span class="o">.</span><span class="n">allow?</span><span class="p">(</span><span class="n">socket</span><span class="o">.</span><span class="n">id</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="nc">Endpoint</span><span class="o">.</span><span class="n">broadcast!</span><span class="p">(</span><span class="s2">&#34;reactions:</span><span class="si">#{</span><span class="n">socket</span><span class="o">.</span><span class="n">assigns</span><span class="o">.</span><span class="n">talk</span><span class="o">.</span><span class="n">slug</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="s2">&#34;new_reaction&#34;</span><span class="p">,</span> <span class="p">%{</span><span class="ss">emoji</span><span class="p">:</span> <span class="n">emoji</span><span class="p">})</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:noreply</span><span class="p">,</span> <span class="n">socket</span><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>That&rsquo;s the whole thing. Five lines. PubSub does the fan-out.</p>
<h3 id="rate-limiting-with-ets">Rate limiting with ETS</h3>
<p>The rate limiter is a <code>GenServer</code> that owns an ETS table. ETS is an in-memory
key/value store built into the BEAM runtime. It&rsquo;s extremely fast and,
crucially, you can configure it for concurrent reads without going through the
GenServer process. This matters because with many attendees tapping at once,
you don&rsquo;t want all those requests queuing up behind a single process.</p>
<p>The table stores <code>{session_id, last_reaction_at}</code>. The <code>allow?/1</code> function
looks up the session, checks if enough time has passed, and updates the
timestamp atomically. No database round-trip, no lock contention.</p>
<p>There&rsquo;s also a client-side rate limit in JavaScript: buttons are disabled for 3
seconds with a visible countdown timer. That&rsquo;s just UX, the real enforcement is
on the server.</p>
<h3 id="the-chrome-extension">The Chrome extension</h3>
<p>The extension has two parts: a popup where you enter the talk slug once, and a
content script injected into Google Slides pages that handles the actual
connection and overlay.</p>
<p>The content script connects a Phoenix WebSocket client to the server, joins the
<code>reactions:${slug}</code> channel, and listens for <code>new_reaction</code> messages. When one
arrives, it spawns a floating <code>&lt;span&gt;</code> element with a CSS animation that drifts
up and fades out.</p>
<hr>
<h2 id="it-never-works-on-the-first-try">It <em>never</em> works on the first try</h2>
<p><strong>Double emojis on slides.</strong> Early on, every reaction appeared twice on the
speaker&rsquo;s screen. The bug was that the Chrome extension was subscribing to
PubSub directly as well as receiving the Channel push. Two subscriptions, two
deliveries. The fix was removing the redundant subscription and letting the
Channel handle delivery exclusively.</p>
<p><strong>Fullscreen mode swallows the overlay.</strong> When you go fullscreen in Google
Slides, the browser creates a new stacking context. Any <code>position: fixed</code>
element on <code>&lt;body&gt;</code> becomes invisible. The fix was listening for
<code>fullscreenchange</code> events and re-parenting the overlay <code>&lt;div&gt;</code> into
<code>document.fullscreenElement</code> when it fires:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-javascript">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s2">&#34;fullscreenchange&#34;</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">overlay</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="s2">&#34;joyconf-overlay&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">fullscreenElement</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">document</span><span class="p">.</span><span class="nx">fullscreenElement</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">overlay</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">overlay</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">});</span></span></span></code></pre></div></div>
<p>Not obvious, but straightforward once you know it.</p>
<p><strong>Button clicks were being swallowed.</strong> The initial implementation disabled the
emoji buttons immediately on click. That blocked the <code>phx-click</code> handler from
firing, so the server never received the event. The fix was deferring the
button disable to a <code>setTimeout(..., 0)</code>, which lets the click event propagate
before the buttons get disabled.</p>
<p><strong>The Chrome extension&rsquo;s origin.</strong> Chrome extensions run from a
<code>chrome-extension://</code> origin, which Phoenix&rsquo;s <code>check_origin</code> protection rejects
by default. One line in <code>endpoint.ex</code> fixes it:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-elixir">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-elixir" data-lang="elixir"><span class="line"><span class="cl"><span class="n">socket</span> <span class="s2">&#34;/socket&#34;</span><span class="p">,</span> <span class="nc">JoyconfWeb.UserSocket</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">websocket</span><span class="p">:</span> <span class="p">[</span><span class="ss">check_origin</span><span class="p">:</span> <span class="no">false</span><span class="p">]</span></span></span></code></pre></div></div>
<hr>
<h2 id="the-tech-stack-briefly">The tech stack, briefly</h2>
<table>
	<thead>
			<tr>
					<th>Concern</th>
					<th>Choice</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Language / framework</td>
					<td>Elixir / Phoenix LiveView</td>
			</tr>
			<tr>
					<td>Real-time</td>
					<td>Phoenix PubSub + Channels</td>
			</tr>
			<tr>
					<td>Database</td>
					<td>PostgreSQL (Fly.io managed)</td>
			</tr>
			<tr>
					<td>Rate limiting</td>
					<td>ETS-backed GenServer</td>
			</tr>
			<tr>
					<td>QR codes</td>
					<td><code>eqrcode</code> hex package</td>
			</tr>
			<tr>
					<td>Deployment</td>
					<td>Fly.io</td>
			</tr>
			<tr>
					<td>Extension</td>
					<td>Chrome Manifest V3</td>
			</tr>
	</tbody>
</table>
<p>The database has one table: <code>talks</code>. Reactions are ephemeral and never
persisted. If the server restarts, in-flight reactions are lost, which is fine.</p>
<hr>
<h2 id="whats-next">What&rsquo;s next</h2>
<p>JoyConf is an MVP. It does one thing and it&rsquo;s ready for me to use at my talk.
It&rsquo;s not productized, it&rsquo;s not super-polished, and it&rsquo;s likely not easy for
the average non-technical user to deploy.</p>
<p>Things that might be nice to add in the future:</p>
<ul>
<li>Ease of deployment for less technical users.</li>
<li>Reaction analytics tied to slides so you can see which parts of your talk landed</li>
<li>Support for other presentation tools beyond Google Slides</li>
</ul>
<p>For now, I&rsquo;m just going to go use it.</p>
<hr>
<p>The source code is on <a href="https://github.com/grymoire7/joyconf" target="_blank" rel="noopener noreffer ">GitHub</a>. If you
want to run your own instance, the README should have everything you need.</p>
]]></description></item></channel></rss>