foxygit / sxbar Log in
commit 1b2a2b5415e68d75daf304394202c004fca5549b
Author:     MrJensK <jens.se@icloud.com>
AuthorDate: Fri Jul 31 13:37:42 2026 +0200
Commit:     MrJensK <jens.se@icloud.com>
CommitDate: Fri Jul 31 13:37:42 2026 +0200

    Unify built-in and custom modules; add startmenu/demo_menu scripts

    Every module now resolves to scripts/<name>.sh the same way, whether
    sxbar ships it or the user wrote it -- the `custom` directive and the
    hardcoded built-in module list are gone, replaced by one `module :`
    directive that creates modules on demand and lets any script self-declare
    its popup via a `menu` subcommand. Also adds scripts/startmenu.sh (a
    favorites launcher popup) and scripts/demo_menu.sh (a runnable reference
    for every popup row type), and updates default_sxbarc, the wiki, README,
    and the man page to match.

    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
 README.md            |  82 ++++++++++++++++++++
 default_sxbarc       | 214 +++++++++++++++++----------------------------------
 docs/wiki.html       |  93 +++++++++++++---------
 scripts/demo_menu.sh |  48 ++++++++++++
 scripts/startmenu.sh |  22 ++++++
 src/parser.c         | 108 +++++++++++---------------
 src/sxbar.c          |  76 ++----------------
 sxbar.1              |  49 +++++++-----
 8 files changed, 364 insertions(+), 328 deletions(-)

diff --git a/README.md b/README.md
index e384fa3..2b4152b 100644
--- a/README.md
+++ b/README.md
@@ -1026,6 +1026,88 @@ together with `cursor-dpi-fix.patch` in the order `install.sh` uses.
   that honours `_NET_ACTIVE_WINDOW` requests -- for sxwm specifically,
   that means the patch above.

+## Two more self-contained module scripts: a start menu and an every-row-type demo
+
+Two more scripts joining the built-in set, both using the
+`<script> menu`-declares-its-own-popup convention `scripts/usermenu.sh`
+already established, rather than needing any popup content in sxbarc.
+
+### Changes
+- New `scripts/startmenu.sh`: bar text is a static `"Start"` label; its
+  `menu` subcommand declares a hover popup of favorite apps/scripts as
+  button rows (Terminal, File manager, Web browser, Lock screen by
+  default). Adding, removing or reordering favorites is a one-line edit to
+  the script's own `popup_item` lines -- no sxbarc editing needed, same as
+  editing `usermenu.sh`'s Sleep/Log out/Shut down rows.
+- New `scripts/demo_menu.sh`: a runnable reference combining one of every
+  popup row type in a single popup -- `popup_info` (plain text),
+  `popup_image` (picks whatever icon it can find under
+  `/usr/share/pixmaps/` or `/usr/share/icons/hicolor/48x48/apps/`, empty
+  if none, same "no output" convention as `media.sh art`), `popup_item`,
+  `popup_set` (slider), and `popup_buttons` (three segments). The
+  button/slider rows fire a desktop notification via `scripts/demo_popup.sh`
+  (already shipped, previously wired up as three separate sxbarc
+  directives) so all five row types can be exercised safely before
+  pointing a real module's popup at anything.
+
+### Result
+- `module : startmenu : true : 3600` is a working start menu.
+- `module : demo_menu : true : 999` is a hands-on reference for every
+  popup row type sxbar supports, in one place.
+
+## No more built-in vs. custom modules -- every module resolves from scripts/
+
+Built-in modules (the fixed list `add_builtin_module()` registered in
+`init_modules()`, `src/sxbar.c`) and custom modules (the `custom : name :
+"command" : interval` directive, `src/parser.c`) worked differently:
+built-ins resolved to a script by name and could self-declare a popup via
+`<script> menu`; custom modules ran an arbitrary quoted command and (until
+this change) had no such mechanism. Not a distinction worth keeping --
+`scripts/startmenu.sh`/`demo_menu.sh` above are exactly as legitimate a
+module as `clock.sh`, just not pre-registered in the binary. This removes
+the split entirely: one `module :` directive, one resolution mechanism,
+for every module.
+
+### Changes
+- `resolve_script(name)` -- checks `~/.config/sxbar/scripts/<name>.sh`,
+  then `$PREFIX/share/sxbar/scripts/<name>.sh`, else a no-op `:` -- moved
+  from `sxbar.c` to `parser.c` (`static`, same behaviour) so the `module`
+  directive's parsing can call it directly.
+- `module : name : enabled : interval` (`src/parser.c`) now creates the
+  module on the spot if `name` isn't already registered, instead of
+  erroring `unknown builtin module`: resolves the script via
+  `resolve_script()`, then self-declares its popup via
+  `load_popup_from_script()` -- the exact sequence `add_builtin_module()`
+  used to run at startup for the fixed list, now run lazily for whichever
+  names sxbarc actually mentions.
+- The `custom` directive is gone. A one-off shell pipeline that used to be
+  a `custom` line now needs an actual script file, e.g.
+  `~/.config/sxbar/scripts/temp.sh` containing `sensors | grep 'Package' |
+  awk '{print $4}'`, enabled with `module : temp : true : 5` like anything
+  else. An old config's `custom :` lines are now an unrecognized directive
+  (a harmless per-line warning, config parsing continues).
+- `add_builtin_module()` and the eleven `add_builtin_module("clock", ...)`
+  etc. calls are gone from `init_modules()` (`src/sxbar.c`), which now
+  just allocates the (still growable) `config.modules` array and returns
+  -- every module, including the ones sxbar ships a script for, is created
+  entirely from sxbarc's `module :` lines via the mechanism above.
+  `default_sxbarc` is unchanged in what it enables by default, since it
+  already had a `module :` line for each of them.
+
+### Result
+- Writing your own module is now genuinely identical to using a shipped
+  one: put a script at `~/.config/sxbar/scripts/<name>.sh`, add
+  `module : <name> : true : <interval>` to sxbarc. No `custom` syntax to
+  learn, no separate mental model for "mine" vs. "sxbar's".
+- A config with no `module :` lines at all now starts with zero modules
+  (previously the eleven built-ins would still be pre-registered,
+  disabled/enabled per their compiled-in defaults, even with an empty or
+  missing sxbarc) -- config now fully determines what exists, not just
+  what's turned on.
+- A typo'd module name (`module : cclock : true : 1`) no longer errors --
+  it silently resolves to the no-op fallback, same as any other name
+  without a matching script.
+
 ## Disk footprint

 - `sxbar` is lightweight: the compiled binary is about 36 KB without
diff --git a/default_sxbarc b/default_sxbarc
index e44c983..9c2ab4a 100644
--- a/default_sxbarc
+++ b/default_sxbarc
@@ -45,10 +45,22 @@ font                : monospace:size=8
 # workspace_icon : 2 : ""   # terminal
 # workspace_icon : 3 : ""   # code

-# Built-in modules
+# Modules -- any name works here, not just the ones listed below. A
+# module's command is resolved by name: your own script at
+# ~/.config/sxbar/scripts/<name>.sh wins if it exists, else the reference
+# copy `make install` places at $(PREFIX)/share/sxbar/scripts/<name>.sh,
+# else the module is a silent no-op. There's no separate "custom module"
+# directive -- a module you write yourself is exactly as first-class as
+# one sxbar ships a script for; both are just a script in scripts/,
+# referenced the same way:
+#
 # module : name : enabled : refresh_interval_seconds
-# Available: clock, date, battery, volume, cpu, brightness, bluetooth,
-#            usermenu, network, media, taskbar
+#
+# The modules below are the ones sxbar ships a script for out of the box
+# (scripts/clock.sh, scripts/date.sh, ...; see "Reference scripts" in the
+# wiki for the full list). Each also self-declares its own popup (if it
+# has one) straight from its script -- see the `menu` subcommand note
+# under "Floating popups" further down.
 # clock/date: hover over either to reveal an "Open calendar" shortcut (needs gsimplecal)
 module : clock      : true  : 1
 module : date       : true  : 60
@@ -75,19 +87,26 @@ module : media      : false : 2
 # actually focus what you click.
 module : taskbar    : false : 1

-# Custom script modules (like polybar exec)
-# Command must be quoted. Output is displayed in the bar.
-# custom : label : "command or script path" : refresh_interval_seconds
+# Your own modules -- write a script, drop it in ~/.config/sxbar/scripts/
+# as <name>.sh (executable), then enable it exactly like any module above:
 #
-# Examples:
-# custom : temp     : "sensors | grep 'Package' | awk '{print $4}'" : 5
-# custom : mem      : "free -h | awk '/^Mem:/{print $3\"/\"$2}'" : 10
-# custom : netscript : "~/.config/sxbar/scripts/network.sh" : 5
-# custom : updates  : "checkupdates | wc -l | tr -d ' '" : 300
+# module : temp : true : 5
+#
+# ...with ~/.config/sxbar/scripts/temp.sh being e.g.:
+#   #!/bin/sh
+#   sensors | grep 'Package' | awk '{print $4}'
+#
+# Two ready-made examples ship in scripts/ to copy from:
+#
+# module : startmenu : true : 3600   # your favorites as popup buttons --
+#                                     # edit the popup_item lines in
+#                                     # startmenu.sh itself to customize
+# module : demo_menu : true : 999    # one of every popup row type, see
+#                                     # "Floating popups" further down

-# Prefix / icon — text prepended to a module's output (works for both
-# built-in and custom modules). Needs a Nerd Font set via `font` above to
-# render icon glyphs correctly.
+# Prefix / icon — text prepended to a module's output (works for any
+# module, by name). Needs a Nerd Font set via `font` above to render icon
+# glyphs correctly.
 # prefix : module_name : "icon text"
 # (icon : ... is accepted as an alias for prefix)
 #
@@ -122,26 +141,18 @@ module : taskbar    : false : 1
 # and are installed to $(PREFIX)/share/sxbar/scripts/ -- copy them to
 # ~/.config/sxbar/scripts/ (the path above) and edit from there.
 #
-# The built-in modules' own bar text/popup commands work the same way,
-# under the hood: each resolves to a script in scripts/ (one per module --
-# cpu.sh, network.sh, battery.sh, volume.sh, brightness.sh, bluetooth.sh,
-# usermenu.sh, clock.sh, date.sh), checked in this order: your own copy at
-# ~/.config/sxbar/scripts/<name>.sh, then the installed system copy, else
-# a harmless no-op. Copy any of them over and edit -- no config line or
-# recompile needed, sxbar just picks up your copy on the next restart.
-#
-# Each of these scripts is also self-contained about its own popup menu:
-# running it as `<script> menu` (which sxbar does once at startup) prints
-# that module's popup/popup_item/popup_info/popup_set lines -- the exact
-# same directives as below, just without the module-name field, since a
-# script only ever describes itself. This is what usermenu.sh's Sleep/Log
-# out/Shut down rows are, for instance -- add, remove or edit rows there
-# to customize your user menu, no sxbarc editing needed. sxbarc's own
+# Any module's script is also self-contained about its own popup menu:
+# running it as `<script> menu` (which sxbar does once at startup, for
+# every module -- see "Modules"/"Your own modules" above) prints that
+# module's popup/popup_item/popup_info/popup_set lines -- the exact same
+# directives as below, just without the module-name field, since a script
+# only ever describes itself. This is what usermenu.sh's Sleep/Log out/
+# Shut down rows are, for instance -- add, remove or edit rows there to
+# customize your user menu, no sxbarc editing needed. sxbarc's own
 # popup_item/popup_info/popup_set lines further down still work exactly as
 # before, and still replace a module's rows on the first line for that
-# module -- whether those rows came from the compiled-in fallback or a
-# script no longer matters, sxbarc always wins if you use it. See
-# "Floating popups" below for the full directive reference.
+# module regardless of where those rows came from. See "Floating popups"
+# below for the full directive reference.

 # Icon only -- hides a module's own text, showing just its prefix/icon.
 # Needs a prefix/prefix_cmd set (see above) or there's nothing left to show.
@@ -243,14 +254,14 @@ module : taskbar    : false : 1
 # colour : network    : #2ee6d6

 # Click commands — run a command when a module is left-clicked
-# Works for both built-in modules and custom modules
+# Works for any module, by name
 # click : module_name : "command"
 #
 # Examples:
 # click : volume  : "pavucontrol"
 # click : clock   : "xclock"
 # click : battery : "xterm -e 'upower -i /org/freedesktop/UPower/devices/battery_BAT0; read'"
-# click : netscript : "xterm -e nmtui"     # or use the built-in `network` module's popup instead
+# click : network : "xterm -e nmtui"     # or use the built-in `network` module's own popup instead
 #
 # Scroll wheel — scroll up/down over a module to run a command
 # scroll_up   : module_name : "command"
@@ -263,10 +274,10 @@ module : taskbar    : false : 1
 # Floating popups -- a module can open a small floating window instead of
 # (or as well as) running a plain click_command. A popup is just a list of
 # rows, and text/button/slider/image rows can all be freely mixed in the
-# same popup (see demo_menu below for an example combining the first
-# three). Either trigger works with any row -- every built-in popup below
-# defaults to `hover` for a consistent feel, but `click` remains available
-# if you'd rather a menu only appear on a deliberate click:
+# same popup (see scripts/demo_menu.sh, further down, for an example
+# combining all five). Either trigger works with any row -- every shipped
+# module's popup defaults to `hover` for a consistent feel, but `click`
+# remains available if you'd rather a menu only appear on a deliberate click:
 #   text    -- purely informational, not clickable at all (popup_info)
 #   button  -- runs its own command on click; the popup stays open (popup_item)
 #   slider  -- a draggable 0-100% track; dragging doesn't close the popup (popup_set)
