diff --git a/README.md b/README.md index e5ef215..54a249e 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 + optional `retro` Memphis/8-bit theme — see [Themes](#themes)) # Getting Started @@ -56,6 +57,76 @@ 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 an opt-in theme system. The default theme keeps the existing +minimal look. Built-in alternatives currently include: + +| 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. | + +## 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. + +## How themes work + +A theme can override any view by placing a same-named file under +`app/views/themes//`. Rails' view lookup is augmented at request time +(see `app/controllers/concerns/theming.rb`) so that: + + app/views/themes/retro/blog/index.html.erb + +wins over the default + + app/views/blog/index.html.erb + +whenever `Rails.application.config.theme == "retro"`. The same is true for +the `layouts/application.html.erb`, every partial under `shared/`, and the +page/links/papers views. + +A theme can also ship its own stylesheets under +`app/assets/stylesheets/themes/.css` and, optionally, +`themes/-highlight.css`. These are loaded automatically by the +default layout via the `theme_stylesheets` helper (in +`app/helpers/application_helper.rb`) when the theme is active. + +Finally, a theme can request the simpler `MinimalMarkdownRender` (semantic +HTML output, no inline Tailwind utility classes) by adding itself to +`Rails.application.config.themes_using_minimal_renderer` in +`config/initializers/themes.rb`. This lets the theme style markdown content +entirely from a wrapper class (e.g. `.prose-retro`) rather than fighting +the renderer's hard-coded utility classes. + +## Authoring a new theme + +The simplest theme is just a CSS file: + + # add a stylesheet under + app/assets/stylesheets/themes/sunset.css + + # set the theme: + ABBEY_THEME=sunset bin/dev + +The theme will be loaded after the default stylesheet, so it can override +anything cosmetic without touching the rest of the app. Add views under +`app/views/themes/sunset/` only when you need to change markup. + # Deploying to AWS ## Assumptions diff --git a/app/assets/stylesheets/themes/grimoire-highlight.css b/app/assets/stylesheets/themes/grimoire-highlight.css new file mode 100644 index 0000000..4f07a61 --- /dev/null +++ b/app/assets/stylesheets/themes/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/assets/stylesheets/themes/grimoire.css b/app/assets/stylesheets/themes/grimoire.css new file mode 100644 index 0000000..2b59836 --- /dev/null +++ b/app/assets/stylesheets/themes/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/assets/stylesheets/themes/retro-highlight.css b/app/assets/stylesheets/themes/retro-highlight.css new file mode 100644 index 0000000..9544796 --- /dev/null +++ b/app/assets/stylesheets/themes/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/assets/stylesheets/themes/retro.css b/app/assets/stylesheets/themes/retro.css new file mode 100644 index 0000000..b921ad5 --- /dev/null +++ b/app/assets/stylesheets/themes/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/assets/tailwind/application.css b/app/assets/tailwind/application.css index 08d6aa3..6014c59 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -2,15 +2,8 @@ @config '../../../config/tailwind.config.js'; -/* - -@layer components { - .btn-primary { - @apply py-2 px-4 bg-blue-200; - } -} - -*/ +@plugin "@tailwindcss/forms"; +@plugin "@tailwindcss/typography"; /* The default border color has changed to `currentcolor` in Tailwind CSS v4, @@ -30,5 +23,106 @@ } } -@plugin "@tailwindcss/forms"; -@plugin "@tailwindcss/typography"; +/* ============================================================================= + Theme design tokens (Tailwind v4 `@theme` directive) + + Defining custom colors/shadows/fonts/animations here registers them as + real Tailwind utilities — `bg-memphis-pink`, `text-grim-blood`, + `shadow-retro-lg`, `font-blackletter`, `animate-blink`, etc. — with full + support for variants (`dark:`, `hover:`, `group-hover:`) and arbitrary + values (`text-[0.62rem]`) out of the box. + + Themes never need to hand-write `.theme-foo .bg-foo-bar` declarations + for these utilities — Tailwind generates them. The theme stylesheets in + `app/assets/stylesheets/themes/*.css` only need to provide: + 1. theme-root environmental styles (page backdrop, scanlines, etc.) + 2. component classes (`.card-retro`, `.btn-retro`, `.tome`, etc.) + 3. typography styles for `.prose-retro` / `.prose-grimoire` + + Font-family tokens that collide with Tailwind defaults (`--font-sans`, + `--font-mono`) are NOT overridden globally; each theme rebinds them + inside its own `.theme-foo` scope via CSS variable cascade, so the + default theme keeps Tailwind's stock sans/mono. +============================================================================= */ + +@theme { + /* ---------- Retro / 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; + + /* ---------- 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; + + /* ---------- Retro hard-shadow utilities ---------- + Generate `shadow-retro`, `shadow-retro-sm`, `shadow-retro-lg`, + plus colored variants `shadow-retro-pink`, …-cyan, etc. */ + --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-specific font families ---------- + These generate the `font-display`, `font-blackletter`, `font-engraved`, + `font-plex`, `font-manuscript` utilities used by retro and grimoire + views. The values here are the defaults the utility resolves to outside + any theme scope; inside `.theme-retro` / `.theme-grimoire` the matching + CSS variables are rebound to the theme-specific fonts. The default + theme never uses these classes, so the global values are inert. */ + --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 ---------- + Generate `animate-blink`, `animate-wiggle`, `animate-pop-in` utilities. + Keyframes are also emitted into the bundle so the animations work + without needing the theme CSS file. */ + --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/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..aed72d6 --- /dev/null +++ b/app/controllers/concerns/theming.rb @@ -0,0 +1,31 @@ +# Prepends the active theme's view directory to the lookup path so that +# `app/views/themes//foo/bar.html.erb` overrides +# `app/views/foo/bar.html.erb` (including layouts) when a non-default 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? + end + + private + + def current_theme + Rails.application.config.theme.to_s.presence || "default" + end + + def theme_active? + current_theme != "default" + end + + def prepend_theme_view_path + return unless theme_active? + + theme_root = Rails.root.join("app/views/themes", current_theme) + return unless theme_root.exist? + + prepend_view_path theme_root.to_s + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index de6be79..b2ec717 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,2 +1,34 @@ module ApplicationHelper + # Override Propshaft's `:app` bulk inclusion so that stylesheets which + # live under `app/assets/stylesheets/themes/` are NOT auto-included. + # Those files belong to optional themes and are loaded separately via + # #theme_stylesheets only when their theme is active. This keeps the + # default theme byte-for-byte unchanged regardless of how many themes + # the project ships. + def app_stylesheets_paths + super.reject { |path| path.to_s.start_with?("themes/") } + end + + # Returns the names of any extra stylesheets that the active theme ships + # under `app/assets/stylesheets/themes/`. The default theme contributes + # nothing extra; other themes load `themes/.css` and (if present) + # `themes/-highlight.css`. + def theme_stylesheets + return [] unless theme_active? + + candidates = [ + "themes/#{current_theme}", + "themes/#{current_theme}-highlight" + ] + candidates.select { |name| theme_stylesheet_exists?(name) } + 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 end diff --git a/app/models/concerns/rendering.rb b/app/models/concerns/rendering.rb index b796cfa..292deb8 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,17 @@ def render(text) processed_markdown end end + + class_methods do + # Themes can request the minimal renderer (semantic HTML, no inline + # Tailwind classes) by adding their name to + # Rails.application.config.themes_using_minimal_renderer (Array). + # The default theme keeps the original MarkdownRender for backwards + # compatibility with existing imported posts. + def markdown_renderer + themes = Rails.application.config.try(:themes_using_minimal_renderer) || [] + active = Rails.application.config.try(:theme).to_s + themes.include?(active) ? MinimalMarkdownRender : MarkdownRender + end + end end diff --git a/app/views/themes/grimoire/blog/index.html.erb b/app/views/themes/grimoire/blog/index.html.erb new file mode 100644 index 0000000..c8c01ef --- /dev/null +++ b/app/views/themes/grimoire/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/views/themes/grimoire/blog/index_by_tag.html.erb b/app/views/themes/grimoire/blog/index_by_tag.html.erb new file mode 100644 index 0000000..d7ef060 --- /dev/null +++ b/app/views/themes/grimoire/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/views/themes/grimoire/blog/show.html.erb b/app/views/themes/grimoire/blog/show.html.erb new file mode 100644 index 0000000..31247aa --- /dev/null +++ b/app/views/themes/grimoire/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/views/themes/grimoire/layouts/application.html.erb b/app/views/themes/grimoire/layouts/application.html.erb new file mode 100644 index 0000000..2df8356 --- /dev/null +++ b/app/views/themes/grimoire/layouts/application.html.erb @@ -0,0 +1,112 @@ + + + + <%= content_for(:title) || Rails.application.config.site_name %> + + + + + + <%# Pixel-runic favicon: pentagram-circle with a single ember. %> + + + <%# Grimoire display fonts — only loaded when the grimoire theme is active. %> + + + + + <%= 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/themes/grimoire/links/_link.html.erb b/app/views/themes/grimoire/links/_link.html.erb new file mode 100644 index 0000000..fffb39b --- /dev/null +++ b/app/views/themes/grimoire/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/views/themes/grimoire/links/index.html.erb b/app/views/themes/grimoire/links/index.html.erb new file mode 100644 index 0000000..f3cb73d --- /dev/null +++ b/app/views/themes/grimoire/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/views/themes/grimoire/pages/show.html.erb b/app/views/themes/grimoire/pages/show.html.erb new file mode 100644 index 0000000..5f82116 --- /dev/null +++ b/app/views/themes/grimoire/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/views/themes/grimoire/papers/_paper.html.erb b/app/views/themes/grimoire/papers/_paper.html.erb new file mode 100644 index 0000000..4df35fa --- /dev/null +++ b/app/views/themes/grimoire/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/views/themes/grimoire/papers/index.html.erb b/app/views/themes/grimoire/papers/index.html.erb new file mode 100644 index 0000000..7a36c16 --- /dev/null +++ b/app/views/themes/grimoire/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/views/themes/grimoire/shared/_admin_navigation.html.erb b/app/views/themes/grimoire/shared/_admin_navigation.html.erb new file mode 100644 index 0000000..0224e4c --- /dev/null +++ b/app/views/themes/grimoire/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/views/themes/grimoire/shared/_footer.html.erb b/app/views/themes/grimoire/shared/_footer.html.erb new file mode 100644 index 0000000..9dc9797 --- /dev/null +++ b/app/views/themes/grimoire/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/views/themes/grimoire/shared/_navigation.html.erb b/app/views/themes/grimoire/shared/_navigation.html.erb new file mode 100644 index 0000000..39556b7 --- /dev/null +++ b/app/views/themes/grimoire/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/views/themes/grimoire/shared/_tags.html.erb b/app/views/themes/grimoire/shared/_tags.html.erb new file mode 100644 index 0000000..a5232ce --- /dev/null +++ b/app/views/themes/grimoire/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/views/themes/retro/blog/index.html.erb b/app/views/themes/retro/blog/index.html.erb new file mode 100644 index 0000000..3ada400 --- /dev/null +++ b/app/views/themes/retro/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/views/themes/retro/blog/index_by_tag.html.erb b/app/views/themes/retro/blog/index_by_tag.html.erb new file mode 100644 index 0000000..d0a8ee2 --- /dev/null +++ b/app/views/themes/retro/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/views/themes/retro/blog/show.html.erb b/app/views/themes/retro/blog/show.html.erb new file mode 100644 index 0000000..59f001f --- /dev/null +++ b/app/views/themes/retro/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/views/themes/retro/layouts/application.html.erb b/app/views/themes/retro/layouts/application.html.erb new file mode 100644 index 0000000..14548bb --- /dev/null +++ b/app/views/themes/retro/layouts/application.html.erb @@ -0,0 +1,49 @@ + + + + <%= content_for(:title) || Rails.application.config.site_name %> + + + + + + <%# Pixelated favicon — 4 nested squares in the Memphis palette. %> + + + <%# Retro display fonts — only loaded when the retro theme is active. %> + + + + + <%= 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/themes/retro/links/_link.html.erb b/app/views/themes/retro/links/_link.html.erb new file mode 100644 index 0000000..4b49fad --- /dev/null +++ b/app/views/themes/retro/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/views/themes/retro/links/index.html.erb b/app/views/themes/retro/links/index.html.erb new file mode 100644 index 0000000..236e2af --- /dev/null +++ b/app/views/themes/retro/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/views/themes/retro/pages/show.html.erb b/app/views/themes/retro/pages/show.html.erb new file mode 100644 index 0000000..bdd8df0 --- /dev/null +++ b/app/views/themes/retro/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/views/themes/retro/papers/_paper.html.erb b/app/views/themes/retro/papers/_paper.html.erb new file mode 100644 index 0000000..03ffd01 --- /dev/null +++ b/app/views/themes/retro/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/views/themes/retro/papers/index.html.erb b/app/views/themes/retro/papers/index.html.erb new file mode 100644 index 0000000..9a975c8 --- /dev/null +++ b/app/views/themes/retro/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/views/themes/retro/shared/_admin_navigation.html.erb b/app/views/themes/retro/shared/_admin_navigation.html.erb new file mode 100644 index 0000000..988974d --- /dev/null +++ b/app/views/themes/retro/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/views/themes/retro/shared/_footer.html.erb b/app/views/themes/retro/shared/_footer.html.erb new file mode 100644 index 0000000..8e62c4e --- /dev/null +++ b/app/views/themes/retro/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/views/themes/retro/shared/_navigation.html.erb b/app/views/themes/retro/shared/_navigation.html.erb new file mode 100644 index 0000000..5878998 --- /dev/null +++ b/app/views/themes/retro/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/views/themes/retro/shared/_tags.html.erb b/app/views/themes/retro/shared/_tags.html.erb new file mode 100644 index 0000000..210c565 --- /dev/null +++ b/app/views/themes/retro/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/config/initializers/themes.rb b/config/initializers/themes.rb new file mode 100644 index 0000000..63c679d --- /dev/null +++ b/config/initializers/themes.rb @@ -0,0 +1,25 @@ +# Opt-in theme system for Abbey. +# +# A theme can override any view by placing a same-named file under +# `app/views/themes//` (e.g. `app/views/themes/retro/blog/index.html.erb` +# wins over `app/views/blog/index.html.erb` when the theme is active). +# +# A theme can also ship its own stylesheets under +# `app/assets/stylesheets/themes/.css` (and optionally +# `themes/-highlight.css`) which the layout loads in addition to the +# default Tailwind build. +# +# Set the active theme with the ABBEY_THEME env var, or override here. +# Built-in themes: +# "default" — the original minimal abbey look +# "retro" — Memphis / 8-bit / CRT (loud, colorful, hand-typed HTML vibes) +# "grimoire" — retro hacker dark fantasy (Matrix-minimal monospace, void + +# phosphor + ember, illuminated drop caps, summoning circle) + +Rails.application.config.theme = ENV.fetch("ABBEY_THEME", "default") + +# Themes listed here render markdown via the MinimalMarkdownRender (semantic +# HTML, no inline Tailwind classes) so they can style content entirely from +# a wrapper class scope. Default theme keeps the original utility-class +# renderer to preserve current behavior. +Rails.application.config.themes_using_minimal_renderer = %w[retro grimoire] 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/test/integration/themes_test.rb b/test/integration/themes_test.rb new file mode 100644 index 0000000..741946a --- /dev/null +++ b/test/integration/themes_test.rb @@ -0,0 +1,98 @@ +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{themes/retro}, response.body, "default theme should not load theme stylesheets") + assert_no_match(%r{themes/grimoire}, response.body, "default theme should not load theme stylesheets") + assert_select "header h1" + assert_select "footer" + 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{themes/retro}, response.body, "retro theme should load themes/retro 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}