diff --git a/.gitignore b/.gitignore index afd85fd..58f5e34 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ !/log/.keep !/tmp/.keep +# Generator test scratch directory (Rails::Generators::TestCase destination). +/test/tmp/ + # Ignore pidfiles, but keep the directory. /tmp/pids/* !/tmp/pids/ diff --git a/Procfile.dev b/Procfile.dev index da151fe..61d1611 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,2 +1,3 @@ web: bin/rails server css: bin/rails tailwindcss:watch +themes: bin/rails themes:tailwind:watch diff --git a/README.md b/README.md index e5ef215..0258449 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Minimal blog using Rails 8, designed to be easily [self-hosted on AWS](https://g * Markdown and Code Highlighting * [Link Blog](https://capotej.com/links) * Drag and Drop image uploads for Pages and Posts +* Themable (default minimal look + drop-in community themes — see [Themes](#themes)) # Getting Started @@ -56,6 +57,66 @@ This will scan the given path for files ending in `.markdown` and create a seed **Note: This will delete everything in the local database and re-seed using `db/seeds/*`.** +# Themes + +Abbey ships with a drop-in theme system designed for community contribution. A +theme is **one self-contained folder** under `app/themes//`: + +``` +app/themes/aurora/ + theme.rb # manifest (Abbey::Theme.register) + assets/tailwind.css # per-theme Tailwind build + views/ # ERB overrides (any subset, optional) + README.md +``` + +Dropping a folder in and setting `ABBEY_THEME=` is the entire install — +zero edits to any central file. The default Abbey bundle stays byte-for-byte +unchanged no matter how many themes the project ships. + +## Built-in themes + +| Theme | Description | +|------------|-------------| +| `default` | Original minimal Abbey look (no behavioural change). | +| `retro` | Memphis-style / 8-bit / 80s computer chrome — neo-brutalist cards, CRT scanlines, terminal code blocks, pixel-display headings. | +| `grimoire` | Retro hacker dark fantasy — Matrix-minimal monospace, parchment + void palette with phosphor/ember/gold accents, tome cards, wax-seal tags, an animated summoning circle, and a Konami-code easter egg. | +| `midnight` | Sample drop-in theme. ~80 lines total demonstrating the "30-second recolor" pattern. Deep slate palette with warm amber accents, Inter + JetBrains Mono. | + +## Switching themes + +Set the `ABBEY_THEME` environment variable before booting the app: + + $ ABBEY_THEME=retro bin/dev + +Or hardcode it in `config/initializers/themes.rb`: + +```ruby +Rails.application.config.theme = "retro" +``` + +When the active theme is `default`, the app behaves identically to before — +no extra assets are loaded, the default Tailwind build is unchanged, and the +existing markdown renderer is used. + +## Authoring a new theme + +```sh +bin/rails g abbey:theme aurora # full scaffold +bin/rails g abbey:theme aurora --minimal # pure recolor (theme.rb + tailwind.css + 3-line layout) +bin/rails g abbey:theme aurora --from=retro # clone retro as starting point +``` + +Then edit `app/themes/aurora/theme.rb` (display name, colors, fonts) and +`app/themes/aurora/assets/tailwind.css` (your `@theme` tokens). Boot with +`ABBEY_THEME=aurora bin/dev`. + +For a guided walkthrough — concepts, manifest reference, common patterns, +gotchas — see [**docs/THEMES.md**](docs/THEMES.md). For the exhaustive +manifest field reference and registry API, see +[**docs/THEMES_API.md**](docs/THEMES_API.md). For a minimal working +example to fork, look at [`app/themes/midnight/`](app/themes/midnight/). + # Deploying to AWS ## Assumptions diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index 08d6aa3..39d795c 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -1,16 +1,16 @@ @import 'tailwindcss'; -@config '../../../config/tailwind.config.js'; - -/* +/* Exclude every drop-in theme from the default Tailwind scan. Each theme + (under app/themes//) compiles its own self-contained Tailwind + bundle from its own views, so the default Abbey bundle stays + byte-for-byte unchanged regardless of how many themes the project + ships. */ +@source not "../../themes"; -@layer components { - .btn-primary { - @apply py-2 px-4 bg-blue-200; - } -} +@config '../../../config/tailwind.config.js'; -*/ +@plugin "@tailwindcss/forms"; +@plugin "@tailwindcss/typography"; /* The default border color has changed to `currentcolor` in Tailwind CSS v4, @@ -29,6 +29,3 @@ border-color: var(--color-gray-200, currentcolor); } } - -@plugin "@tailwindcss/forms"; -@plugin "@tailwindcss/typography"; diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 94e7183..2902bd2 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,5 +1,6 @@ class ApplicationController < ActionController::Base include Authentication + include Theming # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern end diff --git a/app/controllers/concerns/theming.rb b/app/controllers/concerns/theming.rb new file mode 100644 index 0000000..e0bfa24 --- /dev/null +++ b/app/controllers/concerns/theming.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# Prepends the active theme's view directory to Rails' lookup path so that +# `app/themes//views//.html.erb` overrides +# `app/views//.html.erb` (including layouts) while the theme +# is active. The default theme is a no-op. +module Theming + extend ActiveSupport::Concern + + included do + before_action :prepend_theme_view_path + helper_method :current_theme, :theme_active?, :active_theme + end + + private + + # Returns the active `Abbey::Theme` instance (or its DefaultTheme sentinel + # when no named theme is configured). Always non-nil. + def active_theme + Abbey::Theme.active + end + + # Backward-compat string accessor used by older view helpers. + def current_theme + active_theme.name.to_s + end + + def theme_active? + !active_theme.default? + end + + def prepend_theme_view_path + theme = active_theme + return if theme.default? + + views = theme.views_path + return unless views&.directory? + + prepend_view_path views.to_s + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index de6be79..bf07d4e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,2 +1,80 @@ module ApplicationHelper + # Override Propshaft's `:app` bulk inclusion so the per-theme assets + # (tailwind-.css and every file from app/themes//assets/) + # are NOT auto-included with the rest of the app bundle. They belong + # to optional themes and are loaded separately via #theme_stylesheets + # only when their theme is active. This keeps the default theme bundle + # byte-for-byte unchanged regardless of how many themes ship. + def app_stylesheets_paths + excluded = theme_excluded_logical_paths + super.reject do |path| + slug = path.to_s.delete_suffix(".css") + slug.start_with?("themes/") || excluded.include?(slug) + end + end + + # Returns the logical names of every stylesheet the active theme ships, + # suitable for `stylesheet_link_tag`. Always includes the per-theme + # Tailwind bundle first (`tailwind-`), then any extra CSS files + # the theme contributes from its `assets/` folder. The default theme + # contributes nothing extra. + # + # Resolution order: + # 1. `tailwind-` (compiled by `themes:tailwind:build`) so + # theme tokens land before component CSS that references them. + # 2. Manifest-declared stylesheets (filesystem scan of assets/, with + # tailwind.css excluded since it's the compiler input, not output). + # 3. Legacy `themes/` / `themes/-highlight` fallback for + # code paths that haven't moved to the consolidated folder. + def theme_stylesheets + theme = Abbey::Theme.active + return [] if theme.default? + + sheets = [] + sheets << "tailwind-#{theme.name}" if theme_stylesheet_exists?("tailwind-#{theme.name}") + + candidates = theme.stylesheets + candidates = [ + "themes/#{theme.name}", + "themes/#{theme.name}-highlight" + ] if candidates.empty? + + sheets.concat(candidates.select { |name| theme_stylesheet_exists?(name) }) + end + + # Whether dark mode is currently active on the request. The chrome + # partial uses this to pick between the active theme's dark_html_class + # and light_html_class. + def dark_mode? + cookies[:dark_mode] == "true" + end + + # Build the `class="..."` value for the chrome partial's element + # by combining the active theme's always-on classes with its + # dark/light variants based on the request's dark mode state. + def chrome_html_class(theme = Abbey::Theme.active) + parts = [ + theme.html_class, + dark_mode? ? theme.dark_html_class : theme.light_html_class + ] + parts.compact.reject(&:blank?).join(" ").presence + end + + private + + def theme_stylesheet_exists?(logical_name) + Rails.application.assets&.load_path&.find("#{logical_name}.css").present? || + Rails.root.join("app/assets/stylesheets/#{logical_name}.css").exist? + rescue StandardError + Rails.root.join("app/assets/stylesheets/#{logical_name}.css").exist? + end + + # Every logical asset path contributed by any registered theme. Used + # by `app_stylesheets_paths` to exclude theme assets from the default + # bundle without hard-coding any theme name. + def theme_excluded_logical_paths + @_theme_excluded_logical_paths ||= Abbey::Theme.registry.flat_map do |name, theme| + ["tailwind-#{name}"] + theme.stylesheets + end.to_set + end end diff --git a/app/models/concerns/rendering.rb b/app/models/concerns/rendering.rb index b796cfa..8856f82 100644 --- a/app/models/concerns/rendering.rb +++ b/app/models/concerns/rendering.rb @@ -1,4 +1,5 @@ require "markdown_render" +require "minimal_markdown_render" module Rendering extend ActiveSupport::Concern @@ -7,7 +8,7 @@ module Rendering included do def render(text) - processed_markdown = Redcarpet::Markdown.new(MarkdownRender, fenced_code_blocks: true).render(text) + processed_markdown = Redcarpet::Markdown.new(self.class.markdown_renderer, fenced_code_blocks: true).render(text) # Replace signed IDs with img tags, handling both href and src attributes processed_markdown.gsub!(/(href|src)="(.*?)"/) do |match| @@ -32,4 +33,21 @@ def render(text) processed_markdown end end + + class_methods do + # Resolve the Redcarpet renderer class to use for this request. + # + # Themes declare their renderer in their manifest: + # + # Abbey::Theme.register(:retro) do |t| + # t.markdown_renderer = :minimal # or :default, or a custom class + # end + # + # The default theme (no manifest) keeps `MarkdownRender` for backward + # compatibility with existing imported posts that depend on its inline + # Tailwind class output. + def markdown_renderer + Abbey::Theme.active.markdown_renderer + end + end end diff --git a/app/themes/grimoire/assets/grimoire-highlight.css b/app/themes/grimoire/assets/grimoire-highlight.css new file mode 100644 index 0000000..4f07a61 --- /dev/null +++ b/app/themes/grimoire/assets/grimoire-highlight.css @@ -0,0 +1,114 @@ +/* ============================================================================= + Abbey – Grimoire Theme Syntax Highlighting + ------------------------------------------------------------------ + The wizard's terminal: phosphor green base, gold sigils for keywords, + blood-red for strings (literal incantations), ember for comments + (footnotes from the necromancer). + + All Rouge classes are scoped under `.theme-grimoire` so this file is a + no-op when another theme (or the default theme) is active. + =============================================================================*/ + +.theme-grimoire pre.highlight, +.theme-grimoire .highlight pre { + background: #07080d !important; + color: #57f287; +} + +/* Comments — the necromancer's marginalia */ +.theme-grimoire .highlight .c, +.theme-grimoire .highlight .ch, +.theme-grimoire .highlight .cd, +.theme-grimoire .highlight .cm, +.theme-grimoire .highlight .cpf, +.theme-grimoire .highlight .c1, +.theme-grimoire .highlight .cs { color: #f08029; font-style: italic; opacity: 0.85; } +.theme-grimoire .highlight .cp { color: #f08029; font-weight: 700; } + +/* Errors — broken sigils */ +.theme-grimoire .highlight .err { color: #ff5a5f; background: rgba(255,90,95,0.12); } + +/* Keywords — true names */ +.theme-grimoire .highlight .k, +.theme-grimoire .highlight .kc, +.theme-grimoire .highlight .kd, +.theme-grimoire .highlight .kn, +.theme-grimoire .highlight .kp, +.theme-grimoire .highlight .kr, +.theme-grimoire .highlight .kv { color: #c8a44d; font-weight: 700; } +.theme-grimoire .highlight .kt { color: #c8a44d; } + +/* Operators / punctuation */ +.theme-grimoire .highlight .o, +.theme-grimoire .highlight .ow { color: #c8a44d; } +.theme-grimoire .highlight .p { color: #e9e0c8; } + +/* Names */ +.theme-grimoire .highlight .n { color: #e9e0c8; } +.theme-grimoire .highlight .na { color: #c8a44d; } +.theme-grimoire .highlight .nb { color: #c8a44d; } +.theme-grimoire .highlight .nc { color: #f08029; font-weight: 700; } +.theme-grimoire .highlight .no { color: #c8a44d; } +.theme-grimoire .highlight .nd { color: #f08029; } +.theme-grimoire .highlight .ne { color: #ff5a5f; font-weight: 700; } +.theme-grimoire .highlight .nf { color: #57f287; font-weight: 700; } +.theme-grimoire .highlight .nl { color: #c8a44d; } +.theme-grimoire .highlight .nn { color: #e9e0c8; } +.theme-grimoire .highlight .py { color: #c8a44d; } +.theme-grimoire .highlight .nt { color: #f08029; font-weight: 700; } +.theme-grimoire .highlight .nv, +.theme-grimoire .highlight .vc, +.theme-grimoire .highlight .vg, +.theme-grimoire .highlight .vi { color: #c8a44d; } + +/* Literals */ +.theme-grimoire .highlight .l { color: #57f287; } +.theme-grimoire .highlight .ld { color: #57f287; } + +/* Strings — literal incantations */ +.theme-grimoire .highlight .s, +.theme-grimoire .highlight .sb, +.theme-grimoire .highlight .sc, +.theme-grimoire .highlight .dl, +.theme-grimoire .highlight .sd, +.theme-grimoire .highlight .s2, +.theme-grimoire .highlight .se, +.theme-grimoire .highlight .sh, +.theme-grimoire .highlight .si, +.theme-grimoire .highlight .sx, +.theme-grimoire .highlight .sr, +.theme-grimoire .highlight .s1, +.theme-grimoire .highlight .ss { color: #ff8d97; } + +/* Numbers */ +.theme-grimoire .highlight .m, +.theme-grimoire .highlight .mb, +.theme-grimoire .highlight .mf, +.theme-grimoire .highlight .mh, +.theme-grimoire .highlight .mi, +.theme-grimoire .highlight .il, +.theme-grimoire .highlight .mo, +.theme-grimoire .highlight .mx { color: #f08029; } + +/* Diff */ +.theme-grimoire .highlight .gd { color: #ff5a5f; background: rgba(255,90,95,0.10); } +.theme-grimoire .highlight .gi { color: #57f287; background: rgba(87,242,135,0.10); } + +/* Generic */ +.theme-grimoire .highlight .ge { font-style: italic; } +.theme-grimoire .highlight .gh { color: #c8a44d; font-weight: 700; } +.theme-grimoire .highlight .gs { font-weight: 700; } +.theme-grimoire .highlight .gu { color: #f08029; font-weight: 700; } +.theme-grimoire .highlight .gp { color: #c8a44d; } +.theme-grimoire .highlight .gt { color: #ff5a5f; } +.theme-grimoire .highlight .gl { color: #e9e0c8; } + +/* Line numbers */ +.theme-grimoire .highlight .lineno, +.theme-grimoire .highlight .gh.filename { + color: #6a4cab; + border-right: 1px solid rgba(200,164,77,0.25); + padding-right: .55em; + margin-right: .55em; + user-select: none; +} diff --git a/app/themes/grimoire/assets/grimoire.css b/app/themes/grimoire/assets/grimoire.css new file mode 100644 index 0000000..2b59836 --- /dev/null +++ b/app/themes/grimoire/assets/grimoire.css @@ -0,0 +1,754 @@ +/* ============================================================================= + Abbey – Grimoire Theme + Retro hacker dark fantasy. Phrack zine + illuminated manuscript + terminal + man page. Matrix-minimal: monospace display, modern sans body, no ornate + blackletter — but the dark mode still feels like a cryptarchive. + + Loaded only when `Rails.application.config.theme == "grimoire"`. All + declarations are scoped under `.theme-grimoire` (set on by + themes/grimoire/layouts/application.html.erb) so loading this file in + any other context is a no-op. + + Color/font/animation TOKENS live in `app/assets/tailwind/application.css` + under `@theme`, so Tailwind auto-generates `bg-grim-blood`, + `text-grim-gold`, `dark:border-grim-gold`, `hover:text-grim-ember`, + `font-blackletter`, `font-plex`, `text-[0.62rem]`, `tracking-[0.18em]`, + etc. The arbitrary-value classes and `dark:` / `hover:` / `group-hover:` + variants used across grimoire views all "just work" — no hand-rolled + `.theme-grimoire .bg-grim-X` declarations needed. + + This file ships: + 1. theme-root environmental styles (parchment grain, ember dust, + scanlines, custom scrollbar) + 2. font-family rebinding inside `.theme-grimoire` so `font-sans` / + `font-mono` resolve to the grimoire stack, and `font-display` / + `font-blackletter` / `font-engraved` / `font-plex` / `font-manuscript` + resolve to the right theme fonts + 3. component classes (`.tome`, `.spell-btn`, `.icon-rune`, `.wax-seal`, + `.h-blackletter`, `.h-engraved`, `.rune-divider`, `.cursor-blink`, + `.arc-link`, `.summoning-circle`, `.grim-marquee`, `.incant-overlay`) + 4. typography for the `.prose-grimoire` wrapper (matrix drop-cap, + terminal pre blocks, dotted-underline links, EOF rune
) + + Palette: parchment #ebd9b3 · vellum #f1e3c2 · ink #1a1410 · shadow #3a2f25 + void #07080d · obsidian #0e0f17 · tomb #161826 · ichor #2d1b3a + blood #8b1d27 · ember #f08029 · bone #e9e0c8 + phosphor #57f287 · arcane #6a4cab · gold #c8a44d +============================================================================= */ + +/* ---------------------------------------------------------------------- + Font-family rebinding via CSS variable cascade. + Tailwind generates `.font-X { font-family: var(--font-X) }` for every + font token declared in `@theme`. Redefining those variables on + `.theme-grimoire` makes `font-sans`, `font-mono`, `font-blackletter`, + `font-engraved`, `font-plex`, `font-manuscript` resolve to the grimoire + stack inside this theme — without affecting the default theme. +---------------------------------------------------------------------- */ + +.theme-grimoire { + --font-sans: "Inter", system-ui, -apple-system, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, monospace; + --font-display: "JetBrains Mono", ui-monospace, monospace; + --font-blackletter: "JetBrains Mono", ui-monospace, monospace; + --font-engraved: "JetBrains Mono", ui-monospace, monospace; + --font-plex: "IBM Plex Mono", ui-monospace, monospace; + --font-manuscript: "Inter", system-ui, -apple-system, sans-serif; +} + +/* ---------------------------------------------------------------------- + Base – html background, body backdrop, scanlines, selection, scrollbar +---------------------------------------------------------------------- */ + +html.theme-grimoire { + background-color: var(--color-grim-parchment); + color: var(--color-grim-ink); + font-family: var(--font-sans); + font-feature-settings: "ss01" on, "cv11" on, "kern", "liga"; +} +html.theme-grimoire.dark { + background-color: var(--color-grim-void); + color: var(--color-grim-bone); +} + +html.theme-grimoire body { + position: relative; + overflow-x: hidden; + min-height: 100vh; +} + +/* Aged parchment (light) — fibre grain + corner foxing */ +html.theme-grimoire body::before { + content: ""; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + opacity: 1; +} +html.theme-grimoire:not(.dark) body::before { + background: + radial-gradient(circle at 0% 0%, rgba(120,80,30,0.18), transparent 24%), + radial-gradient(circle at 100% 0%, rgba(120,80,30,0.18), transparent 24%), + radial-gradient(circle at 0% 100%, rgba(120,80,30,0.18), transparent 24%), + radial-gradient(circle at 100% 100%, rgba(120,80,30,0.18), transparent 24%), + url("data:image/svg+xml;utf8,"); + background-size: 100% 100%, 100% 100%, 100% 100%, 100% 100%, 600px 600px; + background-repeat: no-repeat, no-repeat, no-repeat, no-repeat, repeat; +} + +/* Starless void (dark) — drifting ember/arcane dust */ +html.theme-grimoire.dark body::before { + opacity: 0.9; + background: + radial-gradient(1200px 600px at 80% -10%, rgba(106,76,171,0.18), transparent 60%), + radial-gradient(900px 500px at 10% 110%, rgba(240,128,41,0.10), transparent 65%), + radial-gradient(2px 2px at 23% 16%, rgba(200,164,77,0.7), transparent 100%), + radial-gradient(1.5px 1.5px at 67% 44%, rgba(87,242,135,0.55), transparent 100%), + radial-gradient(2px 2px at 89% 72%, rgba(200,164,77,0.55), transparent 100%), + radial-gradient(1.5px 1.5px at 12% 78%, rgba(106,76,171,0.55), transparent 100%), + radial-gradient(1.5px 1.5px at 44% 22%, rgba(200,164,77,0.4), transparent 100%), + radial-gradient(1px 1px at 56% 88%, rgba(255,255,255,0.5), transparent 100%); + background-repeat: no-repeat; + animation: grimoire-mist 22s ease-in-out infinite; +} + +/* CRT scanlines — dark-mode only, gentle, preserves readability */ +html.theme-grimoire body::after { + content: ""; + position: fixed; + inset: 0; + z-index: 100; + pointer-events: none; + background-image: none; +} +html.theme-grimoire.dark body::after { + background-image: repeating-linear-gradient( + to bottom, + rgba(87, 242, 135, 0) 0px, + rgba(87, 242, 135, 0) 3px, + rgba(87, 242, 135, 0.018) 3px, + rgba(87, 242, 135, 0.018) 4px + ); + mix-blend-mode: screen; +} + +html.theme-grimoire main, +html.theme-grimoire header, +html.theme-grimoire footer, +html.theme-grimoire nav, +html.theme-grimoire .content-layer { + position: relative; + z-index: 1; +} + +html.theme-grimoire ::selection { background: var(--color-grim-blood); color: var(--color-grim-parchment); } +html.theme-grimoire.dark ::selection { background: var(--color-grim-ember); color: var(--color-grim-void); } + +/* Terminal-style dark scrollbar */ +html.theme-grimoire.dark ::-webkit-scrollbar { width: 12px; } +html.theme-grimoire.dark ::-webkit-scrollbar-track { background: var(--color-grim-void); } +html.theme-grimoire.dark ::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, var(--color-grim-arcane), var(--color-grim-ichor)); + border: 1px solid var(--color-grim-gold); +} +html.theme-grimoire.dark ::-webkit-scrollbar-thumb:hover { background: var(--color-grim-ember); } + +/* ---------------------------------------------------------------------- + Components +---------------------------------------------------------------------- */ + +/* TOME — the card. A weathered codex page (light) or obsidian slate (dark) + with a thin gold/ink double border + tiny sigils at opposite corners. */ +.theme-grimoire .tome { + position: relative; + background: + linear-gradient(180deg, rgba(255,250,235,0.92), rgba(235,217,179,0.92)), + url("data:image/svg+xml;utf8,"); + color: var(--color-grim-ink); + border: 1px solid var(--color-grim-ink); + box-shadow: + inset 0 0 0 6px var(--color-grim-parchment), + inset 0 0 0 7px var(--color-grim-gold), + 0 18px 36px -16px rgba(20,12,6,0.55); + padding: 1.75rem; + transition: transform 0.25s ease, box-shadow 0.25s ease; +} +.theme-grimoire .tome:hover { + transform: translateY(-2px); + box-shadow: + inset 0 0 0 6px var(--color-grim-parchment), + inset 0 0 0 7px var(--color-grim-gold), + 0 28px 48px -20px rgba(20,12,6,0.6); +} +.theme-grimoire.dark .tome { + background: + linear-gradient(180deg, #11131c, #0a0b12), + radial-gradient(120% 60% at 50% 0%, rgba(106,76,171,0.18), transparent 70%); + color: var(--color-grim-bone); + border-color: var(--color-grim-gold); + box-shadow: + inset 0 0 0 4px var(--color-grim-void), + inset 0 0 0 5px rgba(200,164,77,0.55), + inset 0 0 80px rgba(106,76,171,0.18), + 0 22px 50px -20px rgba(0,0,0,0.85); +} +.theme-grimoire.dark .tome:hover { + box-shadow: + inset 0 0 0 4px var(--color-grim-void), + inset 0 0 0 5px var(--color-grim-gold), + inset 0 0 90px rgba(240,128,41,0.18), + 0 28px 60px -22px rgba(0,0,0,0.95); +} +.theme-grimoire .tome::before, +.theme-grimoire .tome::after { + content: ""; + position: absolute; + width: 28px; + height: 28px; + background-repeat: no-repeat; + background-size: contain; + opacity: 0.85; + pointer-events: none; +} +.theme-grimoire .tome::before { + top: -14px; + left: -14px; + background-image: url("data:image/svg+xml;utf8,"); +} +.theme-grimoire .tome::after { + bottom: -14px; + right: -14px; + background-image: url("data:image/svg+xml;utf8,"); +} +.theme-grimoire.dark .tome::after { + background-image: url("data:image/svg+xml;utf8,"); +} + +/* SPELL BUTTON — bookish raised metal-and-paper button */ +.theme-grimoire .spell-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-family: var(--font-plex); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--color-grim-ink); + background: linear-gradient(180deg, #f4e6c0, #d8c08a); + border: 1px solid var(--color-grim-ink); + box-shadow: 0 1px 0 var(--color-grim-gold), 0 2px 0 var(--color-grim-ink), 0 4px 12px -2px rgba(20,12,6,0.4); + padding: 0.55rem 1rem; + transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.25s ease; + cursor: pointer; +} +.theme-grimoire .spell-btn:hover { + transform: translateY(-1px); + background: linear-gradient(180deg, #f8edc8, #e2cd9a); + box-shadow: 0 1px 0 var(--color-grim-gold), 0 4px 0 var(--color-grim-ink), 0 8px 16px -2px rgba(20,12,6,0.45); +} +.theme-grimoire .spell-btn:active { + transform: translateY(2px); + box-shadow: 0 1px 0 var(--color-grim-gold), 0 0 0 var(--color-grim-ink); +} +.theme-grimoire.dark .spell-btn { + color: var(--color-grim-parchment); + background: linear-gradient(180deg, #1a1d2a, var(--color-grim-obsidian)); + border-color: var(--color-grim-gold); + box-shadow: 0 1px 0 rgba(200,164,77,0.4), 0 2px 0 var(--color-grim-void), 0 0 18px rgba(240,128,41,0.18); +} +.theme-grimoire.dark .spell-btn:hover { + background: linear-gradient(180deg, #232636, #15172a); + box-shadow: 0 1px 0 var(--color-grim-gold), 0 4px 0 var(--color-grim-void), 0 0 24px rgba(240,128,41,0.45); + color: var(--color-grim-ember); +} +.theme-grimoire .spell-btn-blood { + background: linear-gradient(180deg, #b1262f, #7a161e); + color: var(--color-grim-parchment); + border-color: var(--color-grim-ink); +} +.theme-grimoire .spell-btn-blood:hover { background: linear-gradient(180deg, #c93340, #8a1b24); } +.theme-grimoire.dark .spell-btn-blood { background: linear-gradient(180deg, #7a161e, #4b0d12); color: var(--color-grim-parchment); } + +/* ICON RUNE — square icon button (theme toggle, RSS) */ +.theme-grimoire .icon-rune { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + background: linear-gradient(180deg, #f4e6c0, #d8c08a); + color: var(--color-grim-ink); + border: 1px solid var(--color-grim-ink); + box-shadow: 0 1px 0 var(--color-grim-gold), 0 2px 0 var(--color-grim-ink); + transition: all 0.15s ease; + cursor: pointer; +} +.theme-grimoire .icon-rune:hover { color: var(--color-grim-blood); transform: translateY(-1px); } +.theme-grimoire.dark .icon-rune { + background: linear-gradient(180deg, #1a1d2a, var(--color-grim-obsidian)); + color: var(--color-grim-gold); + border-color: var(--color-grim-gold); + box-shadow: 0 0 14px rgba(240,128,41,0.25); +} +.theme-grimoire.dark .icon-rune:hover { color: var(--color-grim-ember); box-shadow: 0 0 22px rgba(240,128,41,0.55); } + +/* WAX SEAL — tag pills */ +.theme-grimoire .wax-seal { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.25rem; + padding: 0.5rem 0.9rem; + font-family: var(--font-blackletter); + font-weight: 700; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #f6ead0; + background: + radial-gradient(circle at 30% 25%, rgba(255,255,255,0.35), transparent 50%), + var(--color-grim-blood); + border: 1px solid var(--color-grim-ink); + border-radius: 999px; + box-shadow: + inset 0 -2px 4px rgba(0,0,0,0.4), + inset 0 2px 2px rgba(255,255,255,0.15), + 0 4px 8px -2px rgba(20,12,6,0.45); + transform: rotate(-4deg); + transition: transform 0.2s ease; + text-shadow: 0 1px 0 rgba(0,0,0,0.35); +} +.theme-grimoire .wax-seal:hover { transform: rotate(0deg) scale(1.05); } +.theme-grimoire.dark .wax-seal { + border-color: var(--color-grim-gold); + box-shadow: + inset 0 -2px 4px rgba(0,0,0,0.55), + inset 0 2px 2px rgba(200,164,77,0.25), + 0 0 16px rgba(240,128,41,0.2); +} +.theme-grimoire .wax-seal-blood { background: radial-gradient(circle at 30% 25%, rgba(255,255,255,0.35), transparent 50%), var(--color-grim-blood); } +.theme-grimoire .wax-seal-arcane { background: radial-gradient(circle at 30% 25%, rgba(255,255,255,0.35), transparent 50%), var(--color-grim-arcane); } +.theme-grimoire .wax-seal-ember { background: radial-gradient(circle at 30% 25%, rgba(255,255,255,0.35), transparent 50%), #b65a13; color: #fff3d8; } +.theme-grimoire .wax-seal-phosphor { background: radial-gradient(circle at 30% 25%, rgba(255,255,255,0.35), transparent 50%), #2aa758; } +.theme-grimoire .wax-seal-gold { background: radial-gradient(circle at 30% 25%, rgba(255,255,255,0.4), transparent 50%), var(--color-grim-gold); color: var(--color-grim-ink); text-shadow: 0 1px 0 rgba(255,255,255,0.25); } +.theme-grimoire .wax-seal-ichor { background: radial-gradient(circle at 30% 25%, rgba(255,255,255,0.3), transparent 50%), var(--color-grim-ichor); } + +/* HEADINGS — Matrix-minimal monospace, no ornate text-shadow */ +.theme-grimoire .h-blackletter { + font-family: var(--font-blackletter); + font-weight: 800; + color: var(--color-grim-blood); + line-height: 1.0; + letter-spacing: -0.04em; + text-transform: uppercase; + font-feature-settings: "ss02" on, "calt" on; +} +.theme-grimoire.dark .h-blackletter { + color: var(--color-grim-gold); + text-shadow: 0 0 10px rgba(200,164,77,0.35), 0 0 28px rgba(240,128,41,0.12); +} +.theme-grimoire .h-engraved { + font-family: var(--font-engraved); + font-weight: 500; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--color-grim-shadow); +} +.theme-grimoire.dark .h-engraved { color: var(--color-grim-gold); } + +/* RUNE DIVIDER — horizontal rule with center label */ +.theme-grimoire .rune-divider { + display: flex; + align-items: center; + gap: 0.9rem; + color: var(--color-grim-blood); + font-family: var(--font-blackletter); + font-size: 0.72rem; + letter-spacing: 0.4em; + text-transform: uppercase; + margin: 1.6rem 0; +} +.theme-grimoire .rune-divider::before, +.theme-grimoire .rune-divider::after { + content: ""; + flex: 1; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(139,29,39,0.6), transparent); +} +.theme-grimoire.dark .rune-divider { color: var(--color-grim-gold); } +.theme-grimoire.dark .rune-divider::before, +.theme-grimoire.dark .rune-divider::after { + background: linear-gradient(90deg, transparent, rgba(200,164,77,0.6), transparent); +} + +/* BLINKING TERMINAL CURSOR */ +.theme-grimoire .cursor-blink::after { + content: "▮"; + display: inline-block; + margin-left: 0.35rem; + color: var(--color-grim-ember); + animation: var(--animate-blink); +} +.theme-grimoire.dark .cursor-blink::after { color: var(--color-grim-phosphor); } + +/* ARC LINK — subtle inline link styling */ +.theme-grimoire .arc-link { + color: var(--color-grim-blood); + text-decoration: underline; + text-decoration-style: dotted; + text-decoration-thickness: 1px; + text-underline-offset: 3px; + transition: color 0.15s ease; +} +.theme-grimoire .arc-link:hover { color: var(--color-grim-ember); text-decoration-style: solid; } +.theme-grimoire.dark .arc-link { color: var(--color-grim-gold); } +.theme-grimoire.dark .arc-link:hover { color: var(--color-grim-ember); } + +/* SUMMONING CIRCLE — decorative SVG container */ +.theme-grimoire .summoning-circle { + position: relative; + width: 11rem; + height: 11rem; + margin: 0 auto; +} +.theme-grimoire .summoning-circle svg { width: 100%; height: 100%; } +.theme-grimoire .summoning-circle .ring-outer { + animation: grimoire-sigil-spin 42s linear infinite; + transform-origin: center; +} +.theme-grimoire .summoning-circle .ring-inner { + animation: grimoire-sigil-spin 56s linear infinite reverse; + transform-origin: center; +} + +/* MARQUEE — footer scrolling band */ +.theme-grimoire .grim-marquee { + overflow: hidden; + white-space: nowrap; + background: linear-gradient(180deg, var(--color-grim-void), var(--color-grim-obsidian)); + color: var(--color-grim-gold); + border-top: 1px solid var(--color-grim-gold); + border-bottom: 1px solid var(--color-grim-gold); + padding: 0.55rem 0; + font-family: var(--font-blackletter); + font-weight: 500; + font-size: 0.74rem; + letter-spacing: 0.22em; + text-transform: uppercase; +} +.theme-grimoire .grim-marquee__track { + display: inline-block; + animation: grimoire-marquee 38s linear infinite; +} +.theme-grimoire .grim-marquee__token { display: inline-block; padding: 0 1.5rem; } +.theme-grimoire .grim-marquee__token span { color: var(--color-grim-ember); padding-right: 1.5rem; } + +/* INCANT OVERLAY — Konami code easter egg */ +.theme-grimoire .incant-overlay { + position: fixed; + inset: 0; + background: radial-gradient(circle, rgba(7,8,13,0.85), rgba(7,8,13,0.98)); + color: var(--color-grim-gold); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 9999; + font-family: var(--font-blackletter); + text-align: center; + animation: grimoire-incant 1.2s ease-out forwards; +} +.theme-grimoire .incant-overlay .incant-title { + font-family: var(--font-blackletter); + font-weight: 800; + letter-spacing: -0.02em; + font-size: clamp(2.5rem, 9vw, 6rem); + color: var(--color-grim-ember); + text-shadow: 0 0 24px rgba(240,128,41,0.65), 0 0 60px rgba(240,128,41,0.35); +} +.theme-grimoire .incant-overlay .incant-sub { + font-family: var(--font-plex); + color: var(--color-grim-phosphor); + letter-spacing: 0.4em; + margin-top: 1rem; + text-transform: uppercase; +} + +/* ---------------------------------------------------------------------- + Rendered markdown content (`.prose-grimoire` wrapper) +---------------------------------------------------------------------- */ + +.theme-grimoire .prose-grimoire { + font-family: var(--font-sans); + font-size: 1.05rem; + line-height: 1.72; + color: var(--color-grim-ink); + font-feature-settings: "ss01" on, "cv11" on, "kern", "liga"; +} +.theme-grimoire.dark .prose-grimoire { color: var(--color-grim-bone); } + +/* Matrix drop cap — chunky monospace block letter, thin underline */ +.theme-grimoire .prose-grimoire > p:first-of-type::first-letter { + font-family: var(--font-blackletter); + font-weight: 800; + float: left; + font-size: 4.4rem; + line-height: 0.95; + padding: 0.15rem 0.55rem 0.15rem 0; + margin-right: 0.25rem; + color: var(--color-grim-blood); + border-bottom: 2px solid currentColor; +} +.theme-grimoire.dark .prose-grimoire > p:first-of-type::first-letter { + color: var(--color-grim-phosphor); + text-shadow: 0 0 10px rgba(87,242,135,0.45), 0 0 24px rgba(87,242,135,0.18); + border-bottom-color: rgba(87,242,135,0.55); +} + +.theme-grimoire .prose-grimoire h1, +.theme-grimoire .prose-grimoire h2, +.theme-grimoire .prose-grimoire h3 { + font-family: var(--font-blackletter); + font-weight: 700; + margin-top: 2em; + margin-bottom: 0.6em; + letter-spacing: -0.02em; + color: var(--color-grim-shadow); + text-transform: uppercase; +} +.theme-grimoire.dark .prose-grimoire h1, +.theme-grimoire.dark .prose-grimoire h2, +.theme-grimoire.dark .prose-grimoire h3 { color: var(--color-grim-gold); } +.theme-grimoire .prose-grimoire h1 { + font-size: 1.65rem; + border-bottom: 1px solid var(--color-grim-blood); + padding-bottom: 0.35em; +} +.theme-grimoire .prose-grimoire h2 { font-size: 1.35rem; color: var(--color-grim-blood); } +.theme-grimoire .prose-grimoire h3 { font-size: 1.1rem; } +.theme-grimoire.dark .prose-grimoire h1 { border-bottom-color: var(--color-grim-gold); } +.theme-grimoire.dark .prose-grimoire h2 { color: var(--color-grim-ember); } + +.theme-grimoire .prose-grimoire p { margin-bottom: 1.1em; } + +.theme-grimoire .prose-grimoire strong { color: var(--color-grim-blood); font-weight: 800; } +.theme-grimoire.dark .prose-grimoire strong { color: var(--color-grim-ember); } + +.theme-grimoire .prose-grimoire em { font-style: italic; color: var(--color-grim-arcane); } +.theme-grimoire.dark .prose-grimoire em { color: var(--color-grim-gold); } + +.theme-grimoire .prose-grimoire a { + color: var(--color-grim-blood); + text-decoration: underline; + text-decoration-style: dotted; + text-decoration-thickness: 1px; + text-underline-offset: 3px; +} +.theme-grimoire .prose-grimoire a:hover { color: var(--color-grim-ember); text-decoration-style: solid; } +.theme-grimoire.dark .prose-grimoire a { color: var(--color-grim-gold); } +.theme-grimoire.dark .prose-grimoire a:hover { color: var(--color-grim-ember); } + +.theme-grimoire .prose-grimoire ul, +.theme-grimoire .prose-grimoire ol { + margin: 1em 0 1.2em 1.5em; + padding-left: 0.5em; +} +.theme-grimoire .prose-grimoire ul { list-style: none; } +.theme-grimoire .prose-grimoire ul > li::before { + content: "✦"; + color: var(--color-grim-blood); + font-weight: 700; + margin-right: 0.55em; + display: inline-block; + transform: translateY(-1px); +} +.theme-grimoire.dark .prose-grimoire ul > li::before { color: var(--color-grim-gold); } +.theme-grimoire .prose-grimoire ol { list-style: decimal; } +.theme-grimoire .prose-grimoire ol::marker { + color: var(--color-grim-blood); + font-family: var(--font-blackletter); + font-weight: 700; +} +.theme-grimoire .prose-grimoire li { margin-bottom: 0.4em; } + +.theme-grimoire .prose-grimoire blockquote { + position: relative; + border-left: 2px solid var(--color-grim-blood); + background: rgba(200,164,77,0.10); + padding: 1em 1.2em 1em 2.4em; + margin: 1.5em 0; + font-style: italic; + color: var(--color-grim-shadow); +} +.theme-grimoire .prose-grimoire blockquote::before { + content: ">"; + position: absolute; + left: 0.7em; + top: 1em; + font-family: var(--font-blackletter); + font-weight: 700; + font-style: normal; + font-size: 1.05em; + color: var(--color-grim-blood); + line-height: 1; +} +.theme-grimoire.dark .prose-grimoire blockquote { + border-left-color: var(--color-grim-gold); + background: rgba(106,76,171,0.10); + color: var(--color-grim-bone); +} +.theme-grimoire.dark .prose-grimoire blockquote::before { color: var(--color-grim-gold); } + +/* Code blocks — wizard's terminal (phosphor green on void) */ +.theme-grimoire .prose-grimoire pre, +.theme-grimoire pre.highlight { + background: var(--color-grim-void) !important; + color: var(--color-grim-phosphor) !important; + border: 1px solid var(--color-grim-gold); + box-shadow: + inset 0 0 80px rgba(106,76,171,0.18), + 0 0 22px rgba(87,242,135,0.18), + 0 18px 40px -18px rgba(0,0,0,0.7); + padding: 1rem 1.1rem; + margin: 1.5em 0; + overflow-x: auto; + font-family: var(--font-plex); + font-size: 0.92rem; + line-height: 1.55; + position: relative; +} +.theme-grimoire .prose-grimoire pre::before, +.theme-grimoire pre.highlight::before { + content: "▒▓ ~/grimoire/spells $ cast"; + display: block; + font-family: var(--font-blackletter); + font-weight: 500; + font-size: 0.66rem; + color: var(--color-grim-gold); + letter-spacing: 0.12em; + text-transform: uppercase; + margin: -1rem -1.1rem 0.8rem; + padding: 0.55rem 0.9rem; + background: var(--color-grim-obsidian); + border-bottom: 1px solid var(--color-grim-gold); +} +.theme-grimoire .prose-grimoire pre::after, +.theme-grimoire pre.highlight::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background-image: repeating-linear-gradient( + to bottom, + transparent 0 3px, + rgba(87,242,135,0.04) 3px 4px + ); +} + +.theme-grimoire .prose-grimoire code, +.theme-grimoire code:not(pre code) { + font-family: var(--font-plex); + background: rgba(139,29,39,0.10); + color: var(--color-grim-blood); + padding: 0 6px; + border: 1px solid rgba(139,29,39,0.35); + font-size: 0.92em; + border-radius: 3px; +} +.theme-grimoire.dark .prose-grimoire code, +.theme-grimoire.dark code:not(pre code) { + background: rgba(200,164,77,0.10); + color: var(--color-grim-gold); + border-color: rgba(200,164,77,0.4); +} +.theme-grimoire .prose-grimoire pre code, +.theme-grimoire pre.highlight code { + background: transparent !important; + color: inherit !important; + border: none !important; + padding: 0 !important; + font-size: inherit; +} + +.theme-grimoire .prose-grimoire hr { + border: none; + margin: 2.2em 0; + height: 14px; + background-image: url("data:image/svg+xml;utf8,// EOF //"); + background-repeat: no-repeat; + background-position: center; +} +.theme-grimoire.dark .prose-grimoire hr { + background-image: url("data:image/svg+xml;utf8,// EOF //"); +} + +.theme-grimoire .prose-grimoire img { + border: 1px solid var(--color-grim-ink); + box-shadow: + inset 0 0 0 4px var(--color-grim-parchment), + inset 0 0 0 5px var(--color-grim-gold), + 0 18px 36px -16px rgba(20,12,6,0.55); + margin: 1.5em 0; +} +.theme-grimoire.dark .prose-grimoire img { + border-color: var(--color-grim-gold); + box-shadow: + inset 0 0 0 4px var(--color-grim-void), + inset 0 0 0 5px var(--color-grim-gold), + 0 18px 36px -16px rgba(0,0,0,0.85); +} + +.theme-grimoire .prose-grimoire table { + width: 100%; + border-collapse: collapse; + margin: 1.4em 0; + font-family: var(--font-blackletter); + font-size: 0.86rem; +} +.theme-grimoire .prose-grimoire th, +.theme-grimoire .prose-grimoire td { + border: 1px solid var(--color-grim-ink); + padding: 0.55rem 0.8rem; + text-align: left; +} +.theme-grimoire .prose-grimoire th { + background: var(--color-grim-gold); + color: var(--color-grim-ink); + text-transform: uppercase; + letter-spacing: 0.12em; + font-weight: 700; +} +.theme-grimoire.dark .prose-grimoire th { + background: var(--color-grim-ichor); + color: var(--color-grim-gold); + border-color: var(--color-grim-gold); +} +.theme-grimoire.dark .prose-grimoire td { border-color: rgba(200,164,77,0.4); } + +/* ---------------------------------------------------------------------- + Keyframes that are theme-local. The shared `theme-blink` keyframe + (registered in @theme as `--animate-blink`) is also available via the + `animate-blink` utility, but `.cursor-blink` and others reference + `var(--animate-blink)` directly to keep this file self-contained. +---------------------------------------------------------------------- */ + +@keyframes grimoire-marquee { + 0% { transform: translateX(0); } + 100% { transform: translateX(-50%); } +} + +@keyframes grimoire-sigil-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +@keyframes grimoire-incant { + 0% { opacity: 0; transform: translateY(-12px) scale(0.9); filter: blur(8px); } + 60% { opacity: 1; filter: blur(0); } + 100% { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes grimoire-mist { + 0%, 100% { transform: translateX(-2%) translateY(0); } + 50% { transform: translateX(2%) translateY(-1%); } +} diff --git a/app/themes/grimoire/assets/tailwind.css b/app/themes/grimoire/assets/tailwind.css new file mode 100644 index 0000000..2b56b60 --- /dev/null +++ b/app/themes/grimoire/assets/tailwind.css @@ -0,0 +1,60 @@ +/* ============================================================================= + Grimoire theme Tailwind entry point. + + Compiled by `bin/rails tailwindcss:build` into + `app/assets/builds/tailwind-grimoire.css` and loaded only when + ABBEY_THEME=grimoire is active (via app/helpers/application_helper.rb# + theme_stylesheets). Self-contained: the default Abbey bundle never + sees these tokens or scans these views. +============================================================================= */ + +@import 'tailwindcss'; + +@custom-variant dark (&:where(.dark, .dark *)); + +@config '../../../../config/tailwind.config.js'; + +@source "../views"; + +@plugin "@tailwindcss/typography"; + +@theme { + /* ---------- Grimoire palette ---------- */ + --color-grim-parchment: #ebd9b3; + --color-grim-vellum: #f1e3c2; + --color-grim-ink: #1a1410; + --color-grim-shadow: #3a2f25; + --color-grim-void: #07080d; + --color-grim-obsidian: #0e0f17; + --color-grim-tomb: #161826; + --color-grim-ichor: #2d1b3a; + --color-grim-blood: #8b1d27; + --color-grim-ember: #f08029; + --color-grim-bone: #e9e0c8; + --color-grim-phosphor: #57f287; + --color-grim-arcane: #6a4cab; + --color-grim-gold: #c8a44d; + + /* ---------- Theme font families ---------- + Default values are inert system stacks; .theme-grimoire rebinds + them to the Google Fonts loaded in the layout (Inter, JetBrains + Mono, IBM Plex Mono). See grimoire.css for the rebinding. */ + --font-display: ui-sans-serif, system-ui, sans-serif; + --font-blackletter: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + --font-engraved: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + --font-plex: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + --font-manuscript: ui-sans-serif, system-ui, sans-serif; + + /* ---------- Theme animations ---------- */ + --animate-blink: theme-blink 1s steps(2, start) infinite; + --animate-pop-in: theme-pop-in 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) both; + + @keyframes theme-blink { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } + } + @keyframes theme-pop-in { + 0% { opacity: 0; transform: translateY(8px) scale(0.96); } + 100% { opacity: 1; transform: translateY(0) scale(1); } + } +} diff --git a/app/themes/grimoire/theme.rb b/app/themes/grimoire/theme.rb new file mode 100644 index 0000000..1a6db42 --- /dev/null +++ b/app/themes/grimoire/theme.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +Abbey::Theme.register(:grimoire) do |t| + t.display_name = "Grimoire (Retro hacker dark fantasy)" + t.html_class = "theme-grimoire" + t.body_class = "min-h-screen flex flex-col bg-grim-parchment dark:bg-grim-void" + t.main_class = "container mx-auto px-4 py-10 max-w-5xl content-layer flex-1" + t.markdown_renderer = :minimal + t.theme_color_light = "#ebd9b3" + t.theme_color_dark = "#07080d" + + t.fonts = [ + "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700;800&family=IBM+Plex+Mono:wght@400;500;700&display=swap" + ] + + # Pixel-runic favicon: pentagram-circle with an ember. + t.favicon_svg = <<~SVG.strip + + + + + + + SVG +end diff --git a/app/themes/grimoire/views/blog/index.html.erb b/app/themes/grimoire/views/blog/index.html.erb new file mode 100644 index 0000000..c8c01ef --- /dev/null +++ b/app/themes/grimoire/views/blog/index.html.erb @@ -0,0 +1,56 @@ +<% + # Roman numeral helper (small range — fine for year stickers since 1900–2099). + to_roman = ->(num) { + map = { 1000 => 'M', 900 => 'CM', 500 => 'D', 400 => 'CD', + 100 => 'C', 90 => 'XC', 50 => 'L', 40 => 'XL', + 10 => 'X', 9 => 'IX', 5 => 'V', 4 => 'IV', 1 => 'I' } + result = +"" + map.each { |val, sym| while num >= val; result << sym; num -= val; end } + result + } +%> + +
+ <% @posts.each do |post| %> +
+ <%# Folio · Anno date sticker (Roman numerals for years) %> +
+ Folio · Anno <%= to_roman.call(post.created_at.year) %> +
+ +