@@ -317,8 +328,9 @@ module : taskbar    : false : 1
 #   one row split into N equal-width button segments, each running its own
 #   command on click; the popup stays open -- same behaviour as popup_item
 #   but laid out side by side instead of stacked. Needs an even number of
-#   quoted label/command pairs; glyphs work well as labels here (see the
-#   media example below) so you get an icon row instead of stacked text.
+#   quoted label/command pairs; glyphs work well as labels here (see
+#   scripts/media.sh's own popup) so you get an icon row instead of
+#   stacked text.
 #
 # popup_set : module_name : "command"   -- adds (or updates) one slider row
 #   for this module. Run when the value changes; receives the new value as
@@ -327,109 +339,21 @@ module : taskbar    : false : 1
 #   separate "get" command is needed. Can coexist with popup_item/popup_info/
 #   popup_image/popup_buttons rows on the same module.
 #
-# All ten built-in modules below already come with sensible popup
-# defaults built in (see README) -- these directives are for overriding
-# them or adding this behaviour to your own custom modules. Want click
-# instead of hover for one of them? Just override its trigger, e.g.
-# `popup : usermenu : click : buttons`.
-#
-# The built-in modules' own commands (bar text, popup rows, slider
-# set-commands) all resolve to a script under scripts/ -- one script per
-# module, dispatched by subcommand where a module needs more than one
-# piece of output (e.g. cpu.sh handles bar text, its usage/mem/cores
-# popup rows). Copy any of them to ~/.config/sxbar/scripts/ and edit
-# freely, same as prefix_cmd scripts -- see README for the full list.
-#
-# Examples (these already match each module's own script default -- see
-# "Built-in modules" further up -- so uncommenting one as-is changes
-# nothing; they're here as a starting point to override from, e.g. to
-# reorder rows, add more, or point usermenu's "Log out" at your own
-# WM/session. Paths assume you've copied the scripts to
-# ~/.config/sxbar/scripts/, same as the prefix_cmd examples above):
-# popup      : clock : hover : buttons
-# popup_item : clock : "Open calendar" : "gsimplecal"
-#
-# popup      : date : hover : buttons
-# popup_item : date : "Open calendar" : "gsimplecal"
-#
-# popup      : battery : hover : buttons
-# popup_info : battery : "~/.config/sxbar/scripts/battery.sh status"
-# popup_item : battery : "Toggle power saver" : "~/.config/sxbar/scripts/battery.sh toggle-powersave"
-#
-# popup     : volume : hover : slider
-# popup_set : volume : "~/.config/sxbar/scripts/volume.sh set"
-#
-# popup      : cpu : hover : buttons
-# popup_info : cpu : "~/.config/sxbar/scripts/cpu.sh usage 'CPU: '"
-# popup_info : cpu : "~/.config/sxbar/scripts/cpu.sh mem"
-# popup_info : cpu : "~/.config/sxbar/scripts/cpu.sh cores"
-#
-# popup     : brightness : hover : slider
-# popup_set : brightness : "~/.config/sxbar/scripts/brightness.sh set"
-#
-# popup      : bluetooth : hover : buttons
-# popup_item : bluetooth : "Turn on"                : "bluetoothctl power on"
-# popup_item : bluetooth : "Turn off"               : "bluetoothctl power off"
-# popup_item : bluetooth : "Search for devices"     : "bluetoothctl --timeout 10 scan on"
-# popup_item : bluetooth : "Pair last found device" : "~/.config/sxbar/scripts/bluetooth.sh pair"
-#
-# popup      : usermenu : hover : buttons
-# popup_item : usermenu : "Sleep"     : "systemctl suspend"
-# popup_item : usermenu : "Log out"  : "pkill sxwm"        # adjust to your WM/session
-# popup_item : usermenu : "Shut down" : "systemctl poweroff"
-#
-# popup      : network : hover : buttons
-# popup_info : network : "~/.config/sxbar/scripts/network.sh wifi"
-# popup_info : network : "~/.config/sxbar/scripts/network.sh ethernet"
-#
-# popup         : media : hover : buttons
-# popup_image   : media : "~/.config/sxbar/scripts/media.sh art"     # album art (needs playerctl; curl for remote art)
-# popup_info    : media : "~/.config/sxbar/scripts/media.sh track"
-# popup_buttons : media : "" : "~/.config/sxbar/scripts/media.sh prev" : "" : "~/.config/sxbar/scripts/media.sh playpause" : "" : "~/.config/sxbar/scripts/media.sh next"
-
-# Try-it-yourself demo popup -- a throwaway custom module combining all
-# three row kinds in one popup, so you can test hover/click/drag mechanics
-# safely before pointing popup_item/popup_info/popup_set at anything real:
-# a plain text row (not clickable at all), two buttons, and a slider. They
-# just fire a desktop notification via scripts/demo_popup.sh (installed to
-# $(PREFIX)/share/sxbar/scripts/ -- copy it to ~/.config/sxbar/scripts/,
-# same as battery_icon.sh/volume_icon.sh, then edit freely).
-#
-# custom     : demo_menu : "echo 50%" : 999
-# popup      : demo_menu : hover : buttons
-# popup_info : demo_menu : "echo 'Just text -- click me, nothing happens'"
-# popup_item : demo_menu : "Say hello" : "~/.config/sxbar/scripts/demo_popup.sh 'Hello!'"
-# popup_item : demo_menu : "Say bye"   : "~/.config/sxbar/scripts/demo_popup.sh 'Bye!'"
-# popup_set  : demo_menu : "~/.config/sxbar/scripts/demo_popup.sh"
-
-# Media controller example (requires playerctl)
-#
-# There's now a built-in `media` module (see "Built-in modules" above and
-# scripts/media.sh) with a hover popup showing album art, track info, and
-# Previous/Play-Pause/Next buttons -- `module : media : true : 2` is
-# usually the easier route. The four-segment custom-module version below
-# is still here for anyone who wants transport controls always visible
-# directly in the bar instead, with no popup involved.
-#
-# playerctl uses the MPRIS2 protocol and works with most players automatically:
-# Firefox (YouTube, Spotify Web), Spotify, VLC, mpv, rhythmbox, etc.
-# It will control whichever player was most recently active.
-#
-# Shows:  <<   >   >>   Artist - Title
-#
-# custom : media_prev  : "echo ' << '"                                                                    : 999
-# custom : media_play  : "playerctl status 2>/dev/null | sed 's/Playing/ > /;s/Paused/ || /;s/Stopped/ [] /'" : 1
-# custom : media_next  : "echo ' >> '"                                                                    : 999
-# custom : media_track : "playerctl metadata --format ' {{artist}} - {{title}} ' 2>/dev/null || echo ' - '" : 2
-#
-# click : media_prev  : "playerctl previous"
-# click : media_play  : "playerctl play-pause"
-# click : media_next  : "playerctl next"
-#
-# If you have multiple players running at the same time and want to always
-# control the one that is currently playing (not just the most recent):
-#
-# custom : media_play : "playerctl -p $(playerctl -l 2>/dev/null | head -1) status 2>/dev/null | sed 's/Playing/ > /;s/Paused/ || /;s/Stopped/ [] /'" : 1
-#
-# List all available players:  playerctl -l
+# Every module listed under "Modules" above already comes with sensible
+# popup defaults built into its own script (see the `menu` subcommand note
+# above) -- these directives are for overriding them, or adding this
+# behaviour to a module of your own. Want click instead of hover for one
+# of them? Just override its trigger, e.g. `popup : usermenu : click :
+# buttons`. For the full reference of what each shipped module's default
+# popup looks like (as the equivalent sxbarc directives, for copy-paste
+# starting points), see "Built-in popups" in the wiki -- not duplicated
+# here since it's identical to each script's own `menu` output.
+#
+# scripts/demo_menu.sh (enabled via `module : demo_menu : true : 999`
+# above) is a runnable example combining one of every row type: plain text
+# (not clickable), an image, a button, a slider, and a row of segmented
+# buttons. Its button/slider rows fire a desktop notification via
+# scripts/demo_popup.sh so you can test hover/click/drag mechanics safely
+# before pointing your own module's popup at anything real -- both ship in
+# scripts/ and install to $(PREFIX)/share/sxbar/scripts/.

