Project

rori

0.0
The project is in a healthy, maintained state
Every page of a Rails app opens as a window in endlessly scrolling column strips, one strip per workspace. Pages say how they want to be shown (size, modal, full width, workspace, reuse key); ⌘K and a drop-down terminal run the same fuzzy-matched command tree; a keymap with a configurable modifier (⌘ in a native shell) and optional Blender-style hover keys drive the windows. Ghostty colour themes and Unsplash wallpapers included. The host app keeps its own controllers and views.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

 Project Readme

Rori

CI Gem Version

Try the live demo → (a desktop browser; it resets every night, so break anything you like)

A niri-style window manager for Rails pages — a "desk" of windows. Every page opens as a window in endlessly scrolling column strips, one strip per workspace; ⌘K and a drop-down terminal (`) run one fuzzy-matched command tree; a keymap with a configurable modifier (⌘ in a native shell) and optional hover keys drive the windows. Ghostty themes and Unsplash wallpapers included.

From an empty desk: ⌘K opens pages as windows, focus moves along the strip, the overview zooms out, and the terminal switches the theme

A Rails engine (not isolated): it wraps the host's own controllers and views.

See it wired into a small app in rori-demo: configuration, window options on pages, a server-side command with a background job, and a Tauri macOS wrapper.

A tour

Windows in strips: columns of any width, windows stacked inside them, the strip running off the edges; one strip per workspace.

A workspace with a users list, two users stacked in one column, and a service running off the right edge

⌘K matches whole paths through nested lists: uthen finds UI › Theme › Nord.

The command palette with "uthen" matching theme entries

The terminal (`) runs the same commands as words, with Tab completion and history. Server-side commands can ask first and report back as notifications.

The drop-down terminal after a confirmed Reindex search, with its notification

Overview (⌥O) zooms out to every workspace.

Overview of three workspaces

Hover keys (opt-in): point at an inactive window and bare keys act on it (W closes, R cycles its width, ⇧1–9 sends it to a workspace) while the cursor stays in the field you're typing in.

A window marked "keys → here" beside a form whose field has focus

Themes: bundled Ghostty palettes, or your own theme files.

The same desk in Rosé Pine Dawn

Every shortcut in one place (⌥?), generated from the live keymap.

The keyboard shortcuts modal

Requirements

Rails 8 with Propshaft, importmap, Turbo and Stimulus.

Your app's views stay whatever they are — ERB, Phlex, anything. Only the engine's own views are written in HAML, so the haml gem comes along as a dependency; you never have to write a line of it.

Why HAML? The desk's views are nothing but nesting, and HAML makes nesting the syntax: no closing tags, no towers of <% end %>. The ERB version had more angle brackets than a Rust signature with lifetimes — fn render<'a, T: View + 'a>(v: &'a T) -> Result<Box<dyn Html + 'a>, Error> — and nobody should have to read that at 2 a.m.

Wiring it into an app

# Gemfile
gem "rori"
bundle install
bin/rails g rori:install

The generator adds config/initializers/rori.rb, includes Rori::Windowed in ApplicationController, draws the desk's routes (and makes the blank desk the root, unless you have one), and registers the Stimulus controllers. Run it again any time; steps already in place are skipped. By hand, that's:

# app/controllers/application_controller.rb — every page renders as a window
class ApplicationController < ActionController::Base
  include Rori::Windowed
end

# config/routes.rb
Rails.application.routes.draw do
  draw :rori                  # ⌘K / terminal command lists
  root "rori/desktops#show"   # the blank desk
end

# config/initializers/rori.rb
Rori.configure do |rori|
  rori.records = %w[ User Project ]   # recent records listed in ⌘K
end
// app/javascript/controllers/index.js
import { registerRori } from "rori"
registerRori(application)

Pages declare how they're shown with window size:, mode:, workspace:, key: (see Rori::WindowHelper); links that should open a window use rori_link_to. A parameterless GET route shows up in ⌘K once it has a label under rori.commands.routes.<controller>.<action> in the app's locale.

CSS

The desk's CSS lives in its own cascade layer, rori (sub-layers rori.reset, rori.tokens, rori.base, rori.layout, rori.components, rori.themes), so it never mixes into the host's layers. Place it among yours with one name:

@layer reset, base, rori, layout, components, utilities;

Unmentioned, it lands after your layers (it loads later).

It needs nothing from the host: its components use only --rori-* tokens, each falling back from a host token when present — --color-canvas, --color-surface, --color-ink, --color-ink-muted, --color-line, --color-primary, --color-on-primary, --color-success, --color-danger, --color-hover, --color-backdrop, --gap, --radius, --radius-sm, --ease, --motion, --font-sans, --font-mono — to a built-in default. Themes set those host tokens, so they re-colour the host's pages as well.

What the host provides

  • Page styles (buttons, forms, tables); the desk styles only its chrome.
  • Head tags (favicons…): override app/views/layouts/rori/_head.html.haml.