+ <%= link_to post.title, dated_post_path(year: post.year, day: post.day, month: post.month, id: post), + class: "arc-link" %> +

+ +

+ // <%= post.created_at.strftime('%Y-%m-%d · %a').downcase %> +

+ +
+ <%= post.rendered_excerpt.html_safe %> +
+ +
+ <%= render "shared/tags", post: post %> +
+ +
+ <% if authenticated? %> + <%= link_to "edit", edit_post_path(post), class: "font-plex text-xs tracking-[0.14em] uppercase text-grim-shadow/70 dark:text-grim-gold/70 hover:text-grim-blood dark:hover:text-grim-ember" %> + · + <% end %> + <%= link_to dated_post_path(year: post.year, day: post.day, month: post.month, id: post), + class: "spell-btn" do %> + ✦ open spellbook + <% end %> +
+
+ <% end %> + +
// LOG //
+ +
+ <%= paginate @posts %> +
+
diff --git a/app/themes/grimoire/views/blog/index_by_tag.html.erb b/app/themes/grimoire/views/blog/index_by_tag.html.erb new file mode 100644 index 0000000..d7ef060 --- /dev/null +++ b/app/themes/grimoire/views/blog/index_by_tag.html.erb @@ -0,0 +1,78 @@ +<% content_for :title do %> + #<%= @tag.name %> | <%= Rails.application.config.site_name %> +<% end %> + +<% content_for :rss do %> + <%= auto_discovery_link_tag(:atom, tag_feed_path(id: @tag.name)) %> +<% end %> + +<% content_for :rss_button do %> + <%= link_to tag_feed_path(id: @tag.name), class: "icon-rune", aria: { label: "RSS Feed" } do %> + <%= render "shared/icons/rss" %> + <% end %> +<% end %> + +<% + to_roman = ->(num) { + map = { 1000 => 'M', 900 => 'CM', 500 => 'D', 400 => 'CD', + 100 => 'C', 90 => 'XC', 50 => 'L', 40 => 'XL', + 10 => 'X', 9 => 'IX', 5 => 'V', 4 => 'IV', 1 => 'I' } + result = +"" + map.each { |val, sym| while num >= val; result << sym; num -= val; end } + result + } +%> + +
+
+

+ // filter · Anno <%= to_roman.call(Date.current.year) %> +

+

+ #<%= @tag.name %> +

+

+ ✦ inscription bound to this rune · <%= pluralize(@posts.total_count, 'transmission') %> +

+
+ +
// FILTER //
+ + <% @posts.each do |post| %> +
+
+ Folio · Anno <%= to_roman.call(post.created_at.year) %> +
+ +

+ <%= link_to post.title, dated_post_path(year: post.year, day: post.day, month: post.month, id: post), + class: "arc-link" %> +

+ +

+ // <%= post.created_at.strftime('%Y-%m-%d') %> +

+ +
+ <%= post.rendered_excerpt.html_safe %> +
+ +
+ <%= render "shared/tags", post: post %> +
+ +
+ <% if authenticated? %> + <%= link_to "edit", edit_post_path(post), class: "font-plex text-xs tracking-[0.14em] uppercase text-grim-shadow/70 dark:text-grim-gold/70 hover:text-grim-blood dark:hover:text-grim-ember" %> + · + <% end %> + <%= link_to dated_post_path(year: post.year, day: post.day, month: post.month, id: post), + class: "spell-btn" do %> + ✦ open spellbook + <% end %> +
+
+ <% end %> + +
<%= paginate @posts %>
+
diff --git a/app/themes/grimoire/views/blog/show.html.erb b/app/themes/grimoire/views/blog/show.html.erb new file mode 100644 index 0000000..31247aa --- /dev/null +++ b/app/themes/grimoire/views/blog/show.html.erb @@ -0,0 +1,56 @@ +<% content_for :title do %> +<%= @post.title %> | <%= Rails.application.config.site_name %> +<% end %> + +<% + to_roman = ->(num) { + map = { 1000 => 'M', 900 => 'CM', 500 => 'D', 400 => 'CD', + 100 => 'C', 90 => 'XC', 50 => 'L', 40 => 'XL', + 10 => 'X', 9 => 'IX', 5 => 'V', 4 => 'IV', 1 => 'I' } + result = +"" + map.each { |val, sym| while num >= val; result << sym; num -= val; end } + result + } +%> + +<%= link_to posts_path, class: "inline-flex items-center gap-2 font-plex text-xs tracking-[0.18em] uppercase text-grim-shadow/70 dark:text-grim-gold/70 hover:text-grim-blood dark:hover:text-grim-ember mb-6" do %> + Back to posts +<% end %> + +
+
+ Folio · Anno <%= to_roman.call(@post.created_at.year) %> +
+ +
+

+ <%= @post.title %> +

+ +
+ + <% if authenticated? %> + + <%= link_to "edit ▸", edit_post_path(@post), + class: "text-grim-blood dark:text-grim-ember hover:underline" %> + <% end %> +
+
+ +
+ <%= @post.rendered_body.html_safe %> +
+ +
// EOF //
+ +

+ exit 0 · end of transmission +

+ +
+

// tagged_with

+
+ <%= render "shared/tags", post: @post %> +
+
+
diff --git a/app/themes/grimoire/views/layouts/application.html.erb b/app/themes/grimoire/views/layouts/application.html.erb new file mode 100644 index 0000000..6195b58 --- /dev/null +++ b/app/themes/grimoire/views/layouts/application.html.erb @@ -0,0 +1,3 @@ +<%= render "layouts/abbey_chrome" do %> + <%= yield %> +<% end %> diff --git a/app/themes/grimoire/views/links/_link.html.erb b/app/themes/grimoire/views/links/_link.html.erb new file mode 100644 index 0000000..fffb39b --- /dev/null +++ b/app/themes/grimoire/views/links/_link.html.erb @@ -0,0 +1,34 @@ +<%= link_to link.url, + rel: "nofollow", + class: "block tome group" do %> +
+
+

+ + <%= link.title %> + <%= render "shared/icons/external_link", class: "w-3.5 h-3.5 opacity-60" %> +

+ +
+ <% if link.description.present? %> +

+ <%= link.description %> +

+ <% end %> +
+<% end %> +<% if authenticated? %> +
+ <%= link_to edit_link_path(link), class: "icon-rune" do %> + <%= render "shared/icons/pencil", class: "w-4 h-4" %> + <% end %> + <%= button_to link_path(link), + method: :delete, + class: "icon-rune", + form: { data: { turbo_confirm: "Are you sure?" } } do %> + <%= render "shared/icons/trash", class: "w-4 h-4" %> + <% end %> +
+<% end %> diff --git a/app/themes/grimoire/views/links/index.html.erb b/app/themes/grimoire/views/links/index.html.erb new file mode 100644 index 0000000..f3cb73d --- /dev/null +++ b/app/themes/grimoire/views/links/index.html.erb @@ -0,0 +1,34 @@ +<% content_for :rss do %> + <%= auto_discovery_link_tag(:atom, links_feed_path) %> +<% end %> + +<% content_for :rss_button do %> + <%= link_to links_feed_path, class: "icon-rune", aria: { label: "RSS Feed" } do %> + <%= render "shared/icons/rss" %> + <% end %> +<% end %> + +<% content_for :title do %> +Forbidden Tomes | <%= Rails.application.config.site_name %> +<% end %> + +
+
+

+ // reliquary +

+

+ Forbidden Tomes +

+

+ ✦ links worth your wax candle ✦ +

+
+ +
// BOOKMARKS //
+ +
+ <%= render @links %> +
+
<%= paginate @links %>
+
diff --git a/app/themes/grimoire/views/pages/show.html.erb b/app/themes/grimoire/views/pages/show.html.erb new file mode 100644 index 0000000..5f82116 --- /dev/null +++ b/app/themes/grimoire/views/pages/show.html.erb @@ -0,0 +1,31 @@ +<% content_for :title do %> +<%= @page.title %> | <%= Rails.application.config.site_name %> +<% end %> + +
+
+ ✦ Chapter · <%= @page.slug %> +
+ +
+

+ <%= @page.title %> +

+ <% if authenticated? %> +
+ <%= link_to "edit ▸", edit_page_path(@page), + class: "text-grim-blood dark:text-grim-ember hover:underline" %> +
+ <% end %> +
+ +
+ <%= @page.rendered_body.html_safe %> +
+ +
// EOF //
+ +

+ exit 0 +

+
diff --git a/app/themes/grimoire/views/papers/_paper.html.erb b/app/themes/grimoire/views/papers/_paper.html.erb new file mode 100644 index 0000000..4df35fa --- /dev/null +++ b/app/themes/grimoire/views/papers/_paper.html.erb @@ -0,0 +1,57 @@ +
+
+ Codex · PDF +
+
+ <% if paper.pdf.attached? && paper.pdf.previewable? %> +
+ <% if paper.arxiv? %> + <%= link_to paper.arxiv_pdf_url, target: "_blank", rel: "noopener", data: { turbo: false } do %> + <%= image_tag paper.pdf.preview(resize_to_fit: [120, 120]), class: "w-28 h-28 object-cover border border-grim-shadow dark:border-grim-gold" %> + <% end %> + <% else %> + <%= link_to paper_view_path(paper), target: (paper_view_path(paper) == paper.url ? "_blank" : nil), data: { turbo: false } do %> + <%= image_tag paper.pdf.preview(resize_to_fit: [120, 120]), class: "w-28 h-28 object-cover border border-grim-shadow dark:border-grim-gold" %> + <% end %> + <% end %> +
+ <% end %> + +
+

+ + <% if paper.arxiv? %> + <%= link_to paper.arxiv_pdf_url, target: "_blank", rel: "noopener", class: "arc-link flex items-center gap-2", data: { turbo: false } do %> + <%= paper.title %> + <%= render "shared/icons/external_link", class: "w-3.5 h-3.5 opacity-60" %> + <% end %> + <% else %> + <%= link_to paper.title, paper_view_path(paper), target: (paper_view_path(paper) == paper.url ? "_blank" : nil), class: "arc-link flex items-center gap-2", data: { turbo: false } %> + <% end %> +

+ + <% if paper.description.present? %> +

+ <%= paper.description %> +

+ <% end %> + +
+ // bound <%= paper.created_at.strftime("%Y-%m-%d") %> +
+
+
+
+<% if authenticated? %> +
+ <%= link_to edit_paper_path(paper), class: "icon-rune" do %> + <%= render "shared/icons/pencil", class: "w-4 h-4" %> + <% end %> + <%= button_to paper_path(paper), + method: :delete, + class: "icon-rune", + form: { data: { turbo_confirm: "Are you sure?" } } do %> + <%= render "shared/icons/trash", class: "w-4 h-4" %> + <% end %> +
+<% end %> diff --git a/app/themes/grimoire/views/papers/index.html.erb b/app/themes/grimoire/views/papers/index.html.erb new file mode 100644 index 0000000..7a36c16 --- /dev/null +++ b/app/themes/grimoire/views/papers/index.html.erb @@ -0,0 +1,45 @@ +<% content_for :title do %> +Treatises | <%= Rails.application.config.site_name %> +<% end %> + +
+
+
+
+

+ // codices & treatises +

+

+ Treatises +

+

+ ✦ arxiv printouts & pdf rabbit holes +

+
+ <% if @papers.any? && authenticated? %> + <%= link_to "+ inscribe paper", new_link_path, class: "spell-btn spell-btn-blood" %> + <% end %> +
+
+ + <% if @papers.any? %> +
// FOLIO //
+ +
+ <%= render @papers %> +
+ +
+ <%= paginate @papers %> +
+ <% else %> +
+

+ // no folios in this archive yet +

+ <% if authenticated? %> + <%= link_to "+ inscribe first paper", new_link_path, class: "spell-btn spell-btn-blood" %> + <% end %> +
+ <% end %> +
diff --git a/app/themes/grimoire/views/shared/_admin_navigation.html.erb b/app/themes/grimoire/views/shared/_admin_navigation.html.erb new file mode 100644 index 0000000..0224e4c --- /dev/null +++ b/app/themes/grimoire/views/shared/_admin_navigation.html.erb @@ -0,0 +1,24 @@ +<% if authenticated? %> +
+
+
+
+ adept@grimoire $ + <%= link_to ":transcribe", new_post_path, class: "text-grim-gold hover:text-grim-ember transition-colors" %> + <%= link_to ":inscribe", new_page_path, class: "text-grim-gold hover:text-grim-ember transition-colors" %> + <%= link_to ":bind", new_link_path, class: "text-grim-gold hover:text-grim-ember transition-colors" %> + <%= link_to ":auguries", feeds_path, class: "text-grim-phosphor hover:text-grim-ember transition-colors" %> + <%= link_to ":scry", feed_posts_path, class: "text-grim-phosphor hover:text-grim-ember transition-colors" %> +
+
+ + <%= button_to session_path, + method: :delete, + class: "text-grim-gold hover:text-grim-ember transition-colors" do %> + :depart + <% end %> +
+
+
+
+<% end %> diff --git a/app/themes/grimoire/views/shared/_dark_mode_script.html.erb b/app/themes/grimoire/views/shared/_dark_mode_script.html.erb new file mode 100644 index 0000000..9c698bd --- /dev/null +++ b/app/themes/grimoire/views/shared/_dark_mode_script.html.erb @@ -0,0 +1,40 @@ +<%# Grimoire override: shared Turbo-safe dark mode + Konami easter egg. %> +<%= render "shared/dark_mode_script_core", theme: Abbey::Theme.active %> + diff --git a/app/themes/grimoire/views/shared/_footer.html.erb b/app/themes/grimoire/views/shared/_footer.html.erb new file mode 100644 index 0000000..9dc9797 --- /dev/null +++ b/app/themes/grimoire/views/shared/_footer.html.erb @@ -0,0 +1,67 @@ +<%# Footer: summoning circle on top, scrolling marquee, terminal copyright. %> + +
+
+ + <%# Summoning circle: counter-rotating rings, runes, pentagram, center sigil %> +
+ +
+ +

+ // summoned by Rails & markdown // +

+
+ + <%# Scrolling marquee — duplicated track for seamless wrap %> +
+
+ <% 2.times do %> + +++ STDERR : INK STILL DRYING · + +++ HEAP : 0xDEADBEEF · + +++ TAIL -f /var/log/grimoire · + +++ KEY : ↑↑↓↓←→←→BA · + +++ MEM : 64K SHOULD BE ENOUGH · + +++ SIG : RING ZERO ENGAGED · + <% end %> +
+
+ + <%# Terminal copyright bar %> +
+ root@grimoire + : + ~ + $ + echo "© <%= Date.current.year %> <%= Rails.application.config.site_name %> · all rites reserved" + +
+
diff --git a/app/themes/grimoire/views/shared/_navigation.html.erb b/app/themes/grimoire/views/shared/_navigation.html.erb new file mode 100644 index 0000000..39556b7 --- /dev/null +++ b/app/themes/grimoire/views/shared/_navigation.html.erb @@ -0,0 +1,60 @@ +
+
+ +
+ <%= link_to root_path, class: "group inline-block" do %> + <%# Terminal masthead: $ cat ./codex_of %> +
+ root@grimoire + : + ~ + $ + cat ./codex_of +
+

+ <%= Rails.application.config.site_name %> +

+

+ // field notes from over 20 years on the web +

+ <% end %> + +
+ <% if content_for? :rss_button %> + <%= yield :rss_button %> + <% else %> + <%= link_to blog_feed_path, class: "icon-rune", aria: { label: "RSS Feed" } do %> + <%= render "shared/icons/rss" %> + <% end %> + <% end %> + +
+
+ + <%# Roman-numeral table-of-contents nav %> +
// NAV //
+ +
+
diff --git a/app/themes/grimoire/views/shared/_tags.html.erb b/app/themes/grimoire/views/shared/_tags.html.erb new file mode 100644 index 0000000..a5232ce --- /dev/null +++ b/app/themes/grimoire/views/shared/_tags.html.erb @@ -0,0 +1,14 @@ +<% + seal_palette = %w[ + wax-seal-blood + wax-seal-arcane + wax-seal-ember + wax-seal-phosphor + wax-seal-gold + wax-seal-ichor + ] +%> +<% post.tags.each do |tag| %> + <% seal = seal_palette[tag.name.sum % seal_palette.length] %> + <%= link_to "##{tag.name}", tag_path(id: tag.name), class: "wax-seal #{seal}" %> +<% end %> diff --git a/app/themes/midnight/README.md b/app/themes/midnight/README.md new file mode 100644 index 0000000..9ea3cc9 --- /dev/null +++ b/app/themes/midnight/README.md @@ -0,0 +1,36 @@ +# Midnight + +Abbey's reference **drop-in sample theme** — a minimal recolor that demonstrates the "30-second theme" pattern. + +```sh +ABBEY_THEME=midnight bin/dev +``` + +## What it shows + +* **The minimum viable theme**: ~80 lines total across `theme.rb` + `assets/tailwind.css` + the 3-line `application.html.erb` layout shell. +* **Zero view overrides**: inherits Abbey's default chrome, navigation, footer, blog index, blog post, page, links, papers — all of it. +* **Single-color palette + accent**: deep slate background, warm amber for links and quote borders. +* **Web fonts**: Inter for body, JetBrains Mono for code — loaded via the manifest's `t.fonts` array, with Google Fonts preconnect tags emitted automatically by the chrome partial. +* **Inline SVG favicon**: a tiny crescent moon, declared inline in `theme.rb` (no precompiled binary assets to ship). +* **Custom prose styling**: overrides Tailwind Typography's CSS variables (`--tw-prose-*`) so markdown content renders correctly on the dark background. + +## Folder layout + +``` +app/themes/midnight/ + theme.rb # 30 lines — manifest + assets/tailwind.css # 50 lines — palette + a few component classes + views/layouts/application.html.erb # 3 lines — chrome shell + README.md +``` + +## How to fork this for your own theme + +```sh +bin/rails g abbey:theme yourname --from=midnight +``` + +That'll clone this entire structure as a starting point. Then edit `theme.rb` (display_name, colors, fonts) and the `@theme` block in `assets/tailwind.css`. You're done. + +For the authoring guide, see [`docs/THEMES.md`](../../../docs/THEMES.md). For the full manifest API reference, see [`docs/THEMES_API.md`](../../../docs/THEMES_API.md). diff --git a/app/themes/midnight/assets/tailwind.css b/app/themes/midnight/assets/tailwind.css new file mode 100644 index 0000000..5de6f3c --- /dev/null +++ b/app/themes/midnight/assets/tailwind.css @@ -0,0 +1,68 @@ +/* ============================================================================= + Midnight theme Tailwind entry point. + + Sample theme for Abbey — a 30-second minimal recolor that demonstrates + the drop-in theme system. Compiled into + `app/assets/builds/tailwind-midnight.css` and loaded only when + ABBEY_THEME=midnight is active. +============================================================================= */ + +@import 'tailwindcss'; + +@custom-variant dark (&:where(.dark, .dark *)); + +@config '../../../../config/tailwind.config.js'; + +@source "../views"; + +@plugin "@tailwindcss/typography"; + +@theme { + /* Deep slate palette with a single warm accent. */ + --color-midnight-bg: #0f172a; + --color-midnight-fg: #e2e8f0; + --color-midnight-muted: #94a3b8; + --color-midnight-accent: #fbbf24; + --color-midnight-card: #1e293b; + --color-midnight-border: #334155; + + /* Inter for body, JetBrains Mono for code — loaded via theme.rb's t.fonts. */ + --font-sans: "Inter", system-ui, -apple-system, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, monospace; +} + +/* Component classes — keep these to a minimum in a sample theme. */ +.theme-midnight a { + color: var(--color-midnight-accent); + text-decoration: none; +} +.theme-midnight a:hover { + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 2px; +} + +/* Card-ish styling for blog post + page articles. */ +.theme-midnight article { + background: var(--color-midnight-card); + border: 1px solid var(--color-midnight-border); + border-radius: 0.5rem; + padding: 2rem; + margin-bottom: 1.5rem; +} + +/* Prose: rely on Tailwind Typography defaults inverted for dark backgrounds. */ +.theme-midnight .prose { + --tw-prose-body: var(--color-midnight-fg); + --tw-prose-headings: var(--color-midnight-fg); + --tw-prose-links: var(--color-midnight-accent); + --tw-prose-bold: var(--color-midnight-fg); + --tw-prose-counters: var(--color-midnight-muted); + --tw-prose-bullets: var(--color-midnight-muted); + --tw-prose-hr: var(--color-midnight-border); + --tw-prose-quotes: var(--color-midnight-fg); + --tw-prose-quote-borders: var(--color-midnight-accent); + --tw-prose-code: var(--color-midnight-accent); + --tw-prose-pre-code: var(--color-midnight-fg); + --tw-prose-pre-bg: #020617; +} diff --git a/app/themes/midnight/theme.rb b/app/themes/midnight/theme.rb new file mode 100644 index 0000000..5aa9dbc --- /dev/null +++ b/app/themes/midnight/theme.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# Midnight — Abbey's sample minimal recolor theme. +# +# Demonstrates the "30-second recolor" pattern: ~80 lines total across +# theme.rb + assets/tailwind.css + layout shell. No view overrides; +# inherits Abbey's default chrome and templates. +# +# Activate with: ABBEY_THEME=midnight bin/dev +Abbey::Theme.register(:midnight) do |t| + t.display_name = "Midnight" + t.html_class = "theme-midnight" + t.body_class = "min-h-screen flex flex-col bg-midnight-bg text-midnight-fg" + t.main_class = "container mx-auto px-4 py-10 max-w-3xl flex-1" + t.markdown_renderer = :minimal + t.theme_color_light = "#0f172a" + t.theme_color_dark = "#020617" + + t.fonts = [ + "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500;700&display=swap" + ] + + t.favicon_svg = <<~SVG.strip + + + + + + SVG +end diff --git a/app/themes/midnight/views/layouts/application.html.erb b/app/themes/midnight/views/layouts/application.html.erb new file mode 100644 index 0000000..6195b58 --- /dev/null +++ b/app/themes/midnight/views/layouts/application.html.erb @@ -0,0 +1,3 @@ +<%= render "layouts/abbey_chrome" do %> + <%= yield %> +<% end %> diff --git a/app/themes/retro/assets/retro-highlight.css b/app/themes/retro/assets/retro-highlight.css new file mode 100644 index 0000000..9544796 --- /dev/null +++ b/app/themes/retro/assets/retro-highlight.css @@ -0,0 +1,114 @@ +/* ============================================================================= + Abbey – Retro Theme: Rouge syntax highlighting + "Retro Terminal" palette – neon on CRT black. + All selectors are scoped under `.theme-retro` so default theme highlighting + is untouched. + ============================================================================= */ + +.theme-retro .highlight { + width: 100%; + max-width: none; + overflow-x: auto; + background: #0a0e1a; + color: #d6ffe9; +} + +.theme-retro .highlight pre { + white-space: pre; + overflow-x: auto; + padding: 1rem; + width: 100%; + margin: 0; + background: transparent; + color: inherit; +} + +.theme-retro .highlight code, +.theme-retro pre.highlight code { + font-family: "VT323", ui-monospace, monospace; + background: transparent; + color: inherit; + border: none; + padding: 0; +} + +.theme-retro code:not(.highlight code) { + background: #ffd400; + color: #0d0d12; + border: 2px solid #0d0d12; + padding: 0 6px; + border-radius: 0; + font-family: "VT323", ui-monospace, monospace; + font-size: 0.95em; +} +.theme-retro.dark code:not(.highlight code) { + background: #ff3eb5; + color: #0a0e1a; + border-color: #0a0e1a; +} + +.theme-retro .highlight table td { padding: 5px; } +.theme-retro .highlight table pre { margin: 0; } + +.theme-retro .highlight .err { color: #ff6b4a; } +.theme-retro .highlight .c, +.theme-retro .highlight .ch, +.theme-retro .highlight .cd, +.theme-retro .highlight .cm, +.theme-retro .highlight .cpf, +.theme-retro .highlight .c1, +.theme-retro .highlight .cs { color: #6b7a99; font-style: italic; } +.theme-retro .highlight .cp { color: #00e5ff; } +.theme-retro .highlight .nt { color: #00e5ff; } +.theme-retro .highlight .o, +.theme-retro .highlight .ow { color: #ff3eb5; } +.theme-retro .highlight .p, +.theme-retro .highlight .pi { color: #d6ffe9; } + +.theme-retro .highlight .gi { color: #00ff9c; } +.theme-retro .highlight .gd { color: #ff3eb5; } +.theme-retro .highlight .gh { color: #ffd400; background: transparent; font-weight: bold; } + +.theme-retro .highlight .k, +.theme-retro .highlight .kn, +.theme-retro .highlight .kp, +.theme-retro .highlight .kr, +.theme-retro .highlight .kv { color: #ff3eb5; font-weight: bold; } +.theme-retro .highlight .kc, +.theme-retro .highlight .kt, +.theme-retro .highlight .kd { color: #ffd400; } + +.theme-retro .highlight .s, +.theme-retro .highlight .sb, +.theme-retro .highlight .sc, +.theme-retro .highlight .dl, +.theme-retro .highlight .sd, +.theme-retro .highlight .s2, +.theme-retro .highlight .sh, +.theme-retro .highlight .sx, +.theme-retro .highlight .s1 { color: #00ff9c; } +.theme-retro .highlight .sa { color: #ff3eb5; } +.theme-retro .highlight .sr { color: #00e5ff; } +.theme-retro .highlight .si, +.theme-retro .highlight .se { color: #ff6b4a; } + +.theme-retro .highlight .nn, +.theme-retro .highlight .nc, +.theme-retro .highlight .no { color: #ffd400; } +.theme-retro .highlight .na { color: #00e5ff; } + +.theme-retro .highlight .m, +.theme-retro .highlight .mb, +.theme-retro .highlight .mf, +.theme-retro .highlight .mh, +.theme-retro .highlight .mi, +.theme-retro .highlight .il, +.theme-retro .highlight .mo, +.theme-retro .highlight .mx { color: #b14aff; } +.theme-retro .highlight .ss { color: #00ff9c; } +.theme-retro .highlight .nf { color: #00e5ff; } +.theme-retro .highlight .ne { color: #ff6b4a; font-weight: bold; } +.theme-retro .highlight .nb { color: #ffd400; } +.theme-retro .highlight .vc, +.theme-retro .highlight .vg, +.theme-retro .highlight .vi { color: #ff3eb5; } diff --git a/app/themes/retro/assets/retro.css b/app/themes/retro/assets/retro.css new file mode 100644 index 0000000..b921ad5 --- /dev/null +++ b/app/themes/retro/assets/retro.css @@ -0,0 +1,539 @@ +/* ============================================================================= + Abbey – Retro Theme + Memphis / 8-bit / 80s computer aesthetic. + + Loaded only when `Rails.application.config.theme == "retro"`. All + declarations are scoped under `.theme-retro` (set on by + themes/retro/layouts/application.html.erb) so loading this file in any + other context is a no-op. + + Color/shadow/font/animation TOKENS live in `app/assets/tailwind/application.css` + under `@theme`, which makes Tailwind auto-generate the matching utility + classes (`bg-memphis-pink`, `shadow-retro-lg`, `dark:text-memphis-mint`, + `hover:bg-memphis-pink`, `animate-blink`, `text-[0.62rem]`, etc.) — no + hand-rolled `.theme-retro .bg-foo-bar` declarations needed. + + This file ships: + 1. theme-root environmental styles (page bg, drifting confetti backdrop, + CRT scanlines, selection color) + 2. font-family rebinding inside `.theme-retro` so utilities like + `font-sans`, `font-mono`, `font-display` resolve to the retro stack + 3. component classes (`.card-retro`, `.btn-retro`, `.tag-retro`, + `.h-display`, `.crt-window`, `.marquee`, `.date-sticker`, + `.glitch-hover`, `.cursor-blink`) + 4. typography for the `.prose-retro` wrapper (h1-h3 with hard + text-shadows, wavy-underline links, highlighted , terminal +
 blocks, Memphis tables, dotted SVG 
) + + Palette: ink #0d0d12 · paper #fff8ef · crt #0a0e1a · + pink #ff3eb5 · cyan #00e5ff · yellow #ffd400 · + mint #00ff9c · purple #b14aff · coral #ff6b4a +============================================================================= */ + +/* ---------------------------------------------------------------------- + Font-family rebinding via CSS variable cascade. + Tailwind generates `.font-sans`, `.font-mono`, `.font-display` as + `{ font-family: var(--font-sans|mono|display) }`. Redefining those + variables on `.theme-retro` makes every `font-*` utility inside the + retro theme resolve to the retro font stack — without touching the + default theme. +---------------------------------------------------------------------- */ + +.theme-retro { + --font-sans: "Space Grotesk", system-ui, sans-serif; + --font-mono: "VT323", ui-monospace, monospace; + --font-display: "Press Start 2P", system-ui, sans-serif; +} + +/* ---------------------------------------------------------------------- + Base – body backdrop, CRT scanlines, selection +---------------------------------------------------------------------- */ + +html.theme-retro { + background-color: var(--color-memphis-paper); + color: var(--color-memphis-ink); + image-rendering: pixelated; +} +html.theme-retro.dark { + background-color: var(--color-memphis-crt); + color: #f0fff7; +} + +html.theme-retro body { + font-family: var(--font-sans); + font-feature-settings: "ss01" on, "ss02" on; + position: relative; + overflow-x: hidden; + min-height: 100vh; +} + +/* Memphis confetti drifting in the background */ +html.theme-retro body::before { + content: ""; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + opacity: 0.85; + background-image: url("data:image/svg+xml;utf8,"); + background-size: 420px 420px; + animation: retro-drift 22s ease-in-out infinite; +} +html.theme-retro.dark body::before { + opacity: 0.25; + filter: hue-rotate(20deg) saturate(1.2); +} + +/* Subtle CRT scanlines */ +html.theme-retro body::after { + content: ""; + position: fixed; + inset: 0; + z-index: 100; + pointer-events: none; + background-image: repeating-linear-gradient( + to bottom, + rgba(0, 0, 0, 0) 0, + rgba(0, 0, 0, 0) 3px, + rgba(0, 0, 0, 0.022) 3px, + rgba(0, 0, 0, 0.022) 4px + ); + mix-blend-mode: multiply; +} +html.theme-retro.dark body::after { + background-image: repeating-linear-gradient( + to bottom, + rgba(0, 255, 156, 0) 0, + rgba(0, 255, 156, 0) 3px, + rgba(0, 255, 156, 0.028) 3px, + rgba(0, 255, 156, 0.028) 4px + ); + mix-blend-mode: screen; +} + +html.theme-retro main, +html.theme-retro header, +html.theme-retro footer, +html.theme-retro nav, +html.theme-retro .content-layer { + position: relative; + z-index: 1; +} + +html.theme-retro ::selection { + background: var(--color-memphis-pink); + color: var(--color-memphis-paper); +} +html.theme-retro.dark ::selection { + background: var(--color-memphis-mint); + color: var(--color-memphis-crt); +} + +/* ---------------------------------------------------------------------- + Keyframes that are theme-local (drift/glitch don't need utilities). + Note: blink / wiggle / pop-in / marquee are registered in @theme so + Tailwind generates `animate-blink`, `animate-wiggle`, etc. and inlines + the keyframes — they're not duplicated here. +---------------------------------------------------------------------- */ + +@keyframes retro-drift { + 0%, 100% { background-position: 0 0; } + 50% { background-position: 80px 60px; } +} +@keyframes retro-glitch { + 0% { transform: translate(0, 0); } + 20% { transform: translate(-2px, 2px); } + 40% { transform: translate(2px, -1px); } + 60% { transform: translate(-1px, -2px); } + 80% { transform: translate(2px, 1px); } + 100% { transform: translate(0, 0); } +} + +/* ---------------------------------------------------------------------- + Cards / containers +---------------------------------------------------------------------- */ + +.theme-retro .card-retro { + background: #ffffff; + border: 3px solid var(--color-memphis-ink); + box-shadow: var(--shadow-retro); + padding: 1.5rem; + transition: transform 200ms ease, box-shadow 200ms ease; +} +.theme-retro.dark .card-retro { + background: var(--color-memphis-crt); + border-color: var(--color-memphis-mint); + box-shadow: var(--shadow-retro-mint); +} +.theme-retro .card-retro:hover { + transform: translate(-2px, -2px); + box-shadow: var(--shadow-retro-lg); +} +.theme-retro.dark .card-retro:hover { + box-shadow: 10px 10px 0 0 var(--color-memphis-mint); +} + +.theme-retro .card-shadow-pink { box-shadow: var(--shadow-retro-pink); } +.theme-retro .card-shadow-cyan { box-shadow: var(--shadow-retro-cyan); } +.theme-retro .card-shadow-yellow { box-shadow: var(--shadow-retro-yellow); } +.theme-retro .card-shadow-mint { box-shadow: var(--shadow-retro-mint); } +.theme-retro .card-shadow-purple { box-shadow: var(--shadow-retro-purple); } +.theme-retro .card-shadow-coral { box-shadow: var(--shadow-retro-coral); } + +/* ---------------------------------------------------------------------- + Buttons +---------------------------------------------------------------------- */ + +.theme-retro .btn-retro { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-family: var(--font-display); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + background: var(--color-memphis-yellow); + color: var(--color-memphis-ink); + border: 3px solid var(--color-memphis-ink); + box-shadow: var(--shadow-retro); + padding: 0.5rem 1rem; + transition: transform 150ms ease, box-shadow 150ms ease; + cursor: pointer; +} +.theme-retro .btn-retro:hover { + transform: translate(2px, 2px); + box-shadow: var(--shadow-retro-sm); +} +.theme-retro .btn-retro:active { + transform: translate(4px, 4px); + box-shadow: 0 0 0 0 transparent; +} +.theme-retro .btn-retro-pink { background: var(--color-memphis-pink); color: var(--color-memphis-paper); } +.theme-retro .btn-retro-cyan { background: var(--color-memphis-cyan); color: var(--color-memphis-ink); } +.theme-retro .btn-retro-mint { background: var(--color-memphis-mint); color: var(--color-memphis-ink); } +.theme-retro .btn-retro-purple { background: var(--color-memphis-purple); color: var(--color-memphis-paper); } +.theme-retro .btn-retro-coral { background: var(--color-memphis-coral); color: var(--color-memphis-paper); } +.theme-retro .btn-retro-ghost { background: var(--color-memphis-paper); color: var(--color-memphis-ink); } + +.theme-retro .btn-icon-retro { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + background: var(--color-memphis-paper); + color: var(--color-memphis-ink); + border: 3px solid var(--color-memphis-ink); + box-shadow: var(--shadow-retro-sm); + transition: transform 150ms ease, box-shadow 150ms ease; + cursor: pointer; +} +.theme-retro .btn-icon-retro:hover { + transform: translate(2px, 2px); + box-shadow: 0 0 0 0 transparent; +} +.theme-retro.dark .btn-icon-retro { + background: var(--color-memphis-crt); + color: var(--color-memphis-mint); + border-color: var(--color-memphis-mint); + box-shadow: none; +} + +/* ---------------------------------------------------------------------- + Tag pills +---------------------------------------------------------------------- */ + +.theme-retro .tag-retro { + display: inline-flex; + align-items: center; + font-family: var(--font-mono); + font-size: 1rem; + line-height: 1; + padding: 0.25rem 0.75rem; + border: 2px solid var(--color-memphis-ink); + box-shadow: var(--shadow-retro-sm); + transition: transform 150ms ease; +} +.theme-retro .tag-retro:hover { + transform: rotate(-2deg) scale(1.05); +} +.theme-retro .tag-color-1 { background: var(--color-memphis-pink); color: var(--color-memphis-paper); } +.theme-retro .tag-color-2 { background: var(--color-memphis-cyan); color: var(--color-memphis-ink); } +.theme-retro .tag-color-3 { background: var(--color-memphis-yellow); color: var(--color-memphis-ink); } +.theme-retro .tag-color-4 { background: var(--color-memphis-mint); color: var(--color-memphis-ink); } +.theme-retro .tag-color-5 { background: var(--color-memphis-purple); color: var(--color-memphis-paper); } +.theme-retro .tag-color-6 { background: var(--color-memphis-coral); color: var(--color-memphis-paper); } + +/* ---------------------------------------------------------------------- + Pixel-display headings +---------------------------------------------------------------------- */ + +.theme-retro .h-display { + font-family: var(--font-display); + color: var(--color-memphis-ink); + line-height: 1.4; + letter-spacing: -0.02em; + text-shadow: 3px 3px 0 var(--color-memphis-pink); +} +.theme-retro.dark .h-display { + color: var(--color-memphis-mint); + text-shadow: 3px 3px 0 var(--color-memphis-cyan); +} +.theme-retro .h-display-sm { font-size: 1rem; } +.theme-retro .h-display-md { font-size: 1.25rem; } +.theme-retro .h-display-lg { font-size: 1.5rem; } +@media (min-width: 640px) { + .theme-retro .h-display-sm { font-size: 1.125rem; } + .theme-retro .h-display-md { font-size: 1.5rem; } + .theme-retro .h-display-lg { font-size: 1.875rem; } +} +@media (min-width: 768px) { + .theme-retro .h-display-lg { font-size: 2.25rem; } +} + +/* ---------------------------------------------------------------------- + Blinking terminal cursor + marquee +---------------------------------------------------------------------- */ + +.theme-retro .cursor-blink::after { + content: "▮"; + display: inline-block; + margin-left: 0.25rem; + color: var(--color-memphis-pink); + animation: var(--animate-blink); +} +.theme-retro.dark .cursor-blink::after { color: var(--color-memphis-mint); } + +.theme-retro .marquee { + overflow: hidden; + white-space: nowrap; + border-top: 3px solid var(--color-memphis-ink); + border-bottom: 3px solid var(--color-memphis-ink); + background: repeating-linear-gradient( + 45deg, + var(--color-memphis-yellow) 0, + var(--color-memphis-yellow) 18px, + var(--color-memphis-ink) 18px, + var(--color-memphis-ink) 36px + ); + padding: 4px 0; +} +.theme-retro .marquee__track { + display: inline-block; + animation: var(--animate-marquee); + font-family: var(--font-display); + font-size: 12px; + color: var(--color-memphis-ink); + background: var(--color-memphis-paper); + padding: 6px 16px; + border: 2px solid var(--color-memphis-ink); +} + +/* ---------------------------------------------------------------------- + CRT-style code window + glitch hover +---------------------------------------------------------------------- */ + +.theme-retro .crt-window { + position: relative; + background: var(--color-memphis-crt); + color: var(--color-memphis-mint); + border: 3px solid var(--color-memphis-ink); + box-shadow: var(--shadow-retro); + font-family: var(--font-mono); + font-size: 0.875rem; + overflow: hidden; +} +.theme-retro .crt-window::before { + content: "● ● ●"; + display: block; + background: var(--color-memphis-pink); + color: var(--color-memphis-ink); + font-family: var(--font-display); + font-size: 10px; + letter-spacing: 4px; + padding: 6px 12px; + border-bottom: 3px solid var(--color-memphis-ink); +} + +.theme-retro .glitch-hover:hover { + animation: retro-glitch 0.8s steps(1) 1; +} + +/* ---------------------------------------------------------------------- + Date sticker (rotated label) +---------------------------------------------------------------------- */ + +.theme-retro .date-sticker { + display: inline-block; + font-family: var(--font-display); + font-size: 10px; + background: var(--color-memphis-yellow); + color: var(--color-memphis-ink); + border: 2px solid var(--color-memphis-ink); + padding: 4px 8px; + transform: rotate(-3deg); + box-shadow: 3px 3px 0 0 var(--color-memphis-ink); +} +.theme-retro .date-sticker--pink { background: var(--color-memphis-pink); color: var(--color-memphis-paper); } +.theme-retro .date-sticker--mint { background: var(--color-memphis-mint); color: var(--color-memphis-ink); } + +/* ---------------------------------------------------------------------- + Rendered markdown content (`.prose-retro` wrapper) + Used together with MinimalMarkdownRender (semantic HTML only). +---------------------------------------------------------------------- */ + +.theme-retro .prose-retro { + font-family: var(--font-sans); + font-size: 1.05rem; + line-height: 1.75; + color: var(--color-memphis-ink); +} +.theme-retro.dark .prose-retro { color: #d6ffe9; } + +.theme-retro .prose-retro h1, +.theme-retro .prose-retro h2, +.theme-retro .prose-retro h3 { + font-family: var(--font-display); + line-height: 1.5; + margin: 2em 0 0.8em; + letter-spacing: -0.02em; +} +.theme-retro .prose-retro h1 { font-size: 1.5rem; color: var(--color-memphis-pink); text-shadow: 3px 3px 0 var(--color-memphis-ink); } +.theme-retro .prose-retro h2 { font-size: 1.15rem; color: var(--color-memphis-purple); text-shadow: 2px 2px 0 var(--color-memphis-yellow); } +.theme-retro .prose-retro h3 { font-size: 0.95rem; color: #00a3cc; } + +.theme-retro.dark .prose-retro h1 { color: var(--color-memphis-mint); text-shadow: 3px 3px 0 var(--color-memphis-pink); } +.theme-retro.dark .prose-retro h2 { color: var(--color-memphis-yellow); text-shadow: 2px 2px 0 var(--color-memphis-purple); } +.theme-retro.dark .prose-retro h3 { color: var(--color-memphis-cyan); } + +.theme-retro .prose-retro p { margin-bottom: 1.1em; } + +.theme-retro .prose-retro strong { + background: var(--color-memphis-yellow); + color: var(--color-memphis-ink); + padding: 0 4px; + border: 2px solid var(--color-memphis-ink); + font-weight: 700; +} +.theme-retro.dark .prose-retro strong { + background: var(--color-memphis-mint); + color: var(--color-memphis-crt); + border-color: var(--color-memphis-mint); +} + +.theme-retro .prose-retro em { font-style: italic; color: var(--color-memphis-purple); } +.theme-retro.dark .prose-retro em { color: var(--color-memphis-cyan); } + +.theme-retro .prose-retro a { + color: var(--color-memphis-pink); + font-weight: 600; + text-decoration: underline; + text-decoration-style: wavy; + text-decoration-thickness: 2px; + text-underline-offset: 4px; +} +.theme-retro .prose-retro a:hover { background: var(--color-memphis-yellow); color: var(--color-memphis-ink); } +.theme-retro.dark .prose-retro a { color: var(--color-memphis-mint); } +.theme-retro.dark .prose-retro a:hover { background: var(--color-memphis-pink); color: var(--color-memphis-paper); } + +.theme-retro .prose-retro ul, +.theme-retro .prose-retro ol { + margin: 1em 0 1.2em 1.5em; + padding-left: 0.5em; +} +.theme-retro .prose-retro ul { list-style: none; } +.theme-retro .prose-retro ul > li::before { + content: "▸ "; + color: var(--color-memphis-pink); + font-weight: 700; + margin-right: 0.4em; +} +.theme-retro.dark .prose-retro ul > li::before { color: var(--color-memphis-mint); } +.theme-retro .prose-retro ol { list-style: decimal; } +.theme-retro .prose-retro li { margin-bottom: 0.4em; } + +.theme-retro .prose-retro blockquote { + border-left: 6px solid var(--color-memphis-pink); + background: rgba(255, 212, 0, 0.18); + padding: 1em 1.2em; + margin: 1.5em 0; + font-style: italic; +} +.theme-retro.dark .prose-retro blockquote { + border-left-color: var(--color-memphis-mint); + background: rgba(0, 229, 255, 0.08); +} + +.theme-retro .prose-retro pre, +.theme-retro pre.highlight { + background: var(--color-memphis-crt); + color: var(--color-memphis-mint); + border: 3px solid var(--color-memphis-ink); + box-shadow: var(--shadow-retro-pink); + padding: 1rem 1.1rem; + margin: 1.5em 0; + overflow-x: auto; + font-family: var(--font-mono); + font-size: 1.05rem; + line-height: 1.4; + position: relative; +} +.theme-retro.dark .prose-retro pre, +.theme-retro.dark pre.highlight { + box-shadow: var(--shadow-retro-cyan); + border-color: var(--color-memphis-mint); +} +.theme-retro .prose-retro pre::before, +.theme-retro pre.highlight::before { + content: "● TERMINAL — RUN.EXE"; + display: block; + font-family: var(--font-display); + font-size: 9px; + color: var(--color-memphis-yellow); + letter-spacing: 2px; + margin: -1rem -1.1rem 0.8rem; + padding: 6px 12px; + background: var(--color-memphis-ink); + border-bottom: 2px solid var(--color-memphis-mint); +} + +.theme-retro .prose-retro code, +.theme-retro code:not(pre code) { + font-family: var(--font-mono); + background: var(--color-memphis-yellow); + color: var(--color-memphis-ink); + padding: 0 6px; + border: 2px solid var(--color-memphis-ink); + font-size: 1em; +} +.theme-retro.dark .prose-retro code, +.theme-retro.dark code:not(pre code) { + background: var(--color-memphis-pink); + color: var(--color-memphis-crt); + border-color: var(--color-memphis-crt); +} +.theme-retro .prose-retro pre code, +.theme-retro pre.highlight code { + background: transparent; + color: inherit; + border: none; + padding: 0; + font-size: inherit; +} + +.theme-retro .prose-retro hr { + border: none; + margin: 2em 0; + height: 14px; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: repeat-x; +} +.theme-retro.dark .prose-retro hr { + background-image: url("data:image/svg+xml;utf8,"); +} + +.theme-retro .prose-retro img { + border: 3px solid var(--color-memphis-ink); + box-shadow: 6px 6px 0 0 var(--color-memphis-cyan); + margin: 1.5em 0; +} diff --git a/app/themes/retro/assets/tailwind.css b/app/themes/retro/assets/tailwind.css new file mode 100644 index 0000000..ecdc1fa --- /dev/null +++ b/app/themes/retro/assets/tailwind.css @@ -0,0 +1,79 @@ +/* ============================================================================= + Retro theme Tailwind entry point. + + Compiled by `bin/rails tailwindcss:build` into + `app/assets/builds/tailwind-retro.css` and loaded only when + ABBEY_THEME=retro is active (via app/helpers/application_helper.rb# + theme_stylesheets). Self-contained: the default Abbey bundle never + sees these tokens or scans these views. +============================================================================= */ + +@import 'tailwindcss'; + +/* Match the default Abbey bundle: dark mode is toggled via a `.dark` + class on , not the OS prefers-color-scheme setting. Without + this, `dark:bg-*` utilities ignore our cookie-driven toggle. */ +@custom-variant dark (&:where(.dark, .dark *)); + +@config '../../../../config/tailwind.config.js'; + +/* Scan only this theme's view files. Tailwind walks every directory we + list here; everything else (the default app/views/, other themes) is + ignored, so memphis-* utilities don't bleed into the default bundle. */ +@source "../views"; + +@plugin "@tailwindcss/typography"; + +@theme { + /* ---------- Memphis palette ---------- */ + --color-memphis-paper: #fff8ef; + --color-memphis-ink: #0d0d12; + --color-memphis-crt: #0a0e1a; + --color-memphis-pink: #ff3eb5; + --color-memphis-cyan: #00e5ff; + --color-memphis-yellow: #ffd400; + --color-memphis-mint: #00ff9c; + --color-memphis-purple: #b14aff; + --color-memphis-coral: #ff6b4a; + + /* ---------- Retro hard-shadow utilities ---------- */ + --shadow-retro-sm: 4px 4px 0 0 #0d0d12; + --shadow-retro: 6px 6px 0 0 #0d0d12; + --shadow-retro-lg: 10px 10px 0 0 #0d0d12; + --shadow-retro-pink: 6px 6px 0 0 #ff3eb5; + --shadow-retro-cyan: 6px 6px 0 0 #00e5ff; + --shadow-retro-yellow: 6px 6px 0 0 #ffd400; + --shadow-retro-mint: 6px 6px 0 0 #00ff9c; + --shadow-retro-purple: 6px 6px 0 0 #b14aff; + --shadow-retro-coral: 6px 6px 0 0 #ff6b4a; + + /* ---------- Theme font families ---------- + Default values are inert system stacks; .theme-retro rebinds them + to the Google Fonts loaded in the layout (Space Grotesk, VT323, + Press Start 2P). See retro.css for the rebinding. */ + --font-display: ui-sans-serif, system-ui, sans-serif; + + /* ---------- Theme animations ---------- */ + --animate-blink: theme-blink 1s steps(2, start) infinite; + --animate-wiggle: theme-wiggle 0.4s ease-in-out; + --animate-pop-in: theme-pop-in 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) both; + --animate-marquee: theme-marquee 28s linear infinite; + + @keyframes theme-blink { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } + } + @keyframes theme-wiggle { + 0%, 100% { transform: rotate(0deg); } + 25% { transform: rotate(-3deg); } + 75% { transform: rotate(3deg); } + } + @keyframes theme-pop-in { + 0% { opacity: 0; transform: translateY(8px) scale(0.96); } + 100% { opacity: 1; transform: translateY(0) scale(1); } + } + @keyframes theme-marquee { + from { transform: translateX(0); } + to { transform: translateX(-50%); } + } +} diff --git a/app/themes/retro/theme.rb b/app/themes/retro/theme.rb new file mode 100644 index 0000000..975b8ee --- /dev/null +++ b/app/themes/retro/theme.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +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 bg-memphis-paper dark:bg-memphis-crt" + t.main_class = "container mx-auto px-4 py-8 max-w-5xl content-layer flex-1" + t.markdown_renderer = :minimal + t.theme_color_light = "#fff8ef" + t.theme_color_dark = "#0a0e1a" + + t.fonts = [ + "https://fonts.googleapis.com/css2?family=Press+Start+2P&family=VT323&family=Space+Grotesk:wght@400;500;600;700&display=swap" + ] + + # Pixelated 4-square Memphis favicon. + t.favicon_svg = <<~SVG.strip + + + + + + + SVG +end diff --git a/app/themes/retro/views/blog/index.html.erb b/app/themes/retro/views/blog/index.html.erb new file mode 100644 index 0000000..3ada400 --- /dev/null +++ b/app/themes/retro/views/blog/index.html.erb @@ -0,0 +1,54 @@ +<% + card_palettes = [ + { shadow: 'shadow-retro-pink', accent: 'bg-memphis-pink', text: 'text-memphis-paper' }, + { shadow: 'shadow-retro-cyan', accent: 'bg-memphis-cyan', text: 'text-memphis-ink' }, + { shadow: 'shadow-retro-yellow', accent: 'bg-memphis-yellow', text: 'text-memphis-ink' }, + { shadow: 'shadow-retro-mint', accent: 'bg-memphis-mint', text: 'text-memphis-ink' }, + { shadow: 'shadow-retro-purple', accent: 'bg-memphis-purple', text: 'text-memphis-paper' }, + { shadow: 'shadow-retro-coral', accent: 'bg-memphis-coral', text: 'text-memphis-paper' } + ] +%> + +
+ <% @posts.each_with_index do |post, idx| %> + <% palette = card_palettes[(post.id || idx) % card_palettes.length] %> +
+ +
+ <%= post.created_at.strftime('%b %Y') %> +
+ +