diff --git a/docs/wiki.html b/docs/wiki.html
index b031c0c..279d7d1 100644
--- a/docs/wiki.html
+++ b/docs/wiki.html
@@ -409,7 +409,7 @@
       <ul>
         <li><a href="#modules">Built-in modules</a></li>
         <li><a href="#taskbar">Taskbar</a></li>
-        <li><a href="#custom">Custom modules &amp; scripts</a></li>
+        <li><a href="#custom">Any name is a module</a></li>
         <li><a href="#customize">Icons, colour, layout, clicks</a></li>
       </ul>
     </div>
@@ -452,7 +452,7 @@
     <section id="overview">
       <h2>Overview</h2>
       <p class="lede">sxbar renders a slim EWMH workspace switcher plus a row of
-      modules — clock, battery, volume, custom scripts, anything you configure —
+      modules — clock, battery, volume, scripts you write yourself, anything you configure —
       and can pop a floating slider or menu window out of any module.</p>

       <p>Everything is controlled from a single config file, read from (in order)
@@ -611,15 +611,19 @@ cp /usr/local/share/sxbarc ~/.config/sxbar/sxbarc</pre>
       </div>
       <p>Full detail on each popup is in <a href="#popup-builtins">Built-in popups</a> below.</p>

-      <div class="note"><strong>Every built-in module's command — and its popup —
-      is a script</strong>, not code baked into the binary. Each resolves (in order)
-      to your own copy at <code class="inline">~/.config/sxbar/scripts/&lt;name&gt;.sh</code>,
-      then the system copy <code class="inline">make install</code> places at
-      <code class="inline">/usr/local/share/sxbar/scripts/&lt;name&gt;.sh</code>,
+      <div class="note"><strong>Not special-cased in the binary.</strong> Every
+      module — the ones above and any you write yourself — is resolved by name
+      to a script, purely from <code class="inline">module :</code> lines in
+      sxbarc; there's no separate "custom module" concept or directive. Each
+      name resolves (in order) to your own copy at
+      <code class="inline">~/.config/sxbar/scripts/&lt;name&gt;.sh</code>,
+      then the reference copy <code class="inline">make install</code> places
+      at <code class="inline">/usr/local/share/sxbar/scripts/&lt;name&gt;.sh</code>,
       or a harmless no-op if neither exists. Copy any of them over and edit —
       no recompile needed, and no sxbarc editing needed either to change a
       module's popup content (e.g. adding a row to <code class="inline">usermenu</code>)
-      — see <a href="#custom">Reference scripts</a> below for the full list.</div>
+      — see <a href="#custom">Reference scripts</a> below for the full list, and
+      "Any name is a module" in that section for writing your own.</div>
     </section>

     <section id="taskbar">
@@ -659,28 +663,39 @@ cp /usr/local/share/sxbarc ~/.config/sxbar/sxbarc</pre>
     </section>

     <section id="custom">
-      <h2>Custom modules &amp; scripts</h2>
-      <p class="lede">Any shell command or script becomes a module — its stdout is the
-      bar text, refreshed on its own interval.</p>
-      <pre><span class="k">custom</span> : <span class="v">temp</span>    : <span class="s">"sensors | grep 'Package' | awk '{print $4}'"</span> : 5
-<span class="k">custom</span> : <span class="v">mem</span>     : <span class="s">"free -h | awk '/^Mem:/{print $3\"/\"$2}'"</span>      : 10
-<span class="k">custom</span> : <span class="v">updates</span> : <span class="s">"checkupdates | wc -l | tr -d ' '"</span>              : 300</pre>
-      <p>Custom modules take every other directive on this page too — <code class="inline">prefix</code>,
+      <h2>Any name is a module</h2>
+      <p class="lede">There's no separate "custom module" directive. A module's
+      command is resolved purely by name — write a script, name it
+      <code class="inline">&lt;name&gt;.sh</code>, and it's a module.</p>
+      <pre><span class="k">module</span> : <span class="v">temp</span> : <span class="v">true</span> : 5</pre>
+      <p>...with <code class="inline">~/.config/sxbar/scripts/temp.sh</code> being e.g.:</p>
+      <pre><span class="c">#!/bin/sh</span>
+sensors | grep <span class="s">'Package'</span> | awk <span class="s">'{print $4}'</span></pre>
+      <p>Resolution order: your own copy at
+      <code class="inline">~/.config/sxbar/scripts/&lt;name&gt;.sh</code> wins if it
+      exists, then the reference copy <code class="inline">make install</code> places
+      at <code class="inline">$PREFIX/share/sxbar/scripts/&lt;name&gt;.sh</code>, else
+      the module is a silent no-op — so a typo'd module name just shows nothing
+      rather than erroring. A module you write yourself takes every other
+      directive on this page too — <code class="inline">prefix</code>,
       <code class="inline">colour</code>, <code class="inline">click</code>,
