iBoard.cc
The Programming Kitchen (a dev only blog)
iBoard - ABOUT
iBoard is a little practicing project, figuring out how Elixir-development and AI comes together. So, I recently wrote this Blog-server. The content here doesn’t really matter! The project is not public (yet) but will be published at GitHub once it fulfills some basic quality standards ;-)
The iBoard Project This web application is written in Elixir with Phoenix, LiveView, TailwindCSS, DaisyUI, Ecto/Postgres, to name the most important.
ExDocs
The full dependency list reads like:
:bcrypt_elixir, "~> 3.0"
:phoenix, "~> 1.8.3"
:phoenix_ecto, "~> 4.5"
:ecto_sql, "~> 3.13"
:postgrex, ">= 0.0.0"
:phoenix_html, "~> 4.1"
:phoenix_live_reload, "~> 1.2", only: :dev
:phoenix_live_view, "~> 1.1.0"
:lazy_html, ">= 0.1.0", only: :test
:phoenix_live_dashboard, "~> 0.8.3"
:esbuild, "~> 0.10", runtime: Mix.env() == :dev
:tailwind, "~> 0.3", runtime: Mix.env() == :dev
:heroicons, github: "tailwindlabs/heroicons", tag: "v2.2.0"
:gen_smtp, "~> 1.2"
:swoosh, "~> 1.5"
:castore, "~> 1.0"
:req, "~> 0.5"
:telemetry_metrics, "~> 1.0"
:telemetry_poller, "~> 1.0"
:gettext, "~> 1.0"
:gettext_sigils, "~> 0.1.0"
:jason, "~> 1.2"
:dns_cluster, "~> 0.2.0"
:bandit, "~> 1.5"
:tzdata, "~> 1.1"
:boundary, "~> 0.10", runtime: false
:ex_doc, "~> 0.31", only: [:dev, :prod], runtime: false
:earmark, "~> 1.4"
With a big hug to this gorgeous community!
Follow the tag #iboard if you’re interested in the faith of this project.
Features so far (Buzzwords)
- Accounts, Users (phx.gen.auth)
- Posts, Drafts, Authors, Moderators, Likes, Followers
- User management, invite by e-mail
- Supports DaisyUI Themes
- Supports Locale and Timezone
You know the moment. Something pings. A soft little blip from somewhere in the machine,
and you have no idea what made it. Was that a chat message? A build finishing? A browser tab you forgot you left open three hours ago?
You end up doing the thing we all do: alt-tab through every window hunting for a badge, find nothing, and go back to work slightly annoyed. The sound is gone. Whatever wanted your attention got it, and then declined to say who it was.
So I wrote a small Omarchy shell plugin that just tells you.
Sound Source
https://plugins.omarchy.org/plugin.html?id=io.github.iboard.sound-source
When an app starts playing audio, a short popup appears naming it. That’s the whole idea. A speaker icon sits on the bar; left-click turns announcements on and off, right-click opens a setup dialog.
The dialog is where the interesting bits live. You get the on/off switch again, a per-app Never announce list, and controls for where the popup appears and how long it stays.
One detail there I’m quietly pleased with: the app list isn’t what’s playing right now — it’s everything the plugin has seen play since the shell started. That sounds like a small distinction, but it’s the one that makes the feature usable. By the time you’re irritated enough to go looking for the culprit, the culprit has already stopped making noise. A list of currently playing apps would be empty exactly when you need it. Ignored apps stay in the list too, so you can always take one back off.
New streams, not loud streams
Here’s the part I found genuinely interesting to build.
The obvious way to detect “an app made a sound” is to watch audio levels: attach a meter to everything and announce whenever something goes from silent to loud. It’s the intuitive approach, and it’s the wrong one.
Watch what a discrete sound actually is in the PipeWire graph. An app opens a fresh playback stream, plays its blip, and closes the stream again. One notification sound is one node appearing and then vanishing. That’s a clean, cheap, unambiguous signal — so the plugin watches for new playback streams appearing, reads the app name off the stream, and shows it.
No meters. No polling. Nothing running when nothing is happening.
media.role comes along for free on most streams, so it also picks the glyph
— a music note for Music, Video and Game, something quieter for a
notification.
The double-popup mystery
Of course it wasn’t that clean at first. Early on, every single sound popped up twice.
The culprit was Omarchy’s parametric EQ. It takes the app’s stream, processes
it, and re-publishes it — so the graph gets two new nodes per sound, one from
the app and one from the EQ. The fix is to skip anything tagged
media.role=DSP, plus nodes whose names start with output.omarchy.,
input.omarchy. or omarchy_speaker_tuning. That’s plumbing, not an
application, and it has no business raising a popup.
A couple of other small guards earned their place the same way. A browser
opening three streams at once should announce once, not three times, so
there’s a short repeat window (repeatMs, 1500ms by default). And streams
that are already playing when the shell loads get recorded silently rather
than announced — otherwise every shell restart would fire a volley of popups
at you (startupGraceMs).
What it deliberately doesn’t do
An app that holds one long-lived stream open and plays all its sounds through it produces no new node, so nothing gets announced.
Music players work this way, which is harmless — you know you started the music. But a chat app keeping a permanently open output stream would slip right through, and that’s exactly the case you wanted this for.
Catching it means going back to level watching after all: a peak monitor per stream, announcing on silent-to-loud. And that’s a standing cost — a monitor running on every stream for as long as the shell is up, whether or not you ever hear anything. I left it out rather than shipping it disabled behind a setting. If it turns out people hit this often enough, that’s the moment to reconsider, not before.
I’d rather ship something small that’s honest about its edges than something that quietly burns CPU to cover a case most people never meet.
Living on the bar properly
The bar icon went through a revision that taught me something about Omarchy’s shell.
Originally it stayed drawn and just dimmed when announcements were off. That
worked, but it sat oddly next to the stock indicators — Dnd, NightLight
and friends conceal themselves entirely and collapse their slot until you
hover the indicator area. Mine was the one icon that wouldn’t take a hint.
Making it behave turned out to be less obvious than expected. You can’t simply
list a plugin in omarchy.indicators‘ own items: that widget resolves every
entry to a file in the shell’s packaged indicators directory, so a plugin id
there loads exactly nothing. Instead the widget reaches into the bar’s
moduleSlots, finds the live omarchy.indicators instance, and reads its
reveal state — which lets a plugin join the group from its own slot. Hovering
the icon holds that reveal open, so it can’t slide out from under your pointer
before the click lands.
It only conceals itself if such a host actually exists, mind. On a bar with no indicators widget there’d be no hover to bring it back, and a switch nothing can click is a switch stuck off.
Getting it
No dependencies beyond Omarchy 4.x and PipeWire. It uses Quickshell’s own
Quickshell.Services.Pipewire, so there’s nothing to build and no helper
process to keep alive — pure QML and JavaScript.
omarchy plugin add https://github.com/iboard/sound-source.git --enable --yes
omarchy bar put io.github.iboard.sound-source --after omarchy.indicators
omarchy restart shell
Everything is reachable over IPC too, for keybindings or the Omarchy menu:
omarchy-shell sound-source now # what's playing right now
omarchy-shell sound-source last # the app announced most recently
omarchy-shell sound-source toggle # same switch as the bar icon
Plugins land disabled if you drop the --enable, so you can read the code
first. Which you should — for this or any other plugin, since they run
unsandboxed inside omarchy-shell.
It’s Apache-2.0, and it’s on its way to the Omarchy plugin marketplace. Source, README and the full settings table are on GitHub.
Now go find out what that pinging was.
More Transparency: The New Audit Log
Starting today, the blog keeps an audit log — a complete, human-readable record of what happens on the platform. From “Andreas created the post ‘Welcome!’” to “Hannah liked your post,” every meaningful action is captured and stays traceable.
Two Views
-
My Activity (
/activity): Every signed-in user sees their own timeline — everything you did, or that concerns you (your account, your posts). -
Audit Log (
/admin/audit-log): Administrators see the complete timeline for the entire platform.
Both views update live: new entries appear automatically, no refresh needed. With infinite scrolling and a full-text search across action, person, and subject, you’ll quickly find what you’re after — even in long histories.
Reads or Writes — Your Call
Every entry is tagged as a read or a write. Opening a post, profile, or group counts as a read; everything else counts as a write. The All / Writes / Reads toggle lets you show exactly what matters to you — the total count and live updates follow your selection.
Sessions at a Glance
Sign-ins are part of the timeline too: a guest visit is recorded as “New
session from <IP> (<country>),” and logins and logouts as “logged in” and
“session ended.” The IP, country, and session ID live in the entry’s metadata.
Since these can pile up fast, there’s a Hide sessions switch: one click and the pure session entries drop out of the list, the count, and the live updates — keeping your focus on the events that actually matter.
Server Restarts, Made Visible
When the server restarts, it writes its own entry: “The server started.” Unlike the other entries, this one is visible to all signed-in users. So you can see at a glance whether and when there was an update or a restart.
Why Bother?
Transparency builds trust. The audit log makes it clear who did what and when — both for you personally and for the people running the platform. No more guesswork, no black box.
Enjoy exploring your timeline!
Until now, a post here could be public, private, or sent as a direct message. That covered the extremes — everyone, or just one person — but not the middle ground most of us actually live in: this handful of people, and no one else.
Groups fill that gap.
Create a group in seconds
Head to My Groups in the sidebar (or /groups) and hit New group. Give it a name, and start adding people:
- Friends are added instantly — they’re active right away.
- Anyone else gets an email invitation. Once they confirm, they join the group and become a mutual friend, so the connection works both ways.
Posts for members only
When you write a post, visibility now has a Specific Groups option. Pick which groups may read it — and, separately, which of those may comment. Only active members of a listed group can see or reply.
Every group-restricted post carries a “Who can read this” section, so it’s always clear exactly who’s in the room. No guessing, no accidental oversharing.
It lands in the inbox
Published group posts show up in each member’s inbox (/dm) right alongside direct messages, complete with a Group badge and per-person read/unread tracking — the same unread counter you already know from the navbar. Nobody misses what’s meant for them.
A home for every group
Each group has its own page (/groups/:id) listing its members and how many posts each has published. Any active member can invite new people; you can remove anyone you added and leave whenever you like; and the owner can manage everyone.
Groups are yours to shape — start one today and share with just the right circle.
With the rise of modern AI tools, the concept of generating complete, gapless documentation for an entire codebase seems tempting at first glance. It appears to be the ultimate solution: every function, variable, and logic branch automatically explained. However, upon closer inspection, this approach reveals significant downsides.
No seasoned developer would voluntarily document every single detail—and for good reason. If every line of code were commented, the famous mantra “Read the fucking code” (RTFC) would lose its validity. Yet, RTFC remains entirely justified, especially when dealing with low-level details. Often, the code itself is the most precise description of what is happening at the machine level.
Documentation intended for users—whether other developers or DevOps engineers—should not attempt to include everything. Such “completeness” dilutes the critical information. Readers are forced to wade through a mountain of trivial details to find the actual architectural decisions or usage patterns. This is frustrating, time-consuming, and ultimately leads to the documentation being ignored because the signal gets lost in the noise. Good documentation curates knowledge; it does not merely duplicate it.
Small open-source projects, often sustained by only a handful of contributors, are facing a critical turning point. Historically, these initiatives thrived on the necessity of collaboration and code sharing. However, Artificial Intelligence is fundamentally altering this dynamic. Developers can now generate complex features, bug fixes, and even entire modules using AI assistants alone, removing the dependency on external help or peer reviews.
Yet, this surge in efficiency carries a paradoxical risk: the motivation to share work publicly is diminishing. If every developer can complete their “private closed work” autonomously without relying on the community, the ecosystem risks fragmentation. Instead of refining a shared open solution together, we may see the rise of isolated, AI-generated silos. The thesis is not that open source will vanish, but that the foundation of small, purely community-driven projects is eroding because the barrier to autonomous development is lowering while the incentive for openness fades.
The Docker-Compose File (run)
The following file docker-compose.yml is used to run a docker-container with an image
created in Dockerfile.
The dockerfile builds the phoenix-release of iBoard blog.
The docker-compose file packs a stack with two services.
- web (The Phoenix application)
- db (A Postgres database)
docker-compose.yml
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${PGUSER:-blog}
POSTGRES_PASSWORD: ${PGPASSWORD:?Set PGPASSWORD in .env}
POSTGRES_DB: ${PGDATABASE:-blog_prod}
volumes:
- ./data/postgres:/var/lib/postgresql/data
expose:
- "5432"
networks:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${PGUSER:-blog}"]
interval: 10s
timeout: 5s
retries: 5
web:
image: iboard/blog:latest
env_file:
- path: ./.env
required: false
environment:
PHX_SERVER: "true"
# Endpoint
ENDPOINT_HOST: ${ENDPOINT_HOST:?Set ENDPOINT_HOST in .env}
ENDPOINT_SCHEME: ${ENDPOINT_SCHEME:-https}
ENDPOINT_PORT: ${ENDPOINT_PORT:-4000}
ENDPOINT_IP: ${ENDPOINT_IP:-0.0.0.0}
# Database — connects to the db service on the internal network
DATABASE_URL: "ecto://${PGUSER:-blog}:${PGPASSWORD}@db/${PGDATABASE:-blog_prod}"
POOL_SIZE: ${POOL_SIZE:-10}
# Security
SECRET_KEY_BASE: ${SECRET_KEY_BASE:?Set SECRET_KEY_BASE in .env}
# Mailer
MAILER_ADAPTER: ${MAILER_ADAPTER:-sendgrid}
SENDGRID_API_KEY: ${SENDGRID_API_KEY:-}
# Logging
LOG_LEVEL: ${LOG_LEVEL:-info}
# Clustering (optional)
DNS_CLUSTER_QUERY: ${DNS_CLUSTER_QUERY:-}
ports:
# Host port EXPOSE_PORT → container port ENDPOINT_PORT.
# Put an nginx reverse proxy in front to terminate TLS on 443.
- "${EXPOSE_PORT:-4000}:${ENDPOINT_PORT:-4000}"
volumes:
- ./data/uploads:/app/uploads
depends_on:
db:
condition: service_healthy
networks:
- backend
restart: unless-stopped
networks:
backend:
driver: bridge
Dockerfile
The following dockerfile builds an image from a slim Debian, packed with Erlang and Elixir.
It uses mix to build a release and a docker image.
# Builder Stage
FROM hexpm/elixir:1.19.5-erlang-28.4-debian-bookworm-20260223-slim AS builder
ENV MIX_ENV=prod
WORKDIR /build
# Install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
# Install hex and rebar
RUN mix local.hex --force && mix local.rebar --force
# Install mix dependencies
COPY mix.exs mix.lock ./
COPY config config
COPY README.md README.md
COPY CHANGELOG.md CHANGELOG.md
COPY TODO.md TODO.md
RUN mix deps.get --only $MIX_ENV
RUN mix deps.compile
# Copy application code
COPY priv priv
COPY lib lib
COPY assets assets
# run mix doesn't compile - FIXME: find another way to deploy docs
# RUN mix docs
COPY doc priv/static/docs
# Deploy assets
# Provide dummy environment variables for config/runtime.exs evaluation during build
ENV DATABASE_URL=ecto://postgres:postgres@localhost/w_app_core_prod
#ENV SECRET_KEY_BASE=dummy_secret_key_base_for_build_only_must_be_at_least_64_bytes_long_so_we_add_some_more_chars_here
# Compile the release
RUN mix compile
RUN mix assets.deploy
RUN mix release
# Runner Stage
FROM debian:bookworm-slim AS runner
ENV MIX_ENV=prod
# Install runtime dependencies including wkhtmltopdf
# wkhtmltopdf fontconfig libjpeg62-turbo libxrender1 xfonts-75dpi xfonts-base \
# pdftk \
RUN apt-get update -y && \
apt-get install -y libstdc++6 openssl libncurses5 locales \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
# Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
WORKDIR /app
# Copy the built release from the builder stage
COPY --from=builder /build/_build/prod/rel/w_app_core ./
COPY doc priv/static/docs
# Expose default port
EXPOSE 4000
CMD ["sh", "-c", "/app/bin/w_app_core eval WAppCore.Release.migrate && /app/bin/w_app_core start"]
More posts
Browse all posts you can see.