Configuration

See lib/rori.rb for everything: app_name, records, commands, keymap, modifier / native_modifier / native_user_agent, hover_keys, terminal_key, wallpapers, theme and wallpaper files and cookies.

Records in ⌘K

Rori.configure do |rori|
  rori.records = %w[ User Project ]
  rori.record_limit = 25   # the default
end

Each time ⌘K opens (and when the terminal loads its commands), the desk lists the record_limit most recently updated records of every model named here, so typing “ada” jumps straight to that user. An entry is labelled with record.to_s, grouped by Model.model_name.human, and opens polymorphic_path(record) in a window. Each model therefore needs:

  • an updated_at column (the list is ordered by it);
  • a show route, e.g. resources :users (polymorphic_path raises without one);
  • a meaningful to_s, or the label reads #<User:0x…>.

It's a shortcut to recent work, not a search: older records don't appear, and each model costs one query per open. Leaving a model out only drops its records from ⌘K; its pages still open from links, and its index can still be listed through a route label (rori.commands.routes.<controller>.index).

Server-side commands and notifications

Rori.configure do |rori|
  rori.command :reindex_search, confirm: true do
    SearchReindexJob.perform_later
    I18n.t("search.reindex_started")   # optional: the notification's text (default "Done")
  end
end
en:
  rori:
    commands:
      custom:
        reindex_search: Reindex search

The command shows up in ⌘K and the terminal under Run. Picking it POSTs to /rori/commands/runs, runs the block and shows the result as a corner notification (errors stay until dismissed). confirm: true asks first: ↵ twice in ⌘K, y in the terminal. The block runs inside the request, so hand slow work to a job, which can report back from anywhere:

Rori.notify "Search reindexed", "1,204 records", kind: :success   # :info, :success, :error

Rori.notify broadcasts over Action Cable to Rori.notifications_stream, which every open desk subscribes to — all users see it, so keep it to single-user or admin desks.

Your own themes and wallpapers

Rori.configure do |rori|
  rori.themes_folder = "config/themes"   # Ghostty theme files (relative to Rails.root)
  rori.wallpapers = true
  rori.wallpapers_folder = "wallpapers"  # app/assets/images/wallpapers/*.{jpg,png,webp,avif}
end
  • Themes: any Ghostty theme file (e.g. from iTerm2-Color-Schemes/ghostty) dropped into the folder shows up in UI › Theme next to the bundled ones; a file with a bundled theme's name replaces it. No build step — their CSS is rendered into the page.
  • Wallpapers: the folder is a path inside the app's asset load path, so images are fingerprinted and served by Propshaft. When set, its images replace the bundled Unsplash photos.
  • UI › Wallpaper › Next wallpaper swaps the photo in place; Pin wallpaper keeps the current one across launches (toggle).

The bundled themes are compiled into the gem's themes.css; after changing Rori.themes_directory, run bin/rails rori:themes:build.

Customising the look

The desk's chrome reads only its own --rori-* tokens, and each one defaults to your token of the same meaning. So there are three levels, from broad to precise:

  1. Your design tokens — set them as usual; the desk follows:
    :root { --radius: 4px; --radius-sm: 2px; --gap: 8px; --font-sans: "Inter", sans-serif; }
    Supported: --color-canvas|surface|ink|ink-muted|line|primary|on-primary|success|danger|hover|backdrop, --gap, --radius, --radius-sm, --ease, --motion, --font-sans, --font-mono.
  2. Desk-only tokens — change the desk without touching your pages:
    @layer overrides {        /* any layer AFTER `rori`, or unlayered */
      :root { --rori-radius: 0; --rori-gap: 16px; }
    }
  3. Components — every chrome element has a rori- class (.rori-win, .rori-win__bar, .rori-menubar, .rori-minimap, .rori-palette, .rori-terminal, .rori-col…):
    @layer overrides {
      .rori-win { border-radius: 0; box-shadow: none; }
      .rori-win__bar { height: 26px; }
    }

The one rule: overrides must come after the rori layer. Cascade layers beat specificity, so a rule in a layer before rori loses even with a more specific selector — and so does a --rori-* token set there, since rori.tokens defines them on :root too. Put overrides in a later layer (components, utilities, a dedicated overrides) or leave them unlayered. Overriding your own tokens (level 1) works from any layer, because the desk only reads them.

Themes set the --color-* tokens, so a theme picked in UI › Theme wins over level-1 colours; use level 2 (--rori-ink, --rori-surface…) for colours a theme mustn't change.

Development

bundle install
bundle exec rake test     # models, controllers, the generator, against test/dummy
bundle exec rubocop
bin/rails server          # the dummy app, for poking at the desk

To work on the gem inside a real app, use the GitHub source in the app's Gemfile (gem "rori", github: "varyform/rori", branch: "main") and point Bundler at your checkout: bundle config set --local local.rori /path/to/rori. Releases: see RELEASING.md.