-      <code class="inline">popup</code>, and so on — just referenced by whatever name
-      you gave the <code class="inline">custom</code> line.</p>
+      <code class="inline">popup</code>, and so on — exactly like any module sxbar
+      ships a script for; there's no distinction between the two once
+      <code class="inline">module :</code> has resolved a name to a script. If the
+      script understands a <code class="inline">menu</code> subcommand, it can also
+      declare its own popup, exactly like the reference scripts below — see the
+      note under the table.</p>

       <h3>Reference scripts</h3>
       <p>Every script sxbar ships with lives in <code class="inline">scripts/</code> and
       installs to <code class="inline">$PREFIX/share/sxbar/scripts/</code> as a
       reference copy — copy any of them to <code class="inline">~/.config/sxbar/scripts/</code>
-      and point your config (or nothing, for the built-in module scripts —
-      see the note above) at your own copy so you can edit freely.</p>
+      and edit your own copy freely, no sxbarc changes needed for the name to keep
+      resolving to it.</p>

-      <p>One script per built-in module, dispatched by subcommand where a
-      module needs more than one piece of output. Every one of them also
-      answers a <code class="inline">menu</code> subcommand — see the note
-      below the table.</p>
+      <p>One script per module, dispatched by subcommand where a module needs
+      more than one piece of output. Every one of them also answers a
+      <code class="inline">menu</code> subcommand — see the note below the table.</p>
       <div class="twrap">
         <table>
           <thead><tr><th class="mono">script</th><th>subcommands</th></tr></thead>
@@ -700,8 +715,9 @@ cp /usr/local/share/sxbarc ~/.config/sxbar/sxbarc</pre>
       </div>

       <div class="note"><strong><code class="inline">menu</code></strong> is what
-      makes a built-in module self-contained: run once at startup, it prints
-      that module's <a href="#popup-builtins">popup definition</a> — the same
+      makes a module self-contained: run once at startup for every module
+      (whichever script its name resolves to), it prints that module's
+      <a href="#popup-builtins">popup definition</a> — the same
       <code class="inline">popup</code>/<code class="inline">popup_item</code>/
       <code class="inline">popup_info</code>/<code class="inline">popup_set</code>
       directives sxbarc itself uses, minus the module-name field, since a
@@ -711,7 +727,7 @@ cp /usr/local/share/sxbarc ~/.config/sxbar/sxbarc</pre>
       rather keep everything in one config file (see
       <a href="#popup-rows">Row types</a>).</div>

-      <p>Plus three standalone helpers:</p>
+      <p>Plus five standalone helpers:</p>
       <div class="twrap">
         <table>
           <thead><tr><th class="mono">script</th><th>used as</th><th>does</th></tr></thead>
@@ -719,6 +735,8 @@ cp /usr/local/share/sxbarc ~/.config/sxbar/sxbarc</pre>
             <tr><td class="mono">battery_icon.sh</td><td><code class="inline">prefix_cmd</code></td><td>picks a battery glyph by charge level, swaps to a bolt glyph while charging</td></tr>
             <tr><td class="mono">volume_icon.sh</td><td><code class="inline">prefix_cmd</code></td><td>picks a volume glyph, mute-aware</td></tr>
             <tr><td class="mono">demo_popup.sh</td><td><code class="inline">popup_item</code> / <code class="inline">popup_set</code></td><td>fires a desktop notification — a safe target for testing popup rows before wiring up real commands</td></tr>
+            <tr><td class="mono">demo_menu.sh</td><td><code class="inline">module : demo_menu : true : 999</code></td><td>self-declares one of every popup row type (text, image, button, slider, segmented buttons) via its own <code class="inline">menu</code> subcommand — a runnable reference, and a safe sandbox for testing hover/click/drag before building your own</td></tr>
+            <tr><td class="mono">startmenu.sh</td><td><code class="inline">module : startmenu : true : 3600</code></td><td>a hover popup of favorite apps as buttons — edit its <code class="inline">popup_item</code> lines to add/remove favorites, no sxbarc editing needed</td></tr>
           </tbody>
         </table>
       </div>
@@ -744,14 +762,13 @@ status=$(cat /sys/class/power_supply/BAT*/status 2&gt;/dev/null | head -n1)

     <section id="customize">
       <h2>Icons, colour, layout, clicks</h2>
-      <p class="lede">These directives apply to any module — built-in or custom — by name.</p>
+      <p class="lede">These directives apply to any module, by name.</p>

       <h3>Prefix / icon</h3>
       <pre><span class="k">prefix</span> : module_name : <span class="s">"icon text"</span>    <span class="c"># icon : ... is an accepted alias</span></pre>
       <p>Static text prepended to a module's output. Needs a Nerd Font set via
-      <code class="inline">font</code> to render glyphs. For <code class="inline">custom</code>
-      modules, declare <code class="inline">prefix</code> after the module's
-      <code class="inline">custom</code> line.</p>
+      <code class="inline">font</code> to render glyphs. Declare <code class="inline">prefix</code>
+      after the module's <code class="inline">module</code> line.</p>

       <h4>Dynamic icon (<code class="inline">prefix_cmd</code>)</h4>
       <pre><span class="k">prefix_cmd</span> : module_name : <span class="s">"command or script path"</span>   <span class="c"># icon_cmd is an alias</span></pre>
@@ -902,14 +919,19 @@ status=$(cat /sys/class/power_supply/BAT*/status 2&gt;/dev/null | head -n1)
       <code class="inline">popup_set</code> again afterwards if you cleared a built-in
       slider this way and still want it back.</p>

-      <p>Example combining all five on one custom module:</p>
-      <pre><span class="k">custom</span>        : mymodule : <span class="s">"echo ok"</span> : 5
+      <p>Example combining all five on one module (<code class="inline">mymodule</code>
+      resolving to your own <code class="inline">~/.config/sxbar/scripts/mymodule.sh</code>):</p>
+      <pre><span class="k">module</span>        : mymodule : <span class="v">true</span> : 5
 <span class="k">popup</span>         : mymodule : <span class="v">hover</span> : <span class="v">buttons</span>
 <span class="k">popup_info</span>    : mymodule : <span class="s">"echo 'status: '$(whoami)"</span>
 <span class="k">popup_image</span>   : mymodule : <span class="s">"echo /path/to/icon.png"</span>
 <span class="k">popup_buttons</span> : mymodule : <span class="s">"⏮"</span> : <span class="s">"mymodule-ctl prev"</span> : <span class="s">"⏭"</span> : <span class="s">"mymodule-ctl next"</span>
 <span class="k">popup_item</span>    : mymodule : <span class="s">"Restart"</span> : <span class="s">"systemctl --user restart myservice"</span>
 <span class="k">popup_set</span>     : mymodule : <span class="s">"myservice-set-level"</span></pre>
+      <p>Or skip the sxbarc overrides entirely and declare the same popup from
+      <code class="inline">mymodule.sh</code> itself via its <code class="inline">menu</code>
+      subcommand — see <a href="#custom">Any name is a module</a> above and
+      <code class="inline">scripts/demo_menu.sh</code> for a runnable example of exactly this.</p>
     </section>

     <section id="popup-builtins">
@@ -917,7 +939,7 @@ status=$(cat /sys/class/power_supply/BAT*/status 2&gt;/dev/null | head -n1)
       <p class="lede">What each built-in module's popup contains by default. This
       content isn't hardcoded in the binary — it's each module's own
       <code class="inline">&lt;script&gt; menu</code> output (see
-      <a href="#custom">Custom modules &amp; scripts</a>), shown below as the
+      <a href="#custom">Any name is a module</a>), shown below as the
       equivalent sxbarc directives for reference. Edit the script directly to
       change these, or override wholesale from sxbarc with the directives
       above — either works, since the first <code class="inline">popup_item</code>/
@@ -1057,8 +1079,8 @@ status=$(cat /sys/class/power_supply/BAT*/status 2&gt;/dev/null | head -n1)
 <span class="k">popup_item</span> : usermenu : <span class="s">"Log out"</span>  : <span class="s">"loginctl terminate-session $XDG_SESSION_ID"</span>
 <span class="k">popup_item</span> : usermenu : <span class="s">"Shut down"</span> : <span class="s">"systemctl poweroff"</span>

-<span class="c"># a custom module</span>
-<span class="k">custom</span> : mem : <span class="s">"free -h | awk '/^Mem:/{print $3\"/\"$2}'"</span> : 10
+<span class="c"># a module you wrote yourself (~/.config/sxbar/scripts/mem.sh)</span>
+<span class="k">module</span> : mem : <span class="v">true</span> : 10
 <span class="k">prefix</span> : mem : <span class="s">" "</span></pre>
     </section>

@@ -1070,7 +1092,6 @@ status=$(cat /sys/class/power_supply/BAT*/status 2&gt;/dev/null | head -n1)
           <thead><tr><th class="mono">directive</th><th>syntax</th></tr></thead>
           <tbody>
             <tr><td class="mono">module</td><td class="mono">module : name : true|false : interval</td></tr>
-            <tr><td class="mono">custom</td><td class="mono">custom : name : "command" : interval</td></tr>
             <tr><td class="mono">prefix</td><td class="mono">prefix : name : "text"</td></tr>
             <tr><td class="mono">prefix_cmd</td><td class="mono">prefix_cmd : name : "command"</td></tr>
             <tr><td class="mono">icon_only</td><td class="mono">icon_only : name : true|false</td></tr>
diff --git a/scripts/demo_menu.sh b/scripts/demo_menu.sh
new file mode 100755
index 0000000..a9ea087
--- /dev/null
+++ b/scripts/demo_menu.sh
@@ -0,0 +1,48 @@
+#!/bin/sh
+# demo_menu module -- a self-contained script demonstrating every popup
+# row type sxbar supports, all declared from this one script's `menu`
+# subcommand (see README, "Module-declared menus"):
+#   popup_info    -- plain text, not clickable
+#   popup_image   -- a picture (album art, an icon, etc.)
+#   popup_item    -- a full-width button
+#   popup_set     -- a draggable slider
+#   popup_buttons -- a row of N equal-width button segments
+#
+# Wire it up as a custom module and hover over it to see them all:
+#
+#   custom : demo_menu : "~/.config/sxbar/scripts/demo_menu.sh" : 999
+#
+# The button/slider rows fire a desktop notification via demo_popup.sh
+# (needs notify-send) so you can see them actually run without wiring up
+# anything real yet -- copy whichever row types you want into your own
+# module's script and point them at real commands instead.
+#
+#   demo_menu.sh        -> bar text
+#   demo_menu.sh image  -> path to a local image file for the image row --
+#                          picks whatever icon it can find on this system,
+#                          empty if none (same "no output" convention as
+#                          media.sh's `art` subcommand)
+#   demo_menu.sh menu   -> popup definition, read once at startup
+mode="${1:-text}"
+self="$0"
+dir=$(dirname "$self")
+popup="$dir/demo_popup.sh"
+
+case "$mode" in
+menu)
+	echo 'popup : hover : buttons'
+	echo 'popup_info : "Just text -- click me, nothing happens"'
+	echo "popup_image : \"$self image\""
+	echo "popup_item : \"Say hello\" : \"$popup 'Hello!'\""
+	echo "popup_set : \"$popup\""
+	echo "popup_buttons : \"One\" : \"$popup 'One!'\" : \"Two\" : \"$popup 'Two!'\" : \"Three\" : \"$popup 'Three!'\""
+	;;
+image)
+	for f in /usr/share/pixmaps/*.png /usr/share/icons/hicolor/48x48/apps/*.png; do
+		[ -f "$f" ] && { printf '%s' "$f"; break; }
+	done
+	;;
+*)
+	echo "Demo"
+	;;
+esac
diff --git a/scripts/startmenu.sh b/scripts/startmenu.sh
new file mode 100755
index 0000000..f2148ed
--- /dev/null
+++ b/scripts/startmenu.sh
@@ -0,0 +1,22 @@
+#!/bin/sh
+# startmenu module -- dispatched by subcommand:
+#   startmenu.sh       -> bar text
+#   startmenu.sh menu  -> popup definition, read once at startup (see
+#                         README, "Module-declared menus")
+#
+# This *is* the start menu -- add, remove or reorder popup_item lines below
+# to customize it, no sxbarc editing needed.
+mode="${1:-text}"
+
+case "$mode" in
+menu)
+	echo 'popup : hover : buttons'
+	echo 'popup_item : "Terminal" : "xterm"'
+	echo 'popup_item : "File manager" : "pcmanfm"'
+	echo 'popup_item : "Web browser" : "firefox"'
+	echo 'popup_item : "Lock screen" : "slock"'
+	;;
+*)
+	echo "Start"
+	;;
+esac
diff --git a/src/parser.c b/src/parser.c
index 953b00a..ff8b411 100644
--- a/src/parser.c
+++ b/src/parser.c
@@ -121,6 +121,28 @@ static int grow_modules(Config *cfg)
 	return 0;
 }

+/* resolve a module name to its script: the user's own edited copy at
+ * ~/.config/sxbar/scripts/<name>.sh wins over the installed reference copy
+ * at $PREFIX/share/sxbar/scripts/<name>.sh (currently hardcoded to
+ * /usr/local, matching the Makefile's default PREFIX), or a harmless
+ * shell no-op (`:`) if neither exists yet -- e.g. a freshly built,
+ * not-yet-installed checkout. Every module -- whether sxbar ships a
+ * script for it or the user wrote their own -- is resolved this same way. */
+static char *resolve_script(const char *name)
+{
+	char path[PATH_MAX];
+	const char *home = getenv("HOME");
+	if (home) {
+		snprintf(path, sizeof path, "%s/.config/sxbar/scripts/%s.sh", home, name);
+		if (access(path, X_OK) == 0)
+			return strdup(path);
+	}
+	snprintf(path, sizeof path, "/usr/local/share/sxbar/scripts/%s.sh", name);
+	if (access(path, X_OK) == 0)
+		return strdup(path);
+	return strdup(":");
+}
+
 /* the first popup_item/popup_info line for a module wipes its built-in
  * default rows (if any, including a popup_set-managed slider row); later
  * lines from either directive just append. A popup_set line afterwards
@@ -676,7 +698,19 @@ int parse_config(Config *cfg)
 			cfg->workspace_icons[cfg->workspace_icon_count].icon = strdup(text_start);
 			cfg->workspace_icon_count++;
 		} else if (!strcmp(key, "module")) {
-			/* module : name : enabled : interval */
+			/* module : name : enabled : interval
+			 *
+			 * There's no separate "custom module" directive -- any name
+			 * works here. If a module by this name doesn't exist yet, one
+			 * is created on the spot by resolving scripts/<name>.sh
+			 * (resolve_script(), below): the user's own copy at
+			 * ~/.config/sxbar/scripts/<name>.sh wins over the installed
+			 * reference copy at $PREFIX/share/sxbar/scripts/<name>.sh, or
+			 * a harmless no-op if neither exists yet. A module the user
+			 * wrote themselves is exactly as first-class as one sxbar
+			 * ships with -- both are just a script in scripts/, and both
+			 * can self-declare a popup via a `menu` subcommand
+			 * (load_popup_from_script(), below). */
 			char *p1 = strchr(rest, ':');
 			if (!p1) {
 				fprintf(stderr, "sxbarc:%d: module missing fields\n", lineno);
@@ -696,70 +730,22 @@ int parse_config(Config *cfg)
 			}
 			Module *m = find_module(cfg, name);
 			if (!m) {
-				fprintf(stderr, "sxbarc:%d: unknown builtin module '%s'\n", lineno, name);
-				continue;
+				if (grow_modules(cfg) < 0) {
+					fprintf(stderr, "sxbarc: out of memory\n");
+					fclose(f);
+					return -1;
+				}
+				m = &cfg->modules[cfg->module_count++];
+				memset(m, 0, sizeof *m);
+				m->name             = strdup(name);
+				m->command          = resolve_script(name);
+				m->refresh_interval = 5;
+				m->slider_item_idx  = -1;
+				load_popup_from_script(m, m->command);
 			}
 			m->enabled = parse_bool(enabled_s);
 			if (interval_s && *interval_s)
 				m->refresh_interval = atoi(interval_s);
-		} else if (!strcmp(key, "custom")) {
-			/*
-			 * custom : name : "command" : interval
-			 * command must be quoted; may contain ':' or '#'
-			 * interval is optional (defaults to 5s)
-			 */
-			char *p1 = strchr(rest, ':');
-			if (!p1) {
-				fprintf(stderr, "sxbarc:%d: custom missing name and command\n", lineno);
-				continue;
-			}
-			*p1 = '\0';
-			char *name  = strip(rest);
-			char *after = strip(p1 + 1);
-
-			if (*after != '"' && *after != '\'') {
-				fprintf(stderr, "sxbarc:%d: custom command must be quoted\n", lineno);
-				continue;
-			}
-
-			char q = *after;
-			char *cmd_start = after + 1;
-			char *closing   = strchr(cmd_start, q);
-			if (!closing) {
-				fprintf(stderr, "sxbarc:%d: custom command missing closing quote\n", lineno);
-				continue;
-			}
-			*closing = '\0';
-
-			int interval = 5;
-			char *tail   = strip(closing + 1);
-			if (*tail == ':') {
-				tail = strip(tail + 1);
-				strip_comment(tail);
-				if (*tail)
-					interval = atoi(tail);
-			}
-
-			if (!*cmd_start) {
-				fprintf(stderr, "sxbarc:%d: custom '%s' has empty command\n", lineno, name);
-				continue;
-			}
-
-			if (grow_modules(cfg) < 0) {
-				fprintf(stderr, "sxbarc: out of memory\n");
-				fclose(f);
-				return -1;
-			}
-
-			Module *m         = &cfg->modules[cfg->module_count++];
-			memset(m, 0, sizeof *m);
-			m->name           = strdup(name);
-			m->command        = expand_home(cmd_start);
-			m->enabled        = 1;
-			m->refresh_interval = interval;
-			m->last_update    = 0;
-			m->cached_output  = NULL;
-			m->slider_item_idx = -1;
 		} else if (!strcmp(key, "colour") || !strcmp(key, "color")) {
 			/* colour : module_name : #hex */
 			char *p1 = strchr(rest, ':');
diff --git a/src/sxbar.c b/src/sxbar.c
index 1bc62e0..639cfde 100644
--- a/src/sxbar.c
+++ b/src/sxbar.c
@@ -1587,78 +1587,18 @@ int find_bar(Window win)
 	return 0;
 }

-/* resolve a built-in module's shell command to a script, checked in this
- * order: the user's own edited copy under ~/.config/sxbar/scripts/, then
- * the system-installed reference copy `make install` puts under
- * /usr/local/share/sxbar/scripts/ (same fallback-path convention as the
- * config file lookup in parser.c), or a harmless shell no-op if neither
- * exists yet -- e.g. running straight from a freshly built, not-yet-
- * installed checkout without having copied any scripts over */
-static char *resolve_script(const char *name)
-{
-	char path[PATH_MAX];
-	const char *home = getenv("HOME");
-	if (home) {
-		snprintf(path, sizeof path, "%s/.config/sxbar/scripts/%s.sh", home, name);
-		if (access(path, X_OK) == 0)
-			return strdup(path);
-	}
-	snprintf(path, sizeof path, "/usr/local/share/sxbar/scripts/%s.sh", name);
-	if (access(path, X_OK) == 0)
-		return strdup(path);
-	return strdup(":");
-}
-
-/* add one built-in module: resolve its script, then let the script itself
- * declare its popup content (if any) by running `<script> menu` -- see
- * load_popup_from_script() in parser.c. This is what makes a built-in
- * module self-contained: sxbarc only ever turns it on/off and tunes its
- * refresh interval; everything about *how it behaves* (bar text, and now
- * its popup menu) lives in the module's own script. */
-static void add_builtin_module(const char *name, int enabled, int refresh_interval)
-{
-	Module *m = &config.modules[config.module_count++];
-	*m = (Module){.name = strdup(name),
-	              .command = resolve_script(name),
-	              .enabled = enabled,
-	              .refresh_interval = refresh_interval,
-	              .last_update = 0,
-	              .cached_output = NULL,
-	              .slider_item_idx = -1};
-	load_popup_from_script(m, m->command);
-}
-
+/* every module -- whether sxbar ships a script for it or the user wrote
+ * their own -- is created on demand from sxbarc's `module :` directive
+ * (see parser.c: resolve_script(), and the `module` directive handling in
+ * parse_config()). There's no hardcoded built-in list any more: a config
+ * with no `module :` lines at all starts with zero modules. */
 void init_modules(void)
 {
-	config.max_modules = 16; /* headroom beyond the built-ins added below --
-	                           * grow_modules() (parser.c) takes over for any
-	                           * further custom modules from sxbarc */
+	config.max_modules = 16; /* grow_modules() (parser.c) takes over from
+	                           * here as sxbarc's `module :` lines create
+	                           * more than this fits */
 	config.modules = malloc(config.max_modules * sizeof(Module));
 	config.module_count = 0;
-
-	add_builtin_module("clock",      True,  1);
-	add_builtin_module("date",       True,  60);
-	add_builtin_module("battery",    False, 30);
-	add_builtin_module("volume",     True,  5);
-	add_builtin_module("cpu",        False, 3);
-	add_builtin_module("brightness", False, 5);
-	add_builtin_module("bluetooth",  False, 10);
-	/* usermenu -- sxwm (https://github.com/uint23/sxwm) has no session
-	 * manager or external IPC to trigger its own `quit` keybind, so the
-	 * default "Log out" row (in scripts/usermenu.sh) ends the X session by
-	 * killing the WM -- edit your own copy under ~/.config/sxbar/scripts/
-	 * if this doesn't fit your WM/session, or add more rows of your own. */
-	add_builtin_module("usermenu",   True,  300);
-	add_builtin_module("network",    False, 10);
-	/* media -- MPRIS controls via playerctl; popup shows album art (needs
-	 * curl for remote art URLs) plus Previous/Play-Pause/Next buttons */
-	add_builtin_module("media",      False, 2);
-	/* taskbar -- one clickable entry per window on the current workspace,
-	 * rendered directly in the bar rather than a popup (see the dedicated
-	 * block in draw_bar_into()); needs wmctrl, and a window manager that
-	 * acts on _NET_ACTIVE_WINDOW client messages to actually focus what
-	 * you click (see scripts/taskbar.sh) */
-	add_builtin_module("taskbar",    False, 1);
 }

 unsigned long parse_col(const char *hex)
diff --git a/sxbar.1 b/sxbar.1
index 0d89393..08a2073 100644
--- a/sxbar.1
+++ b/sxbar.1
@@ -9,8 +9,8 @@ sxbar \- a small, fast EWMH status bar for Xorg
 .SH DESCRIPTION
 .B sxbar
 is a single C99 binary that draws an EWMH workspace switcher plus a row of
-modules \(en clock, battery, volume, custom shell commands, anything you
-configure \(en on one bar per monitor (detected via Xinerama). Modules can
+modules \(en clock, battery, volume, scripts you write yourself, anything
+you configure \(en on one bar per monitor (detected via Xinerama). Modules can
 open a small floating popup window on hover or click, containing text rows,
 button rows, a draggable slider row, or an image row (e.g. album art).
 .PP
@@ -248,14 +248,26 @@ the left- and right-aligned modules on its bar, split into equal-width
 segments, rather than anchoring to one side. The currently-focused
 window's segment is highlighted the same way the active workspace pill
 is.
-.SH CUSTOM MODULES
-.B custom : name : "command" : refresh_interval_seconds
-runs an arbitrary shell command on its own interval; its stdout (trailing
-newline stripped) is the bar text. Every other directive on this page
-.RB ( prefix ", " colour ", " click ", " popup ", etc.) works on a custom"
-module by the name given here, exactly as on a built-in one.
+.SH WRITING YOUR OWN MODULE
+There is no separate "custom module" directive. Any name works with
+.BR "module : name : true|false : refresh_interval_seconds" :
+write a script, save it as
+.IR ~/.config/sxbar/scripts/ name .sh " (executable),"
+and enable it exactly like any module listed under
+.B BUILT-IN MODULES
+above \(en both resolve to a script the same way (see that section), so a
+module you write yourself is exactly as first-class as one sxbar ships a
+script for. A name with no matching script anywhere is a silent no-op, not
+an error. If the script understands a
+.B menu
+subcommand it can self-declare its own popup too, exactly like the shipped
+scripts \(en see
+.B POPUPS
+below. Every other directive on this page
+.RB ( prefix ", " colour ", " click ", " popup ", etc.) works on it by the"
+same name.
 .SH ICONS, COLOUR, LAYOUT, CLICKS
-These apply to any module, built-in or custom, by name.
+These apply to any module, by name.
 .TP
 .BR "prefix : name : \(dqtext\(dq" " (or " icon )
 Static text prepended to the module's output. Needs a Nerd Font set via
@@ -430,17 +442,18 @@ and add another
 .B popup_item
 line \(en no sxbarc editing needed. The sxbarc directives above still work
 exactly as documented and can still override a script's menu wholesale, if
-you would rather keep everything in one config file. A
-.B custom
-module has no script to load a menu from, so give it a popup via the
-sxbarc directives instead.
+you would rather keep everything in one config file. This applies to any
+module's script, not just the ones sxbar ships \(en see
+.B WRITING YOUR OWN MODULE
+above.
 .SH FILES
 .TP
 .I ~/.config/sxbarc, ~/.config/sxbar/sxbarc
 User configuration file (see search order above).
 .TP
 .I ~/.config/sxbar/scripts/<name>.sh
-Your own edited copy of a built-in module's script, or of a
+Your own edited copy of a module's script (one sxbar ships, or one you
+wrote yourself), or of a
 .B prefix_cmd
 icon script; checked before the installed system copy.
 .TP
@@ -454,11 +467,11 @@ This man page.
 System-wide fallback default configuration.
 .TP
 .I $PREFIX/share/sxbar/scripts/
-Reference copies of every built-in module's script, plus the
+Reference copies of every module script sxbar ships, plus the
 .IR battery_icon.sh / volume_icon.sh
 prefix_cmd examples and the
-.I demo_popup.sh
-try-it-yourself popup script. Copy any of these to
+.IR demo_popup.sh / demo_menu.sh
+try-it-yourself popup scripts. Copy any of these to
 .I ~/.config/sxbar/scripts/
 and edit freely.
 .TP
@@ -482,7 +495,7 @@ colour : clock : #50fa7b
 scroll_up   : volume : "wpctl set-volume --limit 1.0 @DEFAULT_AUDIO_SINK@ 5%+"
 scroll_down : volume : "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"

-custom : mem : "free -h | awk '/^Mem:/{print $3\\"/\\"$2}'" : 10
+module : mem : true : 10
 .fi
 .SH AUTHOR
 Abhinav Prasai