+ <%= link_to post.title, dated_post_path(year: post.year, day: post.day, month: post.month, id: post), + class: "hover:text-memphis-pink dark:hover:text-memphis-cyan transition-colors glitch-hover" %> +

+ + + +
+ <%= post.rendered_excerpt.html_safe %> +
+ +
+ <%= render "shared/tags", post: post %> +
+ +
+ <% if authenticated? %> + <%= link_to "Edit", edit_post_path(post), class: "font-mono text-base text-memphis-purple dark:text-memphis-cyan hover:underline" %> + | + <% end %> + <%= link_to dated_post_path(year: post.year, day: post.day, month: post.month, id: post), + class: "btn-retro btn-retro-cyan" do %> + ▶ Read more + <% end %> +
+
+ <% end %> + +
+ <%= paginate @posts %> +
+
diff --git a/app/themes/retro/views/blog/index_by_tag.html.erb b/app/themes/retro/views/blog/index_by_tag.html.erb new file mode 100644 index 0000000..d0a8ee2 --- /dev/null +++ b/app/themes/retro/views/blog/index_by_tag.html.erb @@ -0,0 +1,66 @@ +<% content_for :title do %> + #<%= @tag.name %> | <%= Rails.application.config.site_name %> +<% end %> + +<% content_for :rss do %> + <%= auto_discovery_link_tag(:atom, tag_feed_path(id: @tag.name)) %> +<% end %> + +<% content_for :rss_button do %> + <%= link_to tag_feed_path(id: @tag.name), class: "btn-icon-retro", aria: { label: "RSS Feed" } do %> + <%= render "shared/icons/rss" %> + <% end %> +<% end %> + +<% + card_palettes = [ + 'shadow-retro-pink', 'shadow-retro-cyan', 'shadow-retro-yellow', + 'shadow-retro-mint', 'shadow-retro-purple', 'shadow-retro-coral' + ] +%> + +
+ +
+

// filter

+

+ #<%= @tag.name %> +

+

> <%= pluralize(@posts.total_count, 'transmission') %> matched the query_

+
+ + <% @posts.each_with_index do |post, idx| %> + <% shadow = card_palettes[(post.id || idx) % card_palettes.length] %> +
+

+ <%= link_to post.title, dated_post_path(year: post.year, day: post.day, month: post.month, id: post), + class: "hover:text-memphis-pink dark:hover:text-memphis-cyan transition-colors" %> +

+ + + +
+ <%= post.rendered_excerpt.html_safe %> +
+ +
+ <%= render "shared/tags", post: post %> +
+ +
+ <% if authenticated? %> + <%= link_to "Edit", edit_post_path(post), class: "font-mono text-base text-memphis-purple dark:text-memphis-cyan hover:underline" %> + | + <% end %> + <%= link_to dated_post_path(year: post.year, day: post.day, month: post.month, id: post), + class: "btn-retro btn-retro-cyan" do %> + ▶ Read more + <% end %> +
+
+ <% end %> + +
<%= paginate @posts %>
+
diff --git a/app/themes/retro/views/blog/show.html.erb b/app/themes/retro/views/blog/show.html.erb new file mode 100644 index 0000000..59f001f --- /dev/null +++ b/app/themes/retro/views/blog/show.html.erb @@ -0,0 +1,40 @@ +<% content_for :title do %> +<%= @post.title %> | <%= Rails.application.config.site_name %> +<% end %> + +<%= link_to posts_path, class: "inline-flex items-center font-mono text-lg text-memphis-purple dark:text-memphis-cyan hover:text-memphis-pink dark:hover:text-memphis-mint mb-6" do %> + Back to posts +<% end %> + +
+ +
+
+ Post · <%= @post.created_at.strftime('%b %d, %Y') %> +
+ +

+ <%= @post.title %> +

+ +
+ + <% if authenticated? %> + + <%= link_to "Edit ▸", edit_post_path(@post), + class: "text-memphis-pink dark:text-memphis-mint hover:underline" %> + <% end %> +
+
+ +
+ <%= @post.rendered_body.html_safe %> +
+ +
+

// tagged_with

+
+ <%= render "shared/tags", post: @post %> +
+
+
diff --git a/app/themes/retro/views/layouts/application.html.erb b/app/themes/retro/views/layouts/application.html.erb new file mode 100644 index 0000000..6195b58 --- /dev/null +++ b/app/themes/retro/views/layouts/application.html.erb @@ -0,0 +1,3 @@ +<%= render "layouts/abbey_chrome" do %> + <%= yield %> +<% end %> diff --git a/app/themes/retro/views/links/_link.html.erb b/app/themes/retro/views/links/_link.html.erb new file mode 100644 index 0000000..4b49fad --- /dev/null +++ b/app/themes/retro/views/links/_link.html.erb @@ -0,0 +1,37 @@ +<% + shadows = %w[shadow-retro-pink shadow-retro-cyan shadow-retro-yellow shadow-retro-mint shadow-retro-purple shadow-retro-coral] + shadow = shadows[(link.id || 0) % shadows.length] +%> +<%= link_to link.url, + rel: "nofollow", + class: "block bg-memphis-paper dark:bg-memphis-crt border-[3px] border-memphis-ink dark:border-memphis-mint #{shadow} p-5 transition-transform duration-200 hover:-translate-x-1 hover:-translate-y-1 group" do %> +
+
+

+ <%= link.title %> + <%= render "shared/icons/external_link", class: "w-4 h-4 text-memphis-purple dark:text-memphis-cyan" %> +

+ +
+ <% if link.description.present? %> +

+ <%= link.description %> +

+ <% end %> +
+<% end %> +<% if authenticated? %> +
+ <%= link_to edit_link_path(link), class: "btn-icon-retro" do %> + <%= render "shared/icons/pencil", class: "w-4 h-4" %> + <% end %> + <%= button_to link_path(link), + method: :delete, + class: "btn-icon-retro", + form: { data: { turbo_confirm: "Are you sure?" } } do %> + <%= render "shared/icons/trash", class: "w-4 h-4" %> + <% end %> +
+<% end %> diff --git a/app/themes/retro/views/links/index.html.erb b/app/themes/retro/views/links/index.html.erb new file mode 100644 index 0000000..236e2af --- /dev/null +++ b/app/themes/retro/views/links/index.html.erb @@ -0,0 +1,28 @@ +<% content_for :rss do %> + <%= auto_discovery_link_tag(:atom, links_feed_path) %> +<% end %> + +<% content_for :rss_button do %> + <%= link_to links_feed_path, class: "btn-icon-retro", aria: { label: "RSS Feed" } do %> + <%= render "shared/icons/rss" %> + <% end %> +<% end %> + +<% content_for :title do %> +Links | <%= Rails.application.config.site_name %> +<% end %> + +
+
+

// bookmarks

+

+ Links +

+

> stuff worth your eyeballs_

+
+ +
+ <%= render @links %> +
+
<%= paginate @links %>
+
diff --git a/app/themes/retro/views/pages/show.html.erb b/app/themes/retro/views/pages/show.html.erb new file mode 100644 index 0000000..bdd8df0 --- /dev/null +++ b/app/themes/retro/views/pages/show.html.erb @@ -0,0 +1,24 @@ +<% content_for :title do %> +<%= @page.title %> | <%= Rails.application.config.site_name %> +<% end %> + +
+
+
+ Page · <%= @page.slug %> +
+

+ <%= @page.title %> +

+ <% if authenticated? %> +
+ <%= link_to "Edit ▸", edit_page_path(@page), + class: "text-memphis-purple dark:text-memphis-cyan hover:underline" %> +
+ <% end %> +
+ +
+ <%= @page.rendered_body.html_safe %> +
+
diff --git a/app/themes/retro/views/papers/_paper.html.erb b/app/themes/retro/views/papers/_paper.html.erb new file mode 100644 index 0000000..03ffd01 --- /dev/null +++ b/app/themes/retro/views/papers/_paper.html.erb @@ -0,0 +1,60 @@ +<% + shadows = %w[shadow-retro-pink shadow-retro-cyan shadow-retro-yellow shadow-retro-mint shadow-retro-purple shadow-retro-coral] + shadow = shadows[(paper.id || 0) % shadows.length] +%> +
+
+ PDF +
+
+ <% if paper.pdf.attached? && paper.pdf.previewable? %> +
+ <% if paper.arxiv? %> + <%= link_to paper.arxiv_pdf_url, target: "_blank", rel: "noopener", data: { turbo: false } do %> + <%= image_tag paper.pdf.preview(resize_to_fit: [120, 120]), class: "w-28 h-28 object-cover border-[3px] border-memphis-ink shadow-retro-sm" %> + <% end %> + <% else %> + <%= link_to paper_view_path(paper), target: (paper_view_path(paper) == paper.url ? "_blank" : nil), data: { turbo: false } do %> + <%= image_tag paper.pdf.preview(resize_to_fit: [120, 120]), class: "w-28 h-28 object-cover border-[3px] border-memphis-ink shadow-retro-sm" %> + <% end %> + <% end %> +
+ <% end %> + +
+

+ <% if paper.arxiv? %> + <%= link_to paper.arxiv_pdf_url, target: "_blank", rel: "noopener", class: "hover:text-memphis-pink dark:hover:text-memphis-yellow transition-colors flex items-center gap-2", data: { turbo: false } do %> + <%= paper.title %> + <%= render "shared/icons/external_link", class: "w-4 h-4 text-memphis-purple dark:text-memphis-cyan" %> + <% end %> + <% else %> + <%= link_to paper.title, paper_view_path(paper), target: (paper_view_path(paper) == paper.url ? "_blank" : nil), class: "hover:text-memphis-pink dark:hover:text-memphis-yellow transition-colors flex items-center gap-2", data: { turbo: false } %> + <% end %> +

+ + <% if paper.description.present? %> +

+ <%= paper.description %> +

+ <% end %> + +
+ >> added <%= paper.created_at.strftime("%Y-%m-%d") %> +
+
+
+
+<% if authenticated? %> +
+ <%= link_to edit_paper_path(paper), class: "btn-icon-retro" do %> + <%= render "shared/icons/pencil", class: "w-4 h-4" %> + <% end %> + <%= button_to paper_path(paper), + method: :delete, + class: "btn-icon-retro", + form: { data: { turbo_confirm: "Are you sure?" } } do %> + <%= render "shared/icons/trash", class: "w-4 h-4" %> + <% end %> +
+<% end %> diff --git a/app/themes/retro/views/papers/index.html.erb b/app/themes/retro/views/papers/index.html.erb new file mode 100644 index 0000000..9a975c8 --- /dev/null +++ b/app/themes/retro/views/papers/index.html.erb @@ -0,0 +1,37 @@ +<% content_for :title do %> +Papers | <%= Rails.application.config.site_name %> +<% end %> + +
+
+
+
+

// reading_list

+

+ Papers +

+

> arxiv printouts & pdf rabbit holes_

+
+ <% if @papers.any? && authenticated? %> + <%= link_to "+ Add paper", new_link_path, class: "btn-retro btn-retro-pink" %> + <% end %> +
+
+ + <% if @papers.any? %> +
+ <%= render @papers %> +
+ +
+ <%= paginate @papers %> +
+ <% else %> +
+

> no papers in this archive yet_

+ <% if authenticated? %> + <%= link_to "+ Add your first paper", new_link_path, class: "btn-retro btn-retro-pink" %> + <% end %> +
+ <% end %> +
diff --git a/app/themes/retro/views/shared/_admin_navigation.html.erb b/app/themes/retro/views/shared/_admin_navigation.html.erb new file mode 100644 index 0000000..988974d --- /dev/null +++ b/app/themes/retro/views/shared/_admin_navigation.html.erb @@ -0,0 +1,24 @@ +<% if authenticated? %> +
+
+
+
+ SYSOP> + <%= link_to "New Post", new_post_path, class: "hover:text-memphis-pink transition-colors" %> + <%= link_to "New Page", new_page_path, class: "hover:text-memphis-pink transition-colors" %> + <%= link_to "New Link", new_link_path, class: "hover:text-memphis-pink transition-colors" %> + <%= link_to "Feeds", feeds_path, class: "hover:text-memphis-cyan transition-colors" %> + <%= link_to "Read", feed_posts_path, class: "hover:text-memphis-cyan transition-colors" %> +
+
+ + <%= button_to session_path, + method: :delete, + class: "hover:text-memphis-pink transition-colors" do %> + Sign out + <% end %> +
+
+
+
+<% end %> diff --git a/app/themes/retro/views/shared/_footer.html.erb b/app/themes/retro/views/shared/_footer.html.erb new file mode 100644 index 0000000..8e62c4e --- /dev/null +++ b/app/themes/retro/views/shared/_footer.html.erb @@ -0,0 +1,22 @@ +
+
+
+ ★ PRESS START ★ <%= Rails.application.config.site_name.upcase %> ★ INSERT COIN TO CONTINUE ★ 100% HAND-TYPED HTML ★ NO TRACKERS ★ READY P1_    + ★ PRESS START ★ <%= Rails.application.config.site_name.upcase %> ★ INSERT COIN TO CONTINUE ★ 100% HAND-TYPED HTML ★ NO TRACKERS ★ READY P1_    +
+
+
+
+

+ $ + echo "© <%= Time.current.year %> <%= Rails.application.config.site_name %>" + +

+

+ crafted on + abbey + · powered by ⚡ caffeine +

+
+
+
diff --git a/app/themes/retro/views/shared/_navigation.html.erb b/app/themes/retro/views/shared/_navigation.html.erb new file mode 100644 index 0000000..5878998 --- /dev/null +++ b/app/themes/retro/views/shared/_navigation.html.erb @@ -0,0 +1,60 @@ +
+
+ +
+ <%= link_to root_path, class: "group inline-block" do %> +
+ + + +
+

+ <%= Rails.application.config.site_name %> +

+

+ > field notes, code & experiments_ +

+ <% end %> +
+ +
+ + +
+ <% if content_for? :rss_button %> + <%= yield :rss_button %> + <% else %> + <%= link_to blog_feed_path, class: "btn-icon-retro", aria: { label: "RSS Feed" } do %> + <%= render "shared/icons/rss" %> + <% end %> + <% end %> + +
+
+
+
diff --git a/app/themes/retro/views/shared/_tags.html.erb b/app/themes/retro/views/shared/_tags.html.erb new file mode 100644 index 0000000..210c565 --- /dev/null +++ b/app/themes/retro/views/shared/_tags.html.erb @@ -0,0 +1,15 @@ +<% + tag_palette = %w[ + tag-color-1 + tag-color-2 + tag-color-3 + tag-color-4 + tag-color-5 + tag-color-6 + ] +%> +<% post.tags.each do |tag| %> + <% color = tag_palette[tag.name.sum % tag_palette.length] %> + <%= link_to "##{tag.name}", tag_path(id: tag.name), + class: "tag-retro #{color}" %> +<% end %> diff --git a/app/views/layouts/_abbey_chrome.html.erb b/app/views/layouts/_abbey_chrome.html.erb new file mode 100644 index 0000000..995b8d5 --- /dev/null +++ b/app/views/layouts/_abbey_chrome.html.erb @@ -0,0 +1,70 @@ +<%# Shared layout chrome: , body wrapper, nav, footer — driven by Abbey::Theme.active. + Theme layouts render this partial with a block; override shared/* partials via view-path prepend. %> +<% + theme = Abbey::Theme.active +%> +> + + <%= content_for(:title) || Rails.application.config.site_name %> + + + + + <% if theme.theme_color_light && theme.theme_color_dark %> + + + <% elsif theme.theme_color_light %> + + <% elsif theme.theme_color_dark %> + + <% end %> + + <% if theme.favicon_svg %> + + <% end %> + + <% if theme.fonts.any? %> + + + <% theme.fonts.each do |href| %> + + <% end %> + <% end %> + + <%= render "shared/dark_mode_script" %> + + <%# Per-page OG / Twitter / canonical meta tags (link previews). + Show templates can override values via `content_for :meta`. The + `meta_tags` helper is optional; downstream apps that don't define + it (or upstream Abbey before the meta_tags helper lands) get + nothing here, not a NoMethodError. %> + <% if content_for?(:meta) %> + <%= content_for(:meta) %> + <% elsif respond_to?(:meta_tags) %> + <%= meta_tags %> + <% end %> + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= content_for(:rss) || auto_discovery_link_tag(:atom, blog_feed_path) %> + + <%= stylesheet_link_tag "highlight" %> + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%# Extra stylesheets contributed by the active theme. %> + <% theme_stylesheets.each do |sheet| %> + <%= stylesheet_link_tag sheet, "data-turbo-track": "reload" %> + <% end %> + <%= javascript_importmap_tags %> + + > + <%= render "shared/admin_navigation" %> +
+ <%= render "shared/navigation" %> +
+ <%= yield %> +
+
+ <%= render "shared/footer" %> + + diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 8b505ce..6195b58 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,43 +1,3 @@ - - - - <%= content_for(:title) || Rails.application.config.site_name %> - - - - <%= stylesheet_link_tag "highlight" %> - - <%= csrf_meta_tags %> - <%= csp_meta_tag %> - - <%= content_for(:rss) || auto_discovery_link_tag(:atom, blog_feed_path) %> - - - - <%# Includes all stylesheet files in app/assets/stylesheets %> - - <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> - <%= javascript_importmap_tags %> - - - <%= render "shared/admin_navigation" %> -
- <%= render "shared/navigation" %> -
- <%= yield %> -
- -
- <%= render "shared/footer" %> - - - +<%= render "layouts/abbey_chrome" do %> + <%= yield %> +<% end %> diff --git a/app/views/shared/_dark_mode_script.html.erb b/app/views/shared/_dark_mode_script.html.erb new file mode 100644 index 0000000..25e4453 --- /dev/null +++ b/app/views/shared/_dark_mode_script.html.erb @@ -0,0 +1,4 @@ +<%# Default dark-mode toggle. Themes can override this partial by + creating app/themes//views/shared/_dark_mode_script.html.erb + (e.g. grimoire ships a Konami easter egg on top of the core). %> +<%= render "shared/dark_mode_script_core", theme: Abbey::Theme.active %> diff --git a/app/views/shared/_dark_mode_script_core.html.erb b/app/views/shared/_dark_mode_script_core.html.erb new file mode 100644 index 0000000..5eeb74c --- /dev/null +++ b/app/views/shared/_dark_mode_script_core.html.erb @@ -0,0 +1,47 @@ +<%# Shared dark-mode boot + toggle logic. Themes can wrap or extend this + partial (see grimoire's Konami override). Expects a local `theme`. %> + diff --git a/config/initializers/themes.rb b/config/initializers/themes.rb new file mode 100644 index 0000000..f8a1128 --- /dev/null +++ b/config/initializers/themes.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# Opt-in theming for Abbey. +# +# A theme is one self-contained folder under `app/themes//`: +# +# app/themes// +# theme.rb # manifest — Abbey::Theme.register(:name) { |t| ... } +# assets/ # CSS bundled with the theme (loaded via theme_stylesheets) +# tailwind.css # per-theme Tailwind build (Phase 2) +# -highlight.css # optional Rouge syntax theme +# views/ # ERB templates that override default views +# layouts/ # while this theme is active +# shared/ +# blog/ pages/ links/ papers/ +# +# The active theme is selected via the `ABBEY_THEME` env var: +# +# $ ABBEY_THEME=retro bin/dev +# +# (or `Rails.application.config.theme = "retro"` in an environment file). +# With no env var set, the implicit "default" theme renders the original +# Abbey look — no view overrides, no theme stylesheets. + +require "abbey/theme" +require "markdown_render" +require "minimal_markdown_render" + +# Honor the env var as the default; environments / other initializers may +# still override `Rails.application.config.theme`. +Rails.application.config.theme = ENV.fetch("ABBEY_THEME", "default") + +# Populate the registry by loading every `app/themes/*/theme.rb` manifest. +Abbey::Theme.load_all! + +# Register each theme's `assets/` folder with Propshaft so that +# `stylesheet_link_tag "themes//"` resolves. This runs at +# boot so the load path is in place before the first request. +Abbey::Theme.registry.each_value do |theme| + next unless theme.assets_path&.directory? + + Rails.application.config.assets.paths << theme.assets_path.to_s +end diff --git a/docs/THEMES.md b/docs/THEMES.md new file mode 100644 index 0000000..65cce96 --- /dev/null +++ b/docs/THEMES.md @@ -0,0 +1,272 @@ +# Authoring Drop-in Themes for Abbey + +Abbey ships with an opt-in theme system designed so that a community theme is **one self-contained folder** under `app/themes//`. Dropping the folder in and setting `ABBEY_THEME=` is the entire install — zero edits to any central file. This guide walks through the concepts, the recommended workflow, and the gotchas you'll hit along the way. + +If you're looking for the exhaustive manifest reference, see [`docs/THEMES_API.md`](THEMES_API.md). + +## Table of contents + +1. [Concepts](#concepts) +2. [Quick start (30-second recolor)](#quick-start-30-second-recolor) +3. [Folder layout](#folder-layout) +4. [The manifest (`theme.rb`)](#the-manifest-themerb) +5. [The Tailwind entry point (`assets/tailwind.css`)](#the-tailwind-entry-point-assetstailwindcss) +6. [Overriding views](#overriding-views) +7. [Picking a markdown renderer](#picking-a-markdown-renderer) +8. [Per-theme JavaScript](#per-theme-javascript) +9. [Dark mode](#dark-mode) +10. [Common patterns](#common-patterns) +11. [Gotchas](#gotchas) + +## Concepts + +* **Registry**: Every theme registers itself with `Abbey::Theme` in its `theme.rb` manifest. Abbey scans `app/themes/*/theme.rb` at boot. +* **Active theme**: Picked via `ABBEY_THEME=` (or `Rails.application.config.theme = ""`). With no env var, the implicit `default` theme renders Abbey's original look. +* **Chrome partial** (`app/views/layouts/_abbey_chrome.html.erb`): renders ``, `` wrapper, navigation, and footer using values from the active theme's manifest. Most themes never need their own layout markup. +* **Per-theme Tailwind bundle**: Each theme owns `app/themes//assets/tailwind.css`, compiled to `app/assets/builds/tailwind-.css`. The default Abbey bundle is byte-for-byte unchanged no matter how many themes you install — themes don't leak utilities into the default scan. +* **View overrides**: Ship any subset of view files under `app/themes//views/`. They prepend to Rails' view path while your theme is active, so an `app/themes//views/blog/show.html.erb` wins over `app/views/blog/show.html.erb`. + +## Quick start (30-second recolor) + +For a pure recolor that inherits Abbey's default chrome: + +```sh +bin/rails g abbey:theme aurora --minimal +``` + +This generates: + +``` +app/themes/aurora/ + theme.rb # manifest + assets/tailwind.css # @theme tokens + your custom utilities + views/layouts/application.html.erb # 3-line shell + README.md +``` + +Edit `theme.rb` (palette, font, theme color), edit `assets/tailwind.css` (your `@theme` tokens), then: + +```sh +ABBEY_THEME=aurora bin/dev +``` + +That's it. Your theme is live. + +For a serious visual override that needs to customize markup, drop the `--minimal` flag (full scaffold) or use `--from=retro` to clone an existing theme as a starting point: + +```sh +bin/rails g abbey:theme aurora # full scaffold (every view stubbed) +bin/rails g abbey:theme aurora --from=retro # clones retro's views as starter +``` + +## Folder layout + +``` +app/themes/aurora/ + theme.rb # required: registers the theme with Abbey + assets/ + tailwind.css # required if you want utility classes + aurora-highlight.css # optional: Rouge syntax theme override + views/ # optional: ERB overrides; subset is fine + layouts/application.html.erb + shared/_navigation.html.erb + shared/_footer.html.erb + shared/_admin_navigation.html.erb + shared/_tags.html.erb + shared/_dark_mode_script.html.erb + blog/{index,show,index_by_tag}.html.erb + pages/show.html.erb + links/{index,_link}.html.erb + papers/{index,_paper}.html.erb + README.md # author notes (recommended) +``` + +You don't have to ship every file. View resolution falls through to the default templates for anything you don't override. + +## The manifest (`theme.rb`) + +```ruby +Abbey::Theme.register(:aurora) do |t| + t.display_name = "Aurora" + t.html_class = "theme-aurora" + t.body_class = "min-h-screen flex flex-col bg-aurora-bg text-aurora-fg" + t.main_class = "container mx-auto px-4 py-8 max-w-5xl flex-1" + + t.markdown_renderer = :minimal # or :default + + t.theme_color_light = "#f5f3ff" + t.theme_color_dark = "#0b0a1f" + + t.fonts = [ + "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" + ] + + t.favicon_svg = <<~SVG + + + + + SVG +end +``` + +Every field is optional except the symbol name passed to `.register`. See [`THEMES_API.md`](THEMES_API.md) for the complete field list and defaults. + +## The Tailwind entry point (`assets/tailwind.css`) + +```css +@import 'tailwindcss'; + +/* Scan only this theme's views. Tailwind tree-shakes against everything + under here, so the bundle stays minimal. */ +@source "../views"; + +@plugin "@tailwindcss/typography"; /* optional, if you use prose */ + +@theme { + /* Palette */ + --color-aurora-bg: #f5f3ff; + --color-aurora-fg: #0b0a1f; + --color-aurora-accent: #6366f1; + --color-aurora-muted: #6b7280; + + /* Fonts (rebound inside .theme-aurora scope via font-sans utility) */ + --font-display: "Inter", system-ui, sans-serif; + + /* Custom shadows */ + --shadow-aurora: 0 10px 30px -10px rgba(99,102,241,0.5); + + /* Animations */ + --animate-shimmer: aurora-shimmer 2s linear infinite; + + @keyframes aurora-shimmer { + 0% { background-position: 0% 50%; } + 100% { background-position: 100% 50%; } + } +} + +/* Component classes that consume the tokens above. */ +.theme-aurora .aurora-card { + background: var(--color-aurora-bg); + border: 1px solid color-mix(in oklab, var(--color-aurora-accent) 30%, transparent); + box-shadow: var(--shadow-aurora); +} +``` + +Everything in `@theme` becomes Tailwind utilities — `bg-aurora-bg`, `text-aurora-accent`, `shadow-aurora`, `animate-shimmer`, etc. Use those in your view ERBs. + +The compile loop: + +```sh +bin/rails tailwindcss:build # one-shot build, all themes +bin/rails themes:tailwind:watch # foreman entry watches every theme +bin/dev # already wires up the watcher +``` + +## Overriding views + +Drop an ERB file at `app/themes/aurora/views/.html.erb` and it wins over `app/views/.html.erb` whenever your theme is active. This applies to **every** view, including `layouts/application.html.erb`. + +The default chrome partial does most of the layout work for you, so the minimum viable layout is: + +```erb +<%= render "layouts/abbey_chrome" do %> + <%= yield %> +<% end %> +``` + +Add any per-page wrappers, JS injection, or theme-specific markup inside the block. + +To override only the navigation while keeping everything else default, ship just: + +``` +app/themes/aurora/views/shared/_navigation.html.erb +``` + +## Picking a markdown renderer + +Abbey ships two: + +| Renderer | Output | +|-----------------------|---------------------------------------------------------------------------------| +| `MarkdownRender` | Inline Tailwind classes baked into the HTML (paragraphs, headings, code, etc.) | +| `MinimalMarkdownRender` | Semantic HTML only — no inline classes. Style via a wrapper like `.prose-X`. | + +Pick one in your manifest: + +```ruby +t.markdown_renderer = :default # MarkdownRender — backward compatible +t.markdown_renderer = :minimal # MinimalMarkdownRender — recommended for new themes +``` + +If you ship your own renderer, pass the class directly: + +```ruby +t.markdown_renderer = MyCustomRenderer +``` + +`:minimal` is recommended for new themes because it lets you style markdown content from your theme stylesheet (e.g. `.prose-aurora h1 { ... }`) without fighting hard-coded utility classes baked into the rendered HTML. + +## Per-theme JavaScript + +The chrome partial includes `shared/_dark_mode_script` near the end of the body. You can: + +* **Leave it alone** — the default partial ships a simple cookie-based dark mode toggle. +* **Override it** — drop `app/themes/aurora/views/shared/_dark_mode_script.html.erb` to ship a richer version (Turbo-safe handling, keyboard shortcuts, easter eggs — see `app/themes/grimoire/views/shared/_dark_mode_script.html.erb` for an example with Konami code). + +For arbitrary per-page JS (analytics, embeds, etc.), use `content_for(:after_body)` in your view and yield it from your theme's layout if you need it. + +## Dark mode + +Abbey's dark mode is class-based — `` carries `dark` when the cookie is set. Tailwind's `dark:` variant works out of the box for any utility you declare in `@theme`. + +The chrome partial reads two manifest fields to render the right `` classes: + +```ruby +t.dark_html_class = "dark" # add when dark mode is on +t.light_html_class = nil # add when dark mode is off +``` + +Most themes only need `dark_html_class = "dark"` (the default). The default theme uses `"dark bg-gray-900"` / `"bg-white"` because it relies on an ``-level background instead of ``. + +## Common patterns + +### Pure recolor (no markup changes) + +```sh +bin/rails g abbey:theme moss --minimal +``` + +Edit only `theme.rb` and `assets/tailwind.css`. The default chrome and view templates render through. + +### Distinctive cards / typography + +Ship `assets/tailwind.css` with custom `@theme` tokens + component classes, then override `views/blog/show.html.erb` and `views/blog/index.html.erb` to add the new classes. + +### Custom favicon / theme color + +All inline in `theme.rb` — `t.favicon_svg = ""` and `t.theme_color_light = "#fff"`. No precompiled assets to ship. + +### Custom Rouge syntax theme + +Drop `app/themes/aurora/assets/aurora-highlight.css` — Abbey's chrome partial auto-loads any `.css` file from your `assets/` directory (other than `tailwind.css`). + +## Gotchas + +* **Don't override `app/views/layouts/_abbey_chrome.html.erb`.** It's the shared partial — your theme's override won't be loaded because the partial lookup happens from the default view path. If you need a fundamentally different chrome, ship your own `views/layouts/application.html.erb` that doesn't render the chrome partial. + +* **`@source "../views"` is required in your Tailwind entry point.** Without it, Tailwind only generates utilities used in the default app — none of your theme's view classes will get emitted. + +* **Theme class collisions.** Tailwind only generates a utility if it sees the class name in scanned content. If you define `--color-aurora-bg` but never use `bg-aurora-bg` in a view, it won't appear in the CSS bundle. Add the class to a view (even just a comment in a view file) to force it. + +* **Default bundle isolation is enforced.** `app/assets/tailwind/application.css` excludes `app/themes/**` from its scan. If you accidentally reference a theme utility in a default view, Tailwind won't emit it — the default bundle stays clean. + +* **`bin/dev` runs three foreman processes**: web, default-css watcher, theme-css watcher. If you don't see your theme CSS updating on save, make sure the `themes:` process is running (check `Procfile.dev`). + +* **Theme manifests are loaded once at boot.** Editing `theme.rb` in development requires a restart for the chrome partial to pick up new field values. View file edits hot-reload normally. + +## Where to go next + +* [`docs/THEMES_API.md`](THEMES_API.md) — exhaustive manifest field reference and registry API. +* `app/themes/retro/` and `app/themes/grimoire/` — full-featured production themes you can study. +* `app/themes/midnight/` — a minimal sample theme demonstrating the "30-second recolor" pattern. diff --git a/docs/THEMES_API.md b/docs/THEMES_API.md new file mode 100644 index 0000000..40676c1 --- /dev/null +++ b/docs/THEMES_API.md @@ -0,0 +1,210 @@ +# Abbey Theme API Reference + +This document is the exhaustive reference for the `Abbey::Theme` manifest API and registry. For a guided walkthrough of how to author a theme end-to-end, see [`THEMES.md`](THEMES.md). + +> **Stability**: This API is the supported surface for community themes from Abbey 1.0 onward. Backward-incompatible changes will be called out in the changelog and require a major version bump. + +## Table of contents + +1. [Manifest fields](#manifest-fields) +2. [Registry methods](#registry-methods) +3. [Filesystem conventions](#filesystem-conventions) +4. [Boot flow](#boot-flow) +5. [Helpers available in theme views](#helpers-available-in-theme-views) +6. [Generator reference](#generator-reference) +7. [Rake tasks](#rake-tasks) +8. [Versioning](#versioning) + +## Manifest fields + +A theme manifest is a Ruby file at `app/themes//theme.rb`: + +```ruby +Abbey::Theme.register(:aurora) do |t| + t.display_name = "Aurora" + # ... +end +``` + +The block yields an `Abbey::Theme` instance with the following writable attributes. Every field is optional — defaults are shown. + +| Field | Default | Description | +|------------------------|----------------------------------------|-------------| +| `display_name` | `.titleize` | Human-readable name. Used in admin UI and the manifest reference output. | +| `html_class` | `"theme-"` | Always-on classes added to ``. Set to `nil` to omit. | +| `dark_html_class` | `"dark"` | Class added to `` when dark mode cookie is set. | +| `light_html_class` | `nil` | Class added to `` when dark mode is off. | +| `body_class` | `nil` | Class set on ``. Set to `nil` to omit the attribute entirely. | +| `main_class` | `"container mx-auto px-4 py-8"` | Class set on the `
` wrapper inside the chrome partial. | +| `markdown_renderer` | `:default` | `:default`, `:minimal`, or an actual class. See [Renderer](#markdown-renderer). | +| `theme_color_light` | `nil` | Hex color for ``. | +| `theme_color_dark` | `nil` | Hex color for ``. | +| `fonts` | `[]` | Array of full stylesheet URLs to inject in `` (preconnect tags are emitted automatically). | +| `favicon_svg` | `nil` | Inline SVG string emitted as ``. | + +### Markdown renderer + +`markdown_renderer` accepts: + +* `:default` — `MarkdownRender` (Tailwind-class-rich HTML output, backward compatible with older imported posts). +* `:minimal` — `MinimalMarkdownRender` (semantic HTML only; recommended for new themes that style markdown via wrapper classes like `.prose-`). +* A class — your own renderer that responds to Redcarpet's `Renderer` interface. + +```ruby +t.markdown_renderer = MyCustomRenderer +``` + +### Fonts + +`fonts` is just an array of strings. Each is emitted as a separate `` in ``. The chrome partial also emits the standard Google Fonts preconnect tags whenever this array is non-empty. To skip preconnects, leave `fonts` empty and inject the link tags yourself via `content_for(:head)` in a view (Abbey doesn't currently call `yield(:head)` in the chrome partial — see the open issue tracker). + +### Favicon + +`favicon_svg` is the raw SVG source (no surrounding `` tag). The chrome partial URL-encodes it and wraps it in `` so themes don't need to ship precompiled binary assets. + +## Registry methods + +```ruby +Abbey::Theme.register(name, &block) +``` + +Register a theme. `name` is symbolic. The block receives the theme instance for configuration. + +```ruby +Abbey::Theme.active +``` + +Returns the currently active `Abbey::Theme` instance, or the `Abbey::Theme::DefaultTheme` sentinel if no named theme is configured (or the configured name isn't registered). Always non-nil — safe to call without nil-checking. + +```ruby +Abbey::Theme.active? +``` + +Returns `true` if a named theme is active (i.e. `Abbey::Theme.active` is not the `DefaultTheme` sentinel). + +```ruby +Abbey::Theme.registry +``` + +Hash of `{ Symbol => Abbey::Theme }`. Iterable. + +```ruby +Abbey::Theme.discover +``` + +Returns an array of every theme name (`Symbol`) found on disk under `app/themes/*/theme.rb`, regardless of whether they've been loaded into the registry yet. + +```ruby +Abbey::Theme.load_all! +``` + +Loads every manifest under `app/themes/*/theme.rb`. Uses `Kernel#load` (not `require`) so manifests are re-evaluatable for tests and dev reload. Called once at boot from `config/initializers/themes.rb`. + +```ruby +Abbey::Theme.reset! +``` + +Empties the registry. Use in tests; don't call from app code. + +```ruby +Abbey::Theme.configured_name +``` + +Returns the configured theme name string (from `ABBEY_THEME` env var or `Rails.application.config.theme`, defaulting to `"default"`). + +## Filesystem conventions + +``` +app/themes// + theme.rb # manifest (required) + assets/ + tailwind.css # per-theme Tailwind entry point (recommended) + -highlight.css # optional: Rouge syntax theme + *.css # any extra CSS — auto-loaded when theme is active + views/ # ERB overrides (optional; ship any subset) + README.md # author notes +``` + +* The theme's `assets/` folder is registered with Propshaft at boot, so `stylesheet_link_tag ""` resolves correctly. +* Logical asset paths are flat (no `themes/` prefix), so namespace your filenames (`.css`, `-highlight.css`) to avoid collisions. +* The `views/` folder is prepended to Rails' view path **per-request** while the theme is active. Theme switching at runtime works without restarting the process. + +## Boot flow + +``` +Rails boot + └─ config/initializers/themes.rb + ├─ Rails.application.config.theme = ENV.fetch("ABBEY_THEME", "default") + ├─ Abbey::Theme.load_all! + │ └─ scans app/themes/*/theme.rb, evaluates each + └─ Registers each theme's assets/ folder with config.assets.paths + +Per-request + └─ Theming concern (before_action) + └─ Abbey::Theme.active.views_path → prepend_view_path + +Layout render + └─ app/views/layouts/application.html.erb (or theme override) + └─ render "layouts/abbey_chrome" + └─ reads Abbey::Theme.active manifest → emits head/body chrome + └─ render "shared/{navigation,footer,dark_mode_script}" + └─ theme view overrides apply via prepended view path +``` + +## Helpers available in theme views + +Theme view ERBs have access to every helper in `ApplicationHelper`, including: + +| Helper | Returns | +|---------------------------------|---------| +| `current_theme` | The active theme's name as a string. | +| `active_theme` | The active `Abbey::Theme` instance. | +| `theme_active?` | `true` unless the active theme is the default sentinel. | +| `theme_stylesheets` | Array of logical asset paths to load (e.g. `["tailwind-aurora", "aurora", "aurora-highlight"]`). | +| `dark_mode?` | `true` if the dark-mode cookie is set on the current request. | +| `chrome_html_class(theme)` | Computed `` class string for the active theme + dark mode state. | +| `meta_tags(...)` | Emits the full set of OG / Twitter / canonical meta tags. | + +Plus the standard Rails URL helpers, asset helpers, etc. + +## Generator reference + +```sh +bin/rails g abbey:theme NAME [--minimal] [--from=SOURCE] +``` + +| Flag | Effect | +|-------------|--------| +| (no flag) | Full scaffold: `theme.rb` + `assets/tailwind.css` + all 12 view stubs + `README.md`. | +| `--minimal` | Skips the view stubs; emits only manifest + tailwind entry + a 3-line layout + README. Use for pure recolors. | +| `--from=X` | Clones theme `X`'s `views/` and non-`tailwind.css` assets as the starting point. Use for serious overrides. | + +The generator: + +* Validates the name against `/\A[a-z][a-z0-9_]*\z/`. +* Refuses to overwrite an existing `app/themes//`. +* Honors `destination_root` so it composes cleanly with `Rails::Generators::TestCase`. + +## Rake tasks + +| Task | What it does | +|---------------------------------|--------------| +| `bin/rails tailwindcss:build` | Builds the default `tailwind.css` AND every theme's `tailwind-.css` (the per-theme task is chained as an after-action). | +| `bin/rails themes:tailwind:build` | Builds only the per-theme bundles. | +| `bin/rails themes:tailwind:watch` | Spawns one Tailwind watcher per theme (used by `Procfile.dev`). | +| `bin/rails themes:tailwind:clobber` | Removes every compiled `tailwind-.css`. Chained from `tailwindcss:clobber`. | + +## Versioning + +The manifest API and registry methods documented above are versioned alongside Abbey itself. Field additions are non-breaking. Field renames, removals, or behavior changes will be: + +1. Announced in the changelog with a deprecation cycle (one minor version) when feasible. +2. Required to ship in a major version bump otherwise. + +If you're shipping a community theme, pin Abbey's minimum version in your README. + +## See also + +* [`THEMES.md`](THEMES.md) — authoring guide and common patterns. +* [`lib/abbey/theme.rb`](../lib/abbey/theme.rb) — the registry implementation. +* [`app/views/layouts/_abbey_chrome.html.erb`](../app/views/layouts/_abbey_chrome.html.erb) — the chrome partial that consumes the manifest. diff --git a/lib/abbey/theme.rb b/lib/abbey/theme.rb new file mode 100644 index 0000000..5de78fd --- /dev/null +++ b/lib/abbey/theme.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +module Abbey + # Registry of opt-in themes that Abbey can render under. + # + # A theme lives in a single self-contained folder under `app/themes//` + # and declares its metadata in a `theme.rb` manifest: + # + # # app/themes/retro/theme.rb + # 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 bg-memphis-paper dark:bg-memphis-crt" + # t.markdown_renderer = :minimal + # t.theme_color_light = "#fff8ef" + # t.theme_color_dark = "#0a0e1a" + # t.fonts = ["https://fonts.googleapis.com/css2?..."] + # t.favicon_svg = "" + # end + # + # The registry exposes the active theme to: + # * `Theming` controller concern (view path prepending) + # * `ApplicationHelper#theme_stylesheets` (asset enumeration) + # * `Rendering#markdown_renderer` (renderer choice) + # * `_abbey_chrome.html.erb` partial (head/body chrome rendering) + # + # The "default" theme is implicit — no folder, no manifest, no overrides. + # Calling `Abbey::Theme.active` when the configured theme name is "default" + # (or unknown) returns a sentinel `DefaultTheme` instance whose accessors + # return `nil`/sensible blanks, so callers can use the registry without + # special-casing. + class Theme + THEMES_DIR = "app/themes" + + class << self + def registry + @registry ||= {} + end + + def register(name) + theme = new(name) + yield theme if block_given? + registry[name.to_sym] = theme + theme + end + + # Load every `app/themes/*/theme.rb` so manifests register themselves. + # Uses Kernel#load (not require) so manifests are re-evaluated each + # call — important for `reset!` + reload during tests and for code + # reloading in development. + def load_all!(root = Rails.root) + Dir.glob(root.join(THEMES_DIR, "*", "theme.rb")).sort.each do |manifest| + load manifest + end + registry + end + + # The currently active theme, as configured via `ABBEY_THEME` env var + # or `Rails.application.config.theme`. Returns the `default` sentinel + # if the configured theme isn't registered. + def active + name = configured_name + registry[name.to_sym] || DefaultTheme.instance + end + + def active? + !active.is_a?(DefaultTheme) + end + + def configured_name + ENV["ABBEY_THEME"].presence || + Rails.application.config.try(:theme).to_s.presence || + "default" + end + + def reset! + @registry = {} + end + + # All folders under app/themes/ that have a theme.rb. Useful for + # configuring Tailwind builds (Phase 2) and discovery. + def discover(root = Rails.root) + Dir.glob(root.join(THEMES_DIR, "*", "theme.rb")).sort.map do |manifest| + Pathname.new(manifest).parent.basename.to_s.to_sym + end + end + end + + attr_accessor :display_name, :html_class, :dark_html_class, :light_html_class, + :body_class, :main_class, :theme_color_light, :theme_color_dark, + :fonts, :favicon_svg + attr_reader :name, :markdown_renderer_choice + + def initialize(name) + @name = name.to_sym + @display_name = name.to_s.titleize + @html_class = "theme-#{name}" + @dark_html_class = "dark" + @light_html_class = nil + @body_class = nil + @main_class = "container mx-auto px-4 py-8" + @markdown_renderer_choice = :default + @theme_color_light = nil + @theme_color_dark = nil + @fonts = [] + @favicon_svg = nil + end + + # Accept either `:minimal` / `:default` or an actual renderer class. + def markdown_renderer=(value) + @markdown_renderer_choice = value + end + + # Resolve to the actual renderer class. Themes pick a symbol; we map. + def markdown_renderer + case @markdown_renderer_choice + when Class then @markdown_renderer_choice + when :minimal then MinimalMarkdownRender + else MarkdownRender + end + end + + # Filesystem layout helpers (paths relative to Rails.root). + def root_path(root = Rails.root) + root.join(THEMES_DIR, name.to_s) + end + + def views_path(root = Rails.root) + root_path(root).join("views") + end + + def assets_path(root = Rails.root) + root_path(root).join("assets") + end + + # CSS files this theme contributes, returned as logical asset paths + # (no extension) suitable for `stylesheet_link_tag`. Excludes + # `tailwind.css` (the per-theme Tailwind build, loaded via a separate + # mechanism in Phase 2). + # + # The theme's `assets/` folder is registered with Propshaft at boot, + # so logical paths are the bare filename: `themes/retro/retro.css` on + # disk resolves as `retro`. Themes namespace their assets via filename + # (`retro.css`, `retro-highlight.css`) to avoid collisions. + def stylesheets + return [] unless assets_path.directory? + + Dir.children(assets_path) + .select { |f| f.end_with?(".css") } + .reject { |f| f == "tailwind.css" } + .map { |f| f.delete_suffix(".css") } + .sort + end + + def default? + false + end + + # Sentinel for the unthemed default look. Returned by `Theme.active` + # when no named theme is configured (or the configured name isn't + # registered). Lets callers use the same API for default + named themes. + class DefaultTheme < Theme + include Singleton + + def initialize + super(:default) + @display_name = "Default" + @html_class = nil + @dark_html_class = "dark bg-gray-900" + @light_html_class = "bg-white" + @body_class = "min-h-screen bg-white dark:bg-gray-900 transition-colors" + @main_class = "container mx-auto px-4 py-8" + # The original default favicon: wizard emoji as inline SVG. + @favicon_svg = <<~SVG.strip + 🧙‍♂️ + SVG + end + + def markdown_renderer + MarkdownRender + end + + def views_path(_root = Rails.root) = nil + def assets_path(_root = Rails.root) = nil + def stylesheets = [] + def default? = true + end + end +end diff --git a/lib/generators/abbey/theme/templates/README.md.tt b/lib/generators/abbey/theme/templates/README.md.tt new file mode 100644 index 0000000..e32998d --- /dev/null +++ b/lib/generators/abbey/theme/templates/README.md.tt @@ -0,0 +1,28 @@ +# <%= theme_display_name %> + +An Abbey drop-in theme. Activate with: + +```sh +ABBEY_THEME=<%= file_name %> bin/dev +``` + +## Folder layout + +``` +app/themes/<%= file_name %>/ + theme.rb # manifest (Abbey::Theme.register) + assets/ + tailwind.css # per-theme Tailwind entry point + views/ # ERB overrides (any subset; the rest fall back to defaults) + README.md +``` + +## How it loads + +* Abbey boots → scans `app/themes/*/theme.rb` → registers each theme. +* `ABBEY_THEME=<%= file_name %>` (or `Rails.application.config.theme = "<%= file_name %>"`) makes this the active theme. +* The active theme's `views/` directory is prepended to Rails' view path, so any ERB you ship overrides Abbey's default. +* The chrome partial (`app/views/layouts/_abbey_chrome.html.erb`) renders ``, ``, navigation, and footer using values from `theme.rb`. You don't need to maintain a full layout unless you want to. +* The per-theme Tailwind bundle (`tailwind-<%= file_name %>.css`) is built into `app/assets/builds/` and loaded automatically. + +See `docs/THEMES.md` for the authoring guide and `docs/THEMES_API.md` for the full manifest reference. diff --git a/lib/generators/abbey/theme/templates/layouts/application.html.erb.tt b/lib/generators/abbey/theme/templates/layouts/application.html.erb.tt new file mode 100644 index 0000000..3ac7bbf --- /dev/null +++ b/lib/generators/abbey/theme/templates/layouts/application.html.erb.tt @@ -0,0 +1,5 @@ +<%%# Thin layout shell for the <%= file_name %> theme. + The shared chrome partial reads everything from theme.rb. %> +<%%= render "layouts/abbey_chrome" do %> + <%%= yield %> +<%% end %> diff --git a/lib/generators/abbey/theme/templates/tailwind.css.tt b/lib/generators/abbey/theme/templates/tailwind.css.tt new file mode 100644 index 0000000..b3ac182 --- /dev/null +++ b/lib/generators/abbey/theme/templates/tailwind.css.tt @@ -0,0 +1,35 @@ +/* ============================================================================= + <%= theme_display_name %> theme Tailwind entry point. + + Compiled into app/assets/builds/tailwind-<%= file_name %>.css by + `bin/rails tailwindcss:build` and loaded only when ABBEY_THEME=<%= file_name %> + is active. Self-contained: declare your design tokens in @theme below + and they'll generate matching utility classes (bg-*, text-*, shadow-*, + font-*, animate-*) for use in app/themes/<%= file_name %>/views/**. +============================================================================= */ + +@import 'tailwindcss'; + +/* Dark mode follows the `.dark` class on (cookie-driven toggle), + not the OS prefers-color-scheme setting. */ +@custom-variant dark (&:where(.dark, .dark *)); + +@config '../../../../config/tailwind.config.js'; + +/* Scan only this theme's views — keeps every other theme + the default + bundle out of this build so your utility set is just what your theme + uses. */ +@source "../views"; + +@plugin "@tailwindcss/typography"; + +@theme { + /* ---------- <%= theme_display_name %> palette ---------- */ + --color-<%= file_name %>-bg: #ffffff; + --color-<%= file_name %>-fg: #0a0a0a; + --color-<%= file_name %>-accent: <%= theme_module_color %>; + --color-<%= file_name %>-muted: #6b7280; + + /* Add more colors / shadows / animations here. + See docs/THEMES.md for examples + recipes. */ +} diff --git a/lib/generators/abbey/theme/templates/theme.rb.tt b/lib/generators/abbey/theme/templates/theme.rb.tt new file mode 100644 index 0000000..b381443 --- /dev/null +++ b/lib/generators/abbey/theme/templates/theme.rb.tt @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# Manifest for the <%= file_name %> theme. +# See docs/THEMES_API.md for the full manifest reference. +Abbey::Theme.register(:<%= file_name %>) do |t| + t.display_name = <%= theme_display_name.inspect %> + t.html_class = <%= theme_html_class.inspect %> + t.body_class = "min-h-screen flex flex-col bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100" + t.main_class = "container mx-auto px-4 py-8 max-w-5xl flex-1" + + # :default (Tailwind-class-rich HTML) or :minimal (semantic HTML). + # See app/models/concerns/rendering.rb for what each emits. + t.markdown_renderer = :default + + # Browser chrome color — emitted as for + # light + dark modes. Use a single value or both. + t.theme_color_light = "#ffffff" + t.theme_color_dark = "#0a0a0a" + + # Inline SVG favicon (kept inline so themes don't need precompiled + # binary assets shipped through the asset pipeline). Optional. + # t.favicon_svg = "" + + # Web fonts to inject in . Array of full stylesheet URLs. + # The chrome partial adds the preconnect s automatically. + # t.fonts = [ + # "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap", + # ] +end diff --git a/lib/generators/abbey/theme/templates/views/blog/index.html.erb.tt b/lib/generators/abbey/theme/templates/views/blog/index.html.erb.tt new file mode 100644 index 0000000..33f79e4 --- /dev/null +++ b/lib/generators/abbey/theme/templates/views/blog/index.html.erb.tt @@ -0,0 +1,17 @@ +<%%# <%= theme_display_name %> blog index. %> +
+ <%% @posts.each do |post| %> +
+

