diff --git a/Gemfile.lock b/Gemfile.lock
index 4b3ce6e44..0e48985b0 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -123,8 +123,8 @@ GEM
ast (2.4.3)
autotuner (1.1.0)
aws-eventstream (1.4.0)
- aws-partitions (1.1197.0)
- aws-sdk-core (3.240.0)
+ aws-partitions (1.1203.0)
+ aws-sdk-core (3.241.3)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -132,28 +132,28 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
- aws-sdk-kms (1.118.0)
- aws-sdk-core (~> 3, >= 3.239.1)
+ aws-sdk-kms (1.120.0)
+ aws-sdk-core (~> 3, >= 3.241.3)
aws-sigv4 (~> 1.5)
- aws-sdk-s3 (1.208.0)
- aws-sdk-core (~> 3, >= 3.234.0)
+ aws-sdk-s3 (1.211.0)
+ aws-sdk-core (~> 3, >= 3.241.3)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.3.0)
- bcrypt (3.1.20)
- bcrypt_pbkdf (1.1.1)
+ bcrypt (3.1.21)
+ bcrypt_pbkdf (1.1.2)
benchmark (0.5.0)
bigdecimal (4.0.1)
bindex (0.8.1)
- bootsnap (1.19.0)
+ bootsnap (1.20.1)
msgpack (~> 1.2)
- brakeman (7.1.1)
+ brakeman (7.1.2)
racc
builder (3.3.0)
- bundler-audit (0.9.2)
- bundler (>= 1.2.0, < 3)
+ bundler-audit (0.9.3)
+ bundler (>= 1.2.0)
thor (~> 1.0)
capybara (3.40.0)
addressable
@@ -168,23 +168,23 @@ GEM
logger (~> 1.5)
chunky_png (1.4.0)
concurrent-ruby (1.3.6)
- connection_pool (2.5.5)
+ connection_pool (3.0.2)
crack (1.0.1)
bigdecimal
rexml
crass (1.0.6)
date (3.5.1)
- debug (1.11.0)
+ debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
- dotenv (3.1.8)
+ dotenv (3.2.0)
drb (2.2.3)
ed25519 (1.4.0)
erb (6.0.1)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
- faker (3.5.2)
+ faker (3.5.3)
i18n (>= 1.8.11, < 2)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
@@ -224,7 +224,7 @@ GEM
json (2.18.0)
jwt (3.1.2)
base64
- kamal (2.9.0)
+ kamal (2.10.1)
activesupport (>= 7.0)
base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0)
@@ -271,11 +271,11 @@ GEM
stimulus-rails
turbo-rails
mittens (0.3.1)
- mocha (2.8.2)
+ mocha (3.0.1)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
- net-http-persistent (4.0.6)
- connection_pool (~> 2.2, >= 2.2.4)
+ net-http-persistent (4.0.8)
+ connection_pool (>= 2.2.4, < 4)
net-imap (0.6.2)
date
net-protocol
@@ -307,7 +307,7 @@ GEM
racc (~> 1.4)
nokogiri (1.19.0-x86_64-linux-musl)
racc (~> 1.4)
- openssl (3.3.2)
+ openssl (4.0.0)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.10.0)
@@ -319,7 +319,7 @@ GEM
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
- prism (1.6.0)
+ prism (1.8.0)
propshaft (1.3.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
@@ -360,11 +360,11 @@ GEM
reline (0.6.3)
io-console (~> 0.5)
rexml (3.4.4)
- rouge (4.6.1)
- rqrcode (3.1.0)
+ rouge (4.7.0)
+ rqrcode (3.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
- rqrcode_core (2.0.0)
+ rqrcode_core (2.1.0)
rubocop (1.81.7)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
@@ -400,7 +400,7 @@ GEM
ruby2_keywords (0.0.5)
rubyzip (3.2.2)
securerandom (0.4.1)
- selenium-webdriver (4.38.0)
+ selenium-webdriver (4.39.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@@ -430,7 +430,7 @@ GEM
sqlite3 (2.8.0-x86_64-darwin)
sqlite3 (2.8.0-x86_64-linux-gnu)
sqlite3 (2.8.0-x86_64-linux-musl)
- sshkit (1.24.0)
+ sshkit (1.25.0)
base64
logger
net-scp (>= 1.1.2)
@@ -440,7 +440,7 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
- thor (1.4.0)
+ thor (1.5.0)
thruster (0.1.17)
thruster (0.1.17-aarch64-linux)
thruster (0.1.17-arm64-darwin)
@@ -458,16 +458,15 @@ GEM
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uri (1.1.1)
- vcr (6.3.1)
- base64
+ vcr (6.4.0)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
- web-push (3.0.2)
+ web-push (3.1.0)
jwt (~> 3.0)
- openssl (~> 3.0)
+ openssl (>= 3.0)
webmock (3.26.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index 98cf833bc..f340b4ae4 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -201,8 +201,8 @@ GEM
ast (2.4.3)
autotuner (1.1.0)
aws-eventstream (1.4.0)
- aws-partitions (1.1197.0)
- aws-sdk-core (3.240.0)
+ aws-partitions (1.1203.0)
+ aws-sdk-core (3.241.3)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -210,28 +210,28 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
- aws-sdk-kms (1.118.0)
- aws-sdk-core (~> 3, >= 3.239.1)
+ aws-sdk-kms (1.120.0)
+ aws-sdk-core (~> 3, >= 3.241.3)
aws-sigv4 (~> 1.5)
- aws-sdk-s3 (1.208.0)
- aws-sdk-core (~> 3, >= 3.234.0)
+ aws-sdk-s3 (1.211.0)
+ aws-sdk-core (~> 3, >= 3.241.3)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.3.0)
- bcrypt (3.1.20)
- bcrypt_pbkdf (1.1.1)
+ bcrypt (3.1.21)
+ bcrypt_pbkdf (1.1.2)
benchmark (0.5.0)
bigdecimal (4.0.1)
bindex (0.8.1)
- bootsnap (1.19.0)
+ bootsnap (1.20.1)
msgpack (~> 1.2)
- brakeman (7.1.1)
+ brakeman (7.1.2)
racc
builder (3.3.0)
- bundler-audit (0.9.2)
- bundler (>= 1.2.0, < 3)
+ bundler-audit (0.9.3)
+ bundler (>= 1.2.0)
thor (~> 1.0)
capybara (3.40.0)
addressable
@@ -246,16 +246,16 @@ GEM
logger (~> 1.5)
chunky_png (1.4.0)
concurrent-ruby (1.3.6)
- connection_pool (2.5.5)
+ connection_pool (3.0.2)
crack (1.0.1)
bigdecimal
rexml
crass (1.0.6)
date (3.5.1)
- debug (1.11.0)
+ debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
- dotenv (3.1.8)
+ dotenv (3.2.0)
drb (2.2.3)
dry-initializer (3.2.0)
ed25519 (1.4.0)
@@ -263,7 +263,7 @@ GEM
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
- faker (3.5.2)
+ faker (3.5.3)
i18n (>= 1.8.11, < 2)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
@@ -302,7 +302,7 @@ GEM
json (2.18.0)
jwt (3.1.2)
base64
- kamal (2.9.0)
+ kamal (2.10.1)
activesupport (>= 7.0)
base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0)
@@ -349,11 +349,11 @@ GEM
stimulus-rails
turbo-rails
mittens (0.3.1)
- mocha (2.8.2)
+ mocha (3.0.1)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
- net-http-persistent (4.0.6)
- connection_pool (~> 2.2, >= 2.2.4)
+ net-http-persistent (4.0.8)
+ connection_pool (>= 2.2.4, < 4)
net-imap (0.6.2)
date
net-protocol
@@ -383,7 +383,7 @@ GEM
racc (~> 1.4)
nokogiri (1.19.0-x86_64-linux-musl)
racc (~> 1.4)
- openssl (3.3.2)
+ openssl (4.0.0)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.10.0)
@@ -395,7 +395,7 @@ GEM
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
- prism (1.6.0)
+ prism (1.8.0)
prometheus-client-mmap (1.4.0)
base64
bigdecimal
@@ -470,11 +470,11 @@ GEM
io-console (~> 0.5)
rexml (3.4.4)
rinku (2.0.6)
- rouge (4.6.1)
- rqrcode (3.1.0)
+ rouge (4.7.0)
+ rqrcode (3.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
- rqrcode_core (2.0.0)
+ rqrcode_core (2.1.0)
rubocop (1.81.7)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
@@ -511,7 +511,7 @@ GEM
ruby2_keywords (0.0.5)
rubyzip (3.2.2)
securerandom (0.4.1)
- selenium-webdriver (4.38.0)
+ selenium-webdriver (4.39.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@@ -549,7 +549,7 @@ GEM
sqlite3 (2.8.0-arm64-darwin)
sqlite3 (2.8.0-x86_64-linux-gnu)
sqlite3 (2.8.0-x86_64-linux-musl)
- sshkit (1.24.0)
+ sshkit (1.25.0)
base64
logger
net-scp (>= 1.1.2)
@@ -560,7 +560,7 @@ GEM
railties (>= 6.0.0)
stringio (3.2.0)
stripe (18.0.1)
- thor (1.4.0)
+ thor (1.5.0)
thruster (0.1.17)
thruster (0.1.17-aarch64-linux)
thruster (0.1.17-arm64-darwin)
@@ -577,16 +577,15 @@ GEM
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uri (1.1.1)
- vcr (6.3.1)
- base64
+ vcr (6.4.0)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
- web-push (3.0.2)
+ web-push (3.1.0)
jwt (~> 3.0)
- openssl (~> 3.0)
+ openssl (>= 3.0)
webmock (3.26.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css
index 4edb06c64..2c0c67ef9 100644
--- a/app/assets/stylesheets/card-columns.css
+++ b/app/assets/stylesheets/card-columns.css
@@ -849,6 +849,7 @@
--btn-background: var(--card-color);
color: var(--color-ink-inverted);
+ opacity: 1 !important;
@media (hover: hover) {
&:hover {
diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css
index 8fef15ebb..006cbb985 100644
--- a/app/assets/stylesheets/comments.css
+++ b/app/assets/stylesheets/comments.css
@@ -109,6 +109,13 @@
&:not:has(lexxy-editor) {
padding-inline-end: var(--reaction-size);
}
+
+ /* Add an empty space so the last line of text doesn't overlap with the reaction button */
+ .action-text-content > p:last-child::after {
+ content: "";
+ display: inline-block;
+ inline-size: var(--reaction-size);
+ }
}
.comment__content {
diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css
index aa19f0f58..76dedfe12 100644
--- a/app/assets/stylesheets/utilities.css
+++ b/app/assets/stylesheets/utilities.css
@@ -24,6 +24,7 @@
.txt-underline { text-decoration: underline; }
.txt-tight-lines { line-height: 1.2; }
.txt-nowrap { white-space: nowrap; }
+ .txt-balance { text-wrap: balance; }
.txt-break { word-break: break-word; }
.txt-uppercase { text-transform: uppercase; }
.txt-capitalize { text-transform: capitalize; }
diff --git a/app/controllers/users/avatars_controller.rb b/app/controllers/users/avatars_controller.rb
index 420dbe5b6..55d8f06dd 100644
--- a/app/controllers/users/avatars_controller.rb
+++ b/app/controllers/users/avatars_controller.rb
@@ -8,7 +8,7 @@ class Users::AvatarsController < ApplicationController
if @user.system?
redirect_to view_context.image_path("system_user.png")
elsif @user.avatar.attached?
- redirect_to rails_blob_url(@user.avatar_thumbnail, disposition: "inline")
+ redirect_to rails_blob_path(@user.avatar_thumbnail, disposition: "inline")
elsif stale? @user, cache_control: cache_control
render_initials
end
diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb
index 7c064a650..8ae4346ac 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -42,6 +42,6 @@ module AvatarsHelper
end
def avatar_image_tag(user, **options)
- image_tag user_avatar_url(user, script_name: user.account.slug), aria: { hidden: "true" }, size: 48, title: user.name, **options
+ image_tag user_avatar_path(user, script_name: user.account.slug), aria: { hidden: "true" }, size: 48, title: user.name, **options
end
end
diff --git a/app/helpers/columns_helper.rb b/app/helpers/columns_helper.rb
index a92fb2824..b3e98b30e 100644
--- a/app/helpers/columns_helper.rb
+++ b/app/helpers/columns_helper.rb
@@ -5,6 +5,7 @@ module ColumnsHelper
card_triage_path(card, column_id: column),
method: :post,
class: [ "card__column-name btn", { "card__column-name--current": column == card.column && card.open? } ],
+ disabled: column == card.column,
style: "--column-color: #{column.color}",
form_class: "flex gap-half",
data: { turbo_frame: "_top", scroll_to_target: column == card.column && card.open? ? "target" : nil }
diff --git a/app/javascript/controllers/collapsible_columns_controller.js b/app/javascript/controllers/collapsible_columns_controller.js
index 1a9ba4174..e432a054f 100644
--- a/app/javascript/controllers/collapsible_columns_controller.js
+++ b/app/javascript/controllers/collapsible_columns_controller.js
@@ -14,13 +14,12 @@ export default class extends Controller {
}
async connect() {
- await this.#restoreColumnsDisablingTransitions()
- this.#setupIntersectionObserver()
-
this.mediaQuery = window.matchMedia(this.desktopBreakpointValue)
this.handleDesktop = this.#handleDesktop.bind(this)
this.mediaQuery.addEventListener("change", this.handleDesktop)
- this.handleDesktop(this.mediaQuery)
+
+ await this.#restoreColumnsDisablingTransitions()
+ this.#setupIntersectionObserver()
}
disconnect() {
@@ -57,6 +56,7 @@ export default class extends Controller {
async #restoreColumnsDisablingTransitions() {
this.#disableTransitions()
this.#restoreColumns()
+ this.#handleDesktop()
await nextFrame()
this.#enableTransitions()
diff --git a/app/models/board/accessible.rb b/app/models/board/accessible.rb
index 2f668ae4f..e5b86dbe1 100644
--- a/app/models/board/accessible.rb
+++ b/app/models/board/accessible.rb
@@ -46,6 +46,7 @@ module Board::Accessible
mentions_for_user(user).destroy_all
notifications_for_user(user).destroy_all
watches_for(user).destroy_all
+ pins_for(user).destroy_all
end
def watchers
@@ -99,4 +100,8 @@ module Board::Accessible
def watches_for(user)
Watch.where(card: cards, user: user)
end
+
+ def pins_for(user)
+ Pin.where(card: cards, user: user)
+ end
end
diff --git a/app/views/active_storage/blobs/web/_representation.html.erb b/app/views/active_storage/blobs/web/_representation.html.erb
index ca934f469..82a1a2d96 100644
--- a/app/views/active_storage/blobs/web/_representation.html.erb
+++ b/app/views/active_storage/blobs/web/_representation.html.erb
@@ -12,14 +12,14 @@
height: height %>
<% elsif blob.audio? %>
<% elsif blob.variable? %>
- <%= link_to url_for(blob.variant(variant)), data: { lightbox_target: "image", lightbox_caption_value: blob.filename.to_s } do %>
- <%= image_tag url_for(blob.variant(variant)), width: width, height: height %>
+ <%= link_to rails_representation_path(blob.variant(variant)), data: { lightbox_target: "image", lightbox_caption_value: blob.filename.to_s } do %>
+ <%= image_tag rails_representation_path(blob.variant(variant)), width: width, height: height %>
<% end %>
<% elsif blob.previewable? %>
- <%= image_tag url_for(blob.preview(variant)), width: width, height: height %>
+ <%= image_tag rails_representation_path(blob.preview(variant)), width: width, height: height %>
<% else %>
<%= blob.filename.extension&.downcase.presence || "unknown" %>
<% end %>
diff --git a/app/views/boards/edit/_auto_close.html.erb b/app/views/boards/edit/_auto_close.html.erb
index c32c1c050..8993c132e 100644
--- a/app/views/boards/edit/_auto_close.html.erb
+++ b/app/views/boards/edit/_auto_close.html.erb
@@ -1,7 +1,7 @@
-<%= turbo_frame_tag @board, :entropy do %>
+<%= turbo_frame_tag board, :entropy do %>
Auto close
Fizzy doesn’t let stale cards stick around forever. Cards automatically move to “Not now” if no one updates, comments, or moves a card for…
- <%= render "entropy/auto_close", model: board, url: board_entropy_path(board), disabled: !Current.user.can_administer_board?(@board) %>
+ <%= render "entropy/auto_close", model: board, url: board_entropy_path(board), disabled: !Current.user.can_administer_board?(board) %>
<% end %>
diff --git a/app/views/cards/columns/edit.html.erb b/app/views/cards/columns/edit.html.erb
index 586fac20f..162bfbbeb 100644
--- a/app/views/cards/columns/edit.html.erb
+++ b/app/views/cards/columns/edit.html.erb
@@ -5,6 +5,7 @@
<%= button_to "Not now", card_not_now_path(@card),
class: [ "card__column-name btn", { "card__column-name--current": @card.postponed? } ],
style: "--column-color: var(--color-card-complete)",
+ disabled: @card.postponed?,
role: "radio",
aria: { checked: @card.postponed? },
data: { scroll_to_target: @card.postponed? ? "target" : nil },
@@ -12,7 +13,8 @@
<%= button_to "Maybe?", card_triage_path(@card), method: :delete,
class: [ "card__column-name card__column-name--stream btn", { "card__column-name--current": @card.awaiting_triage? } ],
- style: "--column-color: var(--color-card-default)",
+ style: "--column-color: var(--color-card-default)",
+ disabled: @card.awaiting_triage?,
role: "radio",
aria: { checked: @card.awaiting_triage? },
data: { scroll_to_target: @card.awaiting_triage? ? "target" : nil },
@@ -25,6 +27,7 @@
<%= button_to "Done", card_closure_path(@card),
class: [ "card__column-name btn", { "card__column-name--current": @card.closed? } ],
style: "--column-color: var(--color-card-complete)",
+ disabled: @card.closed?,
role: "radio",
aria: { checked: @card.closed? },
data: { scroll_to_target: @card.closed? ? "target" : nil },
diff --git a/app/views/events/event/attachments/_attachment.html.erb b/app/views/events/event/attachments/_attachment.html.erb
index c1eb254d0..fdb3b871b 100644
--- a/app/views/events/event/attachments/_attachment.html.erb
+++ b/app/views/events/event/attachments/_attachment.html.erb
@@ -3,9 +3,9 @@
<% height = attachment.metadata["height"] %>
<% if attachment.previewable? %>
- <%= image_tag url_for(attachment.preview(variant)), class: "attachment attachment--image", width: width, height: height %>
+ <%= image_tag rails_representation_path(attachment.preview(variant)), class: "attachment attachment--image", width: width, height: height %>
<% elsif attachment.variable? %>
- <%= image_tag url_for(attachment.variant(variant)), class: "attachment attachment--image", width: width, height: height %>
+ <%= image_tag rails_representation_path(attachment.variant(variant)), class: "attachment attachment--image", width: width, height: height %>
<% else %>
<%= attachment.filename.extension&.downcase.presence || "unknown" %>
diff --git a/app/views/my/menus/_accounts.html.erb b/app/views/my/menus/_accounts.html.erb
index 2fdf9d8a5..6a25fb233 100644
--- a/app/views/my/menus/_accounts.html.erb
+++ b/app/views/my/menus/_accounts.html.erb
@@ -3,7 +3,7 @@
<%= collapsible_nav_section "Accounts" do %>
<%# Bust cache 1 Dec 2025 %>
<% accounts.each do |account| %>
- <%= filter_place_menu_item landing_url(script_name: account.slug), account.name, "marker", current: account == Current.account, turbo: false %>
+ <%= filter_place_menu_item landing_path(script_name: account.slug), account.name, "marker", current: account == Current.account, turbo: false %>
<% end %>
<% end %>
<% end %>
diff --git a/app/views/my/menus/_settings.html.erb b/app/views/my/menus/_settings.html.erb
index 150b6794a..d1251b7c0 100644
--- a/app/views/my/menus/_settings.html.erb
+++ b/app/views/my/menus/_settings.html.erb
@@ -6,7 +6,7 @@
<%= tag.li class: "popup__item", data: { filter_target: "item", navigable_list_target: "item" } do %>
<%= icon_tag "logout", class: "popup__icon" %>
- <%= button_to session_url(script_name: nil), method: :delete, class: "popup__btn btn", data: { turbo: false } do %>
+ <%= button_to session_path(script_name: nil), method: :delete, class: "popup__btn btn", data: { turbo: false } do %>
Sign out
<% end %>
<% end %>
diff --git a/app/views/prompts/cards/_card.html.erb b/app/views/prompts/cards/_card.html.erb
index 2308bc0c7..1dd4494c4 100644
--- a/app/views/prompts/cards/_card.html.erb
+++ b/app/views/prompts/cards/_card.html.erb
@@ -3,6 +3,6 @@
#<%= card.number %> <%= card.title %>
- <%= link_to "##{card.number} #{card.title}", card_url(card) %>
+ <%= link_to "##{card.number} #{card.title}", card_path(card) %>
diff --git a/app/views/sessions/magic_links/show.html.erb b/app/views/sessions/magic_links/show.html.erb
index c4940e146..aba8411f5 100644
--- a/app/views/sessions/magic_links/show.html.erb
+++ b/app/views/sessions/magic_links/show.html.erb
@@ -3,7 +3,7 @@
">
@@ -15,7 +15,7 @@
data: { magic_link_target: "input", action: "keydown.enter->magic-link#submitOnEnter paste->magic-link#submitOnPaste" } %>
<% end %>
-
The code sent to <%= email_address_pending_authentication %> will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.
+
The code sent to <%= email_address_pending_authentication %> will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.
<% if Rails.env.development? && flash[:magic_link_code].present? %>
diff --git a/app/views/sessions/menus/show.html.erb b/app/views/sessions/menus/show.html.erb
index 9c6867740..09383b72a 100644
--- a/app/views/sessions/menus/show.html.erb
+++ b/app/views/sessions/menus/show.html.erb
@@ -13,7 +13,7 @@
<% @accounts.each do |account| %>
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb
index 7cf5bdf7c..771a8e924 100644
--- a/app/views/users/show.html.erb
+++ b/app/views/users/show.html.erb
@@ -41,7 +41,7 @@
<% if Current.user == @user %>
- <%= button_to session_url(script_name: nil), method: :delete, class: "btn txt-x-small center", data: { turbo: false } do %>
+ <%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", data: { turbo: false } do %>
Sign out of Fizzy on this device
<% end %>
<% end %>
diff --git a/app/views/webhooks/event.html.erb b/app/views/webhooks/event.html.erb
index f2920e8d0..7766020a1 100644
--- a/app/views/webhooks/event.html.erb
+++ b/app/views/webhooks/event.html.erb
@@ -1,4 +1,4 @@
<%= @event.description_for(Current.user).to_plain_text %>
<% if @event.eventable %>
-<%= link_to "↗︎", polymorphic_url(@event.eventable) %>
+<%= link_to "↗︎", polymorphic_url(@event.eventable, only_path: true) %>
<% end %>
diff --git a/config/initializers/active_storage.rb b/config/initializers/active_storage.rb
index 3edd75cc8..586c292a1 100644
--- a/config/initializers/active_storage.rb
+++ b/config/initializers/active_storage.rb
@@ -8,6 +8,17 @@ ActiveSupport.on_load(:active_storage_blob) do
end
end
+ActiveSupport.on_load(:action_text_content) do
+ # Install our extensions after ActionText::Engine's
+ ActiveSupport.on_load(:active_storage_blob) do
+ # Ensure all
s have a "url" attribute that's a relative
+ # path (for portability across host name changes, beta environments, etc).
+ def to_rich_text_attributes(*)
+ super.merge url: Rails.application.routes.url_helpers.polymorphic_url(self, only_path: true)
+ end
+ end
+end
+
# Don't configure replica connections for ActiveStorage::Record.
# When ActiveStorage uses `connects_to`, it creates a separate connection pool
# from ApplicationRecord. This causes after_commit callbacks to fire in
diff --git a/script/migrations/convert-absolute-attachment-urls-to-relative.rb b/script/migrations/convert-absolute-attachment-urls-to-relative.rb
new file mode 100644
index 000000000..009b6c4cb
--- /dev/null
+++ b/script/migrations/convert-absolute-attachment-urls-to-relative.rb
@@ -0,0 +1,132 @@
+#!/usr/bin/env ruby
+
+# Convert absolute attachment URLs in rich text content to relative paths.
+# This fixes URLs that were stored with full hostnames (e.g., https://app.fizzy.do/...)
+# making them portable across beta environments and host changes.
+#
+# MUST BE RUN AFTER `decrypt!` when using ActiveRecord Encryption
+#
+# Run locally:
+# bin/rails runner script/migrations/convert-absolute-attachment-urls-to-relative.rb --help
+#
+# Run via Kamal:
+# kamal app exec -d -p --reuse "bin/rails runner script/migrations/convert-absolute-attachment-urls-to-relative.rb --help"
+#
+# Safe to re-run: won't modify already-relative URLs
+
+class ConvertAbsoluteAttachmentUrlsToRelative
+ # Match absolute URLs pointing to Active Storage routes, keeping the account slug
+ ABSOLUTE_URL_PATTERN = %r{https?://[^/]+(/\d+/rails/active_storage/[^"']+)}
+
+ attr_reader :account, :dry_run
+
+ def initialize(account_id: nil, dry_run: false)
+ @account = Account.find_by(external_account_id: account_id)
+ @dry_run = dry_run
+ end
+
+ def run
+ puts "Converting absolute attachment URLs to relative paths"
+ puts dry_run ? "DRY RUN MODE - no changes will be saved" : "LIVE MODE - changes will be saved"
+ puts account ? "Only account: #{account.external_account_id} - #{account.name}" : "For **ALL ACCOUNTS**"
+
+ puts "\nPress ENTER to continue running or CTRL-C to bail..."
+ gets
+
+ puts "\nRunning..."
+
+ # Suppress SQL logs
+ Rails.event.debug_mode = false
+
+ seconds = Benchmark.realtime do
+ suppressing_turbo_broadcasts do
+ convert_urls
+ end
+ end
+
+ puts "\n\n"
+ puts "Finished in %.2f seconds." % seconds
+ end
+
+ private
+ def suppressing_turbo_broadcasts
+ Board.suppressing_turbo_broadcasts do
+ Card.suppressing_turbo_broadcasts do
+ yield
+ end
+ end
+ end
+
+ def convert_urls
+ scanned = 0
+ fixed = 0
+ urls_converted = 0
+
+ action_texts_scope.find_each do |rich_text|
+ scanned += 1
+
+ body = rich_text.body
+
+ edited = false
+ conversions = 0
+
+ body.send(:attachment_nodes).each do |node|
+ url = node["url"]
+ next unless url
+
+ if url.match?(ABSOLUTE_URL_PATTERN)
+ node["url"] = url.gsub(ABSOLUTE_URL_PATTERN, '\1')
+ edited = true
+ conversions += 1
+ end
+ end
+
+ if edited
+ record = rich_text.record
+ puts " - modifying #{record.class.name} #{record.to_param} (account: #{record.account&.external_account_id}) - #{conversions} URL(s)"
+
+ unless dry_run
+ rich_text.update! body: body.fragment.to_html
+ end
+
+ fixed += 1
+ urls_converted += conversions
+ end
+ end
+
+ puts "\n\nConversion complete!"
+ puts " Rich texts examined: #{scanned}"
+ puts " Rich texts modified: #{fixed}"
+ puts " URLs converted: #{urls_converted}"
+ end
+
+ def action_texts_scope
+ # Only examine rich texts that have embedded attachments
+ scope = ActionText::RichText.joins(:embeds_attachments)
+ scope = scope.where(account: account) if account
+ scope
+ end
+end
+
+require "optparse"
+
+options = { account_id: nil, dry_run: true }
+
+OptionParser.new do |opts|
+ opts.banner = "Usage: bin/rails runner #{__FILE__} [options]"
+
+ opts.on("-a", "--account ACCOUNT_ID", "Restrict to a specific account (external_account_id)") do |id|
+ options[:account_id] = id
+ end
+
+ opts.on("--[no-]dry-run", "Run in dry-run mode (default: --dry-run)") do |v|
+ options[:dry_run] = v
+ end
+
+ opts.on("-h", "--help", "Show this help message") do
+ puts opts
+ exit
+ end
+end.parse!
+
+ConvertAbsoluteAttachmentUrlsToRelative.new(**options).run
diff --git a/script/migrations/convert-relative-attachment-urls-to-absolute.rb b/script/migrations/convert-relative-attachment-urls-to-absolute.rb
new file mode 100644
index 000000000..4b201f34f
--- /dev/null
+++ b/script/migrations/convert-relative-attachment-urls-to-absolute.rb
@@ -0,0 +1,127 @@
+#!/usr/bin/env ruby
+
+# Rollback script: Convert relative attachment URLs back to absolute URLs.
+# Use this if you need to rollback the relative URL changes and want to
+# convert rich texts created during the rollout back to absolute URLs.
+#
+# Run locally:
+# bin/rails runner script/migrations/convert-relative-attachment-urls-to-absolute.rb --help
+#
+# Run via Kamal:
+# kamal app exec -d -p --reuse "bin/rails runner script/migrations/convert-relative-attachment-urls-to-absolute.rb --help"
+
+class ConvertRelativeAttachmentUrlsToAbsolute
+ # Match relative URLs pointing to Active Storage routes (with account slug)
+ RELATIVE_URL_PATTERN = %r{\A(/\d+/rails/active_storage/[^"']+)\z}
+
+ attr_reader :host, :since
+
+ def initialize(host:, since:)
+ @host = host
+ @since = since
+ end
+
+ def run
+ puts "Converting relative attachment URLs to absolute URLs"
+ puts "Host: #{host}"
+ puts "Processing rich texts created since: #{since}"
+
+ puts "\nPress ENTER to continue running or CTRL-C to bail..."
+ gets
+
+ puts "\nRunning..."
+
+ seconds = Benchmark.realtime do
+ suppressing_turbo_broadcasts do
+ convert_urls
+ end
+ end
+
+ puts "\n\n"
+ puts "Finished in %.2f seconds." % seconds
+ end
+
+ private
+ def suppressing_turbo_broadcasts
+ Board.suppressing_turbo_broadcasts do
+ Card.suppressing_turbo_broadcasts do
+ yield
+ end
+ end
+ end
+
+ def convert_urls
+ scanned = 0
+ fixed = 0
+ urls_converted = 0
+
+ action_texts_scope.find_each do |rich_text|
+ scanned += 1
+
+ body = rich_text.body
+
+ edited = false
+ conversions = 0
+
+ body.send(:attachment_nodes).each do |node|
+ url = node["url"]
+ next unless url
+
+ if url.match?(RELATIVE_URL_PATTERN)
+ node["url"] = "#{host}#{url}"
+ edited = true
+ conversions += 1
+ end
+ end
+
+ if edited
+ record = rich_text.record
+ puts " - modifying #{record.class.name} #{record.to_param} (account: #{record.account&.external_account_id}) - #{conversions} URL(s)"
+
+ rich_text.update! body: body.fragment.to_html
+
+ fixed += 1
+ urls_converted += conversions
+ end
+ end
+
+ puts "\n\nConversion complete!"
+ puts " Rich texts examined: #{scanned}"
+ puts " Rich texts modified: #{fixed}"
+ puts " URLs converted: #{urls_converted}"
+ end
+
+ def action_texts_scope
+ ActionText::RichText.joins(:embeds_attachments).where("action_text_rich_texts.created_at >= ?", since)
+ end
+end
+
+require "optparse"
+require "time"
+
+options = {}
+
+OptionParser.new do |opts|
+ opts.banner = "Usage: bin/rails runner #{__FILE__} [options]"
+
+ opts.on("--host HOST", "Host to prepend (e.g., https://app.fizzy.do)") do |host|
+ options[:host] = host
+ end
+
+ opts.on("--since TIME", "Process rich texts created since this time (ISO 8601 format)") do |time|
+ options[:since] = Time.parse(time)
+ end
+
+ opts.on("-h", "--help", "Show this help message") do
+ puts opts
+ exit
+ end
+end.parse!
+
+if options[:host].nil? || options[:since].nil?
+ puts "Error: --host and --since are required"
+ puts "Example: bin/rails runner #{__FILE__} --host https://app.fizzy.do --since 2026-01-14T10:00:00Z"
+ exit 1
+end
+
+ConvertRelativeAttachmentUrlsToAbsolute.new(**options).run
diff --git a/test/models/access_test.rb b/test/models/access_test.rb
index 10787e5d3..b4580420d 100644
--- a/test/models/access_test.rb
+++ b/test/models/access_test.rb
@@ -80,4 +80,27 @@ class AccessTest < ActiveSupport::TestCase
assert_not card.watched_by?(kevin)
end
+
+ test "pins are destroyed when access is lost" do
+ kevin = users(:kevin)
+ board = boards(:writebook)
+ card = cards(:logo) # Kevin has pinned this card
+
+ other_board = boards(:miltons_wish_list)
+ other_card = cards(:radio)
+ other_board.accesses.grant_to(kevin)
+ other_card.pin_by(kevin)
+
+ assert card.pinned_by?(kevin)
+ assert other_card.pinned_by?(kevin)
+
+ kevin_access = accesses(:writebook_kevin)
+
+ perform_enqueued_jobs only: Board::CleanInaccessibleDataJob do
+ kevin_access.destroy
+ end
+
+ assert_not card.pinned_by?(kevin)
+ assert other_card.pinned_by?(kevin), "Pin on other board should not be destroyed"
+ end
end