Skip to content

Add drop-in theme system with retro, grimoire, and midnight themes#156

Open
EnriqueCanals wants to merge 17 commits into
capotej:mainfrom
EnriqueCanals:feature/drop-in-themes
Open

Add drop-in theme system with retro, grimoire, and midnight themes#156
EnriqueCanals wants to merge 17 commits into
capotej:mainfrom
EnriqueCanals:feature/drop-in-themes

Conversation

@EnriqueCanals
Copy link
Copy Markdown
Contributor

@EnriqueCanals EnriqueCanals commented May 24, 2026

Supersedes #152. This PR replaces the original opt-in theme proposal with a drop-in theme system: a community theme is one self-contained folder under app/themes/<name>/, activated via ABBEY_THEME=<name>, with zero edits to central application files.

Why a new PR instead of #152

#152 introduced opt-in theming, but themes still required edits across 5+ central files (themes.rb, application_helper.rb, layouts, central Tailwind config, etc.), duplicated ~50–120 lines of layout boilerplate per theme, and leaked per-theme Tailwind tokens into the default bundle.

This PR delivers the same user-facing goal — opt-in themes that leave the default site unchanged — with a structure designed for community contributions:

  • Self-contained themes: app/themes/<name>/{theme.rb, assets/, views/}
  • Manifest-driven chrome: shared _abbey_chrome.html.erb partial reads from Abbey::Theme.register
  • Isolated Tailwind bundles: per-theme assets/tailwind.css + @source not "../../themes" in the default build
  • Generator: bin/rails g abbey:theme NAME [--minimal] [--from=retro]
  • Docs: docs/THEMES.md (authoring guide) + docs/THEMES_API.md (manifest API)

Built-in themes

