diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml
index 4286f90ae..bbd1eb22a 100644
--- a/.github/workflows/publish-image.yml
+++ b/.github/workflows/publish-image.yml
@@ -42,7 +42,7 @@ jobs:
uses: actions/checkout@v6
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3.11.1
+ uses: docker/setup-buildx-action@v3.12.0
- name: Log in to GHCR
uses: docker/login-action@v3.6.0
@@ -98,7 +98,7 @@ jobs:
- name: Attest image provenance (per-arch)
if: github.event_name != 'pull_request'
- uses: actions/attest-build-provenance@v3.0.0
+ uses: actions/attest-build-provenance@v3.1.0
with:
subject-name: ${{ steps.vars.outputs.canonical }}
subject-digest: ${{ steps.build.outputs.digest }}
@@ -115,7 +115,7 @@ jobs:
IMAGE_NAME: ${{ github.repository }}
steps:
- name: Set up Docker Buildx (for imagetools)
- uses: docker/setup-buildx-action@v3.11.1
+ uses: docker/setup-buildx-action@v3.12.0
- name: Log in to GHCR
uses: docker/login-action@v3.6.0
diff --git a/Dockerfile.dev b/Dockerfile.dev
deleted file mode 100644
index 9c20fcda5..000000000
--- a/Dockerfile.dev
+++ /dev/null
@@ -1,39 +0,0 @@
-# syntax = docker/dockerfile:1
-
-# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
-ARG RUBY_VERSION=3.4.7
-FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base
-
-# Rails app lives here
-WORKDIR /rails
-
-# Set development environment
-ENV RAILS_ENV="development" \
- BUNDLE_PATH="/usr/local/bundle"
-
-# Install packages needed for development
-RUN apt-get update -qq && \
- apt-get install --no-install-recommends -y curl libsqlite3-0 libvips build-essential pkg-config git ffmpeg groff libreoffice-writer libreoffice-impress libreoffice-calc mupdf-tools libyaml-dev libssl-dev sqlite3 && \
- rm -rf /var/lib/apt/lists /var/cache/apt/archives
-
-# Install application gems
-COPY Gemfile Gemfile.lock .ruby-version ./
-COPY lib/fizzy.rb ./lib/fizzy.rb
-COPY gems ./gems/
-RUN --mount=type=secret,id=GITHUB_TOKEN --mount=type=cache,id=fizzy-devbundle-${RUBY_VERSION},sharing=locked,target=/devbundle \
- gem install bundler foreman && \
- BUNDLE_PATH=/devbundle BUNDLE_GITHUB__COM="$(cat /run/secrets/GITHUB_TOKEN):x-oauth-basic" bundle install && \
- cp -a /devbundle/. "$BUNDLE_PATH"/ && \
- bundle clean --force
-
-# Final stage
-FROM base
-
-# Entrypoint prepares the database
-ENTRYPOINT ["/rails/bin/docker-entrypoint"]
-
-# Start the server by default
-EXPOSE 3000
-EXPOSE 3006
-EXPOSE 5001
-CMD ["./bin/thrust", "./bin/rails", "server"]
diff --git a/app/assets/images/jf-avatar.jpg b/app/assets/images/jf-avatar.jpg
new file mode 100644
index 000000000..07f9f6592
Binary files /dev/null and b/app/assets/images/jf-avatar.jpg differ
diff --git a/app/assets/images/jf-signature.svg b/app/assets/images/jf-signature.svg
new file mode 100644
index 000000000..0e0653d06
--- /dev/null
+++ b/app/assets/images/jf-signature.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/stylesheets/_global.css b/app/assets/stylesheets/_global.css
index 30e5da88a..25bc18277 100644
--- a/app/assets/stylesheets/_global.css
+++ b/app/assets/stylesheets/_global.css
@@ -10,7 +10,7 @@
--block-space-double: calc(var(--block-space) * 2);
/* Text */
- --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
+ --font-sans: "Adwaita Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
--font-serif: ui-serif, serif;
--font-mono: ui-monospace, monospace;
@@ -74,11 +74,13 @@
--z-events-column-header: 1;
--z-events-day-header: 3;
--z-popup: 10;
- --z-nav: 30;
- --z-flash: 40;
- --z-tooltip: 50;
- --z-bar: 60;
- --z-tray: 61;
+ --z-nav: 20;
+ --z-flash: 30;
+ --z-tooltip: 40;
+ --z-bar: 50;
+ --z-tray: 51;
+ --z-welcome: 52;
+ --z-nav-open: 100;
/* OKLCH colors: Fixed */
--lch-black: 0% 0 0;
diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css
index aed15d90f..07021a330 100644
--- a/app/assets/stylesheets/card-columns.css
+++ b/app/assets/stylesheets/card-columns.css
@@ -15,7 +15,7 @@
--column-width-collapsed: 40px;
--column-width-expanded: 450px;
--progress-increment: var(--progress-max-height) / var(--progress-max-cards);
- --progress-max-cards: 20;
+ --progress-max-cards: 15; /* should match first geared pagination page size */
--progress-max-height: 50dvh;
container-type: inline-size;
@@ -35,6 +35,7 @@
}
&:has(.cards) {
+ block-size: 100%;
min-block-size: 20lh;
}
@@ -344,6 +345,7 @@
&:after {
block-size: calc(var(--column-width-collapsed) + var(--card-count) * var(--progress-increment));
+ max-block-size: none;
opacity: 1;
inline-size: var(--column-width-collapsed);
}
@@ -756,7 +758,7 @@
--column-padding-expanded: calc(var(--column-gap) * 2);
--column-width-collapsed: 40px;
--progress-increment: var(--progress-max-width) / var(--progress-max-cards);
- --progress-max-cards: 20;
+ --progress-max-cards: 15; /* should match first geared pagination page size */
--progress-max-width: 100%;
padding-inline: 3vw;
diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css
index c3dbb7d74..c8453bc14 100644
--- a/app/assets/stylesheets/comments.css
+++ b/app/assets/stylesheets/comments.css
@@ -23,20 +23,15 @@
}
.comment {
- display: flex;
- margin-inline: auto;
- max-inline-size: var(--comment-max);
- position: relative;
-
- house-md-toolbar {
- margin-block-start: calc(var(--comment-padding-block) * -0.5);
+ /* Distinguish from the .comment class used for code formatting without extra specificity */
+ &:where(.comments &) {
+ display: flex;
+ margin-inline: auto;
+ max-inline-size: var(--comment-max);
+ position: relative;
}
- .house-md-content {
- padding: var(--comment-padding-block) 0 0;
- }
-
- .comment--system & {
+ .comment-by-system & {
--comment-padding-block: var(--block-space-half);
text-align: center;
@@ -72,7 +67,7 @@
var(--stripe-color) 1px 10px);
padding-inline: var(--comment-padding-inline);
- .comments--system-expanded .comment--system & {
+ .comments--system-expanded .comment-by-system & {
--stripe-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas));
}
@@ -144,7 +139,7 @@
position: absolute;
}
- .comment--system {
+ .comment-by-system {
display: none;
transition: var(--dialog-duration) allow-discrete;
transition-property: display;
@@ -155,7 +150,7 @@
}
/* Show the last system comment */
- :nth-last-child(1 of .comment--system) {
+ :nth-last-child(1 of .comment-by-system) {
display: contents;
.comment__history {
@@ -164,7 +159,7 @@
}
/* Hide the "Show history" button if there's only one system comment */
- :nth-child(1 of .comment--system) {
+ :nth-child(1 of .comment-by-system) {
.comment__history {
display: none;
}
diff --git a/app/assets/stylesheets/header.css b/app/assets/stylesheets/header.css
index a3b90ac4f..5a3c7ee83 100644
--- a/app/assets/stylesheets/header.css
+++ b/app/assets/stylesheets/header.css
@@ -15,6 +15,8 @@
max-inline-size: 100dvw;
padding-block: calc(var(--block-space-half) + env(safe-area-inset-top)) var(--block-space-half);
padding-inline: var(--main-padding);
+ position: relative;
+ z-index: var(--z-nav);
/* Change the grid size depending on how many buttons are present */
&:has(.header__actions > *:nth-child(1)) { --header-button-count: 1; }
@@ -25,6 +27,10 @@
row-gap: 0;
}
+ &:has(dialog[open]) {
+ z-index: var(--z-nav-open);
+ }
+
&:has(~ #main .card-columns) {
inline-size: 100dvw;
margin-inline: auto;
diff --git a/app/assets/stylesheets/layout.css b/app/assets/stylesheets/layout.css
index e82992dfc..b9f8d6367 100644
--- a/app/assets/stylesheets/layout.css
+++ b/app/assets/stylesheets/layout.css
@@ -22,11 +22,6 @@
max-inline-size: 100dvw;
}
- :where(#header) {
- position: relative;
- z-index: var(--z-nav);
- }
-
:is(#header, #footer) {
@media print {
display: none;
diff --git a/app/assets/stylesheets/nav.css b/app/assets/stylesheets/nav.css
index 57d1803bd..c48a78034 100644
--- a/app/assets/stylesheets/nav.css
+++ b/app/assets/stylesheets/nav.css
@@ -3,7 +3,7 @@
/* ------------------------------------------------------------------------ */
.nav__trigger {
- --input-background: var(--color-canvas);
+ --input-background: transparent;
--input-border-color: transparent;
--input-padding: 0.3em 2em 0.3em 0.75em;
@@ -17,21 +17,11 @@
@media (any-hover: hover) {
&:hover {
- filter: brightness(0.9);
+ --input-background: var(--color-ink-lighter);
span {
background: var(--color-ink-lighter);
}
-
- html[data-theme="dark"] & {
- --input-background: var(--color-ink-lighter);
- }
-
- @media (prefers-color-scheme: dark) {
- html:not([data-theme]) & {
- --input-background: var(--color-ink-lighter);
- }
- }
}
}
diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css
index e252d8981..91c04ff53 100644
--- a/app/assets/stylesheets/utilities.css
+++ b/app/assets/stylesheets/utilities.css
@@ -16,6 +16,7 @@
.txt-ink { color: var(--color-ink); }
.txt-reversed { color: var(--color-ink-inverted); }
.txt-negative { color: var(--color-negative); }
+ .txt-positive { color: var(--color-positive); }
.txt-subtle { color: var(--color-ink-dark); }
.txt-alert { color: var(--color-marker); }
.txt-undecorated { text-decoration: none; }
diff --git a/app/assets/stylesheets/welcome-letter.css b/app/assets/stylesheets/welcome-letter.css
new file mode 100644
index 000000000..80424ef66
--- /dev/null
+++ b/app/assets/stylesheets/welcome-letter.css
@@ -0,0 +1,27 @@
+@layer components {
+ .welcome-letter {
+ position: relative;
+ view-transition-name: welcome-letter;
+ z-index: var(--z-welcome);
+
+ h2, p {
+ text-wrap: pretty;
+ }
+ }
+
+ .welcome-letter__close {
+ inset: var(--block-space) var(--block-space) auto auto;
+ position: absolute;
+ }
+
+ .welcome-letter__signature {
+ background-color: currentColor;
+ block-size: 3em;
+ display: inline-block;
+ inline-size: 8em;
+ mask-image: url("jf-signature.svg");
+ mask-position: center;
+ mask-repeat: no-repeat;
+ mask-size: 8em 3em;
+ }
+}
diff --git a/app/controllers/cards/closures_controller.rb b/app/controllers/cards/closures_controller.rb
index c4aac26d1..a78b30441 100644
--- a/app/controllers/cards/closures_controller.rb
+++ b/app/controllers/cards/closures_controller.rb
@@ -2,20 +2,30 @@ class Cards::ClosuresController < ApplicationController
include CardScoped
def create
+ capture_card_location
@card.close
+ refresh_stream_if_needed
respond_to do |format|
- format.turbo_stream { render_card_replacement }
+ format.turbo_stream
format.json { head :no_content }
end
end
def destroy
@card.reopen
+ refresh_stream_after_reopen
respond_to do |format|
- format.turbo_stream { render_card_replacement }
+ format.turbo_stream
format.json { head :no_content }
end
end
+
+ private
+ def refresh_stream_after_reopen
+ if @card.awaiting_triage?
+ set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first.preloaded
+ end
+ end
end
diff --git a/app/controllers/cards/not_nows_controller.rb b/app/controllers/cards/not_nows_controller.rb
index 8eeb0e663..ea91436b0 100644
--- a/app/controllers/cards/not_nows_controller.rb
+++ b/app/controllers/cards/not_nows_controller.rb
@@ -2,10 +2,12 @@ class Cards::NotNowsController < ApplicationController
include CardScoped
def create
+ capture_card_location
@card.postpone
+ refresh_stream_if_needed
respond_to do |format|
- format.turbo_stream { render_card_replacement }
+ format.turbo_stream
format.json { head :no_content }
end
end
diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb
index bfdc1ee2e..daf22a5bc 100644
--- a/app/controllers/cards_controller.rb
+++ b/app/controllers/cards_controller.rb
@@ -61,6 +61,6 @@ class CardsController < ApplicationController
end
def card_params
- params.expect(card: [ :status, :title, :description, :image, :created_at, :last_active_at ])
+ params.expect(card: [ :title, :description, :image, :created_at, :last_active_at ])
end
end
diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb
index 1afb878ef..f30d518cc 100644
--- a/app/controllers/concerns/authorization.rb
+++ b/app/controllers/concerns/authorization.rb
@@ -26,7 +26,12 @@ module Authorization
end
def ensure_can_access_account
- redirect_to session_menu_path(script_name: nil) if Current.user.blank? || !Current.user.active?
+ if Current.user.blank? || !Current.user.active?
+ respond_to do |format|
+ format.html { redirect_to session_menu_path(script_name: nil) }
+ format.json { head :forbidden }
+ end
+ end
end
def redirect_existing_user
diff --git a/app/controllers/concerns/card_scoped.rb b/app/controllers/concerns/card_scoped.rb
index 52311809e..72e42d080 100644
--- a/app/controllers/concerns/card_scoped.rb
+++ b/app/controllers/concerns/card_scoped.rb
@@ -17,4 +17,15 @@ module CardScoped
def render_card_replacement
render turbo_stream: turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload })
end
+
+ def capture_card_location
+ @source_column = @card.column
+ @was_in_stream = @card.awaiting_triage?
+ end
+
+ def refresh_stream_if_needed
+ if @was_in_stream
+ set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first.preloaded
+ end
+ end
end
diff --git a/app/controllers/landings_controller.rb b/app/controllers/landings_controller.rb
index b3b4c9162..e1c206c7b 100644
--- a/app/controllers/landings_controller.rb
+++ b/app/controllers/landings_controller.rb
@@ -1,5 +1,7 @@
class LandingsController < ApplicationController
def show
+ flash.keep(:welcome_letter)
+
if Current.user.boards.one?
redirect_to board_path(Current.user.boards.first)
else
diff --git a/app/controllers/signups/completions_controller.rb b/app/controllers/signups/completions_controller.rb
index f0ea56ff9..4fcaaabd3 100644
--- a/app/controllers/signups/completions_controller.rb
+++ b/app/controllers/signups/completions_controller.rb
@@ -11,6 +11,7 @@ class Signups::CompletionsController < ApplicationController
@signup = Signup.new(signup_params)
if @signup.complete
+ flash[:welcome_letter] = true
redirect_to landing_url(script_name: @signup.account.slug)
else
render :new, status: :unprocessable_entity
diff --git a/app/helpers/columns_helper.rb b/app/helpers/columns_helper.rb
index b2dc74727..d99c125cb 100644
--- a/app/helpers/columns_helper.rb
+++ b/app/helpers/columns_helper.rb
@@ -12,6 +12,7 @@ module ColumnsHelper
def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, card_color: "var(--color-card-default)", data: {}, **properties, &block)
classes = token_list("cards", properties.delete(:class), "is-collapsed": collapsed)
+ hotkeys_disabled = data[:card_hotkeys_disabled]
data = {
drag_and_drop_target: "container",
@@ -37,6 +38,7 @@ module ColumnsHelper
navigable_list_auto_select_value: "false",
navigable_list_actionable_items_value: "true",
navigable_list_only_act_on_focused_items_value: "true",
+ card_hotkeys_disabled: hotkeys_disabled,
action: "keydown->navigable-list#navigate"
}, &block)
end
diff --git a/app/helpers/html_helper.rb b/app/helpers/html_helper.rb
index 218d58e78..a3d0bbab3 100644
--- a/app/helpers/html_helper.rb
+++ b/app/helpers/html_helper.rb
@@ -1,64 +1,5 @@
module HtmlHelper
- include ERB::Util
-
- EXCLUDE_PUNCTUATION = %(.?,:!;"'<>)
- EXCLUDE_PUNCTUATION_REGEX = /[#{Regexp.escape(EXCLUDE_PUNCTUATION)}]+\z/
-
def format_html(html)
- fragment = Nokogiri::HTML5.fragment(html)
-
- auto_link(fragment)
-
- fragment.to_html.html_safe
+ Loofah::HTML5::DocumentFragment.parse(html).scrub!(AutoLinkScrubber.new).to_html.html_safe
end
-
- private
- EXCLUDED_ELEMENTS = %w[ a figcaption pre code ]
- EMAIL_AUTOLINK_REGEXP = /\b[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\b/
- URL_REGEXP = URI::DEFAULT_PARSER.make_regexp(%w[http https])
-
- def auto_link(fragment)
- fragment.traverse do |node|
- next unless auto_linkable_node?(node)
-
- # Take care to escape the html text node, so that the subsequent Nokogiri re-parse doesn't
- # create tags where there aren't any.
- content = h(node.text)
- linked_content = content.dup
-
- auto_link_urls(linked_content)
- auto_link_emails(linked_content)
-
- if linked_content != content
- node.replace(Nokogiri::HTML5.fragment(linked_content))
- end
- end
- end
-
- def auto_linkable_node?(node)
- node.text? && node.ancestors.none? { |ancestor| EXCLUDED_ELEMENTS.include?(ancestor.name) }
- end
-
- def auto_link_urls(linked_content)
- linked_content.gsub!(URL_REGEXP) do |match|
- url, trailing_punct = extract_url_and_punctuation(match)
- %(#{url}#{trailing_punct})
- end
- end
-
- def extract_url_and_punctuation(url_match)
- url_match = CGI.unescapeHTML(url_match)
- if match = url_match.match(EXCLUDE_PUNCTUATION_REGEX)
- len = match[0].length
- [ url_match[..-(len+1)], url_match[-len..] ]
- else
- [ url_match, "" ]
- end
- end
-
- def auto_link_emails(text)
- text.gsub!(EMAIL_AUTOLINK_REGEXP) do |match|
- %(#{match})
- end
- end
end
diff --git a/app/javascript/controllers/card_hotkeys_controller.js b/app/javascript/controllers/card_hotkeys_controller.js
new file mode 100644
index 000000000..564c076ad
--- /dev/null
+++ b/app/javascript/controllers/card_hotkeys_controller.js
@@ -0,0 +1,134 @@
+import { Controller } from "@hotwired/stimulus"
+import { post } from "@rails/request.js"
+
+export default class extends Controller {
+ static outlets = [ "navigable-list" ]
+
+ connect() {
+ this.morphCompletePromise = null
+ this.morphCompleteResolver = null
+ }
+
+ handleKeydown(event) {
+ if (this.#shouldIgnore(event) || this.#hasModifier(event)) return
+
+ const handler = this.#keyHandlers[event.key.toLowerCase()]
+ if (handler) {
+ handler.call(this, event)
+ }
+ }
+
+ // Called when turbo:morph completes - resolves our waiting promise
+ handleMorphComplete() {
+ if (this.morphCompleteResolver) {
+ this.morphCompleteResolver()
+ this.morphCompleteResolver = null
+ this.morphCompletePromise = null
+ }
+ }
+
+ // Private
+
+ #shouldIgnore(event) {
+ const target = event.target
+ return target.tagName === "INPUT" ||
+ target.tagName === "TEXTAREA" ||
+ target.isContentEditable ||
+ target.closest("input, textarea, [contenteditable], lexxy-editor")
+ }
+
+ #hasModifier(event) {
+ return event.metaKey || event.ctrlKey || event.altKey || event.shiftKey
+ }
+
+ get #selectedCard() {
+ // Find the navigable-list that currently has focus
+ const focusedList = this.navigableListOutlets.find(list => list.hasFocus)
+ if (!focusedList) return null
+
+ const currentItem = focusedList.currentItem
+ if (currentItem?.classList.contains("card") && !this.#hotkeysDisabled(focusedList)) {
+ return { card: currentItem, controller: focusedList }
+ }
+ return null
+ }
+
+ async #postponeCard(event) {
+ const selection = this.#selectedCard
+ if (!selection) return
+
+ const url = selection.card.dataset.cardNotNowUrl
+ if (url) {
+ event.preventDefault()
+ await this.#performCardAction(url, selection)
+ }
+ }
+
+ async #closeCard(event) {
+ const selection = this.#selectedCard
+ if (!selection) return
+
+ const url = selection.card.dataset.cardClosureUrl
+ if (url) {
+ event.preventDefault()
+ await this.#performCardAction(url, selection)
+ }
+ }
+
+ async #assignToMe(event) {
+ const selection = this.#selectedCard
+ if (!selection) return
+
+ const url = selection.card.dataset.cardAssignToMeUrl
+ if (url) {
+ event.preventDefault()
+ await post(url, { responseKind: "turbo-stream" })
+ }
+ }
+
+ async #performCardAction(url, selection) {
+ const { controller } = selection
+ const visibleItems = controller.visibleItems
+ const currentIndex = visibleItems.indexOf(selection.card)
+ const wasLastItem = currentIndex === visibleItems.length - 1
+
+ // Set up promise to wait for morph completion
+ this.morphCompletePromise = new Promise(resolve => {
+ this.morphCompleteResolver = resolve
+ })
+
+ await post(url, { responseKind: "turbo-stream" })
+
+ // Wait for Turbo Stream morph to complete
+ await Promise.race([
+ this.morphCompletePromise,
+ new Promise(resolve => setTimeout(resolve, 200)) // Fallback timeout
+ ])
+
+ // Select the next card (or previous if it was the last)
+ const newVisibleItems = controller.visibleItems
+ if (newVisibleItems.length === 0) {
+ controller.clearSelection()
+ return
+ }
+
+ if (wasLastItem) {
+ controller.selectLast()
+ } else {
+ const nextIndex = Math.min(currentIndex, newVisibleItems.length - 1)
+ if (newVisibleItems[nextIndex]) {
+ await controller.selectItem(newVisibleItems[nextIndex])
+ }
+ }
+ }
+
+ #hotkeysDisabled(navigableList) {
+ return navigableList?.element.dataset.cardHotkeysDisabled === "true"
+ }
+
+ #keyHandlers = {
+ "["(event) { this.#postponeCard(event) },
+ "]"(event) { this.#closeCard(event) },
+ m(event) { this.#assignToMe(event) }
+ }
+}
diff --git a/app/javascript/controllers/css_variable_counter_controller.js b/app/javascript/controllers/css_variable_counter_controller.js
index 1fa2cd5b9..5b4e9c940 100644
--- a/app/javascript/controllers/css_variable_counter_controller.js
+++ b/app/javascript/controllers/css_variable_counter_controller.js
@@ -5,7 +5,7 @@ export default class extends Controller {
static targets = [ "item", "counter" ]
static values = {
propertyName: String,
- maxValue: { type: Number, default: 20 }
+ maxValue: { type: Number, default: 15 } // should match first geared pagination page size
}
initialize() {
@@ -13,7 +13,9 @@ export default class extends Controller {
}
connect() {
- this.#updateCounter()
+ if (this.itemTargets.length > 0) {
+ this.#updateCounter()
+ }
}
itemTargetConnected() {
diff --git a/app/javascript/controllers/dialog_controller.js b/app/javascript/controllers/dialog_controller.js
index cdc8ea7cd..f6449cea7 100644
--- a/app/javascript/controllers/dialog_controller.js
+++ b/app/javascript/controllers/dialog_controller.js
@@ -5,11 +5,13 @@ export default class extends Controller {
static targets = [ "dialog" ]
static values = {
modal: { type: Boolean, default: false },
- sizing: { type: Boolean, default: true }
+ sizing: { type: Boolean, default: true },
+ autoOpen: { type: Boolean, default: false }
}
connect() {
this.dialogTarget.setAttribute("aria-hidden", "true")
+ if (this.autoOpenValue) this.open()
}
open() {
diff --git a/app/javascript/controllers/navigable_list_controller.js b/app/javascript/controllers/navigable_list_controller.js
index dfe8d6087..0be9bd170 100644
--- a/app/javascript/controllers/navigable_list_controller.js
+++ b/app/javascript/controllers/navigable_list_controller.js
@@ -51,6 +51,10 @@ export default class extends Controller {
this.selectItem(target, true)
}
+ hoverSelect({ currentTarget }) {
+ this.selectItem(currentTarget)
+ }
+
selectCurrentOrReset(event) {
if (this.currentItem) {
this.#setCurrentFrom(this.currentItem)
@@ -227,6 +231,20 @@ export default class extends Controller {
})
}
+ // Public accessors for card_hotkeys_controller outlet
+ get visibleItems() {
+ return this.#visibleItems
+ }
+
+ clearSelection() {
+ this.#clearSelection()
+ this.currentItem = null
+ }
+
+ get hasFocus() {
+ return this.element.contains(document.activeElement)
+ }
+
#keyHandlers = {
ArrowDown(event) {
if (this.supportsVerticalNavigationValue) {
@@ -259,6 +277,6 @@ export default class extends Controller {
} else {
this.#clickCurrentItem(event)
}
- },
+ }
}
}
diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js
index 154bf4fc0..cf64df579 100644
--- a/app/javascript/controllers/theme_controller.js
+++ b/app/javascript/controllers/theme_controller.js
@@ -26,13 +26,27 @@ export default class extends Controller {
set #theme(theme) {
localStorage.setItem("theme", theme)
- if (theme === "auto") {
- document.documentElement.removeAttribute("data-theme")
- } else {
- document.documentElement.setAttribute("data-theme", theme)
+ const currentTheme = document.documentElement.getAttribute("data-theme") || "auto"
+ const hasChanged = currentTheme !== theme
+
+ const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches
+ const animate = hasChanged && !prefersReducedMotion
+
+ const applyTheme = () => {
+ if (theme === "auto") {
+ document.documentElement.removeAttribute("data-theme")
+ } else {
+ document.documentElement.setAttribute("data-theme", theme)
+ }
+
+ this.#updateButtons()
}
- this.#updateButtons()
+ if (animate && document.startViewTransition) {
+ document.startViewTransition(applyTheme)
+ } else {
+ applyTheme()
+ }
}
#applyStoredTheme() {
diff --git a/app/jobs/storage/reconcile_job.rb b/app/jobs/storage/reconcile_job.rb
index be0c0748c..cd3bf3803 100644
--- a/app/jobs/storage/reconcile_job.rb
+++ b/app/jobs/storage/reconcile_job.rb
@@ -1,9 +1,13 @@
class Storage::ReconcileJob < ApplicationJob
+ class ReconcileAborted < StandardError; end
+
queue_as :backend
+ limits_concurrency to: 1, key: ->(owner) { owner }
discard_on ActiveJob::DeserializationError
+ retry_on ReconcileAborted, wait: 1.minute, attempts: 3
def perform(owner)
- owner.reconcile_storage
+ raise ReconcileAborted, "Could not get stable snapshot for #{owner.class}##{owner.id}" unless owner.reconcile_storage
end
end
diff --git a/app/models/account/export.rb b/app/models/account/export.rb
index c5e3d64c3..1fd2689d2 100644
--- a/app/models/account/export.rb
+++ b/app/models/account/export.rb
@@ -37,6 +37,10 @@ class Account::Export < ApplicationRecord
update!(status: :completed, completed_at: Time.current)
end
+ def accessible_to?(accessor)
+ accessor == user
+ end
+
private
def generate_zip
Tempfile.new([ "export", ".zip" ]).tap do |tempfile|
diff --git a/app/models/board/publishable.rb b/app/models/board/publishable.rb
index 6c1ce47d4..8b113cc9e 100644
--- a/app/models/board/publishable.rb
+++ b/app/models/board/publishable.rb
@@ -15,6 +15,7 @@ module Board::Publishable
def published?
publication.present?
end
+ alias_method :publicly_accessible?, :published?
def publish
create_publication! unless published?
diff --git a/app/models/board/storage.rb b/app/models/board/storage.rb
index 64dde9985..24028e714 100644
--- a/app/models/board/storage.rb
+++ b/app/models/board/storage.rb
@@ -12,25 +12,32 @@ module Board::Storage
# Calculate actual storage by summing blob sizes.
#
- # Uses batched pluck queries to avoid loading huge ID arrays, and avoids
- # ActiveRecord model queries on ActiveStorage tables to sidestep cross-pool
- # issues when ActiveStorage uses separate connection pools (e.g., with replicas).
+ # Storage tracking is a business abstraction - we count what users upload.
+ # Original upload bytes only; variants/previews/derivatives excluded.
+ # Physical storage optimizations (deduplication, compression) don't affect quotas.
def calculate_real_storage_bytes
+ @card_ids = nil # Clear memoization for fresh calculation
card_image_bytes + card_embed_bytes + comment_embed_bytes + board_embed_bytes
end
+ def card_ids
+ @card_ids ||= cards.pluck(:id)
+ end
+
def card_image_bytes
sum_blob_bytes_in_batches \
ActiveStorage::Attachment.where(record_type: "Card", name: "image"),
- cards.pluck(:id)
+ card_ids
end
def card_embed_bytes
- sum_embed_bytes_for "Card", cards.pluck(:id)
+ sum_embed_bytes_for "Card", card_ids
end
def comment_embed_bytes
- sum_embed_bytes_for "Comment", Comment.where(card_id: cards.pluck(:id)).pluck(:id)
+ card_ids.each_slice(BATCH_SIZE).sum do |batch|
+ sum_embed_bytes_for "Comment", Comment.where(card_id: batch).pluck(:id)
+ end
end
def board_embed_bytes
@@ -48,9 +55,18 @@ module Board::Storage
end
def sum_blob_bytes_in_batches(base_scope, record_ids)
+ # Count per-attachment to match ledger model.
+ # Same blob attached 3 times = 3x bytes (business abstraction, not physical storage).
+ #
+ # Do NOT remove the join thinking it's a performance optimization - it's required
+ # for correct per-attachment counting. We keep ActiveStorage/ActionText in the same
+ # database (realm/geo partitioning, not functionality partitioning), so cross-table
+ # joins are fine.
record_ids.each_slice(BATCH_SIZE).sum do |batch_ids|
- blob_ids = base_scope.where(record_id: batch_ids).pluck(:blob_id)
- ActiveStorage::Blob.where(id: blob_ids).sum(:byte_size)
+ base_scope
+ .where(record_id: batch_ids)
+ .joins(:blob)
+ .sum("active_storage_blobs.byte_size")
end
end
end
diff --git a/app/models/card.rb b/app/models/card.rb
index 33c96e502..18957cb9b 100644
--- a/app/models/card.rb
+++ b/app/models/card.rb
@@ -48,6 +48,10 @@ class Card < ApplicationRecord
delegate :accessible_to?, to: :board
+ def publicly_accessible?
+ published? && board.publicly_accessible?
+ end
+
def card
self
end
@@ -68,6 +72,55 @@ class Card < ApplicationRecord
end
private
+ STORAGE_BATCH_SIZE = 1000
+
+ # Override to include comments, but only load comments that have attachments.
+ # Cards can have thousands of comments; most won't have attachments.
+ # Streams in batches to avoid loading all IDs into memory at once.
+ def storage_transfer_records
+ comment_ids_with_attachments = storage_comment_ids_with_attachments
+
+ if comment_ids_with_attachments.any?
+ [ self, *comments.where(id: comment_ids_with_attachments).to_a ]
+ else
+ [ self ]
+ end
+ end
+
+ def storage_comment_ids_with_attachments
+ direct = []
+ rich_text_map = {}
+
+ # Stream comment IDs in batches to avoid loading all into memory
+ comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch|
+ batch_ids = batch.pluck(:id)
+
+ direct.concat \
+ ActiveStorage::Attachment
+ .where(record_type: "Comment", record_id: batch_ids)
+ .distinct
+ .pluck(:record_id)
+
+ ActionText::RichText
+ .where(record_type: "Comment", record_id: batch_ids)
+ .pluck(:id, :record_id)
+ .each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id }
+ end
+
+ embed_comment_ids = if rich_text_map.any?
+ rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids|
+ ActiveStorage::Attachment
+ .where(record_type: "ActionText::RichText", record_id: batch_ids)
+ .distinct
+ .pluck(:record_id)
+ end.filter_map { |rt_id| rich_text_map[rt_id] }
+ else
+ []
+ end
+
+ (direct + embed_comment_ids).uniq
+ end
+
def set_default_title
self.title = "Untitled" if title.blank?
end
diff --git a/app/models/comment.rb b/app/models/comment.rb
index d0315eea8..30706e8b5 100644
--- a/app/models/comment.rb
+++ b/app/models/comment.rb
@@ -15,7 +15,7 @@ class Comment < ApplicationRecord
after_create_commit :watch_card_by_creator
- delegate :board, :watch_by, to: :card
+ delegate :publicly_accessible?, :accessible_to?, :board, :watch_by, to: :card
def to_partial_path
"cards/#{super}"
diff --git a/app/models/concerns/storage/totaled.rb b/app/models/concerns/storage/totaled.rb
index 3f220f55f..b54a6ef22 100644
--- a/app/models/concerns/storage/totaled.rb
+++ b/app/models/concerns/storage/totaled.rb
@@ -19,7 +19,7 @@ module Storage::Totaled
# Exact: snapshot + pending entries
def bytes_used_exact
- (storage_total || create_storage_total!).current_usage
+ create_or_find_storage_total.current_usage
end
def materialize_storage_later
@@ -28,7 +28,7 @@ module Storage::Totaled
# Materialize all pending entries into snapshot
def materialize_storage
- total = storage_total || create_storage_total!
+ total = create_or_find_storage_total
total.with_lock do
latest_entry_id = storage_entries.maximum(:id)
@@ -44,26 +44,44 @@ module Storage::Totaled
end
# Reconcile ledger against actual attachment storage.
- # Uses cursor to ensure consistency: captures max entry ID first, then calculates
- # real bytes, then sums only entries up to that cursor. Concurrent uploads during
- # calculation will have entries with IDs beyond the cursor, avoiding double-count.
+ #
+ # Uses two-cursor approach for consistency: capture cursor before AND after the
+ # scan. If they differ, entries were added during the scan and we can't get an
+ # accurate diff without risking double-counting or undercounting.
+ #
+ # Returns true if reconciled successfully, false if aborted due to concurrent
+ # writes. Caller (ReconcileJob) handles retries to avoid amplification.
def reconcile_storage
- max_entry_id = storage_entries.maximum(:id)
+ cursor_before = storage_entries.maximum(:id)
real_bytes = calculate_real_storage_bytes
- ledger_bytes = max_entry_id ? storage_entries.where(id: ..max_entry_id).sum(:delta) : 0
- diff = real_bytes - ledger_bytes
+ cursor_after = storage_entries.maximum(:id)
- if diff.nonzero?
- Storage::Entry.record \
- account: is_a?(Account) ? self : account,
- board: is_a?(Board) ? self : nil,
- recordable: nil,
- delta: diff,
- operation: "reconcile"
+ if cursor_before != cursor_after
+ Rails.logger.warn "[Storage] Reconcile aborted for #{self.class}##{id}: cursor moved during scan"
+ false
+ else
+ ledger_bytes = cursor_after ? storage_entries.where(id: ..cursor_after).sum(:delta) : 0
+ diff = real_bytes - ledger_bytes
+
+ if diff.nonzero?
+ Rails.logger.info "[Storage] Reconcile #{self.class}##{id}: adjusting by #{diff} bytes"
+ Storage::Entry.record \
+ account: is_a?(Account) ? self : account,
+ board: is_a?(Board) ? self : nil,
+ recordable: nil,
+ delta: diff,
+ operation: "reconcile"
+ end
+
+ true
end
end
private
+ def create_or_find_storage_total
+ self.storage_total ||= Storage::Total.create_or_find_by!(owner: self)
+ end
+
def calculate_real_storage_bytes
raise NotImplementedError, "Subclass must implement calculate_real_storage_bytes"
end
diff --git a/app/models/concerns/storage/tracked.rb b/app/models/concerns/storage/tracked.rb
index 7b96e95c0..745ef6692 100644
--- a/app/models/concerns/storage/tracked.rb
+++ b/app/models/concerns/storage/tracked.rb
@@ -1,3 +1,6 @@
+# Storage tracking is a business abstraction - we count what users upload.
+# Original upload bytes only; variants/previews/derivatives excluded.
+# Physical storage optimizations (deduplication, compression) don't affect quotas.
module Storage::Tracked
extend ActiveSupport::Concern
@@ -22,21 +25,27 @@ module Storage::Tracked
private
def board_transfer?
- respond_to?(:board_id_changed?) && board_id_changed?
+ respond_to?(:will_save_change_to_board_id?) && will_save_change_to_board_id?
end
def track_board_transfer
- old_board_id = board_id_was
- current_bytes = storage_bytes
+ old_board = Board.find_by(id: attribute_in_database(:board_id))
+ records = storage_transfer_records.compact
+ return if records.empty?
+
+ attachments_by_record = storage_attachments_for_records(records)
+
+ attachments_by_record.each do |recordable, attachments|
+ bytes = attachments.sum { |attachment| attachment.blob.byte_size }
+ next if bytes.zero?
- if current_bytes.positive?
# Debit old board
- if old_board_id
+ if old_board
Storage::Entry.record \
account: account,
- board_id: old_board_id,
- recordable: self,
- delta: -current_bytes,
+ board: old_board,
+ recordable: recordable,
+ delta: -bytes,
operation: "transfer_out"
end
@@ -44,14 +53,45 @@ module Storage::Tracked
Storage::Entry.record \
account: account,
board: board,
- recordable: self,
- delta: current_bytes,
+ recordable: recordable,
+ delta: bytes,
operation: "transfer_in"
end
end
+ def storage_transfer_records
+ [ self ]
+ end
+
# Override if needed. Default = all direct attachments
- def attachments_for_storage
- ActiveStorage::Attachment.where(record: self)
+ def attachments_for_storage(recordable = self)
+ storage_attachments_for_records([ recordable ]).fetch(recordable, [])
+ end
+
+ def storage_attachments_for_records(recordables)
+ records = Array(recordables).compact
+ return {} if records.empty?
+
+ # Build lookup for records by (type, id) to avoid N+1 when resolving RichText parents
+ records_by_key = records.index_by { |r| [ r.class.name, r.id ] }
+
+ rich_texts = ActionText::RichText.where(record: records)
+ rich_text_to_parent = rich_texts.to_h { |rt| [ rt.id, records_by_key[[ rt.record_type, rt.record_id ]] ] }
+
+ attachments = ActiveStorage::Attachment
+ .where(record: records + rich_texts)
+ .includes(:blob)
+ .to_a
+
+ attachments.each_with_object(Hash.new { |h, k| h[k] = [] }) do |attachment, grouped|
+ # Resolve parent without N+1: use lookup for RichText, direct for others
+ recordable = if attachment.record_type == "ActionText::RichText"
+ rich_text_to_parent[attachment.record_id]
+ else
+ records_by_key[[ attachment.record_type, attachment.record_id ]]
+ end
+
+ grouped[recordable] << attachment if recordable
+ end
end
end
diff --git a/app/models/event/description.rb b/app/models/event/description.rb
index 672784ddb..f44233011 100644
--- a/app/models/event/description.rb
+++ b/app/models/event/description.rb
@@ -14,7 +14,7 @@ class Event::Description
end
def to_plain_text
- to_sentence(creator_name, card.title)
+ to_sentence(creator_name, h(card.title))
end
private
diff --git a/app/models/ssrf_protection.rb b/app/models/ssrf_protection.rb
index a73b6b3be..40be7d157 100644
--- a/app/models/ssrf_protection.rb
+++ b/app/models/ssrf_protection.rb
@@ -16,13 +16,19 @@ module SsrfProtection
def resolve_public_ip(hostname)
ip_addresses = resolve_dns(hostname)
- public_ips = ip_addresses.reject { |ip| private_address?(ip) }
+ public_ips = ip_addresses.reject { |ip| blocked_address?(ip) }
public_ips.sort_by { |ipaddr| ipaddr.ipv4? ? 0 : 1 }.first&.to_s
end
- def private_address?(ip)
+ def blocked_address?(ip)
ip = IPAddr.new(ip.to_s) unless ip.is_a?(IPAddr)
- ip.private? || ip.loopback? || ip.link_local? || ip.ipv4_mapped? || in_disallowed_range?(ip)
+
+ ip.private? ||
+ ip.loopback? ||
+ ip.link_local? ||
+ ip.ipv4_mapped? ||
+ ip.ipv4_compat? ||
+ in_disallowed_range?(ip)
end
private
diff --git a/app/models/storage.rb b/app/models/storage.rb
index 9c17b7cd9..445362b34 100644
--- a/app/models/storage.rb
+++ b/app/models/storage.rb
@@ -2,4 +2,17 @@ module Storage
def self.table_name_prefix
"storage_"
end
+
+ # Record types that participate in storage tracking (ledger entries created on attach).
+ # The no-reuse validation uses this to scope its check.
+ #
+ # IMPORTANT: Update this constant when adding tracked attachments to new models.
+ # If you add a direct attachment (not via RichText embeds) to Comment, Board, or
+ # another model with Storage::Tracked, you must add its record_type here or the
+ # no-reuse validation won't protect it.
+ TRACKED_RECORD_TYPES = %w[Card ActionText::RichText].freeze
+
+ # Account ID for template/demo blobs that can be reused cross-tenant.
+ # Set to nil to disable the whitelist (no exemptions).
+ TEMPLATE_ACCOUNT_ID = nil
end
diff --git a/app/models/storage/attachment_tracking.rb b/app/models/storage/attachment_tracking.rb
index d68f5845d..1c3c7151d 100644
--- a/app/models/storage/attachment_tracking.rb
+++ b/app/models/storage/attachment_tracking.rb
@@ -26,24 +26,23 @@ module Storage::AttachmentTracking
return unless @storage_snapshot
Storage::Entry.record \
- account_id: @storage_snapshot[:account_id],
- board_id: @storage_snapshot[:board_id],
- recordable_type: @storage_snapshot[:recordable_type],
- recordable_id: @storage_snapshot[:recordable_id],
- blob_id: @storage_snapshot[:blob_id],
+ account: @storage_snapshot[:account],
+ board: @storage_snapshot[:board],
+ recordable: @storage_snapshot[:recordable],
+ blob: blob,
delta: -blob.byte_size,
operation: "detach"
end
+ # Snapshot records in before_destroy since parent may be deleted by the time
+ # after_destroy_commit runs. The records may be destroyed but .id still works.
def snapshot_storage_context
return unless storage_tracked_record
@storage_snapshot = {
- account_id: storage_tracked_record.account.id,
- board_id: storage_tracked_record.board_for_storage_tracking&.id,
- recordable_type: storage_tracked_record.class.name,
- recordable_id: storage_tracked_record.id,
- blob_id: blob.id
+ account: storage_tracked_record.account,
+ board: storage_tracked_record.board_for_storage_tracking,
+ recordable: storage_tracked_record
}
end
diff --git a/app/models/storage/entry.rb b/app/models/storage/entry.rb
index a2f6e726b..768a4c6f8 100644
--- a/app/models/storage/entry.rb
+++ b/app/models/storage/entry.rb
@@ -5,30 +5,26 @@ class Storage::Entry < ApplicationRecord
scope :pending, ->(last_entry_id) { where.not(id: ..last_entry_id) if last_entry_id }
- # Accepts either objects or _id params (for after_destroy_commit snapshots)
- def self.record(delta:, operation:, account: nil, account_id: nil, board: nil, board_id: nil,
- recordable: nil, recordable_type: nil, recordable_id: nil, blob: nil, blob_id: nil)
+ # Records may be destroyed (during cascading deletes) but .id still works.
+ # Skip entirely if account is destroyed - no need to track storage for deleted accounts.
+ # Skip materialize jobs for destroyed boards since there's nothing to update.
+ def self.record(delta:, operation:, account:, board: nil, recordable: nil, blob: nil)
return if delta.zero?
-
- account_id ||= account&.id
- board_id ||= board&.id
- blob_id ||= blob&.id
+ return if account.destroyed?
entry = create! \
- account_id: account_id,
- board_id: board_id,
- recordable_type: recordable_type || recordable&.class&.name,
- recordable_id: recordable_id || recordable&.id,
- blob_id: blob_id,
+ account_id: account.id,
+ board_id: board&.id,
+ recordable_type: recordable&.class&.name,
+ recordable_id: recordable&.id,
+ blob_id: blob&.id,
delta: delta,
operation: operation,
user_id: Current.user&.id,
request_id: Current.request_id
- # Enqueue materialization - use find_by to handle cascading deletes
- # (Account/Board may be destroyed while attachments are still being cleaned up)
- Account.find_by(id: account_id)&.materialize_storage_later
- Board.find_by(id: board_id)&.materialize_storage_later if board_id
+ account.materialize_storage_later
+ board&.materialize_storage_later unless board&.destroyed?
entry
end
diff --git a/app/models/user/avatar.rb b/app/models/user/avatar.rb
index 400738cb1..c62d87e09 100644
--- a/app/models/user/avatar.rb
+++ b/app/models/user/avatar.rb
@@ -22,6 +22,11 @@ module User::Avatar
avatar.variable? ? avatar.variant(:thumb) : avatar
end
+ # Avatars are always publicly accessible
+ def publicly_accessible?
+ true
+ end
+
private
def avatar_content_type_allowed
if !ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type)
diff --git a/app/views/boards/show/_closed.html.erb b/app/views/boards/show/_closed.html.erb
index 5d2820399..d4712b832 100644
--- a/app/views/boards/show/_closed.html.erb
+++ b/app/views/boards/show/_closed.html.erb
@@ -1,5 +1,6 @@
<%= column_tag id: "closed-cards", name: "Done", drop_url: columns_card_drops_closure_path("__id__"), class: "cards--closed", style: "--card-color: var(--color-card-complete);",
data: {
+ card_hotkeys_disabled: true,
drag_and_strum_target: "container",
collapsible_columns_target: "column",
action: "focus->collapsible-columns#focusOnColumn"
diff --git a/app/views/boards/show/_columns.html.erb b/app/views/boards/show/_columns.html.erb
index a6001da79..bf7a76fcc 100644
--- a/app/views/boards/show/_columns.html.erb
+++ b/app/views/boards/show/_columns.html.erb
@@ -1,5 +1,5 @@
<%= tag.div class: "card-columns hide-scrollbar", data: {
- controller: "collapsible-columns drag-and-drop drag-and-strum navigable-list",
+ controller: "collapsible-columns drag-and-drop drag-and-strum navigable-list card-hotkeys",
drag_and_drop_dragged_item_class: "drag-and-drop__dragged-item",
drag_and_drop_hover_container_class: "drag-and-drop__hover-container",
collapsible_columns_board_value: board.id,
@@ -11,8 +11,11 @@
navigable_list_prevent_handled_keys_value: true,
navigable_list_auto_select_value: false,
navigable_list_auto_scroll_value: false,
+ card_hotkeys_navigable_list_outlet: ".cards__transition-container",
action: "
keydown->navigable-list#navigate
+ keydown->card-hotkeys#handleKeydown
+ turbo:morph@document->card-hotkeys#handleMorphComplete
dragstart->drag-and-drop#dragStart
dragover->drag-and-drop#dragOver
dragenter->drag-and-strum#dragEnter
diff --git a/app/views/boards/show/_expander.html.erb b/app/views/boards/show/_expander.html.erb
index d58b68715..c66d12ea3 100644
--- a/app/views/boards/show/_expander.html.erb
+++ b/app/views/boards/show/_expander.html.erb
@@ -1,5 +1,5 @@