+ + <%%= post.title %> + +

+ +
+ <%%= post.rendered_excerpt.html_safe %> +
+
+ <%% end %> + <%%= paginate @posts %> +
diff --git a/lib/generators/abbey/theme/templates/views/blog/index_by_tag.html.erb.tt b/lib/generators/abbey/theme/templates/views/blog/index_by_tag.html.erb.tt new file mode 100644 index 0000000..bb52a43 --- /dev/null +++ b/lib/generators/abbey/theme/templates/views/blog/index_by_tag.html.erb.tt @@ -0,0 +1,3 @@ +<%%# <%= theme_display_name %> blog index filtered by tag. %> +

Posts tagged #<%%= @tag.name %>

+<%%= render "blog/index" %> diff --git a/lib/generators/abbey/theme/templates/views/blog/show.html.erb.tt b/lib/generators/abbey/theme/templates/views/blog/show.html.erb.tt new file mode 100644 index 0000000..bac561c --- /dev/null +++ b/lib/generators/abbey/theme/templates/views/blog/show.html.erb.tt @@ -0,0 +1,23 @@ +<%%# <%= theme_display_name %> blog post. %> +<%% content_for :title, @post.title %> +<%% content_for :meta do %> + <%%= meta_tags( + title: @post.title, + description: @post.markdown_excerpt, + type: "article", + published_time: @post.created_at.iso8601, + updated_time: @post.updated_at.iso8601, + tags: @post.tags.map(&:name), + ) %> +<%% end %> + +
+
+

<%%= @post.title %>

+ +
+
+ <%%= @post.rendered_body.html_safe %> +
+ <%%= render "shared/tags", tags: @post.tags %> +
diff --git a/lib/generators/abbey/theme/templates/views/links/_link.html.erb.tt b/lib/generators/abbey/theme/templates/views/links/_link.html.erb.tt new file mode 100644 index 0000000..47b9f22 --- /dev/null +++ b/lib/generators/abbey/theme/templates/views/links/_link.html.erb.tt @@ -0,0 +1,9 @@ +<%%# <%= theme_display_name %> single link. %> +
  • + + <%%= link.title %> + + <%% if link.notes.present? %> +

    <%%= link.notes %>

    + <%% end %> +
  • diff --git a/lib/generators/abbey/theme/templates/views/links/index.html.erb.tt b/lib/generators/abbey/theme/templates/views/links/index.html.erb.tt new file mode 100644 index 0000000..2fb0311 --- /dev/null +++ b/lib/generators/abbey/theme/templates/views/links/index.html.erb.tt @@ -0,0 +1,6 @@ +<%%# <%= theme_display_name %> links index. %> +

    Links

    +
      + <%%= render @links %> +
    +<%%= paginate @links %> diff --git a/lib/generators/abbey/theme/templates/views/pages/show.html.erb.tt b/lib/generators/abbey/theme/templates/views/pages/show.html.erb.tt new file mode 100644 index 0000000..ca0977c --- /dev/null +++ b/lib/generators/abbey/theme/templates/views/pages/show.html.erb.tt @@ -0,0 +1,8 @@ +<%%# <%= theme_display_name %> page. %> +<%% content_for :title, @page.title %> +
    +

    <%%= @page.title %>

    +
    + <%%= @page.rendered_body.html_safe %> +
    +
    diff --git a/lib/generators/abbey/theme/templates/views/papers/_paper.html.erb.tt b/lib/generators/abbey/theme/templates/views/papers/_paper.html.erb.tt new file mode 100644 index 0000000..dc2b31f --- /dev/null +++ b/lib/generators/abbey/theme/templates/views/papers/_paper.html.erb.tt @@ -0,0 +1,9 @@ +<%%# <%= theme_display_name %> single paper. %> +
  • + + <%%= paper.title %> + + <%% if paper.authors.present? %> +

    <%%= paper.authors %>

    + <%% end %> +
  • diff --git a/lib/generators/abbey/theme/templates/views/papers/index.html.erb.tt b/lib/generators/abbey/theme/templates/views/papers/index.html.erb.tt new file mode 100644 index 0000000..87891b5 --- /dev/null +++ b/lib/generators/abbey/theme/templates/views/papers/index.html.erb.tt @@ -0,0 +1,6 @@ +<%%# <%= theme_display_name %> papers index. %> +

    Papers

    +
      + <%%= render @papers %> +
    +<%%= paginate @papers %> diff --git a/lib/generators/abbey/theme/templates/views/shared/_admin_navigation.html.erb.tt b/lib/generators/abbey/theme/templates/views/shared/_admin_navigation.html.erb.tt new file mode 100644 index 0000000..fa3ddde --- /dev/null +++ b/lib/generators/abbey/theme/templates/views/shared/_admin_navigation.html.erb.tt @@ -0,0 +1,9 @@ +<%%# <%= theme_display_name %> theme admin nav. Visible only when authenticated. %> +<%% if authenticated? %> +
    + admin + new post + new page + <%%= button_to "logout", session_path(Current.session), method: :delete, class: "underline ml-auto" %> +
    +<%% end %> diff --git a/lib/generators/abbey/theme/templates/views/shared/_footer.html.erb.tt b/lib/generators/abbey/theme/templates/views/shared/_footer.html.erb.tt new file mode 100644 index 0000000..124ccf5 --- /dev/null +++ b/lib/generators/abbey/theme/templates/views/shared/_footer.html.erb.tt @@ -0,0 +1,9 @@ +<%%# <%= theme_display_name %> theme footer. %> +
    +
    +

    © <%%= Date.today.year %> <%%= Rails.application.config.site_name %>

    +

    crafted on + abbey +

    +
    +
    diff --git a/lib/generators/abbey/theme/templates/views/shared/_navigation.html.erb.tt b/lib/generators/abbey/theme/templates/views/shared/_navigation.html.erb.tt new file mode 100644 index 0000000..8d381b6 --- /dev/null +++ b/lib/generators/abbey/theme/templates/views/shared/_navigation.html.erb.tt @@ -0,0 +1,20 @@ +<%%# <%= theme_display_name %> theme navigation. Override of app/views/shared/_navigation.html.erb. %> +
    + +
    diff --git a/lib/generators/abbey/theme/templates/views/shared/_tags.html.erb.tt b/lib/generators/abbey/theme/templates/views/shared/_tags.html.erb.tt new file mode 100644 index 0000000..9ba2b1b --- /dev/null +++ b/lib/generators/abbey/theme/templates/views/shared/_tags.html.erb.tt @@ -0,0 +1,10 @@ +<%%# <%= theme_display_name %> theme tag chips. %> +<%% if tags.present? %> +
    + <%% tags.each do |tag| %> + + #<%%= tag.name %> + + <%% end %> +
    +<%% end %> diff --git a/lib/generators/abbey/theme/theme_generator.rb b/lib/generators/abbey/theme/theme_generator.rb new file mode 100644 index 0000000..4eadac0 --- /dev/null +++ b/lib/generators/abbey/theme/theme_generator.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require "rails/generators" +require "rails/generators/named_base" + +module Abbey + module Generators + # Scaffold a new drop-in theme under app/themes//. + # + # rails g abbey:theme aurora + # # full skeleton: theme.rb + assets/tailwind.css + a minimal + # # layout that renders through the shared chrome partial. + # + # rails g abbey:theme aurora --minimal + # # bare minimum: theme.rb + assets/tailwind.css + 3-line layout. + # # Use case: pure recolor on top of Abbey's default chrome. + # + # rails g abbey:theme aurora --from=retro + # # clones an existing theme's structure as the starting point. + # # Use case: a serious visual override that wants to start from + # # something more substantial than the bare skeleton. + class ThemeGenerator < Rails::Generators::NamedBase + source_root File.expand_path("templates", __dir__) + + desc "Scaffold a new Abbey drop-in theme under app/themes//" + + class_option :minimal, type: :boolean, default: false, + desc: "Only generate theme.rb + assets/tailwind.css + a 3-line layout (pure recolor)" + class_option :from, type: :string, default: nil, + desc: "Clone an existing theme as the starting point (e.g. --from=retro)" + + def validate_name + return if file_name.match?(/\A[a-z][a-z0-9_]*\z/) + + raise Thor::Error, + "Theme name must be lowercase, start with a letter, and contain only " \ + "letters, digits, and underscores (got: #{file_name.inspect})." + end + + def validate_destination + if File.exist?(File.join(destination_root, theme_path("theme.rb"))) + raise Thor::Error, "A theme already exists at #{theme_path('')}" + end + end + + def create_manifest + template "theme.rb.tt", theme_path("theme.rb") + end + + def create_tailwind_entry + template "tailwind.css.tt", theme_path("assets/tailwind.css") + end + + def create_layout + template "layouts/application.html.erb.tt", theme_path("views/layouts/application.html.erb") + end + + def create_readme + template "README.md.tt", theme_path("README.md") + end + + def clone_from_source + return unless options[:from].present? + + source_dir = Pathname.new(File.join(destination_root, "app/themes/#{options[:from]}")) + # Fall back to repo-level path when the generator is invoked + # against the real app (not a generator test): destination_root + # is the cwd in that case, which is also Rails.root. + source_dir = Rails.root.join("app/themes/#{options[:from]}") unless source_dir.directory? + unless source_dir.directory? + raise Thor::Error, + "--from=#{options[:from]}: no theme found at app/themes/#{options[:from]}" + end + + say_status :clone, "from #{options[:from]} (views/, assets/)" + dest_views = File.join(destination_root, theme_path("views")) + src_views = source_dir.join("views") + if src_views.directory? + FileUtils.mkdir_p(dest_views) + # Copy entry-by-entry so we merge into an existing dest (created + # by create_layout) instead of nesting `views/views/` under it. + Dir.children(src_views).each do |entry| + FileUtils.cp_r(src_views.join(entry).to_s, File.join(dest_views, entry)) + end + end + copy_extra_assets_from(source_dir) + end + + def scaffold_views + return if options[:minimal] || options[:from].present? + + scaffold_view_files.each do |relative| + template "views/#{relative}.tt", theme_path("views/#{relative}") + end + end + + def print_next_steps + say "" + say "Theme #{file_name.inspect} scaffolded.", :green + say "" + say "Next steps:" + say " 1. Edit app/themes/#{file_name}/theme.rb (display_name, colors, fonts)." + say " 2. Tweak app/themes/#{file_name}/assets/tailwind.css with your @theme tokens." + say " 3. (optional) Customize app/themes/#{file_name}/views/ to override layouts/partials." + say " 4. Boot the app with ABBEY_THEME=#{file_name} bin/dev" + end + + private + + # Returns the theme's path RELATIVE to destination_root (which Thor + # prefixes for us). Empty `rel` returns "app/themes/". + def theme_path(rel = "") + File.join("app/themes", file_name, rel.to_s).chomp("/") + end + + def copy_extra_assets_from(source_dir) + assets_src = source_dir.join("assets") + return unless assets_src.directory? + + dest_assets = File.join(destination_root, theme_path("assets")) + FileUtils.mkdir_p(dest_assets) + Dir.children(assets_src).each do |entry| + next if entry == "tailwind.css" + FileUtils.cp_r(assets_src.join(entry).to_s, File.join(dest_assets, entry)) + end + end + + # `layouts/application.html.erb` is emitted by `create_layout`; the + # rest of the view tree is scaffolded here. + def scaffold_view_files + %w[ + shared/_navigation.html.erb + shared/_footer.html.erb + shared/_admin_navigation.html.erb + shared/_tags.html.erb + blog/index.html.erb + blog/show.html.erb + blog/index_by_tag.html.erb + pages/show.html.erb + links/index.html.erb + links/_link.html.erb + papers/index.html.erb + papers/_paper.html.erb + ] + end + + def theme_display_name + file_name.titleize + end + + def theme_html_class + "theme-#{file_name}" + end + + def theme_module_color + "##{[ "0ea5e9", "ec4899", "10b981", "f59e0b", "8b5cf6" ].sample}" + end + end + end +end diff --git a/lib/minimal_markdown_render.rb b/lib/minimal_markdown_render.rb new file mode 100644 index 0000000..13d79e0 --- /dev/null +++ b/lib/minimal_markdown_render.rb @@ -0,0 +1,69 @@ +require "redcarpet" +require "rouge" +require "rouge/plugins/redcarpet" + +# Renderer that emits plain semantic HTML — no inline Tailwind utility +# classes — so themes can style markdown output entirely from a wrapper +# scope (e.g. `.prose-retro`). Used by any theme that registers itself as +# `Rails.application.config.theme_uses_minimal_renderer = true`, or +# explicitly chosen by the Rendering concern. +module Redcarpet + module Render + class MinimalHTML < ::Redcarpet::Render::HTML + def normal_text(text) + text + end + + def block_code(code, language) + %(
    #{code}
    ) + end + + def header(title, level) + "#{title}" + end + + def paragraph(text) + "

    #{text}

    " + end + + def list(content, list_type) + tag = list_type == :ordered ? "ol" : "ul" + "<#{tag}>#{content}" + end + + def list_item(content, _list_type) + "
  • #{content}
  • " + end + + def link(link, title, content) + title_attr = title ? %( title="#{title}") : "" + %(#{content}) + end + + def emphasis(text) + "#{text}" + end + + def double_emphasis(text) + "#{text}" + end + + def block_quote(quote) + "
    #{quote}
    " + end + + def hrule + "
    " + end + + def image(link, title, alt_text) + title_attr = title ? %( title="#{title}") : "" + %(#{alt_text}) + end + end + end +end + +class MinimalMarkdownRender < Redcarpet::Render::MinimalHTML + include Rouge::Plugins::Redcarpet +end diff --git a/lib/tasks/themes.rake b/lib/tasks/themes.rake new file mode 100644 index 0000000..69e4a97 --- /dev/null +++ b/lib/tasks/themes.rake @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +# Per-theme Tailwind builds. +# +# The default Abbey bundle (app/assets/tailwind/application.css) compiles +# to app/assets/builds/tailwind.css and explicitly EXCLUDES every theme +# folder from its @source scan. Each theme owns a self-contained +# app/themes//assets/tailwind.css that compiles to +# app/assets/builds/tailwind-.css and is loaded only when that +# theme is active. +# +# bin/rails tailwindcss:build # default bundle + every theme bundle +# bin/rails themes:tailwind:build +# bin/rails themes:tailwind:watch # one watcher per theme (for bin/dev) + +require "tailwindcss/ruby" +require "tailwindcss/commands" + +namespace :themes do + namespace :tailwind do + desc "Build a Tailwind bundle for every registered theme" + task build: :environment do + Abbey::Theme.load_all! + builds = Abbey::Theme.registry.values.filter_map do |theme| + input = theme.assets_path&.join("tailwind.css") + next unless input&.exist? + [theme.name.to_s, input, theme_output_path(theme.name)] + end + + if builds.empty? + puts "[themes] No theme tailwind.css inputs found, skipping." + next + end + + builds.each do |name, input, output| + FileUtils.mkdir_p(output.dirname) + cmd = build_command(input, output) + puts "[themes] Building #{name}: #{output.relative_path_from(Rails.root)}" + system(*cmd, exception: true) + end + end + + desc "Watch & rebuild every theme's Tailwind bundle (one process per theme)" + task watch: :environment do + Abbey::Theme.load_all! + pids = [] + + Abbey::Theme.registry.each do |name, theme| + input = theme.assets_path&.join("tailwind.css") + next unless input&.exist? + + output = theme_output_path(name) + FileUtils.mkdir_p(output.dirname) + + cmd = build_command(input, output, minify: false) + [ "-w" ] + puts "[themes] Watching #{name}: #{output.relative_path_from(Rails.root)}" + pids << Process.spawn(*cmd) + end + + if pids.empty? + puts "[themes] No theme tailwind.css inputs to watch." + next + end + + shutdown = ->(_sig) { + pids.each { |pid| Process.kill("TERM", pid) rescue nil } + } + trap("INT", shutdown) + trap("TERM", shutdown) + Process.waitall + rescue Interrupt + pids.each { |pid| Process.kill("TERM", pid) rescue nil } + end + + desc "Remove every theme's compiled Tailwind bundle" + task clobber: :environment do + Abbey::Theme.load_all! + Abbey::Theme.registry.each_key do |name| + path = theme_output_path(name) + if path.exist? + puts "[themes] Removing #{path.relative_path_from(Rails.root)}" + path.delete + end + end + end + end +end + +# After the default tailwindcss:build runs, build every theme bundle too. +# `enhance(&block)` registers the block as an *after* action. +if Rake::Task.task_defined?("tailwindcss:build") + Rake::Task["tailwindcss:build"].enhance do + Rake::Task["themes:tailwind:build"].invoke + end +end + +if Rake::Task.task_defined?("tailwindcss:clobber") + Rake::Task["tailwindcss:clobber"].enhance do + Rake::Task["themes:tailwind:clobber"].invoke + end +end + +# Helpers — defined at the top level so the tasks above can call them. + +def theme_output_path(name) + Rails.root.join("app/assets/builds/tailwind-#{name}.css") +end + +def build_command(input, output, minify: true) + cmd = [ Tailwindcss::Ruby.executable, "-i", input.to_s, "-o", output.to_s ] + cmd << "--minify" if minify && minify_default? + postcss = Rails.root.join("postcss.config.js") + cmd += [ "--postcss", postcss.to_s ] if postcss.exist? + cmd +end + +def minify_default? + return false if ENV["TAILWINDCSS_DEBUG"].present? + !Tailwindcss::Commands.rails_css_compressor? +end diff --git a/test/generators/abbey/theme_generator_test.rb b/test/generators/abbey/theme_generator_test.rb new file mode 100644 index 0000000..633d966 --- /dev/null +++ b/test/generators/abbey/theme_generator_test.rb @@ -0,0 +1,97 @@ +require "test_helper" +require "rails/generators/test_case" +require "generators/abbey/theme/theme_generator" + +class Abbey::Generators::ThemeGeneratorTest < Rails::Generators::TestCase + tests Abbey::Generators::ThemeGenerator + + destination File.expand_path("../../tmp/generator_test", __dir__) + setup :prepare_destination + + test "generates a complete theme scaffold under app/themes//" do + run_generator [ "midnight" ] + + assert_file "app/themes/midnight/theme.rb", /Abbey::Theme\.register\(:midnight\)/ + assert_file "app/themes/midnight/theme.rb", /t\.display_name\s+=\s+"Midnight"/ + assert_file "app/themes/midnight/theme.rb", /t\.html_class\s+=\s+"theme-midnight"/ + assert_file "app/themes/midnight/assets/tailwind.css", /@import 'tailwindcss'/ + assert_file "app/themes/midnight/assets/tailwind.css", /@source "..\/views"/ + assert_file "app/themes/midnight/assets/tailwind.css", /--color-midnight-/ + assert_file "app/themes/midnight/views/layouts/application.html.erb", %r{render "layouts/abbey_chrome"} + assert_file "app/themes/midnight/views/shared/_navigation.html.erb" + assert_file "app/themes/midnight/views/shared/_footer.html.erb" + assert_file "app/themes/midnight/views/shared/_admin_navigation.html.erb" + assert_file "app/themes/midnight/views/shared/_tags.html.erb" + assert_file "app/themes/midnight/views/blog/index.html.erb" + assert_file "app/themes/midnight/views/blog/show.html.erb" + assert_file "app/themes/midnight/views/blog/index_by_tag.html.erb" + assert_file "app/themes/midnight/views/pages/show.html.erb" + assert_file "app/themes/midnight/views/links/index.html.erb" + assert_file "app/themes/midnight/views/links/_link.html.erb" + assert_file "app/themes/midnight/views/papers/index.html.erb" + assert_file "app/themes/midnight/views/papers/_paper.html.erb" + assert_file "app/themes/midnight/README.md" + end + + test "--minimal only emits manifest + tailwind + layout shell" do + run_generator [ "spark", "--minimal" ] + + assert_file "app/themes/spark/theme.rb" + assert_file "app/themes/spark/assets/tailwind.css" + assert_file "app/themes/spark/views/layouts/application.html.erb" + assert_file "app/themes/spark/README.md" + assert_no_file "app/themes/spark/views/shared/_navigation.html.erb" + assert_no_file "app/themes/spark/views/blog/show.html.erb" + end + + test "--from clones the source theme's views + assets" do + FileUtils.mkdir_p(File.join(destination_root, "app/themes/retro/views/shared")) + FileUtils.mkdir_p(File.join(destination_root, "app/themes/retro/assets")) + File.write(File.join(destination_root, "app/themes/retro/views/shared/_navigation.html.erb"), "MARKER_NAV") + File.write(File.join(destination_root, "app/themes/retro/assets/retro-highlight.css"), "MARKER_HIGHLIGHT") + + run_generator [ "neon", "--from=retro" ] + + assert_file "app/themes/neon/theme.rb" + assert_file "app/themes/neon/assets/tailwind.css" + assert_file "app/themes/neon/views/shared/_navigation.html.erb", "MARKER_NAV" + assert_file "app/themes/neon/assets/retro-highlight.css", "MARKER_HIGHLIGHT" + # Source theme's tailwind.css is NOT cloned (regenerated from template). + refute_includes File.read(File.join(destination_root, "app/themes/neon/assets/tailwind.css")), + "memphis" + end + + test "rejects invalid theme names" do + # Thor catches Thor::Error and prints to stderr; the generator returns + # normally rather than raising, so we assert on stderr output. + # Rails::Generators::NamedBase runs file_name through #underscore, so + # casing alone isn't enough to fail — invalid input here is anything + # that survives normalization and still doesn't fit the slug shape. + [ "Has Spaces", "9starts-numeric", "with.dots" ].each do |bad| + err = capture(:stderr) { run_generator [ bad ] } + assert_match(/must be lowercase/, err, "should reject name: #{bad.inspect}") + end + end + + test "refuses to overwrite an existing theme" do + run_generator [ "twice" ] + err = capture(:stderr) { run_generator [ "twice" ] } + assert_match(/A theme already exists/, err) + end + + test "generated theme.rb is valid Ruby that registers with Abbey::Theme" do + run_generator [ "valid" ] + + Abbey::Theme.reset! + load File.join(destination_root, "app/themes/valid/theme.rb") + theme = Abbey::Theme.registry[:valid] + + assert theme, "generated manifest should register the :valid theme" + assert_equal "Valid", theme.display_name + assert_equal "theme-valid", theme.html_class + assert_equal MarkdownRender, theme.markdown_renderer + ensure + Abbey::Theme.reset! + Abbey::Theme.load_all! + end +end diff --git a/test/integration/theme_bundle_isolation_test.rb b/test/integration/theme_bundle_isolation_test.rb new file mode 100644 index 0000000..bd1764c --- /dev/null +++ b/test/integration/theme_bundle_isolation_test.rb @@ -0,0 +1,86 @@ +require "test_helper" + +# Regression test for Abbey's "drop-in theme" guarantee: the default +# Tailwind bundle must stay byte-for-byte unchanged regardless of how +# many themes the project ships. Each theme compiles to its own +# tailwind-.css and is loaded only when that theme is active. +# +# These assertions catch: +# * accidental leaks (the default scan picking up app/themes/**) +# * accidental migration of theme tokens back into application.css +# * theme bundles missing their own tokens +# +# Requires `bin/rails tailwindcss:build` to have run (the rails test +# rake task chains tailwindcss:build via test:prepare). +class ThemeBundleIsolationTest < ActiveSupport::TestCase + BUILDS = Rails.root.join("app/assets/builds") + + def read(path) + full = BUILDS.join(path) + skip "Tailwind build #{path} missing — run `bin/rails tailwindcss:build`" unless full.exist? + full.read + end + + test "default tailwind.css does not contain any theme color tokens" do + default = read("tailwind.css") + + refute_match(/--color-memphis-[a-z]+/, default, + "default bundle leaked Memphis color tokens — check @source not config in application.css") + refute_match(/--color-grim-[a-z]+/, default, + "default bundle leaked Grimoire color tokens — check @source not config in application.css") + refute_match(/--shadow-retro/, default, + "default bundle leaked retro shadow tokens") + end + + test "default tailwind.css does not contain any theme-only utilities" do + default = read("tailwind.css") + + refute_match(/\.bg-memphis-pink/, default, "default bundle emitted bg-memphis-pink — check @source not config") + refute_match(/\.bg-grim-void/, default, "default bundle emitted bg-grim-void — check @source not config") + refute_match(/\.shadow-retro-lg/, default, "default bundle emitted shadow-retro-lg — check @source not config") + end + + test "tailwind-retro.css contains memphis tokens + utilities" do + retro = read("tailwind-retro.css") + + assert_match(/--color-memphis-pink/, retro, "retro bundle missing memphis pink token") + assert_match(/--color-memphis-ink/, retro, "retro bundle missing memphis ink token") + assert_match(/--shadow-retro-lg/, retro, "retro bundle missing retro-lg shadow token") + assert_match(/\.bg-memphis-/, retro, "retro bundle missing memphis bg utility (used in views)") + end + + test "theme bundles use class-based dark mode, not prefers-color-scheme" do + %w[tailwind-retro.css tailwind-grimoire.css tailwind-midnight.css].each do |bundle| + css = read(bundle) + + refute_match(/prefers-color-scheme:\s*dark/, css, + "#{bundle} should not use OS dark mode — themes toggle `.dark` on ") + assert_match(/\.dark\\:bg-.*:where\(\.dark/, css, + "#{bundle} should emit class-based dark:bg-* utilities") + end + end + + test "tailwind-grimoire.css contains grim tokens + utilities" do + grimoire = read("tailwind-grimoire.css") + + assert_match(/--color-grim-void/, grimoire, "grimoire bundle missing grim void token") + assert_match(/--color-grim-parchment/, grimoire, "grimoire bundle missing grim parchment token") + assert_match(/\.bg-grim-/, grimoire, "grimoire bundle missing grim bg utility (used in views)") + end + + test "retro bundle does not contain grimoire tokens (themes are isolated from each other)" do + retro = read("tailwind-retro.css") + + refute_match(/--color-grim-[a-z]+/, retro, + "retro bundle leaked Grimoire tokens — each theme should only scan its own views") + end + + test "midnight (sample theme) bundle ships its tokens and stays out of the default" do + midnight = read("tailwind-midnight.css") + default = read("tailwind.css") + + assert_match(/--color-midnight-bg/, midnight, "midnight bundle missing its core token") + assert_match(/--color-midnight-accent/, midnight, "midnight bundle missing its accent token") + refute_match(/--color-midnight-/, default, "default bundle leaked Midnight tokens") + end +end diff --git a/test/integration/themes_test.rb b/test/integration/themes_test.rb new file mode 100644 index 0000000..c98c11f --- /dev/null +++ b/test/integration/themes_test.rb @@ -0,0 +1,144 @@ +require "test_helper" + +class ThemesTest < ActionDispatch::IntegrationTest + # The theme is read from Rails.application.config.theme at boot time, so we + # exercise theme switching by toggling it in place around each request. + setup do + @original_theme = Rails.application.config.theme + end + + teardown do + Rails.application.config.theme = @original_theme + end + + test "default theme renders the original layout" do + Rails.application.config.theme = "default" + + get root_path + assert_response :success + assert_no_match(/class="theme-retro/, response.body) + assert_no_match(/class="theme-grimoire/, response.body) + assert_no_match(%r{/assets/retro[-/]}, response.body, "default theme should not load retro CSS") + assert_no_match(%r{/assets/grimoire[-/]}, response.body, "default theme should not load grimoire CSS") + # Default chrome contract: html background, main wrapper, default favicon. + assert_match(//, response.body) + assert_match(/}, response.body) + assert_match(%r{ entirely from theme manifest" do + Rails.application.config.theme = "retro" + + get root_path + assert_response :success + body = response.body + + assert_match(//, body, "retro html_class should be applied") + assert_match(/}, body) + # theme_color emitted as media-aware pair from manifest + assert_match %r{}, body + assert_match %r{}, body + # Manifest fonts emitted with preconnect + assert_match %r{rel="preconnect" href="https://fonts.googleapis.com"}, body + assert_match %r{Press\+Start\+2P}, body + # Manifest favicon emitted (memphis 4-square SVG) + assert_match %r{/, response.body) + + Rails.application.config.theme = "retro" + get root_path + assert_match(//, response.body) + end + + test "dark mode script syncs cookie state and survives turbo navigation" do + Rails.application.config.theme = "retro" + + get root_path + assert_response :success + assert_match(/applyAbbeyDarkMode/, response.body) + assert_match(/turbo:load/, response.body) + assert_match(/theme-retro/, response.body) + end + + test "retro theme prepends its view path and loads theme stylesheets" do + Rails.application.config.theme = "retro" + + get root_path + assert_response :success + assert_match(/class="theme-retro/, response.body) + assert_match(%r{/assets/tailwind-retro[-.]}, response.body, "retro theme should load its Tailwind bundle") + assert_match(%r{/assets/retro[-.]}, response.body, "retro theme should load retro.css") + assert_match(%r{/assets/retro-highlight[-.]}, response.body, "retro theme should load retro-highlight.css") + # System test contract: header still has h1, footer still present. + assert_select "header h1" + assert_select "footer" + # Required nav links remain after retheming. + %w[Home About Projects Presentations Links Papers].each do |label| + assert_match(/>#{label}#{label}