Theme Description
default Unchanged behavior — same HTML, stylesheets, and markdown renderer
retro Memphis / 8-bit / CRT aesthetic (from #152, now in app/themes/retro/)
grimoire Retro hacker dark-fantasy variant
midnight Minimal sample theme demonstrating a "30-second recolor"

Architecture (5 phases, 15 commits)

  1. Registry + folder consolidationAbbey::Theme registry, manifests, git mv retro/grimoire into app/themes/<name>/
  2. Per-theme Tailwind — isolated bundles via themes:tailwind:build, bundle isolation regression test
  3. Shared chrome partial — theme layouts collapse to 3 lines
  4. Generatorrails g abbey:theme with --minimal / --from flags
  5. Docs + midnight sample — authoring guide, API reference, proof-of-concept theme

Compatibility

  • ABBEY_THEME=default (or unset): byte-for-byte identical default behavior
  • No new runtime gems beyond what upstream already ships
  • Rebased onto current main (includes chore: batch update 5 dependencies #155 dependency batch)
  • System-test selectors preserved across all themes

Test plan

  • bin/rails test — 46 runs, 283 assertions, 0 failures
  • bundle exec rubocop — clean
  • bin/rails tailwindcss:build — emits tailwind.css + per-theme bundles
  • Bundle isolation regression test (theme_bundle_isolation_test.rb)
  • Generator smoke test (test/generators/abbey/theme_generator_test.rb)
  • Manual visual smoke test: ABBEY_THEME={retro,grimoire,midnight} bin/dev
  • Reviewer pass on docs/THEMES.md

Try it

git fetch origin feature/drop-in-themes
git checkout feature/drop-in-themes
bundle install
bin/rails db:migrate
ABBEY_THEME=retro bin/dev

Notes for reviewer

  • The 15 commits are grouped by phase and each phase is independently reviewable if you prefer to land incrementally.
  • Phase 1 (registry + consolidation) alone is the meaningful infrastructure improvement; the rest builds on it.
  • Happy to split into stacked PRs if that's easier — just say the word.

Made with Cursor

EnriqueCanals and others added 15 commits May 24, 2026 17:24
Adds the plumbing for an opt-in theme system while leaving the default
look and behavior unchanged. A theme is selected via the ABBEY_THEME env
var (or `Rails.application.config.theme`) and contributes three things:

  * View overrides: any file under `app/views/themes/<theme>/` overrides
    its same-named default counterpart (including layouts and partials)
    via Rails' view path prepending. Implemented in
    `app/controllers/concerns/theming.rb` and included in
    ApplicationController as a no-op for the default theme.
  * Extra stylesheets: files under `app/assets/stylesheets/themes/` are
    excluded from the existing `:app` Propshaft bulk-include so they
    cannot leak into the default build, and are loaded explicitly via
    the new `theme_stylesheets` helper only when their theme is active.
  * Theme-aware markdown rendering: themes listed in
    `Rails.application.config.themes_using_minimal_renderer` get the new
    `MinimalMarkdownRender`, which emits semantic HTML without inline
    Tailwind utility classes — so themes can style content from a
    wrapper scope (e.g. `.prose-retro`) rather than fighting the
    renderer. The default theme keeps the existing `MarkdownRender`.

No new gems, no Tailwind config changes, no default-theme regressions:
when `ABBEY_THEME=default` (the default) the rendered HTML, asset list
and markdown output are byte-for-byte identical to before this commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
Two pure-CSS stylesheets that ship with the new `retro` theme and are
loaded by the theme system on demand:

  * `themes/retro.css` — Memphis design palette (neon pink/cyan/yellow
    /mint/purple/coral on paper or CRT-black), neo-brutalist cards,
    pixel-display headings (`Press Start 2P`), terminal-mono body
    (`VT323` / `Space Grotesk`), animated marquee, blinking cursor,
    drifting confetti background, subtle CRT scanlines, and a fully
    themed `.prose-retro` block for rendered markdown (h1-h3 with hard
    text-shadows, wavy underlines, highlighted `<strong>`, terminal
    `<pre>` blocks, dotted SVG `<hr>`, etc.).
  * `themes/retro-highlight.css` — Rouge syntax highlighting palette
    that pairs with the terminal aesthetic (neon-on-CRT-black).

Every selector is gated on `.theme-retro` (set on `<html>` by the retro
layout), so even if the file is loaded outside the theme it produces no
visible effect. No Tailwind processing is required — the file is served
directly by Propshaft.

Co-authored-by: Cursor <cursoragent@cursor.com>
Per-theme view overrides under `app/views/themes/retro/` covering every
template the default theme ships:

  * `layouts/application.html.erb` — sets `theme-retro` on `<html>`,
    preloads Google Fonts for `Press Start 2P` / `VT323` /
    `Space Grotesk`, adds a pixelated 4-square SVG favicon and pink
    theme-color, and loads the extra theme stylesheets via
    `theme_stylesheets`.
  * `shared/_navigation.html.erb` — color-block logo, blinking-cursor
    site title, retro tagline, and Memphis-styled nav buttons (Home,
    About, Presentations, Projects, Links, Papers) with hard shadows
    and a wiggle animation on the active page.
  * `shared/_footer.html.erb` — caution-tape marquee + terminal
    command-line copyright bar with blinking cursor.
  * `shared/_admin_navigation.html.erb` — BBS-sysop command line:
    yellow `SYSOP>` prompt, `ONLINE▮` indicator, and the same admin
    actions (New Post / New Page / New Link / Feeds / Read / Sign out)
    as the default bar (selectors `.fixed.top-0`, "New Post",
    "Sign out" preserved so system tests pass under either theme).
  * `shared/_tags.html.erb` — color-cycling tag pills picked from the
    Memphis palette based on the tag name hash.
  * `blog/index.html.erb`, `blog/show.html.erb`,
    `blog/index_by_tag.html.erb` — Memphis cards with color-cycled
    shadows, rotated date "stickers", glitch-on-hover titles, and a
    retro `READ MORE` button. Post body uses the `.prose-retro`
    wrapper from `themes/retro.css`.
  * `pages/show.html.erb` — page-as-Memphis-card with a mint rotated
    slug tag.
  * `links/index.html.erb`, `links/_link.html.erb` — banner header
    plus per-link Memphis cards.
  * `papers/index.html.erb`, `papers/_paper.html.erb` — retro variants
    of the new Papers feature, with PDF preview thumbs framed in hard
    shadows.

All variants preserve the user-facing copy and DOM selectors checked
by the existing Playwright system tests, so enabling the theme does
not regress `test:system`.

Co-authored-by: Cursor <cursoragent@cursor.com>
`test/integration/themes_test.rb` covers the four guarantees the
theme system makes:

  * default theme does NOT emit `theme-retro` or load any
    `themes/...` stylesheets;
  * retro theme DOES set `theme-retro` on `<html>`, loads
    `themes/retro` + `themes/retro-highlight`, and preserves the
    DOM contract checked by system tests (header h1, footer, all
    six nav links);
  * `Post.markdown_renderer` follows
    `Rails.application.config.theme` (MarkdownRender for default,
    MinimalMarkdownRender for retro);
  * `theme_stylesheets` helper is empty for the default theme and
    populated for retro.

Existing tests still pass under both themes:

    bin/rails test                                    # 21 tests, 85 asserts
    bin/rails test test/system/{navigation,blog_public,pages,links}_test.rb
    ABBEY_THEME=retro bin/rails test test/system/...  # same suite, all green

Co-authored-by: Cursor <cursoragent@cursor.com>
Adds a Themes section to the README explaining:

  * how to switch themes via ABBEY_THEME or
    config/initializers/themes.rb,
  * what the built-in `default` and `retro` themes are,
  * how the theme system overrides views, ships extra stylesheets,
    and selects a markdown renderer,
  * how to author a new theme (drop a CSS file under
    `app/assets/stylesheets/themes/<name>.css` and set ABBEY_THEME).

Also adds a one-line feature entry to the top-level features list.

Co-authored-by: Cursor <cursoragent@cursor.com>
Extends the theming system introduced for the retro theme with a second
built-in named theme: "grimoire" — retro hacker dark fantasy. Like retro,
grimoire opts into MinimalMarkdownRender so it can style markdown content
entirely from a wrapper class scope.

No behavioural change for ABBEY_THEME=default or ABBEY_THEME=retro.
README updated to document grimoire alongside retro in the themes table.

Co-authored-by: Cursor <cursoragent@cursor.com>
Pure-CSS theme files served by Propshaft and loaded only when
Rails.application.config.theme == "grimoire" (via the existing
theme_stylesheets helper). All selectors are scoped under
`.theme-grimoire` so loading these files in any other context is a no-op.

Visual language:
- Light mode: aged parchment (#ebd9b3) with subtle noise grain + corner
  foxing; blood-red (#8b1d27) ink accents; gold (#c8a44d) hairlines
- Dark mode: starless void (#07080d) with drifting ember/arcane dust;
  golden text; phosphor green (#57f287) for code/cursor accents; subtle
  CRT scanlines
- Matrix-minimal typography: JetBrains Mono 800 uppercase for display
  headings, Inter for body, IBM Plex Mono for inline code/meta
- Components: tome cards (double gold/ink border + corner sigils),
  spell-btn (raised paper/metal action button), wax-seal tag pills
  (deterministic blood/arcane/ember/phosphor/gold/ichor color cycle),
  icon-rune (square icon button), summoning circle, marquee, rune-divider
- Markdown prose (.prose-grimoire): chunky monospace drop cap with
  thin underline (phosphor green in dark mode), terminal-style code
  blocks with "~/grimoire/spells $ cast" header, // EOF // hr, wax-seal
  tag pills, scoped table styling
- Konami code easter egg overlay (.incant-overlay/.incant-title)
- Custom scrollbar (dark mode only)

The companion grimoire-highlight.css is a Rouge theme: phosphor green
base, gold sigils for keywords, blood-red strings, ember comments — all
scoped under `.theme-grimoire` so it can't clobber the default highlight
theme.

Co-authored-by: Cursor <cursoragent@cursor.com>
Drops a full set of view overrides under app/views/themes/grimoire/ that
the theming system prepends to the view lookup path when grimoire is
active:

  layouts/application.html.erb         — html.theme-grimoire wrapper,
                                         pixel-runic favicon, theme-only
                                         Google Fonts (Inter + JetBrains
                                         Mono + IBM Plex Mono),
                                         cookie-based dark-mode JS that
                                         survives Turbo Drive navigation
                                         (re-applies on turbo:load and
                                         turbo:render, listeners guarded
                                         against double-registration),
                                         and a Konami code easter egg
                                         that unlocks a "necromancer"
                                         overlay
  shared/_navigation.html.erb          — terminal masthead
                                         (`root@grimoire:~$ cat ./codex_of`),
                                         Roman-numeral table-of-contents
                                         nav, RSS + theme-toggle icon-runes
  shared/_footer.html.erb              — animated summoning circle SVG
                                         (counter-rotating ring + pentagram
                                         + center ember), scrolling
                                         marquee of dev-zine tokens,
                                         terminal command-line copyright
  shared/_admin_navigation.html.erb    — adept's console
                                         (`:transcribe / :inscribe / :bind
                                         / :auguries / :scry / :depart`)
  shared/_tags.html.erb                — wax-seal tag pills (deterministic
                                         color cycle by tag name hash)
  blog/index.html.erb,
  blog/show.html.erb,
  blog/index_by_tag.html.erb           — tome cards with "Folio · Anno
                                         <Roman>" date stickers, "open
                                         spellbook" CTAs, // LOG // and
                                         // FILTER // rune dividers
  pages/show.html.erb                  — single-page tome with
                                         "✦ Chapter · <slug> ✦" header
                                         and `exit 0` // EOF // sign-off
  links/index.html.erb, _link.html.erb — "Forbidden Tomes" reliquary
                                         header, link cards rendered as
                                         tomes with ✦ icon
  papers/index.html.erb, _paper.html.erb — "Treatises" header with
                                         "Codex · PDF" stickers

All views consume the pure-CSS classes defined in
app/assets/stylesheets/themes/grimoire.css (tome, spell-btn, wax-seal,
h-blackletter, h-engraved, rune-divider, prose-grimoire, etc.) plus
.theme-grimoire-scoped utility classes that mirror Tailwind syntax
(bg-grim-*, text-grim-*, font-plex, tracking-[Xem], leading-[X], …).

This means grimoire — like retro — needs zero changes to the Tailwind
config to render correctly: it owns its own visual surface and only
takes effect when ABBEY_THEME=grimoire is active.

Co-authored-by: Cursor <cursoragent@cursor.com>
Adds parallel coverage for the grimoire opt-in theme so that future
theming changes can't quietly regress it:

  - default theme renders the original layout: also asserts no
    `theme-grimoire` class and no `themes/grimoire` stylesheets leak in
  - new test: grimoire theme prepends its view path and loads its theme
    stylesheets; nav still contains all the required labels
  - renderer test: grimoire (like retro) uses MinimalMarkdownRender
  - theme_stylesheets helper test: returns themes/grimoire and
    themes/grimoire-highlight when grimoire is active

All 5 tests pass with 68 assertions.

Co-authored-by: Cursor <cursoragent@cursor.com>
Replaces hand-rolled `.theme-X .bg-foo-bar` utility-class declarations (plus
matching `.dark:`, `.hover:`, `.group-hover:`, opacity-modifier and
arbitrary-value mirrors) with Tailwind v4 `@theme` tokens that generate the
same utilities natively. Addresses the PR description's noted trade-off:
"The retro theme uses pure CSS rather than extending the Tailwind config —
deliberate, so the default build stays unchanged."

It turns out we can have both: the theme tokens live in `@theme` so Tailwind
generates the utilities, and the default build stays unchanged because the
default theme's views never reference the new utility classes (so Tailwind's
content-aware extraction never emits them in the default theme's bundle —
only the CSS variables on `:root`, which are inert without matching classes).

What moved into `app/assets/tailwind/application.css` under `@theme`:
  * Memphis palette (--color-memphis-pink/cyan/yellow/mint/purple/coral/...)
  * Grimoire palette (--color-grim-blood/gold/ember/phosphor/arcane/...)
  * Retro hard-shadows (--shadow-retro, --shadow-retro-lg, --shadow-retro-pink/...)
  * Theme-specific font tokens (--font-display/blackletter/engraved/plex/manuscript)
  * Shared animations + @Keyframes (--animate-blink/wiggle/pop-in/marquee)

What dropped out of `themes/retro.css` (626 -> 539 lines) and
`themes/grimoire.css` (819 -> 754 lines):
  * `.theme-X .bg-/text-/border-/shadow-/font-...` declarations — Tailwind
    generates them from the --color-* / --shadow-* / --font-* tokens
  * `.theme-X.dark .dark\:foo-bar` mirrors — the `dark:` variant works
    because <html> already carries both `theme-X` and `dark` classes, and
    the existing `darkMode: 'class'` setting in tailwind.config.js applies
  * `.hover\:foo-bar`, `.group:hover .group-hover\:foo-bar`, opacity mirrors
    (`text-foo\/40`), and ~30 arbitrary-value escapes in grimoire.css
    (`text-\[0\.62rem\]`, `tracking-\[0\.18em\]`) — Tailwind v4 generates
    all of these natively from the registered tokens
  * Hand-written @Keyframes that duplicated --animate-* registrations

What stayed in the per-theme CSS files:
  * theme-root environmental styles (body backdrops, scanlines, scrollbar)
  * 4-line CSS-variable rebinding inside `.theme-X { --font-sans: ...; ... }`
    so utilities like `font-mono` / `font-sans` / `font-display` resolve to
    the theme's font stack inside the theme scope without affecting the
    default theme
  * all component classes (`.card-retro`, `.btn-retro`, `.tome`,
    `.spell-btn`, `.wax-seal`, `.h-display`, `.h-blackletter`,
    `.prose-retro`, `.prose-grimoire`, ...) — now consuming
    `var(--color-memphis-pink)` etc. internally so the @theme tokens are
    the single source of truth for design values
  * theme-local @Keyframes (retro-drift, retro-glitch, grimoire-sigil-spin,
    grimoire-mist, grimoire-incant) that aren't surfaced as utilities

Default theme is still byte-for-byte unchanged. Tests still pass:

  $ bin/rails test                       # 22 runs, 115 assertions, 0 failures
  $ bin/rails test test/integration/themes_test.rb
                                         #  5 runs,  68 assertions, 0 failures
  $ bin/rails tailwindcss:build          # clean

Future themes are now much smaller: drop tokens into `@theme`, rebind
`--font-*` for typography, ship a body backdrop + component classes —
no more growing per-theme boilerplate every time a new color or shadow
is needed.

Co-authored-by: Cursor <cursoragent@cursor.com>
Reorganize Abbey's theming so each theme is one self-contained folder
under app/themes/<name>/ and gets dropped in / removed independently.
Zero central edits to add a new theme.

Folder consolidation (git mv preserves history):
  app/views/themes/retro/                  -> app/themes/retro/views/
  app/views/themes/grimoire/               -> app/themes/grimoire/views/
  app/assets/stylesheets/themes/retro.css  -> app/themes/retro/assets/retro.css
  app/assets/stylesheets/themes/retro-highlight.css
                                           -> app/themes/retro/assets/retro-highlight.css
  (same for grimoire)

New Abbey::Theme registry (lib/abbey/theme.rb, autoloaded via
config.autoload_lib). Themes declare their metadata in app/themes/<name>/
theme.rb manifests:

    Abbey::Theme.register(:retro) do |t|
      t.display_name      = "Retro (Memphis / 8-bit / CRT)"
      t.html_class        = "theme-retro"
      t.body_class        = "min-h-screen flex flex-col font-sans ..."
      t.markdown_renderer = :minimal
      t.theme_color_light = "#fff8ef"
      t.theme_color_dark  = "#0a0e1a"
      t.fonts             = ["https://fonts.googleapis.com/css2?..."]
      t.favicon_svg       = "<svg ...>"
    end

The registry exposes a sentinel DefaultTheme so callers never have to
nil-check; Abbey::Theme.active always returns a Theme.

Boot wiring (config/initializers/themes.rb):
  - Honor ABBEY_THEME env var (Rails.application.config.theme).
  - Abbey::Theme.load_all! scans app/themes/*/theme.rb (uses Kernel#load
    so manifests are re-evaluatable for tests and dev reload).
  - Register each theme's assets/ folder with Propshaft so
    stylesheet_link_tag "<name>" / "<name>-highlight" resolves.

Consumer updates:
  - Theming concern reads from Abbey::Theme.active (still per-request).
  - ApplicationHelper#theme_stylesheets enumerates the active theme's
    manifest-declared stylesheets, with a legacy themes/<name> fallback.
  - Rendering#markdown_renderer delegates to Abbey::Theme.active.

Tests:
  - test/lib/abbey/theme_test.rb (11 cases) covers .register, .active,
    DefaultTheme, env-var override, .discover, .load_all!, #stylesheets.
  - test/integration/themes_test.rb updated to match new logical asset
    paths (themes are now served at /assets/retro[-...].css, not
    /assets/themes/retro[-...].css).

Verified: rails test (37 runs, 207 assertions, 0 failures), rubocop
clean, ABBEY_THEME=retro bin/rails runner smoke test reports the
expected registry, view path, asset path, stylesheets, and renderer.

Co-authored-by: Cursor <cursoragent@cursor.com>
Each theme now ships its own Tailwind input file at
app/themes/<name>/assets/tailwind.css. The default Abbey bundle
(app/assets/tailwind/application.css) compiles to tailwind.css with
`@source not "../../themes"` so theme views never leak utilities into
the default bundle — regardless of how many themes the project ships,
the default bundle stays byte-for-byte unchanged.

Per-theme bundles:
  app/themes/retro/assets/tailwind.css    -> tailwind-retro.css
                                             (memphis palette, retro
                                              shadows, theme animations)
  app/themes/grimoire/assets/tailwind.css -> tailwind-grimoire.css
                                             (grim palette, theme fonts,
                                              theme animations)

Each theme's input declares its own `@source "../views"` so Tailwind
scans only that theme's view files and tree-shakes accordingly.

Build pipeline (lib/tasks/themes.rake):
  - themes:tailwind:build   compiles every theme bundle.
  - themes:tailwind:watch   spawns one watcher per theme (used by
                            Procfile.dev so `bin/dev` picks up changes
                            in any theme's tailwind.css live).
  - themes:tailwind:clobber removes every compiled theme bundle.

`Rake::Task["tailwindcss:build"].enhance` chains the per-theme build so
plain `bin/rails tailwindcss:build` and `assets:precompile` both produce
the full set. test:prepare picks it up automatically too.

Helper update: ApplicationHelper#theme_stylesheets emits
`tailwind-<active>` first (so theme tokens land before component CSS
that consumes them), then the theme's component stylesheets.

New test/integration/theme_bundle_isolation_test.rb (5 cases) asserts:
  * default tailwind.css contains no --color-memphis-*, --color-grim-*,
    --shadow-retro-*, .bg-memphis-pink, .bg-grim-void, .shadow-retro-lg.
  * tailwind-retro.css does contain its tokens + utilities.
  * tailwind-grimoire.css does contain its tokens + utilities.
  * tailwind-retro.css does not leak Grimoire tokens (themes are
    isolated from each other too, not just from the default).

Verified: rails test (42 runs, 239 assertions, 0 failures), rubocop
clean, manual `bin/rails tailwindcss:build` emits tailwind.css,
tailwind-retro.css, and tailwind-grimoire.css with the expected
contents.

Co-authored-by: Cursor <cursoragent@cursor.com>
Extract every theme's <head>/<body>/wrapper boilerplate into one
canonical partial driven by the active Abbey::Theme manifest. Each
theme's layout collapses from ~50-120 lines to 3, and the default
layout drops from 47 lines to 3 as well:

  <%= render "layouts/abbey_chrome" do %>
    <%= yield %>
  <% end %>

The chrome partial (app/views/layouts/_abbey_chrome.html.erb, 75
lines) emits the full DOCTYPE/html/head/body shell, reading everything
configurable from the manifest:

  * html_class          -> <html class="theme-retro">
  * dark_html_class /   -> dark-mode-conditional html classes
    light_html_class
  * body_class          -> <body class="...">
  * main_class          -> <main class="...">
  * theme_color_light + -> media-aware <meta name="theme-color">
    theme_color_dark
  * favicon_svg         -> inline data:image/svg+xml favicon
  * fonts (Array<URL>)  -> Google Fonts preconnect + <link> per font

Manifest API gained dark_html_class / light_html_class / main_class
fields (with sensible defaults: "dark", nil, container-mx-auto). The
DefaultTheme sentinel sets them to the exact values the original
default layout used, so the default HTML stays functionally identical.

Per-theme JS that needs to vary (e.g. grimoire's Turbo-safe dark mode
+ Konami easter egg) moves to shared/_dark_mode_script.html.erb,
overridable per-theme via the normal view-path-prepend mechanism. The
default partial ships the simple cookie toggle.

Bug fix surfaced during this work: ApplicationHelper#app_stylesheets_
paths used to exclude only `themes/*` logical paths. With per-theme
assets registered under their own Propshaft load_paths in Phase 1+2,
the per-theme tailwind-<name>.css and theme component CSS were leaking
into the default :app bundle bulk inclusion. Now we filter explicitly
against every registered theme's contributed paths (cached per-helper-
instance) — no theme assets in the default :app stylesheet_link_tag.

Tests:
  * test/integration/themes_test.rb adds:
    - "chrome partial drives <head> entirely from theme manifest"
      verifies html_class/body_class/main_class/theme-color/font/favicon
      from manifest land in the rendered <head>.
    - default theme test extended to assert no /assets/tailwind-retro
      or /assets/tailwind-grimoire links leak into the default bundle.

Verified: rails test (43 runs, 264 assertions, 0 failures), rubocop
clean. erb_lint shows only the same pre-existing _footer.html.erb
findings present before this branch.

Co-authored-by: Cursor <cursoragent@cursor.com>
Scaffold a new drop-in theme with one command:

  bin/rails g abbey:theme aurora
    # full skeleton: theme.rb, assets/tailwind.css, all 12 view files
    # (layouts/_, shared/*, blog/*, pages/_, links/*, papers/*), README.

  bin/rails g abbey:theme spark --minimal
    # bare minimum: theme.rb + assets/tailwind.css + 3-line layout.
    # Use case: pure recolor that inherits Abbey's default chrome.

  bin/rails g abbey:theme neon --from=retro
    # clones retro's views/ and non-tailwind assets as a starting point.
    # Use case: serious visual override that wants a structural head
    # start rather than 12 empty stubs.

Generator (lib/generators/abbey/theme/theme_generator.rb):
  * Validates name with /\A[a-z][a-z0-9_]*\z/ (after NamedBase#underscore
    normalization). Rejects obvious bad input ("9foo", "with.dots",
    "Has Spaces", etc).
  * Refuses to overwrite an existing app/themes/<name>/.
  * Honors destination_root throughout (relative paths only) so
    Rails::Generators::TestCase isolates cleanly.
  * `--from` copies entry-by-entry into the existing scaffolded views/
    so the clone merges instead of nesting `views/views/`.
  * Prints next-steps after scaffolding.

Templates (lib/generators/abbey/theme/templates/):
  * theme.rb.tt        — manifest stub with sensible defaults + commented
                         examples for favicon/fonts.
  * tailwind.css.tt    — @import + @source "../views" + a starter @theme
                         palette (--color-<name>-bg/fg/accent/muted).
  * layouts/application.html.erb.tt — 3-line shell calling abbey_chrome.
  * views/shared/{_navigation,_footer,_admin_navigation,_tags}.html.erb.tt
  * views/blog/{index,show,index_by_tag}.html.erb.tt
  * views/{pages,links,papers}/...html.erb.tt
  * README.md.tt       — author notes with the standard activation
                         command + folder reference.

Tests (test/generators/abbey/theme_generator_test.rb, 6 cases):
  * full scaffold puts the right files in the right places
  * --minimal skips the view tree
  * --from clones from source and merges into the scaffolded layouts dir
  * invalid names produce stderr errors (Thor catches Thor::Error, so
    we capture(:stderr) rather than assert_raises)
  * refuses to overwrite
  * generated theme.rb is valid Ruby and registers with Abbey::Theme

Also: .gitignore the generator test scratch directory at test/tmp/
(Rails::Generators::TestCase destination).

Verified: rails test (49 runs, 328 assertions, 0 failures), rubocop
clean, manual smoke test (`bin/rails g abbey:theme midnight --minimal`
+ `ABBEY_THEME=midnight bin/rails runner ...`) confirms the generated
theme registers correctly with the expected attributes.

Co-authored-by: Cursor <cursoragent@cursor.com>
Three artifacts so a community contributor can ship a theme without
reverse-engineering the codebase:

docs/THEMES.md — end-to-end authoring guide. Concepts, quick-start
30-second recolor, folder layout, manifest walkthrough, Tailwind entry
point recipe, view overrides, markdown renderer selection, per-theme
JavaScript, dark-mode mechanics, common patterns (recolor, custom
typography, favicon, syntax highlighting), and gotchas.

docs/THEMES_API.md — exhaustive manifest field reference + registry
API. Tables for every Abbey::Theme writable attribute (defaults,
descriptions), registry class methods, filesystem conventions, boot
flow, view helpers, generator flags, rake tasks, and a versioning
commitment for the manifest stability boundary.

app/themes/midnight/ — sample drop-in theme demonstrating the
"30-second recolor" claim from the docs. ~80 lines total across
theme.rb (30) + assets/tailwind.css (50) + 3-line layout shell + a
README that walks through what it shows. Deep slate palette with warm
amber accents, Inter for body / JetBrains Mono for code (loaded via
manifest's t.fonts), inline SVG crescent-moon favicon, custom prose
styling that overrides Tailwind Typography's --tw-prose-* vars for
the dark background. Ships zero view overrides; inherits Abbey's
default chrome and templates entirely.

Forking starting point:
  bin/rails g abbey:theme yourname --from=midnight

Top-level README.md Themes section rewritten: replaces the old
file-based authoring instructions with a folder-based drop-in
description, lists midnight as a built-in, and points contributors at
docs/THEMES.md + docs/THEMES_API.md.

Test: test/integration/theme_bundle_isolation_test.rb adds an
isolation assertion for midnight (tokens land in tailwind-midnight.css,
don't leak into the default tailwind.css). 50 runs, 334 assertions,
0 failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
EnriqueCanals and others added 2 commits May 24, 2026 17:29
The shared chrome header comment accidentally closed early on `%>` and
rendered template text at the top of every page. Dark mode now uses a
shared script core that syncs the dark_mode cookie to the full html class
set (base + light/dark variants), runs before stylesheets in <head>, and
re-applies on turbo:load/turbo:render so back/forward navigation stays in
sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
Per-theme tailwind.css entry points were missing darkMode: class config,
so dark:bg-* utilities compiled against prefers-color-scheme instead of
the `.dark` class our cookie toggle sets. Add @custom-variant dark and
@import the shared tailwind.config.js to retro, grimoire, midnight, and
the theme generator template, with a regression test.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant