From 85b0d802622afbfbfffd161ed5437f79779e8fbf Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Mon, 26 Jan 2026 16:29:03 +0100 Subject: [PATCH 01/96] Adjust dialog transition styles --- app/assets/stylesheets/dialog.css | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/app/assets/stylesheets/dialog.css b/app/assets/stylesheets/dialog.css index 1336cad9b..97f2e7a76 100644 --- a/app/assets/stylesheets/dialog.css +++ b/app/assets/stylesheets/dialog.css @@ -7,17 +7,16 @@ :is(.dialog) { border: 0; opacity: 0; - transform: scale(0.2); - transform-origin: top center; - transition: var(--dialog-duration) allow-discrete; - transition-property: display, opacity, overlay, transform; + transform: scale(0.9); + transform-origin: center; + transition: var(--dialog-duration) ease-out allow-discrete; + transition-property: opacity, overlay, transform; &::backdrop { background-color: var(--color-black); - opacity: 0; - transform: scale(1); - transition: var(--dialog-duration) allow-discrete; - transition-property: display, opacity, overlay; + opacity: 0.4; + transition: var(--dialog-duration) ease-out allow-discrete; + transition-property: opacity, overlay; } &[open] { @@ -32,11 +31,11 @@ @starting-style { &[open] { opacity: 0; - transform: scale(0.2); + transform: scale(0.9); } &[open]::backdrop { - opacity: 0; + opacity: 0.4; } } } From 0d4f4c831e7f27e605ef1d507863d4be2ac7522c Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Mon, 26 Jan 2026 21:54:01 +0100 Subject: [PATCH 02/96] Make QR dialogs smaller on desktop --- app/views/account/join_codes/show.html.erb | 2 +- app/views/users/_transfer.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/account/join_codes/show.html.erb b/app/views/account/join_codes/show.html.erb index f3cfb1e37..ffde42a4f 100644 --- a/app/views/account/join_codes/show.html.erb +++ b/app/views/account/join_codes/show.html.erb @@ -39,7 +39,7 @@ Get QR code <% end %> - +

Scan this code with the camera on your mobile device

<%= qr_code_image(url) %> diff --git a/app/views/users/_transfer.html.erb b/app/views/users/_transfer.html.erb index 13f895ebc..9e00cf905 100644 --- a/app/views/users/_transfer.html.erb +++ b/app/views/users/_transfer.html.erb @@ -18,7 +18,7 @@ Display auto-login QR code <% end %> - + <%= qr_code_image(url) %>
From 850762cd4eab3cfbfde6b7e8b5930eee588dfb83 Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Mon, 26 Jan 2026 21:54:36 +0100 Subject: [PATCH 03/96] Replace `[x]` button with Done button --- app/views/users/_transfer.html.erb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/views/users/_transfer.html.erb b/app/views/users/_transfer.html.erb index 9e00cf905..c67949ec9 100644 --- a/app/views/users/_transfer.html.erb +++ b/app/views/users/_transfer.html.erb @@ -22,9 +22,8 @@ <%= qr_code_image(url) %> -
From 09f9b7fb4561293fa4b14fd57c1925e6f4546b66 Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Mon, 26 Jan 2026 21:59:04 +0100 Subject: [PATCH 04/96] Adjust copy in QR code dialogs --- app/views/account/join_codes/show.html.erb | 2 +- app/views/users/_transfer.html.erb | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/views/account/join_codes/show.html.erb b/app/views/account/join_codes/show.html.erb index ffde42a4f..ce7d479a2 100644 --- a/app/views/account/join_codes/show.html.erb +++ b/app/views/account/join_codes/show.html.erb @@ -40,7 +40,7 @@ <% end %> -

Scan this code with the camera on your mobile device

+

Scan this code to join <%= Current.account.name %>:

<%= qr_code_image(url) %> diff --git a/app/views/users/_transfer.html.erb b/app/views/users/_transfer.html.erb index c67949ec9..415860530 100644 --- a/app/views/users/_transfer.html.erb +++ b/app/views/users/_transfer.html.erb @@ -19,6 +19,10 @@ <% end %> +

+ Scan this code to instantly log in on another device: +

+ <%= qr_code_image(url) %>
@@ -34,4 +38,4 @@ Copy auto-login link <% end %> - \ No newline at end of file + From ea1b6830f400b8ac4ee664558d82dff581312a40 Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Mon, 26 Jan 2026 22:23:33 +0100 Subject: [PATCH 05/96] Adjust animations, use explicit transition properties --- app/assets/stylesheets/dialog.css | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/app/assets/stylesheets/dialog.css b/app/assets/stylesheets/dialog.css index 97f2e7a76..22574979d 100644 --- a/app/assets/stylesheets/dialog.css +++ b/app/assets/stylesheets/dialog.css @@ -7,35 +7,41 @@ :is(.dialog) { border: 0; opacity: 0; - transform: scale(0.9); + transform: scale(0.85); transform-origin: center; - transition: var(--dialog-duration) ease-out allow-discrete; - transition-property: opacity, overlay, transform; + transition-behavior: allow-discrete; + transition-duration: calc(var(--dialog-duration) / 2); /* Faster closing */ + transition-property: display, opacity, overlay, transform; + transition-timing-function: ease-out; &::backdrop { background-color: var(--color-black); - opacity: 0.4; - transition: var(--dialog-duration) ease-out allow-discrete; - transition-property: opacity, overlay; + opacity: 0; + transition-behavior: allow-discrete; + transition-duration: calc(var(--dialog-duration) / 2); + transition-property: display, opacity, overlay; + transition-timing-function: ease-out; } &[open] { opacity: 1; transform: scale(1); + transition-duration: var(--dialog-duration); /* Normal opening speed */ &::backdrop { opacity: 0.5; + transition-duration: var(--dialog-duration); } } @starting-style { &[open] { opacity: 0; - transform: scale(0.9); + transform: scale(0.85); } &[open]::backdrop { - opacity: 0.4; + opacity: 0; } } } From c1ac9d981e4f7dc4cc068346557f2ecf1270b7a5 Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Mon, 26 Jan 2026 22:46:42 +0100 Subject: [PATCH 06/96] Format --- app/views/account/join_codes/show.html.erb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/views/account/join_codes/show.html.erb b/app/views/account/join_codes/show.html.erb index ce7d479a2..aa7ed7d87 100644 --- a/app/views/account/join_codes/show.html.erb +++ b/app/views/account/join_codes/show.html.erb @@ -40,7 +40,9 @@ <% end %> -

Scan this code to join <%= Current.account.name %>:

+

+ Scan this code to join <%= Current.account.name %>: +

<%= qr_code_image(url) %> From 669126250e7a62d8c32b212c64e8a4f8f373599e Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Tue, 27 Jan 2026 18:38:10 +0100 Subject: [PATCH 07/96] Remove `filter` from transition properties to fix glitches in Safari --- app/assets/stylesheets/base.css | 2 +- app/assets/stylesheets/buttons.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/base.css b/app/assets/stylesheets/base.css index 689b80215..0c76ee544 100644 --- a/app/assets/stylesheets/base.css +++ b/app/assets/stylesheets/base.css @@ -34,7 +34,7 @@ :is(a, button, input, textarea, .switch, .btn) { transition: 100ms ease-out; - transition-property: background-color, border-color, box-shadow, filter, outline; + transition-property: background-color, border-color, box-shadow, outline; touch-action: manipulation; /* Keyboard navigation */ diff --git a/app/assets/stylesheets/buttons.css b/app/assets/stylesheets/buttons.css index ac897184f..a1419db16 100644 --- a/app/assets/stylesheets/buttons.css +++ b/app/assets/stylesheets/buttons.css @@ -19,7 +19,7 @@ pointer-events: auto; position: relative; transition: 100ms ease-out; - transition-property: background-color, border, box-shadow, color, filter, opacity, scale; + transition-property: background-color, border, box-shadow, color, opacity, scale; @media (any-hover: hover) { &:hover { From 9b05c9b4f9568e88fa6ce91f3a11080d0b163a41 Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Tue, 27 Jan 2026 19:01:33 +0100 Subject: [PATCH 08/96] Fix card column glitches --- app/assets/stylesheets/card-columns.css | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index 1083c2ffc..6b018d5c9 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -403,6 +403,7 @@ outline-offset: -2px; position: relative; text-transform: uppercase; + will-change: transform; &[disabled] { opacity: 1; From 62e8bb67adb7622cb478a7be570a73770021dcf0 Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Wed, 28 Jan 2026 10:04:14 +0100 Subject: [PATCH 09/96] Fix blurry card columns --- app/assets/stylesheets/card-columns.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index 6b018d5c9..d68e41566 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -185,15 +185,16 @@ .cards__transition-container { block-size: 100%; border-radius: calc(var(--column-width-collapsed) / 2); - translate: 0 0.5ch; /* Allow a little room for the mini bubble */ + margin-block-start: 0.5ch; /* Allow a little room for the mini bubble */ transition: translate var(--column-transition-duration) var(--ease-out-overshoot-subtle); @media (min-width: 640px) { .is-expanded & { - translate: 0 0.5ch; /* Allow a little room for the mini bubble */ + translate: 0; /* Reset when back from collapsed */ } .is-collapsed & { + margin-block-start: 0; translate: 0 var(--column-width-collapsed); } } From c15d7ae99523f8d73d9509cdb1959f861c5603a8 Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Wed, 28 Jan 2026 10:31:56 +0100 Subject: [PATCH 10/96] Cleanup --- app/assets/stylesheets/card-columns.css | 1 - 1 file changed, 1 deletion(-) diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index d68e41566..cfb6704c0 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -404,7 +404,6 @@ outline-offset: -2px; position: relative; text-transform: uppercase; - will-change: transform; &[disabled] { opacity: 1; From 7cda948a6e7d6d48b0ab45d8a527aee6f2ede82c Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Wed, 28 Jan 2026 10:36:24 +0100 Subject: [PATCH 11/96] Update comment --- app/assets/stylesheets/card-columns.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index cfb6704c0..9eb62ec51 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -190,7 +190,7 @@ @media (min-width: 640px) { .is-expanded & { - translate: 0; /* Reset when back from collapsed */ + translate: 0; /* Animate back from collapsed state */ } .is-collapsed & { From b83ccce1c92980ea8cd2096883f3991e59e55c2d Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Thu, 29 Jan 2026 09:57:52 +0100 Subject: [PATCH 12/96] Add padding to QR code images --- app/controllers/qr_codes_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/qr_codes_controller.rb b/app/controllers/qr_codes_controller.rb index aa8ca8334..a49809876 100644 --- a/app/controllers/qr_codes_controller.rb +++ b/app/controllers/qr_codes_controller.rb @@ -6,7 +6,7 @@ class QrCodesController < ApplicationController qr_code_svg = RQRCode::QRCode .new(QrCodeLink.from_signed(params[:id]).url) - .as_svg(viewbox: true, fill: :white, color: :black) + .as_svg(viewbox: true, fill: :white, color: :black, offset: 16) render svg: qr_code_svg end From 999d94bd236a31c59e9dbe7446c09c7843fe4ef9 Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Thu, 29 Jan 2026 09:58:10 +0100 Subject: [PATCH 13/96] Round the corners --- app/assets/stylesheets/qr-codes.css | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/qr-codes.css b/app/assets/stylesheets/qr-codes.css index 5019bf828..8413daed7 100644 --- a/app/assets/stylesheets/qr-codes.css +++ b/app/assets/stylesheets/qr-codes.css @@ -2,5 +2,6 @@ .qr-code { aspect-ratio: 1; block-size: 50dvh; + border-radius: 1ch; } } From a4cf07651762edd8a3c3ea3376655933aa5388e6 Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Fri, 30 Jan 2026 09:56:50 +0100 Subject: [PATCH 14/96] Use `inline-size` to preserve aspect-ratio --- app/assets/stylesheets/qr-codes.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/qr-codes.css b/app/assets/stylesheets/qr-codes.css index 8413daed7..e479e6bfe 100644 --- a/app/assets/stylesheets/qr-codes.css +++ b/app/assets/stylesheets/qr-codes.css @@ -1,7 +1,7 @@ @layer components { .qr-code { aspect-ratio: 1; - block-size: 50dvh; border-radius: 1ch; + inline-size: 50dvh; } } From 2e2824195d41433a3ed9d7053eb4166c2f460d4c Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Fri, 30 Jan 2026 10:03:16 +0100 Subject: [PATCH 15/96] Add vertical spacing --- app/assets/stylesheets/qr-codes.css | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/qr-codes.css b/app/assets/stylesheets/qr-codes.css index e479e6bfe..99d294ec7 100644 --- a/app/assets/stylesheets/qr-codes.css +++ b/app/assets/stylesheets/qr-codes.css @@ -3,5 +3,6 @@ aspect-ratio: 1; border-radius: 1ch; inline-size: 50dvh; + margin-block: var(--block-space); } } From cfb3109b3dde7653480df631e9300cbff15e5107 Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Fri, 30 Jan 2026 10:45:44 +0100 Subject: [PATCH 16/96] Clamp QR code size --- app/assets/stylesheets/qr-codes.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/qr-codes.css b/app/assets/stylesheets/qr-codes.css index 99d294ec7..91aebcd64 100644 --- a/app/assets/stylesheets/qr-codes.css +++ b/app/assets/stylesheets/qr-codes.css @@ -2,7 +2,7 @@ .qr-code { aspect-ratio: 1; border-radius: 1ch; - inline-size: 50dvh; + inline-size: clamp(20ch, 50dvh, 70ch); margin-block: var(--block-space); } } From b4e1d74f5d07161c8538fef167848c793d3fcb17 Mon Sep 17 00:00:00 2001 From: Alp Keser Date: Fri, 30 Jan 2026 13:28:26 +0300 Subject: [PATCH 17/96] Add json.has_attachments for the card --- app/views/cards/_card.json.jbuilder | 1 + 1 file changed, 1 insertion(+) diff --git a/app/views/cards/_card.json.jbuilder b/app/views/cards/_card.json.jbuilder index 0ceb7e339..f8e727732 100644 --- a/app/views/cards/_card.json.jbuilder +++ b/app/views/cards/_card.json.jbuilder @@ -3,6 +3,7 @@ json.cache! card do json.description card.description.to_plain_text json.description_html card.description.to_s json.image_url card.image.presence && url_for(card.image) + json.has_attachments card.has_attachments? json.tags card.tags.pluck(:title).sort From 171b8caa452841a9269233d731ebf1964ad351ef Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Wed, 4 Feb 2026 18:35:12 +0100 Subject: [PATCH 18/96] Switch non-animated scrolling in the mobile apps --- app/javascript/controllers/collapsible_columns_controller.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/javascript/controllers/collapsible_columns_controller.js b/app/javascript/controllers/collapsible_columns_controller.js index 939fe8441..2b2c73f38 100644 --- a/app/javascript/controllers/collapsible_columns_controller.js +++ b/app/javascript/controllers/collapsible_columns_controller.js @@ -1,5 +1,6 @@ import { Controller } from "@hotwired/stimulus" import { nextFrame, debounce } from "helpers/timing_helpers"; +import { isNative } from "helpers/platform_helpers"; export default class extends Controller { static classes = [ "collapsed", "expanded", "noTransitions", "titleNotVisible" ] @@ -114,7 +115,7 @@ export default class extends Controller { } if (window.matchMedia('(max-width: 639px)').matches) { - column.scrollIntoView({ behavior: "smooth", inline: "center" }) + column.scrollIntoView({ behavior: isNative() ? "instant" : "smooth", inline: "center" }) } } From 9d4655d35e83ba30f1367de2f42ad17340d017e8 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 5 Feb 2026 11:12:50 +0100 Subject: [PATCH 19/96] Make filters "open" by default then hide in mobile apps --- app/javascript/controllers/expandable_on_native_controller.js | 4 +--- app/views/filters/_settings.html.erb | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/javascript/controllers/expandable_on_native_controller.js b/app/javascript/controllers/expandable_on_native_controller.js index a58ddfa9a..913437809 100644 --- a/app/javascript/controllers/expandable_on_native_controller.js +++ b/app/javascript/controllers/expandable_on_native_controller.js @@ -9,8 +9,6 @@ export default class extends Controller { static values = { autoExpandSelector: String } connect() { - if (this.hasAutoExpandSelectorValue && this.element.querySelector(this.autoExpandSelectorValue)) { - this.element.open = true - } + this.element.open = this.hasAutoExpandSelectorValue && this.element.querySelector(this.autoExpandSelectorValue) } } diff --git a/app/views/filters/_settings.html.erb b/app/views/filters/_settings.html.erb index 66bbe3d54..d0a7d3d3e 100644 --- a/app/views/filters/_settings.html.erb +++ b/app/views/filters/_settings.html.erb @@ -1,4 +1,5 @@ <%= tag.details class: "expandable-on-native", + open: true, data: { controller: "expandable-on-native", expandable_on_native_auto_expand_selector_value: "[data-filter-show=true]" } do %> From d689f560b4f4a0ef15e7b801502d6bbfb887c71f Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 5 Feb 2026 17:04:03 +0100 Subject: [PATCH 20/96] Show "Go to" board button in card perma --- app/helpers/boards_helper.rb | 11 +++++++++-- app/views/cards/container/_closure_buttons.html.erb | 2 +- app/views/cards/show.html.erb | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index 5f12b9259..9ffa01941 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -1,6 +1,13 @@ module BoardsHelper - def link_back_to_board(board) - back_link_to board.name, board, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click click->turbo-navigation#backIfSamePath" + def link_back_to_board(board, bridged = false) + back_link_to board.name, + board, + "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click click->turbo-navigation#backIfSamePath", + data: ({ + bridge__buttons_target: "button", + bridge_icon_url: bridge_icon("board"), + bridge_title: "Go to #{board.name}" + } if bridged) end def link_to_edit_board(board) diff --git a/app/views/cards/container/_closure_buttons.html.erb b/app/views/cards/container/_closure_buttons.html.erb index 05e292315..990a17982 100644 --- a/app/views/cards/container/_closure_buttons.html.erb +++ b/app/views/cards/container/_closure_buttons.html.erb @@ -1,6 +1,6 @@
<%= link_to edit_card_path(card), class: "btn btn--circle-mobile borderless", - data: { controller: "hotkey", action: "keydown.e@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Edit", bridge_icon_url: bridge_icon("pencil"), turbo_frame: dom_id(card, :edit) } do %> + data: { controller: "hotkey", action: "keydown.e@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Edit", turbo_frame: dom_id(card, :edit) } do %> <%= icon_tag "pencil", class: "icon--mobile-only" %> Edit e diff --git a/app/views/cards/show.html.erb b/app/views/cards/show.html.erb index bfaa9c676..b114c43df 100644 --- a/app/views/cards/show.html.erb +++ b/app/views/cards/show.html.erb @@ -7,7 +7,7 @@ <% content_for :header do %>
- <%= link_back_to_board(@card.board) %> + <%= link_back_to_board(@card.board, bridged: true) %>
<% end %> From 186d759672fd420ccf2d7c28b5b3b9ade68273ae Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 5 Feb 2026 17:17:49 +0100 Subject: [PATCH 21/96] Instant scrolling on restore, animated on user action --- .../controllers/collapsible_columns_controller.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/javascript/controllers/collapsible_columns_controller.js b/app/javascript/controllers/collapsible_columns_controller.js index 2b2c73f38..35c5efedf 100644 --- a/app/javascript/controllers/collapsible_columns_controller.js +++ b/app/javascript/controllers/collapsible_columns_controller.js @@ -50,7 +50,7 @@ export default class extends Controller { focusOnColumn({ target }) { if (this.#isDesktop && this.#isCollapsed(target)) { this.#collapseAllExcept(target) - this.#expand(target) + this.#expand({ column: target }) } } @@ -75,7 +75,7 @@ export default class extends Controller { this.#collapseAllExcept(column) if (this.#isCollapsed(column)) { - this.#expand(column) + this.#expand({ column }) } else { this.#collapse(column) } @@ -104,7 +104,7 @@ export default class extends Controller { localStorage.removeItem(key) } - #expand(column, saveState = true) { + #expand({ column, saveState = true, scrollBehavior = "smooth" }) { this.#buttonFor(column)?.setAttribute("aria-expanded", "true") column.classList.remove(this.collapsedClass) column.classList.add(this.expandedClass) @@ -115,7 +115,7 @@ export default class extends Controller { } if (window.matchMedia('(max-width: 639px)').matches) { - column.scrollIntoView({ behavior: isNative() ? "instant" : "smooth", inline: "center" }) + column.scrollIntoView({ behavior: scrollBehavior, inline: "center" }) } } @@ -133,7 +133,7 @@ export default class extends Controller { const key = this.#localStorageKeyFor(column) if (localStorage.getItem(key)) { this.#collapseAllExcept(column) - this.#expand(column) + this.#expand({ column, scrollBehavior: isNative() ? "instant" : "smooth" }) } } @@ -169,7 +169,7 @@ export default class extends Controller { } async #handleDesktopMode() { - this.#expand(this.maybeColumnTarget, false) + this.#expand({ column: this.maybeColumnTarget, saveState: false }) this.#maybeButton.setAttribute("disabled", true) } @@ -182,7 +182,7 @@ export default class extends Controller { this.#collapseAllExcept(expandedColumn) } else { this.#collapseAllExcept(this.maybeColumnTarget) - this.#expand(this.maybeColumnTarget, false) + this.#expand({ column: this.maybeColumnTarget, saveState: false }) } } From 626b8aa8a6868eec49e55696edcfae30ab72adf7 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 5 Feb 2026 17:31:37 +0100 Subject: [PATCH 22/96] Revert "Show "Go to" board button in card perma" This reverts commit d689f560b4f4a0ef15e7b801502d6bbfb887c71f. --- app/helpers/boards_helper.rb | 11 ++--------- app/views/cards/container/_closure_buttons.html.erb | 2 +- app/views/cards/show.html.erb | 2 +- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index 9ffa01941..5f12b9259 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -1,13 +1,6 @@ module BoardsHelper - def link_back_to_board(board, bridged = false) - back_link_to board.name, - board, - "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click click->turbo-navigation#backIfSamePath", - data: ({ - bridge__buttons_target: "button", - bridge_icon_url: bridge_icon("board"), - bridge_title: "Go to #{board.name}" - } if bridged) + def link_back_to_board(board) + back_link_to board.name, board, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click click->turbo-navigation#backIfSamePath" end def link_to_edit_board(board) diff --git a/app/views/cards/container/_closure_buttons.html.erb b/app/views/cards/container/_closure_buttons.html.erb index 990a17982..05e292315 100644 --- a/app/views/cards/container/_closure_buttons.html.erb +++ b/app/views/cards/container/_closure_buttons.html.erb @@ -1,6 +1,6 @@
<%= link_to edit_card_path(card), class: "btn btn--circle-mobile borderless", - data: { controller: "hotkey", action: "keydown.e@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Edit", turbo_frame: dom_id(card, :edit) } do %> + data: { controller: "hotkey", action: "keydown.e@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Edit", bridge_icon_url: bridge_icon("pencil"), turbo_frame: dom_id(card, :edit) } do %> <%= icon_tag "pencil", class: "icon--mobile-only" %> Edit e diff --git a/app/views/cards/show.html.erb b/app/views/cards/show.html.erb index b114c43df..bfaa9c676 100644 --- a/app/views/cards/show.html.erb +++ b/app/views/cards/show.html.erb @@ -7,7 +7,7 @@ <% content_for :header do %>
- <%= link_back_to_board(@card.board, bridged: true) %> + <%= link_back_to_board(@card.board) %>
<% end %> From fe555f751ecdef0c12351488919dc5eabdebd0bc Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 5 Feb 2026 17:33:29 +0100 Subject: [PATCH 23/96] Move "Edit" action to ellipsis menu --- app/views/cards/container/_closure_buttons.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/cards/container/_closure_buttons.html.erb b/app/views/cards/container/_closure_buttons.html.erb index 05e292315..990a17982 100644 --- a/app/views/cards/container/_closure_buttons.html.erb +++ b/app/views/cards/container/_closure_buttons.html.erb @@ -1,6 +1,6 @@
<%= link_to edit_card_path(card), class: "btn btn--circle-mobile borderless", - data: { controller: "hotkey", action: "keydown.e@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Edit", bridge_icon_url: bridge_icon("pencil"), turbo_frame: dom_id(card, :edit) } do %> + data: { controller: "hotkey", action: "keydown.e@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Edit", turbo_frame: dom_id(card, :edit) } do %> <%= icon_tag "pencil", class: "icon--mobile-only" %> Edit e From 4072688974df17031e0fb55503822865f74b1aac Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 5 Feb 2026 17:36:48 +0100 Subject: [PATCH 24/96] Use a dedicated link to bridge a "go to" board button --- app/helpers/boards_helper.rb | 8 ++++++++ app/views/cards/show.html.erb | 1 + 2 files changed, 9 insertions(+) diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index 5f12b9259..eae2f3665 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -9,4 +9,12 @@ module BoardsHelper icon_tag("settings") + tag.span("Settings for #{board.name}", class: "for-screen-reader") end end + + def bridged_button_to_board(board) + link_to "Go to #{board.name}", board, hidden: true, data: { + bridge__buttons_target: "button", + bridge_icon_url: bridge_icon("board"), + bridge_title: "Go to #{board.name}" + } + end end diff --git a/app/views/cards/show.html.erb b/app/views/cards/show.html.erb index bfaa9c676..bbbff8275 100644 --- a/app/views/cards/show.html.erb +++ b/app/views/cards/show.html.erb @@ -8,6 +8,7 @@ <% content_for :header do %>
<%= link_back_to_board(@card.board) %> + <%= bridged_button_to_board(@card.board) %>
<% end %> From 8e93f9d45ec67d653bbf7445916ab3ce73e63364 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Thu, 5 Feb 2026 13:20:01 -0600 Subject: [PATCH 25/96] Content overflow scrolling for users list --- app/assets/stylesheets/settings.css | 8 ++++++++ app/views/account/settings/_users.html.erb | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/settings.css b/app/assets/stylesheets/settings.css index 0e39c33ee..2a2d0c9cd 100644 --- a/app/assets/stylesheets/settings.css +++ b/app/assets/stylesheets/settings.css @@ -58,6 +58,14 @@ /* Users /* ------------------------------------------------------------------------ */ + .settings__section--users { + @media (min-width: 640px) { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + } + } .settings__user-filter { --btn-size: 3.5ch; --avatar-size: var(--btn-size); diff --git a/app/views/account/settings/_users.html.erb b/app/views/account/settings/_users.html.erb index aeee05638..ceb17ff23 100644 --- a/app/views/account/settings/_users.html.erb +++ b/app/views/account/settings/_users.html.erb @@ -1,9 +1,9 @@ -
+

People on this account

- <%= tag.div class: "flex flex-column gap settings__user-filter", data: { + <%= tag.div class: "settings__user-filter flex flex-column gap", data: { controller: "filter navigable-list", action: "keydown->navigable-list#navigate filter:changed->navigable-list#reset", navigable_list_focus_on_selection_value: true, From 4f9cfa94b7d5fa43242ba970982e1c0a4d225725 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Fri, 6 Feb 2026 12:30:28 +0100 Subject: [PATCH 26/96] Allow comment author block to wrap on 2 lines --- app/assets/stylesheets/comments.css | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index a8605503b..01a9fbe91 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -91,6 +91,17 @@ .btn { font-weight: inherit; } + + @media (max-width: 639px) { + margin-block-end: calc(var(--block-space-half) / 2); + + h3 { + display: flex; + flex-wrap: wrap; + align-items: baseline; + column-gap: 0.4em; + } + } } .comment__avatar { @@ -155,7 +166,7 @@ text-transform: capitalize; @media (max-width: 639px) { - line-height: 1.5lh; + font-size: var(--text-small); } } From bd1ef1f6503f6f3d3e47a5e808cc4c9d83233909 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 6 Feb 2026 10:24:04 -0500 Subject: [PATCH 27/96] dep: bump console1984 to 746cd443 (#2495) to pick up the session incineration fix ref: https://3.basecamp.com/2914079/buckets/27/card_tables/cards/9552285400 --- Gemfile.saas.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 8de89e1fb..ad321c2de 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -13,7 +13,7 @@ GIT GIT remote: https://github.com/basecamp/console1984 - revision: 02b1b9ee7fd7050174b6a98c2b43057553621dc4 + revision: 23419ec8861e73f12ced75a3fc559d03f7be5854 specs: console1984 (0.2.2) irb (~> 1.13) From 8f95a6f228f8b1dc130f1adab6829c3292fd5770 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Fri, 6 Feb 2026 17:32:55 +0100 Subject: [PATCH 28/96] Add a little space around stage buttons --- app/assets/stylesheets/card-perma.css | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index b3d37d68a..dfd198948 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -115,12 +115,12 @@ @media (max-width: 639px) { border: 1px solid var(--card-color); - border-radius: 0.25em; + border-radius: 0.4em; flex-direction: row; gap: 0; overflow: auto; max-inline-size: 100%; - padding: 0; + padding: 0.2em; position: relative; white-space: nowrap; @@ -138,7 +138,6 @@ .card__column-name { @media (max-width: 639px) { - --btn-border-radius: 0; justify-content: center; } } From aa2f0e7fdca99f875f7d4b70564a2bf3d143f545 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Fri, 6 Feb 2026 17:45:09 +0100 Subject: [PATCH 29/96] Fix unwanted divider --- app/assets/stylesheets/card-perma.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index dfd198948..25afbc43c 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -130,7 +130,7 @@ min-block-size: 2.5em; } - & > form + form:not(:has(.card__column-name--current)) { + & > form:not(:has(.card__column-name--current)) + form:not(:has(.card__column-name--current)) { box-shadow: -1px 0 0 0 var(--color-container); } } From 52ec23ff21147143c2ae9a9515efc26d89a11f00 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 10:35:11 -0800 Subject: [PATCH 30/96] Bump docker/login-action from 3.6.0 to 3.7.0 (#2500) Bumps [docker/login-action](https://github.com/docker/login-action) from 3.6.0 to 3.7.0. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/v3.6.0...v3.7.0) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: 3.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish-image.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index d89381607..11487c18f 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -48,7 +48,7 @@ jobs: uses: docker/setup-buildx-action@v3.12.0 - name: Log in to GHCR - uses: docker/login-action@v3.6.0 + uses: docker/login-action@v3.7.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -121,7 +121,7 @@ jobs: uses: docker/setup-buildx-action@v3.12.0 - name: Log in to GHCR - uses: docker/login-action@v3.6.0 + uses: docker/login-action@v3.7.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} From 9622e5dad4fa90933e07885ef197bf1f5f404e65 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 10:35:19 -0800 Subject: [PATCH 31/96] Bump actions/attest-build-provenance from 3.1.0 to 3.2.0 (#2499) Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 3.1.0 to 3.2.0. - [Release notes](https://github.com/actions/attest-build-provenance/releases) - [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md) - [Commits](https://github.com/actions/attest-build-provenance/compare/v3.1.0...v3.2.0) --- updated-dependencies: - dependency-name: actions/attest-build-provenance dependency-version: 3.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index 11487c18f..676e6b7a3 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -101,7 +101,7 @@ jobs: - name: Attest image provenance (per-arch) if: github.event_name != 'pull_request' - uses: actions/attest-build-provenance@v3.1.0 + uses: actions/attest-build-provenance@v3.2.0 with: subject-name: ${{ steps.vars.outputs.canonical }} subject-digest: ${{ steps.build.outputs.digest }} From 2bfd54437ce834f92d9108d02e52c5019c47490d Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Mon, 9 Feb 2026 09:31:50 +0100 Subject: [PATCH 32/96] Use a px padding --- app/assets/stylesheets/card-perma.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 25afbc43c..d916e81d8 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -115,12 +115,12 @@ @media (max-width: 639px) { border: 1px solid var(--card-color); - border-radius: 0.4em; + border-radius: calc(0.2em + 3px); flex-direction: row; gap: 0; overflow: auto; max-inline-size: 100%; - padding: 0.2em; + padding: 3px; position: relative; white-space: nowrap; From 692fd73512be1330777871ea1cd52b7eaf15c3e2 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Mon, 9 Feb 2026 13:14:00 +0100 Subject: [PATCH 33/96] Run only Solid Queue's maintenance tasks in staging And not all production tasks, because that causes unexpected side effects such as cards being auto-postponed and firing webhooks configured to trigger on that event. --- config/recurring.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/recurring.yml b/config/recurring.yml index 209d8fed4..f1c1216dc 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -41,11 +41,11 @@ production: &production schedule: every 60 seconds <% end %> -beta: +beta: &beta # Only Solid Queue maintenance clear_solid_queue_finished_jobs: command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" schedule: every hour at minute 12 -staging: *production +staging: *beta development: *production From 4127eaeacfce10dc46d94fbc1c20031e4d47f1d9 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Mon, 9 Feb 2026 10:22:58 -0600 Subject: [PATCH 34/96] Truncate long account names in the menu --- app/views/sessions/menus/show.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/sessions/menus/show.html.erb b/app/views/sessions/menus/show.html.erb index 914f35f32..fa84a579d 100644 --- a/app/views/sessions/menus/show.html.erb +++ b/app/views/sessions/menus/show.html.erb @@ -14,7 +14,7 @@ <% end %> From 8c5b47d774837e46c97b61919bdbbe945b430825 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 9 Feb 2026 11:29:11 -0500 Subject: [PATCH 35/96] Group notification emails by board (#2506) * Group notification emails by board Notification bundle emails now group items by board instead of showing the board name redundantly for each card. Each board section has a linked header and an
separator. * Adjust board title styles and remove rem units * Sort board groups alphabetically Sort notification email board sections alphabetically by name (case-insensitive). Also, rewrite mailer tests to use Nokogiri::HTML5 for precise DOM assertions instead of regex matching. --------- Co-authored-by: Andy Smith --- app/views/layouts/mailer.html.erb | 32 ++++++--- .../bundle_mailer/notification.html.erb | 11 +-- .../bundle_mailer/notification.text.erb | 11 ++- .../notification/bundle_mailer_test.rb | 69 ++++++++++++++++--- 4 files changed, 94 insertions(+), 29 deletions(-) diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb index 51acf3ea0..40cf6191d 100644 --- a/app/views/layouts/mailer.html.erb +++ b/app/views/layouts/mailer.html.erb @@ -9,11 +9,12 @@ } body { + color: #17233C; font-family: system-ui, sans-serif; font-size: 16px; line-height: 1.3; margin: 0; - padding: 1rem; + padding: 16px; } img { @@ -72,13 +73,13 @@ .footer { border-top: 1px solid #ccc; - margin-top: 3em; + margin-top: 32px; padding-top: 1em; } .notification { - margin-top: 1rem; - margin-bottom: 1rem; + margin-top: 16px; + margin-bottom: 16px; } .notification__author { @@ -88,12 +89,25 @@ } .notification__board { - font-size: 0.8em; - font-weight: normal; - margin-bottom: 0; + font-size: 1.4em; + font-weight: 900; + margin-bottom: 16px; margin-top: 0; - opacity: 0.66; - text-transform: uppercase; + } + + .notification__board a { + color: #17233C; + text-decoration: none; + } + + .notification__board a:hover { + text-decoration: underline; + } + + .notification__board-separator { + border: none; + border-top: 1px solid #ddd; + margin: 2em 0 1em; } .notification__details { diff --git a/app/views/mailers/notification/bundle_mailer/notification.html.erb b/app/views/mailers/notification/bundle_mailer/notification.html.erb index e1fec4895..28ca0be36 100644 --- a/app/views/mailers/notification/bundle_mailer/notification.html.erb +++ b/app/views/mailers/notification/bundle_mailer/notification.html.erb @@ -5,10 +5,13 @@ You have <%= link_to pluralize(@notifications.count, "new notification"), notifications_url %><%= " in #{ Current.account.name }" if @user.identity.accounts.many? %>.

- <% @notifications.group_by(&:card).each do |card, notifications| %> -

<%= card.board.name %>

- <%= link_to "##{ card.number } #{ card.title }", card, class: "card__title" %> - <%= render partial: "notification/bundle_mailer/notification", collection: notifications, as: :notification %> + <% @notifications.group_by { |n| n.card.board }.sort_by { |board, _| board.name.downcase }.each do |board, board_notifications| %> +
+

<%= link_to board.name, board %>

+ <% board_notifications.group_by(&:card).each do |card, notifications| %> + <%= link_to "##{ card.number } #{ card.title }", card, class: "card__title" %> + <%= render partial: "notification/bundle_mailer/notification", collection: notifications, as: :notification %> + <% end %> <% end %>
+
<% cache card do %>
<%= render "cards/container/image", card: card %> From af390c436d5c679d41be65b4980b37321f1ecfff Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Wed, 11 Feb 2026 11:28:57 +0100 Subject: [PATCH 39/96] Allow board name/picker and tags to wrap on several lines --- app/assets/stylesheets/card-perma.css | 21 ++++++++++++++++++++ app/views/cards/display/perma/_tags.html.erb | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index b3d37d68a..0f661bf2b 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -63,6 +63,27 @@ opacity: 0.2; } + .card__header { + @media (max-width: 639px) { + flex-wrap: wrap; + gap: var(--card-header-space) unset; + } + } + + .card__tags { + @media (max-width: 639px) { + padding: 0.25lh; + } + } + + .card__tags-list { + @media (min-width: 640px) { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + } + .card__body { position: relative; diff --git a/app/views/cards/display/perma/_tags.html.erb b/app/views/cards/display/perma/_tags.html.erb index ea4d1f76b..cdfede474 100644 --- a/app/views/cards/display/perma/_tags.html.erb +++ b/app/views/cards/display/perma/_tags.html.erb @@ -15,7 +15,7 @@
<% if card.tags.any? %> -
+
<% card.tags.each_with_index do |tag, index| %> <%= link_to cards_path(board_ids: [ card.board ], tag_ids: [ tag.id ]), class: "card__tag btn btn--plain min-width txt-uppercase fill-transparent" do %> From 37d7f5c5698ccb790f09c59746a4034513f1a143 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 11 Feb 2026 13:47:21 +0100 Subject: [PATCH 40/96] Give more detail about why the import failed --- app/mailers/import_mailer.rb | 5 +- .../account/data_transfer/record_set.rb | 5 +- app/models/account/import.rb | 19 +++++- app/views/account/imports/show.html.erb | 8 ++- .../mailers/import_mailer/failed.html.erb | 11 ++-- .../mailers/import_mailer/failed.text.erb | 9 ++- ...7_add_failure_reason_to_account_imports.rb | 5 ++ db/schema.rb | 3 +- db/schema_sqlite.rb | 3 +- test/mailers/import_mailer_test.rb | 35 +++++++++++ test/models/account/import_test.rb | 61 +++++++++++++++++++ 11 files changed, 148 insertions(+), 16 deletions(-) create mode 100644 db/migrate/20260211122517_add_failure_reason_to_account_imports.rb diff --git a/app/mailers/import_mailer.rb b/app/mailers/import_mailer.rb index c626ddb2e..3ab25e481 100644 --- a/app/mailers/import_mailer.rb +++ b/app/mailers/import_mailer.rb @@ -4,7 +4,8 @@ class ImportMailer < ApplicationMailer mail to: identity.email_address, subject: "Your Fizzy account import is done" end - def failed(identity) - mail to: identity.email_address, subject: "Your Fizzy account import failed" + def failed(import) + @import = import + mail to: import.identity.email_address, subject: "Your Fizzy account import failed" end end diff --git a/app/models/account/data_transfer/record_set.rb b/app/models/account/data_transfer/record_set.rb index b7955932a..8eeafbe10 100644 --- a/app/models/account/data_transfer/record_set.rb +++ b/app/models/account/data_transfer/record_set.rb @@ -1,5 +1,6 @@ class Account::DataTransfer::RecordSet class IntegrityError < StandardError; end + class ConflictError < IntegrityError; end IMPORT_BATCH_SIZE = 100 @@ -93,7 +94,7 @@ class Account::DataTransfer::RecordSet end if model.exists?(id: data["id"]) - raise IntegrityError, "#{model} record with ID #{data['id']} already exists" + raise ConflictError, "#{model} record with ID #{data['id']} already exists" end check_associations_dont_exist(data) @@ -118,7 +119,7 @@ class Account::DataTransfer::RecordSet end if associated_class.exists?(id: associated_id) - raise IntegrityError, "#{model} record references existing #{association.name} (#{associated_class}) with ID #{associated_id}" + raise ConflictError, "#{model} record references existing #{association.name} (#{associated_class}) with ID #{associated_id}" end end diff --git a/app/models/account/import.rb b/app/models/account/import.rb index 814e6b00c..14ee376ea 100644 --- a/app/models/account/import.rb +++ b/app/models/account/import.rb @@ -7,6 +7,7 @@ class Account::Import < ApplicationRecord has_one_attached :file enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending + enum :failure_reason, %w[ conflict invalid_export ].index_by(&:itself), prefix: :failed_due_to, scopes: false scope :expired, -> { where(completed_at: ...24.hours.ago).or(where(status: :failed, created_at: ...7.days.ago)) } @@ -26,6 +27,12 @@ class Account::Import < ApplicationRecord record_set.check(from: zip, start: last_id, callback: callback) end end + rescue Account::DataTransfer::RecordSet::ConflictError => e + mark_as_failed(:conflict) + raise e + rescue Account::DataTransfer::RecordSet::IntegrityError, ZipFile::InvalidFileError => e + mark_as_failed(:invalid_export) + raise e rescue => e mark_as_failed raise e @@ -44,6 +51,12 @@ class Account::Import < ApplicationRecord reconcile_account_storage mark_completed + rescue Account::DataTransfer::RecordSet::ConflictError => e + mark_as_failed(:conflict) + raise e + rescue Account::DataTransfer::RecordSet::IntegrityError, ZipFile::InvalidFileError => e + mark_as_failed(:invalid_export) + raise e rescue => e mark_as_failed raise e @@ -60,9 +73,9 @@ class Account::Import < ApplicationRecord ImportMailer.completed(identity, account).deliver_later end - def mark_as_failed - failed! - ImportMailer.failed(identity).deliver_later + def mark_as_failed(failure_reason = nil) + update!(status: :failed, failure_reason: failure_reason) + ImportMailer.failed(self).deliver_later end def add_importer_to_all_access_boards diff --git a/app/views/account/imports/show.html.erb b/app/views/account/imports/show.html.erb index 26dbf88d5..a6d966059 100644 --- a/app/views/account/imports/show.html.erb +++ b/app/views/account/imports/show.html.erb @@ -13,7 +13,13 @@ <%= link_to "Go to your account", landing_url(script_name: @import.account.slug), class: "btn btn--link center txt-medium" %> <% when "failed" %>

Your import failed.

-

This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export.

+ <% if @import.failed_due_to_conflict? %> +

It looks like the account you are trying to import already exists.

+ <% elsif @import.failed_due_to_invalid_export? %> +

The ZIP file isn't a Fizzy account export.

+ <% else %> +

This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export.

+ <% end %> <%= link_to "Try again", new_account_import_path, class: "btn btn--plain center txt-medium" %> <% end %>
diff --git a/app/views/mailers/import_mailer/failed.html.erb b/app/views/mailers/import_mailer/failed.html.erb index c7b6d9b5c..6aa9fab0a 100644 --- a/app/views/mailers/import_mailer/failed.html.erb +++ b/app/views/mailers/import_mailer/failed.html.erb @@ -1,8 +1,11 @@

Unfortunately, we couldn't import your Fizzy account.

-

-This may be due to corrupted export data or a conflict with existing data. -Please try again with a fresh export, or reach out for help if the problem persists. -

+<% if @import.failed_due_to_conflict? %> +

It looks like the account you are trying to import already exists.

+<% elsif @import.failed_due_to_invalid_export? %> +

The ZIP file isn't a Fizzy account export.

+<% else %> +

This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export, or reach out for help if the problem persists.

+<% end %> diff --git a/app/views/mailers/import_mailer/failed.text.erb b/app/views/mailers/import_mailer/failed.text.erb index 82e3b772a..1895f0d5d 100644 --- a/app/views/mailers/import_mailer/failed.text.erb +++ b/app/views/mailers/import_mailer/failed.text.erb @@ -1,6 +1,11 @@ Unfortunately, we couldn't import your Fizzy account. -This may be due to corrupted export data or a conflict with existing data. -Please try again with a fresh export, or reach out for help if the problem persists. +<% if @import.failed_due_to_conflict? -%> +It looks like the account you are trying to import already exists. +<% elsif @import.failed_due_to_invalid_export? -%> +The ZIP file isn't a Fizzy account export. +<% else -%> +This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export, or reach out for help if the problem persists. +<% end -%> Need help? Send us an email at support@fizzy.do diff --git a/db/migrate/20260211122517_add_failure_reason_to_account_imports.rb b/db/migrate/20260211122517_add_failure_reason_to_account_imports.rb new file mode 100644 index 000000000..d7e7d6ceb --- /dev/null +++ b/db/migrate/20260211122517_add_failure_reason_to_account_imports.rb @@ -0,0 +1,5 @@ +class AddFailureReasonToAccountImports < ActiveRecord::Migration[8.2] + def change + add_column :account_imports, :failure_reason, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index a1f2a33bc..8bc5f5c7d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_01_21_155752) do +ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -42,6 +42,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_01_21_155752) do t.uuid "account_id" t.datetime "completed_at" t.datetime "created_at", null: false + t.string "failure_reason" t.uuid "identity_id", null: false t.string "status", default: "pending", null: false t.datetime "updated_at", null: false diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index dad03edde..248bf97d4 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_01_21_155752) do +ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -42,6 +42,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_01_21_155752) do t.uuid "account_id" t.datetime "completed_at" t.datetime "created_at", null: false + t.string "failure_reason" t.uuid "identity_id", null: false t.string "status", limit: 255, default: "pending", null: false t.datetime "updated_at", null: false diff --git a/test/mailers/import_mailer_test.rb b/test/mailers/import_mailer_test.rb index 0acff9bfb..740793353 100644 --- a/test/mailers/import_mailer_test.rb +++ b/test/mailers/import_mailer_test.rb @@ -12,4 +12,39 @@ class ImportMailerTest < ActionMailer::TestCase assert_equal "Your Fizzy account import is done", email.subject assert_match accounts(:"37s").slug, email.body.encoded end + + test "failed with no reason" do + import = Account::Import.create!(account: Current.account, identity: identities(:david), status: :failed) + email = ImportMailer.failed(import) + + assert_emails 1 do + email.deliver_now + end + + assert_equal [ "david@37signals.com" ], email.to + assert_equal "Your Fizzy account import failed", email.subject + assert_match "corrupted export data", email.body.encoded + end + + test "failed with conflict reason" do + import = Account::Import.create!(account: Current.account, identity: identities(:david), status: :failed, failure_reason: :conflict) + email = ImportMailer.failed(import) + + assert_emails 1 do + email.deliver_now + end + + assert_match "account you are trying to import already exists", email.body.encoded + end + + test "failed with invalid_export reason" do + import = Account::Import.create!(account: Current.account, identity: identities(:david), status: :failed, failure_reason: :invalid_export) + email = ImportMailer.failed(import) + + assert_emails 1 do + email.deliver_now + end + + assert_match "isn't a Fizzy account export", email.body.encoded + end end diff --git a/test/models/account/import_test.rb b/test/models/account/import_test.rb index dfce60cf3..a377f69c2 100644 --- a/test/models/account/import_test.rb +++ b/test/models/account/import_test.rb @@ -62,6 +62,67 @@ class Account::ImportTest < ActiveSupport::TestCase export_tempfile&.unlink end + test "check sets no failure_reason for unexpected errors" do + import = Account::Import.create!(identity: identities(:david), account: Account.create!(name: "Import Test")) + + assert_raises(NoMethodError) { import.check } + + assert import.failed? + assert_nil import.failure_reason + end + + test "check sets failure_reason to invalid_export for non-Fizzy ZIP" do + target_account = Account.create!(name: "Import Test") + import = Account::Import.create!(identity: identities(:david), account: target_account) + + # Create a ZIP with no account.json + tempfile = Tempfile.new([ "bad_export", ".zip" ]) + tempfile.binmode + writer = ZipFile::Writer.new(tempfile) + writer.add_file("data/dummy.json", '{"hello": "world"}') + writer.close + tempfile.rewind + + Current.set(account: target_account) do + import.file.attach(io: tempfile, filename: "export.zip", content_type: "application/zip") + end + + assert_raises(Account::DataTransfer::RecordSet::IntegrityError) { import.check } + + assert import.failed? + assert_equal "invalid_export", import.failure_reason + ensure + tempfile&.close + tempfile&.unlink + end + + test "check sets failure_reason to conflict when records already exist" do + source_account = accounts("37s") + exporter = users(:david) + identity = exporter.identity + + export = Account::Export.create!(account: source_account, user: exporter) + export.build + + export_tempfile = Tempfile.new([ "export", ".zip" ]) + export.file.open { |f| FileUtils.cp(f.path, export_tempfile.path) } + + # Import without destroying the source, so records still exist + target_account = Account.create_with_owner(account: { name: "Import Test" }, owner: { identity: identity, name: exporter.name }) + import = Account::Import.create!(identity: identity, account: target_account) + Current.set(account: target_account) do + import.file.attach(io: File.open(export_tempfile.path), filename: "export.zip", content_type: "application/zip") + end + + assert_raises(Account::DataTransfer::RecordSet::ConflictError) { import.check } + + assert import.failed? + assert_equal "conflict", import.failure_reason + ensure + export_tempfile&.close + export_tempfile&.unlink + end + private def account_digest(account) { From 69e185b3c27d246b8ebb865ed3b5ca2dc1c79fa1 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Wed, 11 Feb 2026 17:24:25 +0100 Subject: [PATCH 41/96] Change layout of meta data to avoid early truncation --- app/assets/stylesheets/card-perma.css | 68 ++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index b3d37d68a..735480182 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -108,6 +108,63 @@ .card__meta { grid-area: meta; margin-inline-end: auto; + + @media (max-width: 639px) { + --meta-spacer-block: 0.75ch; + + inline-size: 100%; + grid-template-areas: + "avatars-author text-added" + "avatars-author text-author" + "text-updated text-updated" + "text-assignees text-assignees" + "avatars-assignees avatars-assignees"; + grid-template-columns: auto 1fr; + + &:has(.card__meta-avatars--assignees .avatar) { + &:not(:has(.reaction)) { + .card__meta-avatars--assignees { + margin-inline-end: calc(var(--btn-size) + var(--meta-spacer-block)); + } + } + } + + &:not(:has(.card__meta-avatars--assignees .avatar)) { + grid-template-areas: + "avatars-author text-added" + "avatars-author text-author" + "... text-updated" + "avatars-assignees text-assignees"; + + .card__meta-text--assignees { + margin-inline-end: calc(var(--btn-size) + var(--meta-spacer-block)); + } + } + + .card__meta-text { + border: 0; + padding: 0; + } + + .card__meta-text + .card__meta-text { + border-block-start: var(--card-border); + margin-block-start: var(--meta-spacer-block); + padding-block-start: var(--meta-spacer-block); + } + + .card__meta-avatars--assignees { + margin-inline-start: unset; + margin-block-start: calc(var(--meta-spacer-block) * 2); + } + + .card__meta-avatars--author { + display: initial; + } + + .card__meta-avatars--assignees .avatar { + display: grid; + } + } } .card__stages { @@ -163,6 +220,7 @@ @media (max-width: 639px) { font-size: var(--text-x-small); + position: relative; } } @@ -192,11 +250,17 @@ } &:not(:has(.reaction)) { - position: static; - .reactions__trigger { --btn-border-color: var(--color-ink-light); } + + @media (max-width: 639px) { + inset-inline-end: 0; + } + + @media (min-width: 640px) { + position: static; + } } } From 9d8a5f03ff4a7d0d22bd17b5e181bb0b214051b8 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Wed, 11 Feb 2026 17:29:33 +0100 Subject: [PATCH 42/96] Fix margin --- app/assets/stylesheets/card-perma.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 735480182..4babc58fe 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -125,6 +125,7 @@ &:not(:has(.reaction)) { .card__meta-avatars--assignees { margin-inline-end: calc(var(--btn-size) + var(--meta-spacer-block)); + margin-block-start: var(--meta-spacer-block); } } } @@ -138,6 +139,7 @@ .card__meta-text--assignees { margin-inline-end: calc(var(--btn-size) + var(--meta-spacer-block)); + margin-block-start: calc(var(--meta-spacer-block) * 2); } } @@ -154,7 +156,6 @@ .card__meta-avatars--assignees { margin-inline-start: unset; - margin-block-start: calc(var(--meta-spacer-block) * 2); } .card__meta-avatars--author { From f8b08c18901b472ddd783ba646e544d1f28f9121 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Wed, 11 Feb 2026 17:36:13 +0100 Subject: [PATCH 43/96] Fix margin again --- app/assets/stylesheets/card-perma.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 4babc58fe..0b09fddaf 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -139,6 +139,9 @@ .card__meta-text--assignees { margin-inline-end: calc(var(--btn-size) + var(--meta-spacer-block)); + } + + .card__meta-avatars--assignees { margin-block-start: calc(var(--meta-spacer-block) * 2); } } From b786bf24e0c0aa6d49da67c8bbec3e42ed5faf06 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Wed, 11 Feb 2026 16:36:09 -0600 Subject: [PATCH 44/96] Clarify importing accounts --- app/assets/stylesheets/import.css | 33 +++++++++++++++++++++++++ app/assets/stylesheets/utilities.css | 1 + app/views/account/imports/new.html.erb | 14 ++++++++--- app/views/account/imports/show.html.erb | 32 ++++++++++++++---------- 4 files changed, 63 insertions(+), 17 deletions(-) create mode 100644 app/assets/stylesheets/import.css diff --git a/app/assets/stylesheets/import.css b/app/assets/stylesheets/import.css new file mode 100644 index 000000000..5ab6b2cc5 --- /dev/null +++ b/app/assets/stylesheets/import.css @@ -0,0 +1,33 @@ +@layer components { + .import-status { + --import-status-border-color: var(--color-ink-light); + --import-status-color: var(--color-ink); + + border: 1px dashed var(--import-status-border-color); + border-radius: 1ch; + color: var(--import-status-color); + font-size: var(--text-medium); + padding: 1.5ch; + + .btn { + font-size: var(--text-small); + margin-block-start: 1.5ch; + } + } + + .import-status--success { + --import-status-border-color: var(--color-positive); + --import-status-color: var(--color-positive); + } + + .import-status--error { + --import-status-border-color: var(--color-negative); + --import-status-color: var(--color-negative); + } + + @keyframes dash { + to { + background-position: 100% 0%, 0% 100%, 0% 0%, 100% 100%; + } + } +} diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css index 76dedfe12..53837034c 100644 --- a/app/assets/stylesheets/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -32,6 +32,7 @@ .txt-link { color: var(--color-link); text-decoration: underline; } .font-weight-normal { font-weight: normal; } + .font-weight-semibold { font-weight: 600; } .font-weight-bold { font-weight: bold; } .font-weight-black { font-weight: 900; } diff --git a/app/views/account/imports/new.html.erb b/app/views/account/imports/new.html.erb index fd837a7b6..ab6af3a51 100644 --- a/app/views/account/imports/new.html.erb +++ b/app/views/account/imports/new.html.erb @@ -1,9 +1,15 @@ <% @page_title = "Import an account" %> - -
+
-

Import a Fizzy account

-
Create an account using data from a Fizzy export. Upload the exported .zip file below.
+

Import a Fizzy account

+ + <% if Fizzy.saas? %> +
Move a self-hosted account to fizzy.do
+
Export your self-hosted account, then upload the .zip file below.
+ <% else %> +
Move an account from fizzy.do to a self-hosted instance
+
Export your fizzy.do account, then upload the .zip file below.
+ <% end %>
<%= form_with url: account_imports_path, class: "flex flex-column gap", data: { controller: "form upload-preview" }, multipart: true do |form| %> diff --git a/app/views/account/imports/show.html.erb b/app/views/account/imports/show.html.erb index a6d966059..44d9228e6 100644 --- a/app/views/account/imports/show.html.erb +++ b/app/views/account/imports/show.html.erb @@ -2,25 +2,31 @@ <%= turbo_stream_from @import %> -
+

Import status

<% case @import.status %> <% when "pending", "processing" %> -

Your import is in progress. This may take a while for large accounts.

+
+ Your import is in progress. This may take a while for large accounts. +
<% when "completed" %> -

Your import has completed successfully!

- <%= link_to "Go to your account", landing_url(script_name: @import.account.slug), class: "btn btn--link center txt-medium" %> +
+
Your import was successfull!
+ <%= link_to "Go to your account →", landing_url(script_name: @import.account.slug), class: "btn btn--link" %> +
<% when "failed" %> -

Your import failed.

- <% if @import.failed_due_to_conflict? %> -

It looks like the account you are trying to import already exists.

- <% elsif @import.failed_due_to_invalid_export? %> -

The ZIP file isn't a Fizzy account export.

- <% else %> -

This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export.

- <% end %> - <%= link_to "Try again", new_account_import_path, class: "btn btn--plain center txt-medium" %> +
+
Import failed
+ <% if @import.failed_due_to_conflict? %> +
The account you‘re trying to import already exists. Make sure you‘re importing a <%= Fizzy.saas? ? "self-hosted" : "fizzy.do" %> account.
+ <% elsif @import.failed_due_to_invalid_export? %> +
The .zip file you uploaded doesn‘t look like a Fizzy account export.
+ <% else %> +
This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export.
+ <% end %> + <%= link_to "Try again", new_account_import_path, class: "btn" %> +
<% end %>
From c84d783b2a79531f75f63294aebf124c711c527a Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 12 Feb 2026 08:07:51 +0100 Subject: [PATCH 45/96] Add card_id and unread_count to notifications --- db/migrate/20260206104338_add_card_id_to_notifications.rb | 6 ++++++ db/schema.rb | 2 ++ db/schema_sqlite.rb | 2 ++ 3 files changed, 10 insertions(+) create mode 100644 db/migrate/20260206104338_add_card_id_to_notifications.rb diff --git a/db/migrate/20260206104338_add_card_id_to_notifications.rb b/db/migrate/20260206104338_add_card_id_to_notifications.rb new file mode 100644 index 000000000..8d89f61a3 --- /dev/null +++ b/db/migrate/20260206104338_add_card_id_to_notifications.rb @@ -0,0 +1,6 @@ +class AddCardIdToNotifications < ActiveRecord::Migration[8.2] + def change + add_column :notifications, :card_id, :uuid + add_column :notifications, :unread_count, :integer, null: false, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index 8bc5f5c7d..ddfdb3b60 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -385,11 +385,13 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do create_table "notifications", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false + t.uuid "card_id" t.datetime "created_at", null: false t.uuid "creator_id" t.datetime "read_at" t.uuid "source_id", null: false t.string "source_type", null: false + t.integer "unread_count", default: 0, null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["account_id"], name: "index_notifications_on_account_id" diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 248bf97d4..5fa6d05b7 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -385,11 +385,13 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do create_table "notifications", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false + t.uuid "card_id" t.datetime "created_at", null: false t.uuid "creator_id" t.datetime "read_at" t.uuid "source_id", null: false t.string "source_type", limit: 255, null: false + t.integer "unread_count", default: 0, null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["account_id"], name: "index_notifications_on_account_id" From 2ee745cf82b7d0499d63668d13ee9d5b8543f28f Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 12 Feb 2026 09:53:48 +0100 Subject: [PATCH 46/96] Lexxy version bump --- Gemfile.lock | 4 ++-- Gemfile.saas.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 86a856e37..b00219210 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,8 @@ GIT remote: https://github.com/basecamp/lexxy - revision: 4f0fc4d5773bc6892de70f175440c259974c12a7 + revision: 4bcf8d0213971560e90734493f8bd835addc3642 specs: - lexxy (0.7.0.beta) + lexxy (0.7.4.beta) rails (>= 8.0.2) GIT diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index ad321c2de..07740c63b 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -23,9 +23,9 @@ GIT GIT remote: https://github.com/basecamp/lexxy - revision: 4f0fc4d5773bc6892de70f175440c259974c12a7 + revision: 4bcf8d0213971560e90734493f8bd835addc3642 specs: - lexxy (0.7.0.beta) + lexxy (0.7.4.beta) rails (>= 8.0.2) GIT From a586f77a714fa88f817a3b768bdd50ee8755b512 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Fri, 30 Jan 2026 17:57:22 +0100 Subject: [PATCH 47/96] Use Lexxy's built in CSS for Lexxy --- app/assets/stylesheets/lexxy.css | 508 ------------------- app/assets/stylesheets/rich-text-content.css | 28 +- app/views/layouts/shared/_head.html.erb | 3 + 3 files changed, 18 insertions(+), 521 deletions(-) diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index c35f14fee..3959424f9 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -3,513 +3,5 @@ /* ------------------------------------------------------------------------ */ lexxy-editor { - display: block; - position: relative; - overflow: visible; - - figure.node--selected, - div.node--selected { - &:has(img) { - img { - outline: var(--focus-ring-size) solid var(--focus-ring-color); - outline-offset: var(--focus-ring-offset); - } - } - - &:not(:has(img)) { - outline: var(--focus-ring-size) solid var(--focus-ring-color); - outline-offset: var(--focus-ring-offset); - } - } - } - - .lexxy-content__table-wrapper { - margin: 0; - margin-block: 1ch; - overflow-x: auto; - } - - table { - th, td { - &.table-cell--selected { - background-color: var(--color-selected-light); - } - - &.lexxy-content__table-cell--selected { - background-color: var(--color-selected); - border-color: var(--color-selected-dark); - } - } - - &.lexxy-content__table--selection { - ::selection { - background: transparent; - } - } - } - - /* Lexical uses the `lexxy-editor--empty` class even if you have a list - * started but haven't added other characters. Here, we hide the placeholder - * when you click the List button in the toolbar. */ - .lexxy-editor--empty { - .lexxy-editor__content:not(:has(ul, ol))::before { - content: attr(placeholder); - color: currentColor; - cursor: text; - opacity: 0.66; - pointer-events: none; - position: absolute; - white-space: pre-line; - } - } - - .lexxy-editor__content { - margin-block-start: var(--block-space-half); - min-block-size: calc(7lh + var(--block-space)); - outline: 0; - - /* Allow color highlights to show through a bit */ - ::selection { - background: oklch(var(--lch-blue-light) / 0.5); - } - - > :last-child { - margin-block-end: 0; - } - } - - .lexxy-editor--drag-over { - background-color: var(--color-selected); - border-radius: 4px; - outline: 2px dashed var(--color-selected-dark); - } - - .lexxy-code-language-picker { - --caret-icon: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E"); - --caret-icon-dark: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E"); - - -webkit-appearance: none; - appearance: none; - background-color: var(--color-canvas); - background-image: var(--caret-icon); - background-position: center right 0.9em; - background-repeat: no-repeat; - background-size: 0.5em; - border: 1px solid var(--color-ink-light); - border-radius: 99rem; - color: var(--color-ink); - cursor: pointer; - font-family: var(--font-base); - font-size: var(--text-x-small); - font-weight: 500; - inset-inline-end: 0; - line-height: 1.15lh; - margin: 0.75ch 0.75ch 0 0; - padding-inline: 1.5ch 1.8em; - text-align: start; - - html[data-theme="dark"] & { - --caret-icon: var(--caret-icon-dark); - } - - @media (prefers-color-scheme: dark) { - html:not([data-theme]) & { - --caret-icon: var(--caret-icon-dark); - } - } - - option { - background-color: var(--color-canvas); - color: var(--color-ink); - } - } - - /* Toolbar - /* ------------------------------------------------------------------------ */ - - /* TODO: Temporary - hide table button while we work on the styles */ - button[name="table"] { - display: none; - } - - lexxy-toolbar { - --lexxy-toolbar-icon-size: 1em; - - background-color: var(--lexxy-bg-color, var(--color-canvas)); - border-block-end: 1px solid var(--color-ink-light); - color: currentColor; - display: flex; - font-size: inherit; - margin: 0; - max-inline-size: 100%; - padding: 0.2em 0; - position: relative; - position: sticky; - inset-block-start: 0; - z-index: 2; - } - - .lexxy-editor__toolbar-button { - --toolbar-button-size: 44px; - - appearance: none; - aspect-ratio: 1; - background-color: transparent; - block-size: var(--toolbar-button-size); - border: none; - border-radius: 0.2em; - color: currentColor; - cursor: pointer; - display: grid; - font-size: inherit; - place-items: center; - - &:is(:focus, :hover) { - background-color: var(--color-ink-lighter); - box-shadow: none; - } - - &:is(:active), - &[aria-pressed="true"] { - background-color: var(--color-selected); - } - - svg { - -webkit-touch-callout: none; - block-size: var(--lexxy-toolbar-icon-size); - fill: currentColor; - grid-area: 1/1; - inline-size: var(--lexxy-toolbar-icon-size); - user-select: none; - } - - @media (min-width: 640px) { - --toolbar-button-size: 2em; - } - } - - .lexxy-editor__toolbar-overflow-menu { - background-color: var(--color-canvas); - border-radius: 0.5ch; - box-shadow: var(--shadow); - display: flex; - flex-wrap: wrap; - inset-inline-end: 0; - padding: 4px; - position: absolute; - z-index: 2; - } - - .lexxy-editor__toolbar-spacer { - flex: 1; - } - - /* Dropdowns - /* ------------------------------------------------------------------------ */ - - .lexxy-editor__toolbar-dropdown-content { - --lexxy-dropdown-padding: 0.75rem; - --lexxy-dropdown-btn-size: 2rem; - - background-color: var(--color-canvas); - border: 1px solid var(--color-ink-lighter); - border-radius: 0.5em; - box-shadow: var(--shadow); - color: var(--color-ink); - font-size: var(--text-small); - padding: var(--lexxy-dropdown-padding); - position: absolute; - z-index: 2; - - button { - block-size: var(--lexxy-dropdown-btn-size); - } - } - - .lexxy-editor__toolbar-dropdown-actions { - display: flex; - font-size: var(--text-x-small); - flex: 1 1 0%; - gap: var(--lexxy-dropdown-padding); - margin-block-start: var(--lexxy-dropdown-padding); - - .btn { - --radius: 99rem; - - inline-size: 100%; - } - - .btn[type="submit"] { - --btn-background: var(--card-color, var(--color-link)); - --btn-color: var(--color-ink-inverted); - --focus-ring-color: var(--card-color, var(--color-link)); - } - - span { - inline-size: 100%; - } - } - - lexxy-link-dropdown { - .input { - min-inline-size: 30ch; - } - } - - lexxy-highlight-dropdown { - --gap: 0.5ch; - - [data-button-group] { - display: flex; - flex-direction: row; - gap: var(--gap); - - + & { - margin-block-start: var(--gap); - } - - @media (max-width: 639px) { - flex-wrap: wrap; - } - } - } - - .lexxy-highlight-button { - --outline-color: oklch(var(--lch-ink-darkest) / 0.2); - - appearance: none; - background: var(--color-canvas); - border: none; - border-radius: 0.5ch; - color: inherit; - display: grid; - font-weight: 500; - inline-size: var(--lexxy-dropdown-btn-size); - place-content: center; - position: relative; - outline: none; - - &:after { - content: "Aa"; - } - - &:hover, - &[aria-pressed="true"] { - box-shadow: 0 0 0 1px var(--color-canvas), 0 0 0 3px var(--outline-color); - } - - &[aria-pressed="true"] { - --outline-color: var(--color-link); - - &:after { - content: "✓"; - } - } - } - - .lexxy-editor__toolbar-dropdown-reset { - background: var(--color-canvas); - border: 1px solid var(--color-ink-light); - border-radius: 99rem; - inline-size: 100%; - margin-block-start: var(--lexxy-dropdown-padding); - - &[disabled] { - display: none; - } - } - - .lexxy-table-handle-buttons { - --button-size: 2.5em; - - color: var(--color-ink-inverted); - display: none; - flex-direction: row; - font-size: var(--text-x-small); - gap: 0.25ch; - line-height: 1; - position: absolute; - transform: translate(-50%, -120%); - z-index: 1; - - &:has([open]) { - z-index: 4; - } - - .lexxy-table-control { - align-items: center; - background-color: var(--color-ink); - border-radius: 0.75ch; - display: flex; - flex-direction: row; - gap: 1ch; - padding: 2px; - white-space: nowrap; - - button, - summary { - aspect-ratio: 1 / 1; - align-items: center; - background-color: transparent; - border-radius: 0.5ch; - border: 0; - color: var(--color-ink-inverted); - cursor: pointer; - display: flex; - font-size: inherit; - font-weight: 700; - justify-content: center; - line-height: 1; - list-style: none; - min-block-size: var(--button-size); - min-inline-size: var(--button-size); - padding: 0; - user-select: none; - -webkit-user-select: none; - - &:hover { - background-color: var(--color-ink-darker); - } - - &:focus, - &:focus-visible { - background-color: var(--color-ink-darker); - outline: var(--focus-ring-size) solid var(--focus-ring-color); - outline-offset: var(--focus-ring-offset); - } - - svg { - block-size: 1em; - inline-size: 1em; - fill: currentColor; - } - - span { - display: none; - } - } - } - - .lexxy-table-control__more-menu { - gap: 0; - padding: 2px; - position: relative; - - summary { - &::-webkit-details-marker { - display: none; - } - } - - .lexxy-table-control__more-menu-details { - display: flex; - flex-direction: column; - gap: 0.25ch; - inset-block-start: 105%; - inset-inline-end: 0; - padding: 0; - position: absolute; - - .lexxy-table-control__more-menu-section { - background: var(--color-ink); - border-radius: 0.75ch; - display: flex; - flex-direction: column; - padding: 2px; - } - - button { - aspect-ratio: unset; - align-items: center; - flex-direction: row; - font-weight: normal; - gap: 1ch; - justify-content: flex-start; - padding: 0.5ch 2ch; - padding-inline-start: 1ch; - white-space: nowrap; - - svg { - block-size: 1.3em; - inline-size: 1.3em; - } - - span { - display: inline-block; - } - } - } - } - } - - /* Prompt menu (@mentions, etc.) - /* ------------------------------------------------------------------------ */ - - .lexxy-prompt-menu { - --lexxy-prompt-avatar-size: 24px; - --lexxy-prompt-min-width: 20ch; - --lexxy-prompt-padding: 0.5ch; - - background-color: var(--color-canvas); - border-radius: calc(var(--lexxy-prompt-padding) * 2); - box-shadow: var(--shadow); - color: var(--color-ink); - font-family: var(--font-sans); - font-size: var(--text-small); - list-style: none; - margin: 0; - max-height: 200px; - min-inline-size: var(--lexxy-prompt-min-width); - overflow: auto; - padding: var(--lexxy-prompt-padding); - visibility: hidden; - z-index: var(--z-popup); - } - - .lexxy-prompt-menu--visible { - visibility: initial; - } - - .lexxy-prompt-menu__item { - align-items: center; - border-radius: 0.5ch; - cursor: pointer; - display: flex; - gap: var(--lexxy-prompt-padding); - padding: var(--lexxy-prompt-padding); - white-space: nowrap; - - &:hover { - background-color: var(--color-ink-lightest); - } - - &[aria-selected] { - background-color: var(--color-selected); - } - - img { - block-size: var(--lexxy-prompt-avatar-size); - border-radius: 50%; - flex-shrink: 0; - inline-size: var(--lexxy-prompt-avatar-size); - margin: 0; - } - - + & { - margin-top: 2px; - } - - code { - background-color: var(--color-terminal-text-light); - border-radius: 0.25ch; - font-size: 0.95em; - padding-inline: 0.5ch; - } - } - - /* Empty state */ - .lexxy-prompt-menu__item--empty { - color: var(--color-ink-medium); - padding: var(--lexxy-prompt-padding); } } diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css index bf5328478..5111423f9 100644 --- a/app/assets/stylesheets/rich-text-content.css +++ b/app/assets/stylesheets/rich-text-content.css @@ -225,27 +225,29 @@ th, td { + background-color: var(--color-canvas); border: 1px solid var(--color-ink-light); - padding: 0.5ch 1ch; + font-size: var(--text-small); + min-width: 5ch; + max-width: 50ch; + padding: 0.75ch; text-align: start; word-break: normal; - + *:last-child { margin-block-end: 0; } - - *:not(pre, code) { - word-break: normal; + + &.lexxy-content__table-cell--header { + background-color: var(--color-ink-lightest); + font-weight: bold; } - } - th, - .lexxy-content__table-cell--header { - background: var(--color-ink-lightest); - } - - td { - background: var(--color-canvas); + *:is(code, pre) { + hyphens: auto; + text-wrap: wrap; + white-space: pre-wrap; + } } } } diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index 4be536454..aad07a44d 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -16,6 +16,9 @@ <% turbo_refreshes_with method: :morph, scroll: :preserve %> <%= render "layouts/theme_preference" %> + <%= stylesheet_link_tag "lexxy-variables" %> + <%= stylesheet_link_tag "lexxy-content" %> + <%= stylesheet_link_tag "lexxy-editor" %> <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> <%= stylesheet_link_tag "fizzy/saas", "data-turbo-track": "reload" if Fizzy.saas? %> <%= javascript_importmap_tags %> From 4e5a5e1523ab8251d5e02550918c3a7cba47daa7 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Fri, 30 Jan 2026 14:14:21 -0600 Subject: [PATCH 48/96] Use built-in lexxy styles and sprinkle in some overrides --- app/assets/stylesheets/cards.css | 2 +- app/assets/stylesheets/lexxy.css | 33 ++++++++++++++++++++++--- app/views/layouts/shared/_head.html.erb | 3 --- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css index cbaa66c91..f36b4e76f 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -229,7 +229,7 @@ } lexxy-toolbar { - border-block-start: 1px solid var(--color-ink-light); + border-block-start: 1px solid var(--lexxy-border-color); } & ~ .btn.btn--reversed { diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index 3959424f9..316d9e4c0 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -1,7 +1,34 @@ -@layer components { - /* Editor - /* ------------------------------------------------------------------------ */ +@import url("lexxy-variables.css") layer(base); +@import url("lexxy-content.css") layer(base); +@import url("lexxy-editor.css") layer(base); +@layer components { lexxy-editor { + --lexxy-border-color: oklch(var(--lch-ink-darkest) / 20%); + --lexxy-editor-padding: 0; + --lexxy-toolbar-button-size: 2rem; + --lexxy-toolbar-gap: 0; + + background-color: transparent; + border: none; + border-radius: 0; + } + + lexxy-toolbar { + border-color: var(--lexxy-border-color); + } + + .lexxy-editor__toolbar-button { + background: transparent; + + @media(any-hover: hover) { + &:hover { + background-color: color-mix(in srgb, transparent, var(--color-ink) 10%); + } + } + } + + .lexxy-editor__content { + margin-block-start: 0.5lh; } } diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index aad07a44d..4be536454 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -16,9 +16,6 @@ <% turbo_refreshes_with method: :morph, scroll: :preserve %> <%= render "layouts/theme_preference" %> - <%= stylesheet_link_tag "lexxy-variables" %> - <%= stylesheet_link_tag "lexxy-content" %> - <%= stylesheet_link_tag "lexxy-editor" %> <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> <%= stylesheet_link_tag "fizzy/saas", "data-turbo-track": "reload" if Fizzy.saas? %> <%= javascript_importmap_tags %> From a4303dbe71ffeab3d0041b51393dc3b0a22be80c Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Fri, 30 Jan 2026 15:36:16 -0600 Subject: [PATCH 49/96] Use existing lexxy-content styles instead of rewriting everything --- app/assets/stylesheets/attachments.css | 207 ++++++++ app/assets/stylesheets/lexxy.css | 81 ++- app/assets/stylesheets/rich-text-content.css | 471 ------------------ app/views/boards/edit/_publication.html.erb | 2 +- app/views/cards/comments/_comment.html.erb | 2 +- app/views/cards/comments/_new.html.erb | 2 +- app/views/cards/comments/edit.html.erb | 2 +- app/views/cards/container/_content.html.erb | 2 +- .../cards/container/_content_display.html.erb | 2 +- app/views/cards/edit.html.erb | 2 +- app/views/public/boards/show.html.erb | 2 +- app/views/public/cards/show/_content.html.erb | 2 +- 12 files changed, 294 insertions(+), 483 deletions(-) create mode 100644 app/assets/stylesheets/attachments.css delete mode 100644 app/assets/stylesheets/rich-text-content.css diff --git a/app/assets/stylesheets/attachments.css b/app/assets/stylesheets/attachments.css new file mode 100644 index 000000000..85dc7652d --- /dev/null +++ b/app/assets/stylesheets/attachments.css @@ -0,0 +1,207 @@ +@layer components { + .attachment { + block-size: auto; + display: block; + inline-size: fit-content; + position: relative; + max-inline-size: 100%; + + progress { + inline-size: 100%; + margin: auto; + } + } + + .attachment__caption { + color: color-mix(in oklch, var(--color-ink) 66%, transparent); + font-size: var(--text-small); + + textarea { + --input-border-radius: 0.3em; + --input-border-size: 0; + --input-padding: 0; + + background-color: var(--input-background, transparent); + border: none; + color: inherit; + inline-size: 100%; + max-inline-size: 100%; + resize: none; + text-align: center; + + &:focus { + --focus-ring-size: 0; + } + + @supports (field-sizing: content) { + field-sizing: content; + inline-size: auto; + min-inline-size: 20ch; + } + } + } + + .attachment__icon { + aspect-ratio: 4/5; + background-color: color-mix(var(--attachment-icon-color), transparent 90%); + block-size: 2.5lh; + border: 2px solid var(--attachment-icon-color); + border-block-start-width: 1ch; + border-radius: 0.5ch; + box-sizing: border-box; + color: var(--attachment-icon-color); + display: grid; + font-size: var(--text-small); + font-weight: bold; + inline-size: auto; + padding-inline: 0.5ch; + place-content: center; + text-transform: uppercase; + white-space: nowrap; + } + + .attachment--preview { + margin-inline: auto; + text-align: center; + + img, video { + block-size: auto; + display: block; + margin-inline: auto; + max-inline-size: 100%; + user-select: none; + } + + > a { + display: block; + } + + .attachment__caption { + column-gap: 0.5ch; + display: flex; + flex-wrap: wrap; + justify-content: center; + margin-block-start: 0.5ch; + } + } + + .attachment--file { + --attachment-icon-color: var(--color-ink-medium); + + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 1ch; + inline-size: 100%; + margin-inline: 0; + + .attachment__caption { + display: grid; + flex: 1; + text-align: start; + } + + .attachment__name { + color: var(--color-ink); + font-weight: bold; + } + + /* Video attachments don't have an identifiable class, but we need to + * make sure the caption is always below the video */ + &:has(video) { + .attachment__caption { + flex: none; + inline-size: 100%; + } + } + } + + .attachment--psd, + .attachment--key, + .attachment--sketch, + .attachment--ai, + .attachment--eps, + .attachment--indd, + .attachment--svg, + .attachment--ppt, + .attachment--pptx { + --attachment-icon-color: oklch(var(--lch-red-medium)); + } + + .attachment--css, + .attachment--crash, + .attachment--php, + .attachment--json, + .attachment--htm, + .attachment--html, + .attachment--rb, + .attachment--erb, + .attachment--ts, + .attachment--js { + --attachment-icon-color: oklch(var(--lch-purple-medium)); + } + + .attachment--txt, + .attachment--pages, + .attachment--rtf, + .attachment--md, + .attachment--doc, + .attachment--docx { + --attachment-icon-color: oklch(var(--lch-blue-medium)); + } + + .attachment--csv &, + .attachment--numbers &, + .attachment--xls &, + .attachment--xlsx & { + --attachment-icon-color: oklch(var(--lch-green-medium)); + } + + .attachment__link { + color: var(--color-link); + text-decoration: underline; + } + + /* Custom attachments such as mentions, etc. */ + action-text-attachment[content-type^='application/vnd.actiontext'] { + --attachment-bg-color: transparent; + --attachment-image-size: 1em; + --attachment-text-color: currentColor; + + align-items: center; + background: var(--attachment-bg-color); + border-radius: 99rem; + box-shadow: + -0.25ch 0 0 var(--attachment-bg-color), + 0.5ch 0 0 var(--attachment-bg-color); + color: var(--attachment-text-color); + display: inline-flex; + gap: 0.25ch; + margin: 0; + padding: 0; + position: relative; + vertical-align: bottom; + white-space: normal; + + lexxy-editor & { + cursor: pointer; + } + + img { + block-size: var(--attachment-image-size); + border-radius: 50%; + inline-size: var(--attachment-image-size); + } + + &.node--selected { + --attachment-bg-color: oklch(var(--lch-blue-dark)); + --attachment-text-color: var(--color-ink-inverted); + } + } + + action-text-attachment[content-type^='application/vnd.actiontext.mention'] { + img { + object-fit: cover; + } + } +} diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index 316d9e4c0..7eb0377ef 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -3,11 +3,13 @@ @import url("lexxy-editor.css") layer(base); @layer components { + /* Editor + /* ------------------------------------------------------------------------ */ + lexxy-editor { --lexxy-border-color: oklch(var(--lch-ink-darkest) / 20%); --lexxy-editor-padding: 0; --lexxy-toolbar-button-size: 2rem; - --lexxy-toolbar-gap: 0; background-color: transparent; border: none; @@ -21,9 +23,35 @@ .lexxy-editor__toolbar-button { background: transparent; + &[aria-pressed="true"] { + background-color: oklch(var(--lch-blue-medium) / 20%); + } + @media(any-hover: hover) { - &:hover { - background-color: color-mix(in srgb, transparent, var(--color-ink) 10%); + &:hover:not([aria-pressed="true"]) { + background-color: oklch(var(--lch-ink-dark) / 20%); + } + } + } + + lexxy-link-dropdown { + --lexxy-toolbar-spacing: 1.5ch; + + max-inline-size: 20rem; + + form { + flex-direction: column; + } + + .lexxy-editor__toolbar-button { + border-radius: 99rem; + + &[type="submit"] { + background-color: var(--color-link); + } + + &[type="button"] { + border: 1px solid var(--color-ink-light); } } } @@ -31,4 +59,51 @@ .lexxy-editor__content { margin-block-start: 0.5lh; } + + .lexxy-code-language-picker { + border-radius: 99rem; + } + + [data-lexical-cursor] { + animation: blink 1s step-end infinite; + block-size: 1lh; + border-inline-start: 1px solid currentColor; + line-height: inherit; + margin-block: 1em; + } + + /* Content + /* ------------------------------------------------------------------------ */ + + .lexxy-content { + --lexxy-content-margin: 0.5lh; + + color: currentColor; + + h1, h2, h3, h4, h5, h6 { + font-weight: 800; + letter-spacing: -0.02ch; + line-height: 1.1; + overflow-wrap: break-word; + text-wrap: balance; + } + + p { + margin: 0; + } + + blockquote { + border-inline-start: 0.25em solid var(--color-ink-lighter); + padding-block: 0; + } + + code { + background: var(--color-canvas); + border: 1px solid var(--color-ink-lighter); + } + + hr { + border-block-end-width: 2px; + } + } } diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css deleted file mode 100644 index 5111423f9..000000000 --- a/app/assets/stylesheets/rich-text-content.css +++ /dev/null @@ -1,471 +0,0 @@ -@layer components { - :where(.rich-text-content) { - --block-margin: 0.5lh; - - h1, h2, h3, h4, h5, h6 { - display: block; - font-weight: 800; - letter-spacing: -0.02ch; - line-height: 1.1; - margin-block: 0 var(--block-margin); - overflow-wrap: break-word; - text-wrap: balance; - } - - h1 { font-size: 2em; } - h2 { font-size: 1.5em; } - h3 { font-size: 1.17em; } - h4 { font-size: 1em; } - h5 { font-size: 0.83em; } - h6 { font-size: 0.67em; } - - p, ul, ol, dl, blockquote, figure, .attachment { - margin-block: 0 var(--block-margin); - - &:not(lexxy-editor &) { - overflow-wrap: break-word; - text-wrap: pretty; - } - } - - p:has(+ p) { - margin: 0; - } - - ol, ul { - padding-inline-start: 3ch; - } - - ol { - list-style: decimal; - } - - ul { - list-style: disc; - } - - li:has(li) { - list-style: none; - - ol, ul { - margin: 0; - padding-inline-start: 2ch; - } - } - - b, strong, .lexxy-content__bold { - font-weight: 700; - } - - i, em, .lexxy-content__italic { - font-style: italic; - } - - s, .lexxy-content__strikethrough { - text-decoration: line-through; - } - - mark, .lexxy-content__highlight { - background-color: transparent; - color: inherit; - } - - p, blockquote { - letter-spacing: -0.005ch; - } - - /* Avoid extra space due to empty paragraphs */ - p:empty { - display: none; - } - - blockquote { - border-inline-start: 0.25em solid var(--color-ink-lighter); - font-style: italic; - margin: var(--block-margin) 0; - padding-inline-start: 2ch; - } - - img, video, embed, object { - inline-size: auto; - margin-inline: auto; - max-block-size: 32rem; - object-fit: contain; - } - - /* Links should hug media contained within */ - a:has(img), - a:has(video) { - inline-size: fit-content; - margin-inline: auto; - } - - hr { - color: currentColor; - block-size: 0; - border: none; - border-block-end: 2px solid currentColor; - inline-size: 20%; - margin: calc(var(--block-margin) * 2) 0; - } - - .horizontal-divider { - margin: var(--block-margin) 0; - padding-block: var(--block-margin); - } - - /* Code */ - code, pre { - background-color: var(--color-canvas); - border: 1px solid var(--color-ink-lighter); - border-radius: 0.3em; - color: var(--color-ink); - font-family: var(--font-mono); - font-size: 0.85em; - font-weight: 500; - margin-block: var(--block-margin); - padding: 0.1em 0.3em; - - &:is(pre), - &[data-language] { - border-radius: 0.5em; - display: block; - overflow-x: auto; - padding: 0.5lh 2ch; - tab-size: 2; - text-wrap: nowrap; - white-space: pre; - word-break: break-word; - - /* Keywords and attributes */ - .code-token__attr, - .token.atrule, - .token.attr, - .token.keyword { - color: var(--color-code-token__att); - } - - /* Constants, booleans, numbers, properties, tags */ - .code-token__property, - .token.boolean, - .token.constant, - .token.deleted, - .token.number, - .token.property, - .token.symbol, - .token.tag { - color: var(--color-code-token__property); - } - - /* Strings, selectors, and built-in constructs */ - .code-token__selector, - .token.builtin, - .token.char, - .token.inserted, - .token.selector, - .token.string { - color: var(--color-code-token__selector); - } - - /* Comments and meta information */ - .code-token__comment, - .token.cdata, - .token.comment, - .token.doctype, - .token.prolog { - color: var(--color-code-token__comment); - font-style: italic; - } - - /* Operators and symbolic entities */ - .code-token__operator, - .token.entity, - .token.operator, - .token.url { - color: var(--color-code-token__operator); - } - - /* Functions and class names */ - .code-token__function, - .token.class, - .token.class-name, - .token.function { - color: var(--color-code-token__function); - } - - /* Variables, regex, namespaces, important */ - .code-token__variable, - .token.important, - .token.namespace, - .token.regex, - .token.variable { - color: var(--color-code-token__variable); - } - - /* Punctuation */ - .code-token__punctuation, - .token.punctuation { - color: var(--color-code-token__punctuation); - } - } - } - - /* Tables */ - .lexxy-content__table-wrapper { - margin: 0; - margin-block: 1ch; - overflow-x: auto; - } - - table { - border-collapse: collapse; - border-spacing: 0; - inline-size: calc(100% - 0.5ch); - margin: 0.25ch; - - th, - td { - background-color: var(--color-canvas); - border: 1px solid var(--color-ink-light); - font-size: var(--text-small); - min-width: 5ch; - max-width: 50ch; - padding: 0.75ch; - text-align: start; - word-break: normal; - - *:last-child { - margin-block-end: 0; - } - - &.lexxy-content__table-cell--header { - background-color: var(--color-ink-lightest); - font-weight: bold; - } - - *:is(code, pre) { - hyphens: auto; - text-wrap: wrap; - white-space: pre-wrap; - } - } - } - } - - /* Attachments - /* ------------------------------------------------------------------------ */ - - .attachment { - block-size: auto; - display: block; - inline-size: fit-content; - position: relative; - max-inline-size: 100%; - - progress { - inline-size: 100%; - margin: auto; - } - } - - .attachment__caption { - color: color-mix(in oklch, var(--color-ink) 66%, transparent); - font-size: var(--text-small); - - textarea { - --input-border-radius: 0.3em; - --input-border-size: 0; - --input-padding: 0; - - background-color: var(--input-background, transparent); - border: none; - color: inherit; - inline-size: 100%; - max-inline-size: 100%; - resize: none; - text-align: center; - - &:focus { - --focus-ring-size: 0; - } - - @supports (field-sizing: content) { - field-sizing: content; - inline-size: auto; - min-inline-size: 20ch; - } - } - } - - .attachment__icon { - aspect-ratio: 4/5; - background-color: color-mix(var(--attachment-icon-color), transparent 90%); - block-size: 2.5lh; - border: 2px solid var(--attachment-icon-color); - border-block-start-width: 1ch; - border-radius: 0.5ch; - box-sizing: border-box; - color: var(--attachment-icon-color); - display: grid; - font-size: var(--text-small); - font-weight: bold; - inline-size: auto; - padding-inline: 0.5ch; - place-content: center; - text-transform: uppercase; - white-space: nowrap; - } - - .attachment--preview { - margin-inline: auto; - text-align: center; - - img, video { - block-size: auto; - display: block; - margin-inline: auto; - max-inline-size: 100%; - user-select: none; - } - - > a { - display: block; - } - - .attachment__caption { - column-gap: 0.5ch; - display: flex; - flex-wrap: wrap; - justify-content: center; - margin-block-start: 0.5ch; - } - } - - .attachment--file { - --attachment-icon-color: var(--color-ink-medium); - - align-items: center; - display: flex; - flex-wrap: wrap; - gap: 1ch; - inline-size: 100%; - margin-inline: 0; - - .attachment__caption { - display: grid; - flex: 1; - text-align: start; - } - - .attachment__name { - color: var(--color-ink); - font-weight: bold; - } - - /* Video attachments don't have an identifiable class, but we need to - * make sure the caption is always below the video */ - &:has(video) { - .attachment__caption { - flex: none; - inline-size: 100%; - } - } - } - - .attachment--psd, - .attachment--key, - .attachment--sketch, - .attachment--ai, - .attachment--eps, - .attachment--indd, - .attachment--svg, - .attachment--ppt, - .attachment--pptx { - --attachment-icon-color: oklch(var(--lch-red-medium)); - } - - .attachment--css, - .attachment--crash, - .attachment--php, - .attachment--json, - .attachment--htm, - .attachment--html, - .attachment--rb, - .attachment--erb, - .attachment--ts, - .attachment--js { - --attachment-icon-color: oklch(var(--lch-purple-medium)); - } - - .attachment--txt, - .attachment--pages, - .attachment--rtf, - .attachment--md, - .attachment--doc, - .attachment--docx { - --attachment-icon-color: oklch(var(--lch-blue-medium)); - } - - .attachment--csv &, - .attachment--numbers &, - .attachment--xls &, - .attachment--xlsx & { - --attachment-icon-color: oklch(var(--lch-green-medium)); - } - - .attachment__link { - color: var(--color-link); - text-decoration: underline; - } - - /* Custom attachments such as mentions, etc. */ - action-text-attachment[content-type^='application/vnd.actiontext'] { - --attachment-bg-color: transparent; - --attachment-image-size: 1em; - --attachment-text-color: currentColor; - - align-items: center; - background: var(--attachment-bg-color); - border-radius: 99rem; - box-shadow: - -0.25ch 0 0 var(--attachment-bg-color), - 0.5ch 0 0 var(--attachment-bg-color); - color: var(--attachment-text-color); - display: inline-flex; - gap: 0.25ch; - margin: 0; - padding: 0; - position: relative; - vertical-align: bottom; - white-space: normal; - - lexxy-editor & { - cursor: pointer; - } - - img { - block-size: var(--attachment-image-size); - border-radius: 50%; - inline-size: var(--attachment-image-size); - } - - &.node--selected { - --attachment-bg-color: oklch(var(--lch-blue-dark)); - --attachment-text-color: var(--color-ink-inverted); - } - } - - action-text-attachment[content-type^='application/vnd.actiontext.mention'] { - img { - object-fit: cover; - } - } - - [data-lexical-cursor] { - animation: blink 1s step-end infinite; - block-size: 1lh; - border-inline-start: 1px solid currentColor; - line-height: inherit; - margin-block: 1em; - } -} diff --git a/app/views/boards/edit/_publication.html.erb b/app/views/boards/edit/_publication.html.erb index c0f60cd77..b37f0c1ac 100644 --- a/app/views/boards/edit/_publication.html.erb +++ b/app/views/boards/edit/_publication.html.erb @@ -30,7 +30,7 @@ Add an optional description to the public page
<%= form_with model: board, class: "txt-align-start", data: { controller: "form", turbo_frame: "_top" } do |form| %> - <%= form.rich_textarea :public_description, class: "rich-text-content txt-small", + <%= form.rich_textarea :public_description, class: "lexxy-content txt-small", placeholder: "Add a public note about this board…", data: { action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" }, readonly: !Current.user.can_administer_board?(@board) %> diff --git a/app/views/cards/comments/_comment.html.erb b/app/views/cards/comments/_comment.html.erb index 080f53bdd..0f42afdc0 100644 --- a/app/views/cards/comments/_comment.html.erb +++ b/app/views/cards/comments/_comment.html.erb @@ -32,7 +32,7 @@ <% end %>
-
+
<%= comment.body %>
diff --git a/app/views/cards/comments/_new.html.erb b/app/views/cards/comments/_new.html.erb index 115f30ebe..d2de939a5 100644 --- a/app/views/cards/comments/_new.html.erb +++ b/app/views/cards/comments/_new.html.erb @@ -4,7 +4,7 @@
-
+
<%= form_with model: Comment.new, url: card_comments_path(card), class: "flex flex-column gap full-width", data: { controller: "form local-save", local_save_key_value: "comment-#{card.id}", diff --git a/app/views/cards/comments/edit.html.erb b/app/views/cards/comments/edit.html.erb index ca23b331f..15e3cbd4a 100644 --- a/app/views/cards/comments/edit.html.erb +++ b/app/views/cards/comments/edit.html.erb @@ -5,7 +5,7 @@
-
+
<%= form_with model: [ @card, @comment ], class: "flex flex-column gap full-width", data: { controller: "form", action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do |form| %> <%= form.rich_textarea :body, required: true, autofocus: true, placeholder: new_comment_placeholder(@card) do %> diff --git a/app/views/cards/container/_content.html.erb b/app/views/cards/container/_content.html.erb index ba94041c1..5da73f266 100644 --- a/app/views/cards/container/_content.html.erb +++ b/app/views/cards/container/_content.html.erb @@ -20,7 +20,7 @@ <% end %> - <%= form.rich_textarea :description, class: "card__description rich-text-content", + <%= form.rich_textarea :description, class: "card__description lexxy-content", placeholder: "Add some notes, context, pictures, or video about this…", data: { action: "lexxy:change->auto-save#change focusout->auto-save#submit" } do %> <%= general_prompts(card.board) %> diff --git a/app/views/cards/container/_content_display.html.erb b/app/views/cards/container/_content_display.html.erb index 3395c40bf..46a9a83d1 100644 --- a/app/views/cards/container/_content_display.html.erb +++ b/app/views/cards/container/_content_display.html.erb @@ -3,7 +3,7 @@ <% unless card.description.blank? %> -
+
<%= card.description %>
<% end %> diff --git a/app/views/cards/edit.html.erb b/app/views/cards/edit.html.erb index a7efd21fe..1e81896a1 100644 --- a/app/views/cards/edit.html.erb +++ b/app/views/cards/edit.html.erb @@ -16,7 +16,7 @@ <% end %> - <%= form.rich_textarea :description, class: "card__description rich-text-content", + <%= form.rich_textarea :description, class: "card__description lexxy-content", placeholder: "Add some notes, context, pictures, or video about this…", data: { local_save_target: "input", action: "lexxy:change->local-save#save turbo:morph-element->local-save#restoreContent keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do %> <%= general_prompts(@card.board) %> diff --git a/app/views/public/boards/show.html.erb b/app/views/public/boards/show.html.erb index b73a17050..c4bd75861 100644 --- a/app/views/public/boards/show.html.erb +++ b/app/views/public/boards/show.html.erb @@ -20,7 +20,7 @@ <% end %> <% if @board.public_description.present? %> -
+
<%= @board.public_description %>
<% end %> diff --git a/app/views/public/cards/show/_content.html.erb b/app/views/public/cards/show/_content.html.erb index cbdf7603f..0bb74b3ce 100644 --- a/app/views/public/cards/show/_content.html.erb +++ b/app/views/public/cards/show/_content.html.erb @@ -2,7 +2,7 @@

<%= tag.span card.title, class: "card__title-link" %>

-
+
<%= card.description %>
From a315af1090c11af7c08d91a143efa7cc88dd02a6 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Mon, 2 Feb 2026 12:03:20 +0100 Subject: [PATCH 50/96] Table tweaks --- app/assets/stylesheets/lexxy.css | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index 7eb0377ef..1dc512373 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -64,6 +64,10 @@ border-radius: 99rem; } + lexxy-table-tools { + font-size: var(--text-x-small); + } + [data-lexical-cursor] { animation: blink 1s step-end infinite; block-size: 1lh; @@ -105,5 +109,14 @@ hr { border-block-end-width: 2px; } + + table { + th, td { + font-size: var(--text-small); + padding-block: 0.75ch; + } + th:not([class*="selected"], [data-action="delete"], [data-action="toggle"]) { background-color: var(--color-ink-lightest); } + td:not([class*="selected"], [data-action="delete"], [data-action="toggle"]) { background-color: var(--color-canvas); } + } } } From 374856f6410ba3ee6684d482c5022f28522a8fda Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Mon, 2 Feb 2026 12:03:42 +0100 Subject: [PATCH 51/96] Fix toolbar dropdown icon background --- app/assets/stylesheets/lexxy.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index 1dc512373..9344b4e8e 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -23,7 +23,8 @@ .lexxy-editor__toolbar-button { background: transparent; - &[aria-pressed="true"] { + &[aria-pressed="true"], + [open] > & { background-color: oklch(var(--lch-blue-medium) / 20%); } From 0d45a42ebffa07c4c3ef9a45f7c5577aaaf45f7e Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Mon, 2 Feb 2026 12:04:59 +0100 Subject: [PATCH 52/96] Remove link dialog size limit --- app/assets/stylesheets/lexxy.css | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index 9344b4e8e..4b69760f9 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -38,12 +38,6 @@ lexxy-link-dropdown { --lexxy-toolbar-spacing: 1.5ch; - max-inline-size: 20rem; - - form { - flex-direction: column; - } - .lexxy-editor__toolbar-button { border-radius: 99rem; From 322de4217f8d873599ba252c100e7af45ec01afe Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Mon, 2 Feb 2026 10:04:42 -0600 Subject: [PATCH 53/96] Touch up link button hover styles --- app/assets/stylesheets/lexxy.css | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index 4b69760f9..7712aeef9 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -41,7 +41,15 @@ .lexxy-editor__toolbar-button { border-radius: 99rem; - &[type="submit"] { + @media(any-hover: hover) { + &:hover { + filter: brightness(0.9); + opacity: 1; + } + } + + &[type="submit"], + &[type="submit"]:hover { background-color: var(--color-link); } From 9a2e30b9c8c185bf047646ea0abf0c406f85c3ed Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Wed, 4 Feb 2026 10:58:13 +0100 Subject: [PATCH 54/96] Regression fixes --- app/assets/stylesheets/lexxy.css | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index 7712aeef9..037bcd804 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -18,6 +18,7 @@ lexxy-toolbar { border-color: var(--lexxy-border-color); + gap: 0; } .lexxy-editor__toolbar-button { @@ -95,10 +96,14 @@ text-wrap: balance; } - p { + p:has(+ p) { margin: 0; } + ol, ul { + padding-inline-start: 1ch; + } + blockquote { border-inline-start: 0.25em solid var(--color-ink-lighter); padding-block: 0; @@ -121,5 +126,13 @@ th:not([class*="selected"], [data-action="delete"], [data-action="toggle"]) { background-color: var(--color-ink-lightest); } td:not([class*="selected"], [data-action="delete"], [data-action="toggle"]) { background-color: var(--color-canvas); } } + + .attachment { + margin-inline: auto; + } + } + + .attachment { + margin-inline: 0; } } From 343bf016a3960543c461b019d06dcbf9b794994f Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Wed, 4 Feb 2026 11:22:38 +0100 Subject: [PATCH 55/96] Removing duplicate code language picker --- app/helpers/rich_text_helper.rb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/helpers/rich_text_helper.rb b/app/helpers/rich_text_helper.rb index c1bc2232a..c580b4507 100644 --- a/app/helpers/rich_text_helper.rb +++ b/app/helpers/rich_text_helper.rb @@ -15,11 +15,7 @@ module RichTextHelper content_tag "lexxy-prompt", "", trigger: "#", src: prompts_cards_path, name: "card", "insert-editable-text": true, "remote-filtering": true, "supports-space-in-searches": true end - def code_language_picker - content_tag "lexxy-code-language-picker" - end - def general_prompts(board) - safe_join([ mentions_prompt(board), cards_prompt, code_language_picker ]) + safe_join([ mentions_prompt(board), cards_prompt ]) end end From e0f8a182c55e180aa78febd4adec8bd586083010 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 5 Feb 2026 10:31:09 +0100 Subject: [PATCH 56/96] HR fixes --- app/assets/stylesheets/lexxy.css | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index 037bcd804..d87724408 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -114,8 +114,17 @@ border: 1px solid var(--color-ink-lighter); } + .horizontal-divider { + padding-block: var(--lexxy-content-margin); + hr { margin: 0; } + } + hr { - border-block-end-width: 2px; + border: 0; + border-block-end: 2px solid currentColor; + color: currentColor; + inline-size: 20%; + margin: calc(var(--lexxy-content-margin) * 2) 0; } table { From 36ee253a1a8f6d677dfa9340f3a97e19cd3cfaf6 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 12 Feb 2026 10:04:32 +0100 Subject: [PATCH 57/96] Stack notifications everywhere We had client-side notification stacking in the tray since launch, but now we want to stack notifications in the notifications page, in API responses and in email bundles. --- app/controllers/cards/readings_controller.rb | 4 +- .../notifications/trays_controller.rb | 2 +- app/helpers/notifications_helper.rb | 6 +- .../notifications_tray_controller.js | 89 ------------------- app/models/card/readable.rb | 20 +---- app/models/concerns/push_notifiable.rb | 1 + app/models/notification.rb | 45 ++++++---- app/models/notification/bundle.rb | 4 +- app/models/notifier.rb | 8 +- app/models/user/notifiable.rb | 6 +- .../cards/readings/create.turbo_stream.erb | 3 - .../notifications/_notification.html.erb | 2 +- .../notifications/_notification.json.jbuilder | 2 +- app/views/notifications/_tray.html.erb | 5 +- app/views/notifications/index.json.jbuilder | 2 +- .../index/_notification.html.erb | 6 -- .../index/_read_notifications.html.erb | 2 +- .../index/_unread_notifications.html.erb | 4 +- app/views/notifications/trays/show.html.erb | 1 - ...0209165805_notifications_data_migration.rb | 84 +++++++++++++++++ db/schema.rb | 3 +- db/schema_sqlite.rb | 3 +- .../cards/readings_controller_test.rb | 38 ++------ .../bulk_readings_controller_test.rb | 4 +- .../notifications/readings_controller_test.rb | 23 +++-- .../notifications_controller_test.rb | 7 +- test/fixtures/notifications.yml | 26 ++---- .../notification/bundle_mailer_test.rb | 1 + test/models/card/readable_test.rb | 61 ++++--------- test/models/notification/bundle_test.rb | 11 +-- test/models/notification_pusher_test.rb | 5 +- test/models/notification_test.rb | 80 ++++++++++------- test/models/notifier/event_notifier_test.rb | 14 +-- test/models/user/notifiable_test.rb | 5 +- test/system/smoke_test.rb | 8 +- 35 files changed, 265 insertions(+), 320 deletions(-) delete mode 100644 app/javascript/controllers/notifications_tray_controller.js delete mode 100644 app/views/cards/readings/create.turbo_stream.erb delete mode 100644 app/views/notifications/index/_notification.html.erb create mode 100644 db/migrate/20260209165805_notifications_data_migration.rb diff --git a/app/controllers/cards/readings_controller.rb b/app/controllers/cards/readings_controller.rb index 95acc7511..1fc675b84 100644 --- a/app/controllers/cards/readings_controller.rb +++ b/app/controllers/cards/readings_controller.rb @@ -2,12 +2,12 @@ class Cards::ReadingsController < ApplicationController include CardScoped def create - @notifications = @card.read_by(Current.user) + @card.read_by(Current.user) record_board_access end def destroy - @notifications = @card.unread_by(Current.user) + @card.unread_by(Current.user) record_board_access end diff --git a/app/controllers/notifications/trays_controller.rb b/app/controllers/notifications/trays_controller.rb index c2bac662a..0adc0e17b 100644 --- a/app/controllers/notifications/trays_controller.rb +++ b/app/controllers/notifications/trays_controller.rb @@ -1,6 +1,6 @@ class Notifications::TraysController < ApplicationController def show - @notifications = Current.user.notifications.preloaded.unread.ordered.limit(100) + @notifications = Current.user.notifications.unread.preloaded.ordered.limit(100) # Invalidate on the whole set instead of the unread set since the max updated at in the unread set # can stay the same when reading old notifications. diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index 355f60550..f376d15c1 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -29,9 +29,7 @@ module NotificationsHelper def notification_tag(notification, &) tag.div id: dom_id(notification), class: "tray__item tray__item--notification", data: { navigable_list_target: "item", - notifications_tray_target: "notification", - card_id: notification.card.id, - timestamp: notification.created_at.to_i + card_id: notification.card.id } do link_to(notification, class: [ "card card--notification", { "card--closed": notification.card.closed? }, { "unread": !notification.read? } ], @@ -58,7 +56,7 @@ module NotificationsHelper data: { action: "form#submit:stop badge#update:stop", form_target: "submit" }, form: { data: { controller: "form" } } do concat(icon_tag("remove")) - concat(tag.span("1", class: "badge-count", data: { group_count: "" })) + concat(tag.span(notification.unread_count, class: "badge-count")) if notification.unread_count > 1 end end end diff --git a/app/javascript/controllers/notifications_tray_controller.js b/app/javascript/controllers/notifications_tray_controller.js deleted file mode 100644 index 12077425b..000000000 --- a/app/javascript/controllers/notifications_tray_controller.js +++ /dev/null @@ -1,89 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = [ "notification", "hiddenNotifications" ] - static classes = [ "grouped" ] - - connect() { - this.group() - } - - group() { - const notificationsByCardId = this.#groupNotificationsByCardId() - - for (const cardId in notificationsByCardId) { - const notifications = notificationsByCardId[cardId] - if (notifications.length > 1) { - this.#renderGroup(notifications) - } - } - - this.grouped = true - } - - notificationTargetConnected(notification) { - if (this.grouped && notification.parentElement !== this.hiddenNotificationsTarget) { - this.#groupNotification(notification) - } - } - - #groupNotificationsByCardId() { - const notificationsByCardId = {} - - this.notificationTargets.forEach(notification => { - const cardId = notification.dataset.cardId - notificationsByCardId[cardId] ||= [] - notificationsByCardId[cardId].push(notification) - }) - - return notificationsByCardId - } - - #groupNotification(notification) { - const groupedNotifications = this.#groupedNotificationsFor(notification) - - if (groupedNotifications.length > 1) { - this.#renderGroup(groupedNotifications) - } - } - - #groupedNotificationsFor(notification) { - const cardId = notification.dataset.cardId - return this.notificationTargets - .filter(notification => notification.dataset.cardId === cardId) - } - - #renderGroup(groupedNotifications) { - groupedNotifications.sort((notification_1, notification_2) => parseInt(notification_1.dataset.timestamp) - parseInt(notification_2.dataset.timestamp)) - - this.#showAsGrouped(groupedNotifications[0], groupedNotifications.length) - groupedNotifications.slice(1).forEach(notification => this.#hideInGroup(notification)) - } - - #showAsGrouped(notification, size) { - notification.classList.add(this.groupedClass) - this.#showGroupedNotification(notification) - this.#setGroupCount(notification, size) - } - - #hideInGroup(notification) { - this.#hideGroupedNotification(notification) - this.#setGroupCount(notification, "") - } - - // We use a hidden container instead of hiding the notifications directly so that the CSS that sort the - // tray element indexes doesn't get messed up with the child positions changing. - #showGroupedNotification(notification) { - if (notification.parentElement === this.hiddenNotificationsTarget) { - this.hiddenNotificationsTarget.parentElement.insertBefore(notification, this.hiddenNotificationsTarget) - } - } - - #hideGroupedNotification(notification) { - this.hiddenNotificationsTarget.appendChild(notification) - } - - #setGroupCount(notification, count) { - notification.querySelector("[data-group-count]").textContent = count - } -} diff --git a/app/models/card/readable.rb b/app/models/card/readable.rb index 9c272dd75..0168f00a9 100644 --- a/app/models/card/readable.rb +++ b/app/models/card/readable.rb @@ -2,15 +2,11 @@ module Card::Readable extend ActiveSupport::Concern def read_by(user) - notifications_for(user).tap do |notifications| - notifications.each(&:read) - end + user.notifications.find_by(card: self)&.read end def unread_by(user) - all_notifications_for(user).tap do |notifications| - notifications.each(&:unread) - end + user.notifications.find_by(card: self)&.unread end def remove_inaccessible_notifications @@ -25,18 +21,6 @@ module Card::Readable Card::RemoveInaccessibleNotificationsJob.perform_later(self) end - def notifications_for(user) - scope = user.notifications.unread - scope.where(source: event_notification_sources) - .or(scope.where(source: mention_notification_sources)) - end - - def all_notifications_for(user) - scope = user.notifications - scope.where(source: event_notification_sources) - .or(scope.where(source: mention_notification_sources)) - end - def event_notification_sources events.or(comment_creation_events) end diff --git a/app/models/concerns/push_notifiable.rb b/app/models/concerns/push_notifiable.rb index e497bc3fe..e7bfdc56a 100644 --- a/app/models/concerns/push_notifiable.rb +++ b/app/models/concerns/push_notifiable.rb @@ -3,6 +3,7 @@ module PushNotifiable included do after_create_commit :push_notification_later + after_update_commit :push_notification_later, if: :source_id_previously_changed? end private diff --git a/app/models/notification.rb b/app/models/notification.rb index 545f6ab75..3d7df5885 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -5,32 +5,39 @@ class Notification < ApplicationRecord belongs_to :user belongs_to :creator, class_name: "User" belongs_to :source, polymorphic: true + belongs_to :card scope :unread, -> { where(read_at: nil) } scope :read, -> { where.not(read_at: nil) } - scope :ordered, -> { order(read_at: :desc, created_at: :desc) } + scope :ordered, -> { order(read_at: :desc, updated_at: :desc) } + scope :preloaded, -> { preload(:card, :creator, :account, source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]) } - after_create_commit :broadcast_unread - after_destroy_commit :broadcast_read + before_validation :set_card after_create :bundle + after_update :bundle, if: :source_id_previously_changed? - scope :preloaded, -> { preload(:creator, :account, source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]) } + after_create_commit -> { broadcast_prepend_later_to user, :notifications, target: "notifications" } + after_update_commit -> { broadcast_update } + after_destroy_commit -> { broadcast_remove_to user, :notifications } delegate :notifiable_target, to: :source - delegate :card, to: :source - def self.read_all - all.each { |notification| notification.read } + class << self + def read_all + all.each(&:read) + end + + def unread_all + all.each(&:unread) + end end def read - update!(read_at: Time.current) - broadcast_read + update!(read_at: Time.current, unread_count: 0) end def unread - update!(read_at: nil) - broadcast_unread + update!(read_at: nil, unread_count: 1) end def read? @@ -38,15 +45,19 @@ class Notification < ApplicationRecord end private - def broadcast_unread - broadcast_prepend_later_to user, :notifications, target: "notifications" - end - - def broadcast_read - broadcast_remove_to user, :notifications + def set_card + self.card = source.card end def bundle user.bundle(self) if user.settings.bundling_emails? end + + def broadcast_update + if read? + broadcast_remove_to(user, :notifications) + else + broadcast_prepend_later_to(user, :notifications, target: "notifications") + end + end end diff --git a/app/models/notification/bundle.rb b/app/models/notification/bundle.rb index fb57a4fd0..1eadb3c9e 100644 --- a/app/models/notification/bundle.rb +++ b/app/models/notification/bundle.rb @@ -5,7 +5,7 @@ class Notification::Bundle < ApplicationRecord enum :status, %i[ pending processing delivered ] scope :due, -> { pending.where("ends_at <= ?", Time.current) } - scope :containing, ->(notification) { where("starts_at <= ? AND ends_at > ?", notification.created_at, notification.created_at) } + scope :containing, ->(notification) { where("starts_at <= ? AND ends_at > ?", notification.updated_at, notification.updated_at) } scope :overlapping_with, ->(other_bundle) do where( "(starts_at <= ? AND ends_at >= ?) OR (starts_at <= ? AND ends_at >= ?) OR (starts_at >= ? AND ends_at <= ?)", @@ -33,7 +33,7 @@ class Notification::Bundle < ApplicationRecord end def notifications - user.notifications.where(created_at: window).unread + user.notifications.where(updated_at: window).unread end def deliver diff --git a/app/models/notifier.rb b/app/models/notifier.rb index 24c1b3b4e..b50fc6d26 100644 --- a/app/models/notifier.rb +++ b/app/models/notifier.rb @@ -16,7 +16,13 @@ class Notifier if should_notify? # Processing recipients in order avoids deadlocks if notifications overlap. recipients.sort_by(&:id).map do |recipient| - Notification.create! user: recipient, source: source, creator: creator + notification = Notification.find_or_initialize_by(user: recipient, card: source.card) + notification.source = source + notification.creator = creator + notification.read_at = nil + notification.unread_count = (notification.unread_count || 0) + 1 + notification.save! + notification end end end diff --git a/app/models/user/notifiable.rb b/app/models/user/notifiable.rb index f6788aaac..7598f0e0f 100644 --- a/app/models/user/notifiable.rb +++ b/app/models/user/notifiable.rb @@ -25,12 +25,12 @@ module User::Notifiable def expand_pending_bundle_for(notification) pending = notification_bundles.pending.last - if pending.present? && notification.created_at < pending.starts_at - pending.update!(starts_at: notification.created_at) # expand the window to include this notification + if pending.present? && notification.updated_at < pending.starts_at + pending.update!(starts_at: notification.updated_at) # expand the window to include this notification end end def create_bundle_for(notification) - notification_bundles.create!(starts_at: notification.created_at) + notification_bundles.create!(starts_at: notification.updated_at) end end diff --git a/app/views/cards/readings/create.turbo_stream.erb b/app/views/cards/readings/create.turbo_stream.erb deleted file mode 100644 index 5d7fe7801..000000000 --- a/app/views/cards/readings/create.turbo_stream.erb +++ /dev/null @@ -1,3 +0,0 @@ -<% @notifications.each do |notification| %> - <%= turbo_stream.remove notification %> -<% end %> diff --git a/app/views/notifications/_notification.html.erb b/app/views/notifications/_notification.html.erb index 02f547c4e..6bed2aa56 100644 --- a/app/views/notifications/_notification.html.erb +++ b/app/views/notifications/_notification.html.erb @@ -2,7 +2,7 @@ <%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %> <%= notification_tag notification do %> <%= render "notifications/notification/header", notification: notification do %> - <%= notification_toggle_read_button(notification, url: card_reading_path(notification.card)) %> + <%= notification_toggle_read_button(notification, url: notification_reading_path(notification)) %> <% end %> <%= render "notifications/notification/body", notification: notification %> <% end %> diff --git a/app/views/notifications/_notification.json.jbuilder b/app/views/notifications/_notification.json.jbuilder index ba27c5425..d2fadfa1b 100644 --- a/app/views/notifications/_notification.json.jbuilder +++ b/app/views/notifications/_notification.json.jbuilder @@ -1,5 +1,5 @@ json.cache! notification do - json.(notification, :id) + json.(notification, :id, :unread_count) json.read notification.read? json.read_at notification.read_at&.utc json.created_at notification.created_at.utc diff --git a/app/views/notifications/_tray.html.erb b/app/views/notifications/_tray.html.erb index 1f5aae469..74d492049 100644 --- a/app/views/notifications/_tray.html.erb +++ b/app/views/notifications/_tray.html.erb @@ -8,9 +8,8 @@ data-navigable-list-reverse-navigation-value="true" data-action="keydown->navigable-list#navigate dialog:show@document->navigable-list#reset turbo-visit->navigable-list#reset keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside"> <%= turbo_frame_tag "notifications", src: tray_notifications_path, refresh: "morph", data: { - controller: "notifications-tray frame-reloader", - notifications_tray_grouped_class: "notification--grouped", - action: "turbo:frame-render->notifications-tray#group focus@window->frame-reloader#reload" } %> + controller: "frame-reloader", + action: "focus@window->frame-reloader#reload" } %>
diff --git a/app/views/notifications/index.json.jbuilder b/app/views/notifications/index.json.jbuilder index 660bbb673..e8d9a93ed 100644 --- a/app/views/notifications/index.json.jbuilder +++ b/app/views/notifications/index.json.jbuilder @@ -1 +1 @@ -json.array! (@unread || []) + @page.records, partial: "notifications/notification", as: :notification +json.array! (@unread || []) + @page.records, partial: "notifications/notification", as: :notification, cached: true diff --git a/app/views/notifications/index/_notification.html.erb b/app/views/notifications/index/_notification.html.erb deleted file mode 100644 index 90f3d45eb..000000000 --- a/app/views/notifications/index/_notification.html.erb +++ /dev/null @@ -1,6 +0,0 @@ -<%= notification_tag notification do %> - <%= render "notifications/notification/header", notification: notification do %> - <%= notification_toggle_read_button(notification, url: notification_reading_path(notification)) %> - <% end %> - <%= render "notifications/notification/body", notification: notification %> -<% end %> diff --git a/app/views/notifications/index/_read_notifications.html.erb b/app/views/notifications/index/_read_notifications.html.erb index 04c2e3c48..c73d1d3c2 100644 --- a/app/views/notifications/index/_read_notifications.html.erb +++ b/app/views/notifications/index/_read_notifications.html.erb @@ -3,7 +3,7 @@

Previously seen

- <%= render partial: "notifications/index/notification", collection: page.records, cached: true %> + <%= render partial: "notifications/notification", collection: page.records, cached: true %>
<% end %> diff --git a/app/views/notifications/index/_unread_notifications.html.erb b/app/views/notifications/index/_unread_notifications.html.erb index 4890a4822..fed57dbb9 100644 --- a/app/views/notifications/index/_unread_notifications.html.erb +++ b/app/views/notifications/index/_unread_notifications.html.erb @@ -11,12 +11,12 @@ <% end %>
- <%= render partial: "notifications/index/notification", collection: unread, cached: true %> + <%= render partial: "notifications/notification", collection: unread, cached: true %>
<% if unread.any? %> <% total_unread_count = Current.user.notifications.unread.count %> - <% if Current.user.notifications.unread.count > NotificationsController::MAX_UNREAD_NOTIFICATIONS %> + <% if total_unread_count > NotificationsController::MAX_UNREAD_NOTIFICATIONS %>
Showing the <%= NotificationsController::MAX_UNREAD_NOTIFICATIONS %> most recent (<%= total_unread_count - NotificationsController::MAX_UNREAD_NOTIFICATIONS %> are hidden)
diff --git a/app/views/notifications/trays/show.html.erb b/app/views/notifications/trays/show.html.erb index 278d97a4d..f61c52d51 100644 --- a/app/views/notifications/trays/show.html.erb +++ b/app/views/notifications/trays/show.html.erb @@ -1,4 +1,3 @@ <%= turbo_frame_tag "notifications" do %> <%= render partial: "notifications/notification", collection: @notifications, cached: true %> - <% end %> diff --git a/db/migrate/20260209165805_notifications_data_migration.rb b/db/migrate/20260209165805_notifications_data_migration.rb new file mode 100644 index 000000000..d1522f780 --- /dev/null +++ b/db/migrate/20260209165805_notifications_data_migration.rb @@ -0,0 +1,84 @@ +class NotificationsDataMigration < ActiveRecord::Migration[8.2] + BATCH_SIZE = 10_000 + + class Notification < ActiveRecord::Base + self.table_name = "notifications" + end + + def change + reversible do |dir| + dir.up do + populate_card_id + collapse_duplicates + end + end + + change_column_null :notifications, :card_id, false + add_index :notifications, [ :user_id, :card_id ], unique: true + end + + private + def populate_card_id + execute(<<~SQL) + UPDATE notifications + SET card_id = ( + SELECT CASE events.eventable_type + WHEN 'Card' THEN events.eventable_id + WHEN 'Comment' THEN (SELECT comments.card_id FROM comments WHERE comments.id = events.eventable_id) + END + FROM events + WHERE events.id = notifications.source_id + ) + WHERE notifications.card_id IS NULL + AND notifications.source_type = 'Event' + SQL + + execute(<<~SQL) + UPDATE notifications + SET card_id = ( + SELECT CASE mentions.source_type + WHEN 'Card' THEN mentions.source_id + WHEN 'Comment' THEN (SELECT comments.card_id FROM comments WHERE comments.id = mentions.source_id) + END + FROM mentions + WHERE mentions.id = notifications.source_id + ) + WHERE notifications.card_id IS NULL + AND notifications.source_type = 'Mention' + SQL + end + + def collapse_duplicates + loop do + duplicates = Notification.find_by_sql(<<~SQL) + SELECT user_id, card_id, + MAX(id) AS keep_id, + COUNT(*) AS total, + SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END) AS unread_total + FROM notifications + WHERE card_id IS NOT NULL + GROUP BY user_id, card_id + HAVING COUNT(*) > 1 + LIMIT #{BATCH_SIZE} + SQL + + break if duplicates.empty? + + duplicates.each do |row| + Notification.where(user_id: row.user_id, card_id: row.card_id) + .where.not(id: row.keep_id) + .delete_all + + Notification.where(id: row.keep_id) + .update_all(unread_count: row.unread_total.to_i) + end + end + + # Set unread_count for remaining non-collapsed notifications + execute(<<~SQL) + UPDATE notifications + SET unread_count = CASE WHEN read_at IS NULL THEN 1 ELSE 0 END + WHERE unread_count = 0 AND card_id IS NOT NULL + SQL + end +end diff --git a/db/schema.rb b/db/schema.rb index ddfdb3b60..db1a2283d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -385,7 +385,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do create_table "notifications", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false - t.uuid "card_id" + t.uuid "card_id", null: false t.datetime "created_at", null: false t.uuid "creator_id" t.datetime "read_at" @@ -397,6 +397,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do t.index ["account_id"], name: "index_notifications_on_account_id" t.index ["creator_id"], name: "index_notifications_on_creator_id" t.index ["source_type", "source_id"], name: "index_notifications_on_source" + t.index ["user_id", "card_id"], name: "index_notifications_on_user_id_and_card_id", unique: true t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc } t.index ["user_id"], name: "index_notifications_on_user_id" end diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 5fa6d05b7..3486ca9d1 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -385,7 +385,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do create_table "notifications", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false - t.uuid "card_id" + t.uuid "card_id", null: false t.datetime "created_at", null: false t.uuid "creator_id" t.datetime "read_at" @@ -397,6 +397,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do t.index ["account_id"], name: "index_notifications_on_account_id" t.index ["creator_id"], name: "index_notifications_on_creator_id" t.index ["source_type", "source_id"], name: "index_notifications_on_source" + t.index ["user_id", "card_id"], name: "index_notifications_on_user_id_and_card_id", unique: true t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc } t.index ["user_id"], name: "index_notifications_on_user_id" end diff --git a/test/controllers/cards/readings_controller_test.rb b/test/controllers/cards/readings_controller_test.rb index b7f7474c7..0c9c9aeed 100644 --- a/test/controllers/cards/readings_controller_test.rb +++ b/test/controllers/cards/readings_controller_test.rb @@ -8,7 +8,7 @@ class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest test "create" do freeze_time - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do + assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do assert_changes -> { accesses(:writebook_kevin).reload.accessed_at }, from: nil, to: Time.current do post card_reading_url(cards(:logo)), as: :turbo_stream end @@ -17,31 +17,20 @@ class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest assert_response :success end - test "read one notification on card visit" do - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do + test "read notification on card visit" do + assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do post card_reading_path(cards(:logo)), as: :turbo_stream end assert_response :success end - test "read multiple notifications on card visit" do - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do - assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do - post card_reading_path(cards(:logo)), as: :turbo_stream - end - end - - assert_response :success - end - test "destroy" do freeze_time - notifications(:logo_published_kevin).read notifications(:logo_assignment_kevin).read - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do + assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do assert_changes -> { accesses(:writebook_kevin).reload.accessed_at }, to: Time.current do delete card_reading_url(cards(:logo)), as: :turbo_stream end @@ -50,26 +39,13 @@ class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest assert_response :success end - test "unread one notification on destroy" do - notifications(:logo_published_kevin).read + test "unread notification on destroy" do + notifications(:logo_assignment_kevin).read - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do + assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do delete card_reading_path(cards(:logo)), as: :turbo_stream end assert_response :success end - - test "unread multiple notifications on destroy" do - notifications(:logo_published_kevin).read - notifications(:logo_assignment_kevin).read - - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do - assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do - delete card_reading_path(cards(:logo)), as: :turbo_stream - end - end - - assert_response :success - end end diff --git a/test/controllers/notifications/bulk_readings_controller_test.rb b/test/controllers/notifications/bulk_readings_controller_test.rb index 3512b7b43..f889dfaac 100644 --- a/test/controllers/notifications/bulk_readings_controller_test.rb +++ b/test/controllers/notifications/bulk_readings_controller_test.rb @@ -6,7 +6,7 @@ class Notifications::BulkReadingsControllerTest < ActionDispatch::IntegrationTes end test "create marks all notifications as read" do - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do + assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do post bulk_reading_path end @@ -24,7 +24,7 @@ class Notifications::BulkReadingsControllerTest < ActionDispatch::IntegrationTes end test "create as JSON" do - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do + assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do post bulk_reading_path, as: :json end diff --git a/test/controllers/notifications/readings_controller_test.rb b/test/controllers/notifications/readings_controller_test.rb index 3f2a690a9..fca29a35b 100644 --- a/test/controllers/notifications/readings_controller_test.rb +++ b/test/controllers/notifications/readings_controller_test.rb @@ -3,38 +3,37 @@ require "test_helper" class Notifications::ReadingsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin + @notification = notifications(:logo_assignment_kevin) end test "create" do - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do - post notification_reading_path(notifications(:logo_published_kevin), format: :turbo_stream) + assert_changes -> { @notification.reload.read? }, from: false, to: true do + post notification_reading_path(@notification, format: :turbo_stream) assert_response :success end end test "destroy" do - notification = notifications(:logo_published_kevin) - notification.read # Mark as read first + @notification.read - assert_changes -> { notification.reload.read? }, from: true, to: false do - delete notification_reading_path(notification, format: :turbo_stream) + assert_changes -> { @notification.reload.read? }, from: true, to: false do + delete notification_reading_path(@notification, format: :turbo_stream) assert_response :success end end test "create as JSON" do - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do - post notification_reading_path(notifications(:logo_published_kevin)), as: :json + assert_changes -> { @notification.reload.read? }, from: false, to: true do + post notification_reading_path(@notification), as: :json assert_response :no_content end end test "destroy as JSON" do - notification = notifications(:logo_published_kevin) - notification.read + @notification.read - assert_changes -> { notification.reload.read? }, from: true, to: false do - delete notification_reading_path(notification), as: :json + assert_changes -> { @notification.reload.read? }, from: true, to: false do + delete notification_reading_path(@notification), as: :json assert_response :no_content end end diff --git a/test/controllers/notifications_controller_test.rb b/test/controllers/notifications_controller_test.rb index e4e7bec38..0d0565f24 100644 --- a/test/controllers/notifications_controller_test.rb +++ b/test/controllers/notifications_controller_test.rb @@ -10,18 +10,17 @@ class NotificationsControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_kind_of Array, @response.parsed_body - assert @response.parsed_body.any? { |n| n["id"] == notifications(:logo_published_kevin).id } + assert @response.parsed_body.any? { |n| n["id"] == notifications(:logo_assignment_kevin).id } end test "index as JSON includes notification attributes" do get notifications_path, as: :json - notification = @response.parsed_body.find { |n| n["id"] == notifications(:logo_published_kevin).id } + notification = @response.parsed_body.find { |n| n["id"] == notifications(:logo_assignment_kevin).id } - assert_not_nil notification["title"] - assert_not_nil notification["body"] assert_not_nil notification["created_at"] assert_not_nil notification["card"] assert_not_nil notification["creator"] + assert_not_nil notification["unread_count"] end end diff --git a/test/fixtures/notifications.yml b/test/fixtures/notifications.yml index 9fe7da317..b7ae167cd 100644 --- a/test/fixtures/notifications.yml +++ b/test/fixtures/notifications.yml @@ -1,15 +1,9 @@ -logo_published_kevin: - id: <%= ActiveRecord::FixtureSet.identify("logo_published_kevin", :uuid) %> - user: kevin_uuid - source: logo_published_uuid (Event) - created_at: <%= 1.week.ago %> - creator: david_uuid - account: 37s_uuid - logo_assignment_kevin: id: <%= ActiveRecord::FixtureSet.identify("logo_assignment_kevin", :uuid) %> user: kevin_uuid source: logo_assignment_km_uuid (Event) + card: logo_uuid + unread_count: 2 created_at: <%= 1.week.ago %> creator: david_uuid account: 37s_uuid @@ -18,22 +12,18 @@ layout_commented_kevin: id: <%= ActiveRecord::FixtureSet.identify("layout_commented_kevin", :uuid) %> user: kevin_uuid source: layout_commented_uuid (Event) + card: layout_uuid + unread_count: 1 created_at: <%= 1.week.ago %> creator: david_uuid account: 37s_uuid -logo_card_david_mention_by_jz: - id: <%= ActiveRecord::FixtureSet.identify("logo_card_david_mention_by_jz_notif", :uuid) %> - user: david_uuid - source: logo_card_david_mention_by_jz_uuid (Mention) - created_at: <%= 1.week.ago %> - creator: david_uuid - account: 37s_uuid - -logo_comment_david_mention_by_jz: - id: <%= ActiveRecord::FixtureSet.identify("logo_comment_david_mention_by_jz_notif", :uuid) %> +logo_mentioned_david: + id: <%= ActiveRecord::FixtureSet.identify("logo_mentioned_david", :uuid) %> user: david_uuid source: logo_comment_david_mention_by_jz_uuid (Mention) + card: logo_uuid + unread_count: 2 created_at: <%= 1.week.ago %> creator: david_uuid account: 37s_uuid diff --git a/test/mailers/notification/bundle_mailer_test.rb b/test/mailers/notification/bundle_mailer_test.rb index 11565dfee..36f9b1e60 100644 --- a/test/mailers/notification/bundle_mailer_test.rb +++ b/test/mailers/notification/bundle_mailer_test.rb @@ -3,6 +3,7 @@ require "test_helper" class Notification::BundleMailerTest < ActionMailer::TestCase setup do @user = users(:david) + @user.notifications.destroy_all @bundle = Notification::Bundle.create!( user: @user, diff --git a/test/models/card/readable_test.rb b/test/models/card/readable_test.rb index 4adb8747e..dd86ee997 100644 --- a/test/models/card/readable_test.rb +++ b/test/models/card/readable_test.rb @@ -1,60 +1,41 @@ require "test_helper" class Card::ReadableTest < ActiveSupport::TestCase - test "read clears events notifications" do - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do - assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do - cards(:logo).read_by(users(:kevin)) - end + test "read marks notification as read" do + assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do + cards(:logo).read_by(users(:kevin)) end end - test "read clear mentions in the description" do - assert_changes -> { notifications(:logo_card_david_mention_by_jz).reload.read? }, from: false, to: true do + test "read marks mention notification as read" do + assert_changes -> { notifications(:logo_mentioned_david).reload.read? }, from: false, to: true do cards(:logo).read_by(users(:david)) end end - test "read clear mentions in comments" do - assert_changes -> { notifications(:logo_comment_david_mention_by_jz).reload.read? }, from: false, to: true do - cards(:logo).read_by(users(:david)) - end - end - - test "read clears notifications from the comments" do + test "read marks comment notification as read" do assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do cards(:layout).read_by(users(:kevin)) end end - test "unread marks events notifications as unread" do - notifications(:logo_published_kevin).read + test "unread marks notification as unread" do notifications(:logo_assignment_kevin).read - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do - assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do - cards(:logo).unread_by(users(:kevin)) - end + assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do + cards(:logo).unread_by(users(:kevin)) end end - test "unread marks mentions in the description as unread" do - notifications(:logo_card_david_mention_by_jz).read + test "unread marks mention notification as unread" do + notifications(:logo_mentioned_david).read - assert_changes -> { notifications(:logo_card_david_mention_by_jz).reload.read? }, from: true, to: false do + assert_changes -> { notifications(:logo_mentioned_david).reload.read? }, from: true, to: false do cards(:logo).unread_by(users(:david)) end end - test "unread marks mentions in comments as unread" do - notifications(:logo_comment_david_mention_by_jz).read - - assert_changes -> { notifications(:logo_comment_david_mention_by_jz).reload.read? }, from: true, to: false do - cards(:logo).unread_by(users(:david)) - end - end - - test "unread marks notifications from the comments as unread" do + test "unread marks comment notification as unread" do notifications(:layout_commented_kevin).read assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: true, to: false do @@ -68,8 +49,8 @@ class Card::ReadableTest < ActiveSupport::TestCase david = users(:david) assert card.accessible_to?(kevin) - kevin_notifications = [ notifications(:logo_published_kevin), notifications(:logo_assignment_kevin) ] - david_notifications = [ notifications(:logo_card_david_mention_by_jz), notifications(:logo_comment_david_mention_by_jz) ] + kevin_notification = notifications(:logo_assignment_kevin) + david_notification = notifications(:logo_mentioned_david) # Kevin loses access card.board.accesses.find_by(user: kevin).destroy @@ -78,14 +59,10 @@ class Card::ReadableTest < ActiveSupport::TestCase card.remove_inaccessible_notifications - # Kevin's notifications removed - kevin_notifications.each do |notification| - assert_not Notification.exists?(notification.id) - end + # Kevin's notification removed + assert_not Notification.exists?(kevin_notification.id) - # David's notifications preserved - david_notifications.each do |notification| - assert Notification.exists?(notification.id) - end + # David's notification preserved + assert Notification.exists?(david_notification.id) end end diff --git a/test/models/notification/bundle_test.rb b/test/models/notification/bundle_test.rb index 2a8b242af..66dd8dd77 100644 --- a/test/models/notification/bundle_test.rb +++ b/test/models/notification/bundle_test.rb @@ -5,6 +5,7 @@ class Notification::BundleTest < ActiveSupport::TestCase setup do @user = users(:david) + @user.notifications.destroy_all @user.settings.bundle_email_every_few_hours! end @@ -25,7 +26,7 @@ class Notification::BundleTest < ActiveSupport::TestCase end end - test "notifications are bundled withing the aggregation period" do + test "notifications are bundled within the aggregation period" do @user.notification_bundles.destroy_all notification_1 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do @@ -34,12 +35,12 @@ class Notification::BundleTest < ActiveSupport::TestCase travel_to 3.hours.from_now notification_2 = assert_no_difference -> { @user.notification_bundles.count } do - @user.notifications.create!(source: events(:logo_published), creator: @user) + @user.notifications.create!(source: events(:layout_published), creator: @user) end travel_to 3.days.from_now notification_3 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do - @user.notifications.create!(source: events(:logo_published), creator: @user) + @user.notifications.create!(source: events(:text_published), creator: @user) end assert_equal 2, @user.notification_bundles.count @@ -150,10 +151,10 @@ class Notification::BundleTest < ActiveSupport::TestCase test "out-of-order notification bundling should still work" do first_notification = @user.notifications.create!(source: events(:logo_published), creator: @user) - second_notification = @user.notifications.create!(source: events(:logo_published), creator: @user) + second_notification = @user.notifications.create!(source: events(:layout_commented), creator: @user) @user.notification_bundles.destroy_all - assert first_notification.created_at < second_notification.created_at + assert first_notification.updated_at <= second_notification.updated_at @user.bundle(second_notification) @user.bundle(first_notification) diff --git a/test/models/notification_pusher_test.rb b/test/models/notification_pusher_test.rb index 21b9e202b..53380effe 100644 --- a/test/models/notification_pusher_test.rb +++ b/test/models/notification_pusher_test.rb @@ -3,10 +3,7 @@ require "test_helper" class NotificationPusherTest < ActiveSupport::TestCase setup do @user = users(:david) - @notification = @user.notifications.create!( - source: events(:logo_published), - creator: users(:jason) - ) + @notification = notifications(:logo_mentioned_david) @pusher = NotificationPusher.new(@notification) @user.push_subscriptions.create!( diff --git a/test/models/notification_test.rb b/test/models/notification_test.rb index 9c8ebbe12..8d9809ba1 100644 --- a/test/models/notification_test.rb +++ b/test/models/notification_test.rb @@ -1,49 +1,67 @@ require "test_helper" class NotificationTest < ActiveSupport::TestCase - test "unread marks notification as unread" do - notification = notifications(:logo_published_kevin) - notification.read # Mark as read first - - assert_changes -> { notification.reload.read? }, from: true, to: false do - notification.unread - end - end - - test "unread broadcasts to notifications" do - notification = notifications(:logo_published_kevin) - notification.read # Mark as read first - - assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do - perform_enqueued_jobs do - notification.unread - end - end - end - test "read marks notification as read" do - notification = notifications(:logo_published_kevin) - # Ensure it starts as unread - notification.update!(read_at: nil) + notification = notifications(:logo_assignment_kevin) + notification.update!(read_at: nil, unread_count: 2) assert_changes -> { notification.reload.read? }, from: false, to: true do notification.read end + + assert_equal 0, notification.unread_count end - test "read broadcasts to notifications" do - notification = notifications(:logo_published_kevin) - # Ensure it starts as unread - notification.update!(read_at: nil) + test "unread marks notification as unread" do + notification = notifications(:logo_assignment_kevin) + notification.read + + assert_changes -> { notification.reload.read? }, from: true, to: false do + notification.unread + end + + assert_equal 1, notification.unread_count + end + + test "read_all marks all notifications and resets unread counts" do + kevin = users(:kevin) + + kevin.notifications.unread.read_all + + assert kevin.notifications.reload.all?(&:read?) + assert kevin.notifications.reload.all? { |n| n.unread_count == 0 } + end + + test "unread_count tracks notification count per card" do + notification = notifications(:logo_assignment_kevin) + assert_equal 2, notification.unread_count + end + + test "broadcasting on create prepends" do + kevin = users(:kevin) + layout = cards(:layout) + + notifications(:layout_commented_kevin).destroy + + perform_enqueued_jobs do + Notification.create!(user: kevin, source: events(:layout_commented), creator: users(:david)) + end + + assert_turbo_stream_broadcasts [ kevin, :notifications ] + end + + test "broadcasting on update when read removes" do + notification = notifications(:layout_commented_kevin) assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do - notification.read + perform_enqueued_jobs do + notification.read + end end end - test "deleting notification broadcasts its removal" do - notification = notifications(:logo_published_kevin) - notification.update!(read_at: nil) + test "broadcasting on destroy removes" do + notification = notifications(:logo_assignment_kevin) assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do notification.destroy diff --git a/test/models/notifier/event_notifier_test.rb b/test/models/notifier/event_notifier_test.rb index 82942b96c..6393ddb99 100644 --- a/test/models/notifier/event_notifier_test.rb +++ b/test/models/notifier/event_notifier_test.rb @@ -71,18 +71,18 @@ class Notifier::EventNotifierTest < ActiveSupport::TestCase test "create notifications on publish for mentionees" do users(:kevin).mentioned_by(users(:david), at: cards(:logo)) - assert_difference -> { users(:kevin).notifications.count }, +1 do - Notifier.for(events(:logo_published)).notify - end + notifications = Notifier.for(events(:logo_published)).notify + + assert_includes notifications.map(&:user), users(:kevin) end - test "don'create notifications on publish for mentionees that are not watching" do + test "create notifications on publish for mentionees that are not watching" do users(:kevin).mentioned_by(users(:david), at: cards(:logo)) cards(:logo).unwatch_by(users(:kevin)) - assert_difference -> { users(:kevin).notifications.count }, +1 do - Notifier.for(events(:logo_published)).notify - end + notifications = Notifier.for(events(:logo_published)).notify + + assert_includes notifications.map(&:user), users(:kevin) end test "don't create notifications on comment for mentionees" do diff --git a/test/models/user/notifiable_test.rb b/test/models/user/notifiable_test.rb index c7bc4fabc..aabfdda2d 100644 --- a/test/models/user/notifiable_test.rb +++ b/test/models/user/notifiable_test.rb @@ -3,6 +3,7 @@ require "test_helper" class User::NotifiableTest < ActiveSupport::TestCase setup do @user = users(:david) + @user.notifications.destroy_all @user.settings.bundle_email_every_few_hours! end @@ -12,7 +13,7 @@ class User::NotifiableTest < ActiveSupport::TestCase end bundle = @user.notification_bundles.last - assert_equal notification.created_at, bundle.starts_at + assert_equal notification.updated_at, bundle.starts_at assert bundle.pending? end @@ -20,7 +21,7 @@ class User::NotifiableTest < ActiveSupport::TestCase @user.notifications.create!(source: events(:logo_published), creator: @user) assert_no_difference -> { @user.notification_bundles.count } do - @user.notifications.create!(source: events(:logo_published), creator: @user) + @user.notifications.create!(source: events(:layout_published), creator: @user) end end end diff --git a/test/system/smoke_test.rb b/test/system/smoke_test.rb index c307c50d2..31e8f723f 100644 --- a/test/system/smoke_test.rb +++ b/test/system/smoke_test.rb @@ -67,13 +67,13 @@ class SmokeTest < ApplicationSystemTestCase test "dismissing notifications" do sign_in_as(users(:david)) - notif = notifications(:logo_card_david_mention_by_jz) + notification = notifications(:logo_mentioned_david) - assert_selector "div##{dom_id(notif)}" + assert_selector "div##{dom_id(notification)}" - within_window(open_new_window) { visit card_url(notif.card) } + within_window(open_new_window) { visit card_url(notification.card) } - assert_no_selector "div##{dom_id(notif)}" + assert_no_selector "div##{dom_id(notification)}" end test "dragging card to a new column" do From 4a181b239db457a6906a40004edbab8bc45ef2aa Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 12 Feb 2026 11:08:13 +0100 Subject: [PATCH 58/96] Tweak space around assignment --- app/assets/stylesheets/card-perma.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 0b09fddaf..22083d130 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -139,10 +139,11 @@ .card__meta-text--assignees { margin-inline-end: calc(var(--btn-size) + var(--meta-spacer-block)); + margin-block-start: unset; } .card__meta-avatars--assignees { - margin-block-start: calc(var(--meta-spacer-block) * 2); + margin-block-start: calc(var(--meta-spacer-block) * -1); } } From 22276a25c82bb8202381681876584de607171acd Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 12 Feb 2026 11:36:35 +0100 Subject: [PATCH 59/96] Index notifications on updated_at As we now update notifications with unread counts we sort them by the time they were updated instead of when they were created. To keep that performant we also have to change the previous indices that used created_at to use updated_at. --- .../20260212102026_fix_notifications_ordered_index.rb | 8 ++++++++ db/schema.rb | 4 ++-- db/schema_sqlite.rb | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 db/migrate/20260212102026_fix_notifications_ordered_index.rb diff --git a/db/migrate/20260212102026_fix_notifications_ordered_index.rb b/db/migrate/20260212102026_fix_notifications_ordered_index.rb new file mode 100644 index 000000000..f7e3cfcac --- /dev/null +++ b/db/migrate/20260212102026_fix_notifications_ordered_index.rb @@ -0,0 +1,8 @@ +class FixNotificationsOrderedIndex < ActiveRecord::Migration[8.2] + def change + add_index :notifications, [ :user_id, :read_at, :updated_at ], + order: { read_at: :desc, updated_at: :desc }, + name: "index_notifications_on_user_id_and_read_at_and_updated_at" + remove_index :notifications, name: "index_notifications_on_user_id_and_read_at_and_created_at" + end +end diff --git a/db/schema.rb b/db/schema.rb index db1a2283d..a46444460 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do +ActiveRecord::Schema[8.2].define(version: 2026_02_12_102026) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -398,7 +398,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do t.index ["creator_id"], name: "index_notifications_on_creator_id" t.index ["source_type", "source_id"], name: "index_notifications_on_source" t.index ["user_id", "card_id"], name: "index_notifications_on_user_id_and_card_id", unique: true - t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc } + t.index ["user_id", "read_at", "updated_at"], name: "index_notifications_on_user_id_and_read_at_and_updated_at", order: { read_at: :desc, updated_at: :desc } t.index ["user_id"], name: "index_notifications_on_user_id" end diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 3486ca9d1..4254c30f1 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do +ActiveRecord::Schema[8.2].define(version: 2026_02_12_102026) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -398,7 +398,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do t.index ["creator_id"], name: "index_notifications_on_creator_id" t.index ["source_type", "source_id"], name: "index_notifications_on_source" t.index ["user_id", "card_id"], name: "index_notifications_on_user_id_and_card_id", unique: true - t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc } + t.index ["user_id", "read_at", "updated_at"], name: "index_notifications_on_user_id_and_read_at_and_updated_at", order: { read_at: :desc, updated_at: :desc } t.index ["user_id"], name: "index_notifications_on_user_id" end From ff80dfeb06567b6cc843cdadb3d46b9cbd9e1558 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 12 Feb 2026 11:36:57 +0100 Subject: [PATCH 60/96] Fix dark mode colors --- app/assets/stylesheets/lexxy.css | 42 ++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index d87724408..5e6781528 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -2,6 +2,42 @@ @import url("lexxy-content.css") layer(base); @import url("lexxy-editor.css") layer(base); +:root { + --lexxy-color-ink: var(--color-ink); + --lexxy-color-ink-medium: var(--color-ink-dark); + --lexxy-color-ink-light: var(--color-ink-light); + --lexxy-color-ink-lighter: var(--color-ink-lighter); + --lexxy-color-ink-lightest: var(--color-ink-lightest); + --lexxy-color-ink-inverted: var(--color-ink-inverted); + + --lexxy-color-canvas: var(--color-canvas); + + --lexxy-color-accent-dark: var(--color-accent-dark); + --lexxy-color-accent-medium: var(--color-accent-medium); + --lexxy-color-accent-light: var(--color-accent-light); + --lexxy-color-accent-lightest: var(--color-accent-lightest); + + --lexxy-color-red: oklch(var(--lch-red-medium)); + --lexxy-color-green: oklch(var(--lch-green-medium)); + --lexxy-color-blue: oklch(var(--lch-blue-medium)); + --lexxy-color-purple: oklch(var(--lch-purple-medium)); + + --lexxy-color-code-token-att: var(--color-code-token__att); + --lexxy-color-code-token-comment: var(--color-code-token__comment); + --lexxy-color-code-token-function: var(--color-code-token__function); + --lexxy-color-code-token-operator: var(--color-code-token__operator); + --lexxy-color-code-token-property: var(--color-code-token__property); + --lexxy-color-code-token-punctuation: var(--color-code-token__punctuation); + --lexxy-color-code-token-selector: var(--color-code-token__selector); + --lexxy-color-code-token-variable: var(--color-code-token__variable); + + --lexxy-color-selected: oklch(var(--lch-blue-light)); + --lexxy-color-selected-dark: oklch(var(--lch-blue-medium)); + + --lexxy-color-table-cell-border: var(--color-ink-light); + --lexxy-color-table-cell-selected-bg: var(--lexxy-color-selected); +} + @layer components { /* Editor /* ------------------------------------------------------------------------ */ @@ -132,8 +168,10 @@ font-size: var(--text-small); padding-block: 0.75ch; } - th:not([class*="selected"], [data-action="delete"], [data-action="toggle"]) { background-color: var(--color-ink-lightest); } - td:not([class*="selected"], [data-action="delete"], [data-action="toggle"]) { background-color: var(--color-canvas); } + tr:not([data-action="delete"]) { + th:not([class*="selected"], [data-action="delete"], [data-action="toggle"]) { background-color: var(--color-ink-lightest); } + td:not([class*="selected"], [data-action="delete"], [data-action="toggle"]) { background-color: var(--color-canvas); } + } } .attachment { From 135fe128f72a142a82cd14bd7cd03da1a25f4f4d Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 12 Feb 2026 11:46:04 +0100 Subject: [PATCH 61/96] Table style tweaks --- app/assets/stylesheets/lexxy.css | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index 5e6781528..0b33162fb 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -5,9 +5,9 @@ :root { --lexxy-color-ink: var(--color-ink); --lexxy-color-ink-medium: var(--color-ink-dark); - --lexxy-color-ink-light: var(--color-ink-light); - --lexxy-color-ink-lighter: var(--color-ink-lighter); - --lexxy-color-ink-lightest: var(--color-ink-lightest); + --lexxy-color-ink-light: var(--color-ink-medium); + --lexxy-color-ink-lighter: var(--color-ink-light); + --lexxy-color-ink-lightest: var(--color-ink-lighter); --lexxy-color-ink-inverted: var(--color-ink-inverted); --lexxy-color-canvas: var(--color-canvas); @@ -34,8 +34,10 @@ --lexxy-color-selected: oklch(var(--lch-blue-light)); --lexxy-color-selected-dark: oklch(var(--lch-blue-medium)); - --lexxy-color-table-cell-border: var(--color-ink-light); + --lexxy-color-table-cell-border: var(--color-ink-ligher); --lexxy-color-table-cell-selected-bg: var(--lexxy-color-selected); + --lexxy-color-table-cell-toggle: var(--lexxy-color-selected); + --lexxy-color-table-cell-remove: oklch(var(--lch-red-medium) / 20%); } @layer components { From 645d21cfbbc67aeeec9ab99d2f1dd42526c9a41d Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 12 Feb 2026 12:25:47 +0100 Subject: [PATCH 62/96] Make reactions larger on touch devices --- app/assets/stylesheets/card-perma.css | 4 ++++ app/assets/stylesheets/reactions.css | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 22083d130..1900b9a07 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -252,6 +252,10 @@ inset: 0 calc(-1 * var(--card-padding-inline)) auto; position: absolute; } + + @media (any-hover: none) { + --reaction-size: 2.25rem; + } } &:not(:has(.reaction)) { diff --git a/app/assets/stylesheets/reactions.css b/app/assets/stylesheets/reactions.css index 64f2a5164..ce836105c 100644 --- a/app/assets/stylesheets/reactions.css +++ b/app/assets/stylesheets/reactions.css @@ -86,6 +86,10 @@ font-size: 0.6em; inline-size: auto; } + + @media (any-hover: none) { + padding-inline-end: 0.12em; + } } .reaction--deleteable { @@ -166,6 +170,11 @@ .reaction__cancel-btn { --btn-size: 1.25rem; --icon-size: var(--btn-size); + + @media (any-hover: none) { + --btn-size: 2rem; + --icon-size: 90%; + } } .reaction__submit-btn { From e2757d897cef36472987b148f957ebeef2697dce Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 12 Feb 2026 12:33:36 +0100 Subject: [PATCH 63/96] Make emoji reaction buttons larger on touch devices --- app/assets/stylesheets/reactions.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/assets/stylesheets/reactions.css b/app/assets/stylesheets/reactions.css index ce836105c..efd487f64 100644 --- a/app/assets/stylesheets/reactions.css +++ b/app/assets/stylesheets/reactions.css @@ -228,6 +228,12 @@ &:hover { scale: 1.3; } + + @media (any-hover: none) { + --btn-size: calc(1.6rem * 1.3); + + font-size: 1.6rem; + } } } } From 43db3b998e281ed7676279f46ba7f876eb992cf5 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 12 Feb 2026 12:41:03 +0100 Subject: [PATCH 64/96] Fix unwanted header padding --- app/views/cards/show.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/cards/show.html.erb b/app/views/cards/show.html.erb index bbbff8275..cf6d239db 100644 --- a/app/views/cards/show.html.erb +++ b/app/views/cards/show.html.erb @@ -8,8 +8,8 @@ <% content_for :header do %>
<%= link_back_to_board(@card.board) %> - <%= bridged_button_to_board(@card.board) %>
+ <%= bridged_button_to_board(@card.board) %> <% end %> <%= turbo_stream_from @card %> From fa21d1f6f960c99f78a26e22e3ebb33ed52a05df Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 12 Feb 2026 12:55:13 +0100 Subject: [PATCH 65/96] Fix unwanted margin in unassigned card with a boost --- app/assets/stylesheets/card-perma.css | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 1900b9a07..cd9a15b7b 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -138,7 +138,6 @@ "avatars-assignees text-assignees"; .card__meta-text--assignees { - margin-inline-end: calc(var(--btn-size) + var(--meta-spacer-block)); margin-block-start: unset; } @@ -172,6 +171,16 @@ } } + @media (max-width: 639px) { + &:not(:has(.reactions .reaction)) { + .card__meta:not(:has(.card__meta-avatars--assignees .avatar)) { + .card__meta-text--assignees { + margin-inline-end: calc(var(--btn-size) + var(--meta-spacer-block)); + } + } + } + } + .card__stages { max-inline-size: 32ch; From f77275fe68691b447364ad53a3422c50a98ecae3 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 12 Feb 2026 15:27:11 +0100 Subject: [PATCH 66/96] Re-position "Done" stamp --- app/assets/stylesheets/card-perma.css | 15 ++++++++++----- app/assets/stylesheets/cards.css | 7 +++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index cd9a15b7b..e97fe87f2 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -172,13 +172,18 @@ } @media (max-width: 639px) { - &:not(:has(.reactions .reaction)) { - .card__meta:not(:has(.card__meta-avatars--assignees .avatar)) { - .card__meta-text--assignees { - margin-inline-end: calc(var(--btn-size) + var(--meta-spacer-block)); - } + &:not(:has(.reactions .reaction)):is( + :not(:has(.card__meta-avatars--assignees .avatar)), + :has(.card__closed) + ) { + .card__meta-text--assignees { + margin-inline-end: calc(var(--btn-size) + var(--meta-spacer-block)); } } + + &:has(.card__closed) { + padding-block-start: 2rem; + } } .card__stages { diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css index cbaa66c91..e05562635 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -372,6 +372,7 @@ --stamp-color: oklch(var(--lch-green-medium) / 0.65); align-items: center; + backdrop-filter: blur(2px); background-color: color-mix(in srgb, var(--card-bg-color) 90%, transparent); border-radius: 0.2em; border: 0.5ch solid var(--stamp-color); @@ -399,6 +400,12 @@ } } } + + @media (max-width: 639px) { + inset: auto calc(var(--card-padding-inline) * -0.5) 100% auto; + translate: 0 40%; + scale: 75%; + } } .card:has(.card__closed), From 1506624cd3de856415dae1b449507e7492daf48a Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 12 Feb 2026 15:35:26 +0000 Subject: [PATCH 67/96] Add stackprof for profiling So you can do something like this: ```ruby require "stackprof" report = StackProf.run(mode: :wall, interval: 100) do Account.find_by(external_account_id: 123) end StackProf::Report.new(report).print_text ``` --- Gemfile | 1 + Gemfile.lock | 2 ++ Gemfile.saas.lock | 2 ++ 3 files changed, 5 insertions(+) diff --git a/Gemfile b/Gemfile index 23b1223ce..ccc4da06a 100644 --- a/Gemfile +++ b/Gemfile @@ -41,6 +41,7 @@ gem "useragent", bc: "useragent" # Operations gem "autotuner" gem "mission_control-jobs" +gem "stackprof" gem "benchmark" # indirect dependency, being removed from Ruby 3.5 stdlib so here to quash warnings group :development, :test do diff --git a/Gemfile.lock b/Gemfile.lock index b00219210..d20d773b7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -437,6 +437,7 @@ GEM net-sftp (>= 2.1.2) net-ssh (>= 2.8.0) ostruct + stackprof (0.2.27) stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.2.0) @@ -529,6 +530,7 @@ DEPENDENCIES solid_cache (~> 1.0) solid_queue (~> 1.2) sqlite3 (>= 2.0) + stackprof stimulus-rails thruster trilogy (~> 2.9) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 07740c63b..84b5cf2b3 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -558,6 +558,7 @@ GEM net-sftp (>= 2.1.2) net-ssh (>= 2.8.0) ostruct + stackprof (0.2.27) stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.2.0) @@ -686,6 +687,7 @@ DEPENDENCIES solid_cache (~> 1.0) solid_queue (~> 1.2) sqlite3 (>= 2.0) + stackprof stimulus-rails stripe (~> 18.0) thruster From c9982fc6eafcfa8fbaf9a2ab7cdc46c48410caff Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 12 Feb 2026 17:07:17 +0100 Subject: [PATCH 68/96] Fix missing margin --- app/assets/stylesheets/card-perma.css | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index e97fe87f2..78643dbaa 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -137,10 +137,6 @@ "... text-updated" "avatars-assignees text-assignees"; - .card__meta-text--assignees { - margin-block-start: unset; - } - .card__meta-avatars--assignees { margin-block-start: calc(var(--meta-spacer-block) * -1); } @@ -183,6 +179,16 @@ &:has(.card__closed) { padding-block-start: 2rem; + + .card__meta-text--assignees { + margin-block-start: var(--meta-spacer-block); + } + } + + &:not(:has(.card__closed), :has(.card__meta-avatars--assignees .avatar)) { + .card__meta-text--assignees { + margin-block-start: unset; + } } } From 851f13a93439ea75dfa40766934ae744a7365498 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 12 Feb 2026 12:07:40 -0500 Subject: [PATCH 69/96] Render inline code in card titles (#2518) * Remove unused marked JS dependency * Remove unused redcarpet dependency * Render inline code in card titles Add card_html_title helper that HTML-escapes input then converts backtick-wrapped text to elements. Apply to card titles in board preview, card detail, public views, and notification emails. Style inline code elements in titles to match description styling. Co-authored-by: Andy Smith --------- Co-authored-by: Andy Smith --- Gemfile | 1 - Gemfile.lock | 2 - Gemfile.saas.lock | 2 - app/assets/stylesheets/cards.css | 9 + app/helpers/html_helper.rb | 6 + .../cards/container/_content_display.html.erb | 2 +- app/views/cards/display/_preview.html.erb | 2 +- .../cards/display/_public_preview.html.erb | 2 +- .../bundle_mailer/notification.html.erb | 2 +- app/views/public/cards/show/_content.html.erb | 2 +- config/importmap.rb | 1 - test/controllers/cards_controller_test.rb | 11 +- test/helpers/html_helper_test.rb | 25 + .../notification/bundle_mailer_test.rb | 10 + vendor/javascript/marked.js | 2583 ----------------- 15 files changed, 63 insertions(+), 2597 deletions(-) delete mode 100644 vendor/javascript/marked.js diff --git a/Gemfile b/Gemfile index ccc4da06a..c524ee3c1 100644 --- a/Gemfile +++ b/Gemfile @@ -25,7 +25,6 @@ gem "trilogy", "~> 2.9" gem "bcrypt", "~> 3.1.7" gem "geared_pagination", "~> 1.2" gem "rqrcode" -gem "redcarpet" gem "rouge" gem "jbuilder" gem "lexxy", bc: "lexxy" diff --git a/Gemfile.lock b/Gemfile.lock index d20d773b7..f937c83e8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -355,7 +355,6 @@ GEM erb psych (>= 4.0.0) tsort - redcarpet (3.6.1) regexp_parser (2.11.3) reline (0.6.3) io-console (~> 0.5) @@ -521,7 +520,6 @@ DEPENDENCIES puma (>= 5.0) rack-mini-profiler rails! - redcarpet rouge rqrcode rubocop-rails-omakase diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 84b5cf2b3..4d267e4fd 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -466,7 +466,6 @@ GEM erb psych (>= 4.0.0) tsort - redcarpet (3.6.1) regexp_parser (2.11.3) reline (0.6.3) io-console (~> 0.5) @@ -676,7 +675,6 @@ DEPENDENCIES rack-mini-profiler rails! rails_structured_logging! - redcarpet rouge rqrcode rubocop-rails-omakase diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css index f36b4e76f..272ff7b1f 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -220,6 +220,15 @@ .card__title-link { color: inherit; } + + code { + background-color: var(--color-canvas); + border: 1px solid var(--color-ink-lighter); + border-radius: 0.25ch; + font-family: var(--font-mono); + font-size: smaller; + padding: 0.1ch 0.25ch; + } } .card__description { diff --git a/app/helpers/html_helper.rb b/app/helpers/html_helper.rb index a3d0bbab3..7b65d34cf 100644 --- a/app/helpers/html_helper.rb +++ b/app/helpers/html_helper.rb @@ -2,4 +2,10 @@ module HtmlHelper def format_html(html) Loofah::HTML5::DocumentFragment.parse(html).scrub!(AutoLinkScrubber.new).to_html.html_safe end + + def card_html_title(card) + return card.title if card.title.blank? + + ERB::Util.html_escape(card.title).gsub(/`([^`]+)`/, '\1').html_safe + end end diff --git a/app/views/cards/container/_content_display.html.erb b/app/views/cards/container/_content_display.html.erb index 46a9a83d1..e99b5784f 100644 --- a/app/views/cards/container/_content_display.html.erb +++ b/app/views/cards/container/_content_display.html.erb @@ -1,5 +1,5 @@

- <%= link_to card.title, edit_card_path(card), class: "card__title-link" %> + <%= link_to card_html_title(card), edit_card_path(card), class: "card__title-link" %>

<% unless card.description.blank? %> diff --git a/app/views/cards/display/_preview.html.erb b/app/views/cards/display/_preview.html.erb index fe8b7c613..f70aa6ade 100644 --- a/app/views/cards/display/_preview.html.erb +++ b/app/views/cards/display/_preview.html.erb @@ -36,7 +36,7 @@

- <%= card.title %> + <%= card_html_title(card) %>

diff --git a/app/views/cards/display/_public_preview.html.erb b/app/views/cards/display/_public_preview.html.erb index 7aa53fed7..0c297df15 100644 --- a/app/views/cards/display/_public_preview.html.erb +++ b/app/views/cards/display/_public_preview.html.erb @@ -17,7 +17,7 @@

- <%= card.title %> + <%= card_html_title(card) %>

diff --git a/app/views/mailers/notification/bundle_mailer/notification.html.erb b/app/views/mailers/notification/bundle_mailer/notification.html.erb index 28ca0be36..1545f8598 100644 --- a/app/views/mailers/notification/bundle_mailer/notification.html.erb +++ b/app/views/mailers/notification/bundle_mailer/notification.html.erb @@ -9,7 +9,7 @@

<%= link_to board.name, board %>

<% board_notifications.group_by(&:card).each do |card, notifications| %> - <%= link_to "##{ card.number } #{ card.title }", card, class: "card__title" %> + <%= link_to card, class: "card__title" do %>#<%= card.number %> <%= card_html_title(card) %><% end %> <%= render partial: "notification/bundle_mailer/notification", collection: notifications, as: :notification %> <% end %> <% end %> diff --git a/app/views/public/cards/show/_content.html.erb b/app/views/public/cards/show/_content.html.erb index 0bb74b3ce..eb3ccd044 100644 --- a/app/views/public/cards/show/_content.html.erb +++ b/app/views/public/cards/show/_content.html.erb @@ -1,6 +1,6 @@

- <%= tag.span card.title, class: "card__title-link" %> + <%= tag.span card_html_title(card), class: "card__title-link" %>

<%= card.description %> diff --git a/config/importmap.rb b/config/importmap.rb index 27dbd7015..5da84683c 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -13,7 +13,6 @@ pin_all_from "app/javascript/initializers", under: "initializers" pin_all_from "app/javascript/bridge/initializers", under: "bridge/initializers" pin_all_from "app/javascript/bridge/helpers", under: "bridge/helpers" pin_all_from "app/javascript/bridge/controllers/bridge", under: "controllers/bridge", to: "bridge/controllers/bridge" -pin "marked" # @15.0.11 pin "lexxy" pin "@rails/activestorage", to: "activestorage.esm.js" pin "@rails/actiontext", to: "actiontext.esm.js" diff --git a/test/controllers/cards_controller_test.rb b/test/controllers/cards_controller_test.rb index 8007657d4..3a97f3166 100644 --- a/test/controllers/cards_controller_test.rb +++ b/test/controllers/cards_controller_test.rb @@ -42,9 +42,14 @@ class CardsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to card_draft_path(card) end - test "show" do - get card_path(cards(:logo)) - assert_response :success + test "show renders inline code in title" do + card = cards(:logo) + card.update_column :title, "Fix the `bug` in production" + + get card_path(card) + assert_select ".card__title-link" do |element| + assert_equal "Fix the bug in production", element.inner_html + end end test "edit" do diff --git a/test/helpers/html_helper_test.rb b/test/helpers/html_helper_test.rb index 1af507ddf..0fd6c268a 100644 --- a/test/helpers/html_helper_test.rb +++ b/test/helpers/html_helper_test.rb @@ -119,4 +119,29 @@ class HtmlHelperTest < ActionView::TestCase assert_no_match(/bug in production", card_html_title(cards(:logo).tap { _1.title = "Fix the `bug` in production" }) + end + + test "card_html_title renders multiple code spans" do + assert_equal "foo and bar", card_html_title(cards(:logo).tap { _1.title = "`foo` and `bar`" }) + end + + test "card_html_title renders code spans without surrounding spaces" do + assert_equal "whataboutthis", card_html_title(cards(:logo).tap { _1.title = "what`about`this" }) + end + + test "card_html_title escapes HTML tags" do + assert_equal "<script>alert(1)</script>", card_html_title(cards(:logo).tap { _1.title = "" }) + end + + test "card_html_title escapes HTML inside backticks" do + assert_equal "<script>", card_html_title(cards(:logo).tap { _1.title = "`") diff --git a/test/models/webhook/delivery_test.rb b/test/models/webhook/delivery_test.rb index d4d95528c..8d40c4417 100644 --- a/test/models/webhook/delivery_test.rb +++ b/test/models/webhook/delivery_test.rb @@ -245,6 +245,102 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase assert_requested request_stub end + test "basecamp webhook payload html-escapes special characters" do + cards(:logo).update_column(:title, %(Tom & Jerry's "Adventure")) + + webhook = Webhook.create!( + board: boards(:writebook), + name: "Basecamp", + url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/789/lines" + ) + delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published)) + + captured_body = nil + stub_request(:post, webhook.url) + .with { |request| captured_body = request.body; true } + .to_return(status: 200) + + delivery.deliver + + content = CGI.parse(captured_body)["content"].first + + expected = <<~HTML.strip + David added "Tom & Jerry's <Great> "Adventure"" + ↗︎ + HTML + assert_equal expected, content + end + + test "slack webhook payload html-escapes special characters" do + cards(:logo).update_column(:title, %(Tom & Jerry's "Adventure")) + + webhook = Webhook.create!( + board: boards(:writebook), + name: "Slack", + url: "https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx" # gitleaks:allow + ) + delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published)) + + captured_body = nil + stub_request(:post, webhook.url) + .with { |request| captured_body = request.body; true } + .to_return(status: 200) + + delivery.deliver + + text = JSON.parse(captured_body)["text"] + + expected = <<~TEXT.strip + David added "Tom & Jerry's <Great> "Adventure"" + TEXT + assert_equal expected, text + end + + test "campfire webhook payload html-escapes special characters" do + cards(:logo).update_column(:title, %(Tom & Jerry's "Adventure")) + + webhook = Webhook.create!( + board: boards(:writebook), + name: "Campfire", + url: "https://example.com/rooms/123/456-room-name/messages" + ) + delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published)) + + captured_body = nil + stub_request(:post, webhook.url) + .with { |request| captured_body = request.body; true } + .to_return(status: 200) + + delivery.deliver + + expected = <<~HTML.strip + David added "Tom & Jerry's <Great> "Adventure"" + ↗︎ + HTML + assert_equal expected, captured_body + end + + test "generic webhook payload json-encodes special characters" do + cards(:logo).update_column(:title, %(Tom & Jerry's "Adventure")) + + webhook = Webhook.create!( + board: boards(:writebook), + name: "Generic", + url: "https://example.com/webhook" + ) + delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published)) + + captured_body = nil + stub_request(:post, webhook.url) + .with { |request| captured_body = request.body; true } + .to_return(status: 200) + + delivery.deliver + + json = JSON.parse(captured_body) + assert_equal %(Tom & Jerry's "Adventure"), json["eventable"]["title"] + end + test "renders creator name when event creator is not current user" do webhook = Webhook.create!( board: boards(:writebook), From 0ab88af118426869d80746604b51caea8962cedb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Fri, 13 Feb 2026 10:04:18 +0000 Subject: [PATCH 78/96] Revert "Add temporary performance degradation message" This reverts commit 2a1f9cd15b0216cdf8503e7f5aaf1be44c81e746. --- app/views/events/index.html.erb | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index 94aafe418..f567f6d91 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -2,9 +2,6 @@ <% @header_class = "header--events" %> <%= render "cards/broadcasts", filter: @filter %> -
- Fizzy is a bit sluggish at the moment, but we're working hard to get things humming smoothly again. Sorry for the disruption to your day. -
<% content_for :header do %> <%= render "events/index/add_card_button", user_filtering: @user_filtering %> From 61489593b91e26f9927587756ea47b8704ec7082 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Fri, 13 Feb 2026 12:02:26 +0100 Subject: [PATCH 79/96] Re-align all the rows --- app/assets/stylesheets/card-perma.css | 40 ++++++--------------------- 1 file changed, 9 insertions(+), 31 deletions(-) diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 78643dbaa..1d00da6bb 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -116,37 +116,24 @@ grid-template-areas: "avatars-author text-added" "avatars-author text-author" - "text-updated text-updated" - "text-assignees text-assignees" - "avatars-assignees avatars-assignees"; + "... text-updated" + "... text-assignees" + "... avatars-assignees"; grid-template-columns: auto 1fr; + .card__meta-text { + border: 0; + padding: 0; + } + &:has(.card__meta-avatars--assignees .avatar) { &:not(:has(.reaction)) { .card__meta-avatars--assignees { margin-inline-end: calc(var(--btn-size) + var(--meta-spacer-block)); - margin-block-start: var(--meta-spacer-block); } } } - &:not(:has(.card__meta-avatars--assignees .avatar)) { - grid-template-areas: - "avatars-author text-added" - "avatars-author text-author" - "... text-updated" - "avatars-assignees text-assignees"; - - .card__meta-avatars--assignees { - margin-block-start: calc(var(--meta-spacer-block) * -1); - } - } - - .card__meta-text { - border: 0; - padding: 0; - } - .card__meta-text + .card__meta-text { border-block-start: var(--card-border); margin-block-start: var(--meta-spacer-block); @@ -155,6 +142,7 @@ .card__meta-avatars--assignees { margin-inline-start: unset; + margin-block-start: var(--meta-spacer-block); } .card__meta-avatars--author { @@ -179,16 +167,6 @@ &:has(.card__closed) { padding-block-start: 2rem; - - .card__meta-text--assignees { - margin-block-start: var(--meta-spacer-block); - } - } - - &:not(:has(.card__closed), :has(.card__meta-avatars--assignees .avatar)) { - .card__meta-text--assignees { - margin-block-start: unset; - } } } From 6bacbdce78d831e5ea6624faae95f5addaa425cd Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Fri, 13 Feb 2026 12:17:36 +0100 Subject: [PATCH 80/96] Fix trailing margin depending on boosts --- app/assets/stylesheets/card-perma.css | 51 +++++++++++++-------------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 1d00da6bb..3b9cbdcc3 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -34,6 +34,26 @@ } } + @media (max-width: 639px) { + &:has(.card__closed) { + padding-block-start: 2rem; + } + + &:has(.reactions .reaction) { + .card__meta-avatars--assignees { + margin-inline-end: unset; + } + } + + &:not(:has(.reactions .reaction)) { + &:has(.card__closed) { + .card__meta-text--assignees { + margin-inline-end: calc(var(--btn-size) + var(--meta-spacer-block)); + } + } + } + } + @media (max-width: 799px) { --half-btn-height: 1.25rem; --padding-inline: 1.5ch; @@ -116,9 +136,9 @@ grid-template-areas: "avatars-author text-added" "avatars-author text-author" - "... text-updated" - "... text-assignees" - "... avatars-assignees"; + "text-updated text-updated" + "text-assignees text-assignees" + "avatars-assignees avatars-assignees"; grid-template-columns: auto 1fr; .card__meta-text { @@ -126,14 +146,6 @@ padding: 0; } - &:has(.card__meta-avatars--assignees .avatar) { - &:not(:has(.reaction)) { - .card__meta-avatars--assignees { - margin-inline-end: calc(var(--btn-size) + var(--meta-spacer-block)); - } - } - } - .card__meta-text + .card__meta-text { border-block-start: var(--card-border); margin-block-start: var(--meta-spacer-block); @@ -141,7 +153,7 @@ } .card__meta-avatars--assignees { - margin-inline-start: unset; + margin-inline: 0 calc(var(--btn-size) + var(--meta-spacer-block)); margin-block-start: var(--meta-spacer-block); } @@ -155,21 +167,6 @@ } } - @media (max-width: 639px) { - &:not(:has(.reactions .reaction)):is( - :not(:has(.card__meta-avatars--assignees .avatar)), - :has(.card__closed) - ) { - .card__meta-text--assignees { - margin-inline-end: calc(var(--btn-size) + var(--meta-spacer-block)); - } - } - - &:has(.card__closed) { - padding-block-start: 2rem; - } - } - .card__stages { max-inline-size: 32ch; From d7a10bea1c51928a6983ed7f0100d4da4c03c44c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 13 Feb 2026 17:40:08 +0100 Subject: [PATCH 81/96] Include missing vips dependencies --- bin/setup | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/setup b/bin/setup index 306282cb5..e6ab0b70b 100755 --- a/bin/setup +++ b/bin/setup @@ -115,7 +115,7 @@ step "Installing Ruby" mise install --yes eval "$(mise hook-env -s bash)" if which pacman >/dev/null 2>&1; then - packages=(imagemagick mariadb-libs openslide libvips gitleaks) + packages=(imagemagick mariadb-libs openslide libvips libheif libwebp libjxl libraw poppler-glib libcgif gitleaks) if ! pacman -Q "${packages[@]}" >/dev/null 2>&1; then step "Installing packages" sudo pacman -S --noconfirm --needed "${packages[@]}" fi From f0583888eb365fa3bc654cd102b15422bef536d4 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 13 Feb 2026 17:47:30 +0100 Subject: [PATCH 82/96] More missing dependencies for libvips --- bin/setup | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/setup b/bin/setup index e6ab0b70b..e095a41e5 100755 --- a/bin/setup +++ b/bin/setup @@ -115,7 +115,7 @@ step "Installing Ruby" mise install --yes eval "$(mise hook-env -s bash)" if which pacman >/dev/null 2>&1; then - packages=(imagemagick mariadb-libs openslide libvips libheif libwebp libjxl libraw poppler-glib libcgif gitleaks) + packages=(imagemagick mariadb-libs openslide libvips libheif libwebp libjxl libraw poppler-glib libcgif ffmpeg rav1e svt-av1 gitleaks) if ! pacman -Q "${packages[@]}" >/dev/null 2>&1; then step "Installing packages" sudo pacman -S --noconfirm --needed "${packages[@]}" fi From da5e67a0a64210ea8080b883e22de763c906d5a2 Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Fri, 13 Feb 2026 18:19:38 +0100 Subject: [PATCH 83/96] Use `h3` instead of `h2` --- .../notifications/settings/_browser.html.erb | 20 +++++++++---------- .../notifications/settings/_install.html.erb | 2 +- .../notifications/settings/_system.html.erb | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/app/views/notifications/settings/_browser.html.erb b/app/views/notifications/settings/_browser.html.erb index 67d86acfd..9fefe79bf 100644 --- a/app/views/notifications/settings/_browser.html.erb +++ b/app/views/notifications/settings/_browser.html.erb @@ -2,18 +2,18 @@
<% case when platform.firefox? && platform.android? %> -

Turn on notifications for <%= platform.browser.capitalize %>.

+

Turn on notifications for <%= platform.browser.capitalize %>.

  1. Tap <%= icon_tag "lock", alt: "the View site information button" %> in the address bar.
  2. Tap Notification to change to Allowed.
<% when platform.edge? && platform.desktop? %> -

Turn on notifications for this website.

+

Turn on notifications for this website.

  1. Click <%= icon_tag "lock", alt: "the View site information button" %> left of the address bar.
  2. Under Permissions for this site > Notifications, choose Allow.
-

Turn on notifications for <%= platform.browser.capitalize %>.

+

Turn on notifications for <%= platform.browser.capitalize %>.

    <% if platform.windows? %>
  1. Click Start, then Settings.
  2. @@ -28,7 +28,7 @@ <% end %>
<% when platform.firefox? && platform.desktop? %> -

Turn on notifications for this website.

+

Turn on notifications for this website.

  1. Click <%= platform.browser.capitalize %> in the top left.
  2. Click Settings….
  3. @@ -38,7 +38,7 @@
  4. Select Allow next to <%= root_url %>.
-

Turn on notifications for <%= platform.browser.capitalize %>.

+

Turn on notifications for <%= platform.browser.capitalize %>.

    <% if platform.windows? %>
  1. Click Start, then Settings.
  2. @@ -53,14 +53,14 @@ <% end %>
<% when platform.chrome? && platform.desktop? %> -

Turn on notifications for this website.

+

Turn on notifications for this website.

  1. Click the <%= icon_tag "sliders", alt: "View site information" %> icon in the address bar.
  2. Click Site Settings.
  3. Ensure notifications are Allowed.
-

Turn on notifications for <%= platform.browser.capitalize %>.

+

Turn on notifications for <%= platform.browser.capitalize %>.

    <% if platform.windows? %>
  1. Click Start, then Settings.
  2. @@ -75,7 +75,7 @@ <% end %>
<% when platform.chrome? && platform.android? %> -

Turn on notifications for <%= platform.browser.capitalize %>.

+

Turn on notifications for <%= platform.browser.capitalize %>.

  1. Tap the <%= icon_tag "menu-dots-vertical", alt: "More options" %> menu button.
  2. Tap Settings.
  3. @@ -85,7 +85,7 @@
  4. Tap <%= icon_tag "bell-alert", alt: "the notification bell" %> and select Allow.
<% when platform.safari? && platform.desktop? %> -

Turn on notifications for this website.

+

Turn on notifications for this website.

  1. Click in the top left.
  2. Click System Settings….
  3. @@ -93,7 +93,7 @@
  4. Click <%= request.base_url %> in the list.
  5. Click <%= icon_tag "switch", alt: "the switch" %> to Allow notifications.
-

Turn on notifications for <%= platform.browser.capitalize %>.

+

Turn on notifications for <%= platform.browser.capitalize %>.

  1. Click <%= platform.browser.capitalize %> in the top left.
  2. Click Settings….
  3. diff --git a/app/views/notifications/settings/_install.html.erb b/app/views/notifications/settings/_install.html.erb index f8d664043..e19be6aa2 100644 --- a/app/views/notifications/settings/_install.html.erb +++ b/app/views/notifications/settings/_install.html.erb @@ -1,6 +1,6 @@ <% unless (platform.chrome? && !platform.ios?) || (platform.firefox? && !platform.android?) %>
    -

    <%= platform.safari? && platform.desktop? ? "…or install" : "Install " -%> Fizzy as a web app.

    +

    <%= platform.safari? && platform.desktop? ? "…or install" : "Install " -%> Fizzy as a web app.

    <% case when platform.edge? %>
      diff --git a/app/views/notifications/settings/_system.html.erb b/app/views/notifications/settings/_system.html.erb index afe0efd45..05210e949 100644 --- a/app/views/notifications/settings/_system.html.erb +++ b/app/views/notifications/settings/_system.html.erb @@ -1,5 +1,5 @@
      -

      Check your <%= platform.operating_system %> settings

      +

      Check your <%= platform.operating_system %> settings

      <% case when platform.firefox? && platform.android? %>
        From 6f839c5b2ffe6f8e05f960e003cd1c22ed2f6001 Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Fri, 13 Feb 2026 18:21:47 +0100 Subject: [PATCH 84/96] Remove periods after ellipses --- app/views/notifications/settings/_browser.html.erb | 12 ++++++------ app/views/notifications/settings/_install.html.erb | 2 +- app/views/notifications/settings/_system.html.erb | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/views/notifications/settings/_browser.html.erb b/app/views/notifications/settings/_browser.html.erb index 9fefe79bf..bc196d535 100644 --- a/app/views/notifications/settings/_browser.html.erb +++ b/app/views/notifications/settings/_browser.html.erb @@ -21,7 +21,7 @@
      1. Click <%= icon_tag "switch", alt: "the switch" %> ON for <%= platform.browser.capitalize %>.
      2. <% else %>
      3. Click in the top left.
      4. -
      5. Click System Settings….
      6. +
      7. Click System Settings…
      8. Click Notifications.
      9. Click <%= platform.browser.capitalize %>.
      10. Click <%= icon_tag "switch", alt: "the switch" %> to Allow notifications.
      11. @@ -31,7 +31,7 @@

        Turn on notifications for this website.

        1. Click <%= platform.browser.capitalize %> in the top left.
        2. -
        3. Click Settings….
        4. +
        5. Click Settings…
        6. Click Privacy & Security in the sidebar.
        7. Scroll down to Permissions.
        8. Click Settings next to Notifications.
        9. @@ -46,7 +46,7 @@
        10. Click <%= icon_tag "switch", alt: "the toggle button" %> ON for <%= platform.browser.capitalize %>.
        11. <% else %>
        12. Click in the top left.
        13. -
        14. Click System Settings….
        15. +
        16. Click System Settings…
        17. Click Notifications.
        18. Click <%= platform.browser.capitalize %>.
        19. Click <%= icon_tag "switch", alt: "the switch" %> to Allow notifications.
        20. @@ -68,7 +68,7 @@
        21. Click <%= icon_tag "switch", alt: "the switch" %> ON for <%= platform.browser.capitalize %>.
        22. <% else %>
        23. Click in the top left.
        24. -
        25. Click System Settings….
        26. +
        27. Click System Settings…
        28. Click Notifications.
        29. Click <%= platform.browser == "Chrome" ? "Google Chrome" : platform.browser.capitalize %>.
        30. Click <%= icon_tag "switch", alt: "the switch" %> to Allow notifications.
        31. @@ -88,7 +88,7 @@

          Turn on notifications for this website.

          1. Click in the top left.
          2. -
          3. Click System Settings….
          4. +
          5. Click System Settings…
          6. Click Notifications.
          7. Click <%= request.base_url %> in the list.
          8. Click <%= icon_tag "switch", alt: "the switch" %> to Allow notifications.
          9. @@ -96,7 +96,7 @@

            Turn on notifications for <%= platform.browser.capitalize %>.

            1. Click <%= platform.browser.capitalize %> in the top left.
            2. -
            3. Click Settings….
            4. +
            5. Click Settings…
            6. Click the Websites tab.
            7. Click Notifications in the sidebar.
            8. Click <%= request.base_url %> in the list.
            9. diff --git a/app/views/notifications/settings/_install.html.erb b/app/views/notifications/settings/_install.html.erb index e19be6aa2..4c2f0d1ca 100644 --- a/app/views/notifications/settings/_install.html.erb +++ b/app/views/notifications/settings/_install.html.erb @@ -20,7 +20,7 @@ <% when platform.safari? && platform.desktop? %>
              1. Click File in the top left.
              2. -
              3. Click Add to Dock….
              4. +
              5. Click Add to Dock…
              <% when (platform.safari? || platform.chrome?) && platform.ios? %>

              To receive push notifications in <%= platform.browser.capitalize %> for <%= platform.operating_system %>, you must first install Fizzy as a web app.

              diff --git a/app/views/notifications/settings/_system.html.erb b/app/views/notifications/settings/_system.html.erb index 05210e949..7630680d7 100644 --- a/app/views/notifications/settings/_system.html.erb +++ b/app/views/notifications/settings/_system.html.erb @@ -22,7 +22,7 @@
            10. Click <%= icon_tag "switch", alt: "the toggle button" %> ON for Fizzy.
            11. <% else %>
            12. Click in the top left.
            13. -
            14. Click System Settings….
            15. +
            16. Click System Settings…
            17. Click Notifications.
            18. Click Fizzy.
            19. Click <%= icon_tag "switch", alt: "the allow notifications switch" %> to Allow notifications.
            20. @@ -31,7 +31,7 @@ <% when platform.safari? && platform.desktop? %>
              1. Click in the top left.
              2. -
              3. Click System Settings….
              4. +
              5. Click System Settings…
              6. Click Notifications.
              7. Click Fizzy.
              8. Click <%= icon_tag "switch", alt: "the allow notifications switch" %> to Allow notifications.
              9. From d7814792e3c2a8dffe9ed3c10aea8fd5f1a8d588 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Fri, 13 Feb 2026 15:17:33 -0600 Subject: [PATCH 85/96] Tweak language --- app/views/account/imports/new.html.erb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/views/account/imports/new.html.erb b/app/views/account/imports/new.html.erb index ab6af3a51..b4e12e94b 100644 --- a/app/views/account/imports/new.html.erb +++ b/app/views/account/imports/new.html.erb @@ -4,11 +4,11 @@

                Import a Fizzy account

                <% if Fizzy.saas? %> -
                Move a self-hosted account to fizzy.do
                +
                Running Fizzy on your own server and want us to host on fizzy.do instead?
                Export your self-hosted account, then upload the .zip file below.
                <% else %> -
                Move an account from fizzy.do to a self-hosted instance
                -
                Export your fizzy.do account, then upload the .zip file below.
                +
                Ready to host Fizzy on your own server?
                +
                Export your fizzy.do account, then upload the .zip file below.
                <% end %> @@ -23,6 +23,8 @@ Start Import → <% end %> + +

                If you run into issues or would like assistance, just send us an email.

      <% content_for :footer do %> From 635ef4fbe5833621d6c764a2e57367803b7e4453 Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Sat, 14 Feb 2026 00:02:56 +0100 Subject: [PATCH 86/96] Fix paragraph rendering as an inline element --- app/assets/stylesheets/notifications.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/notifications.css b/app/assets/stylesheets/notifications.css index fabea58a8..aa54b14ba 100644 --- a/app/assets/stylesheets/notifications.css +++ b/app/assets/stylesheets/notifications.css @@ -95,12 +95,12 @@ display: none; .notifications--on & { - display: inline; + display: revert; } } .notifications__off-message { - display: inline; + display: revert; .notifications--on & { display: none; From bef5bb60c94c0cc6df0849a248ae11fbd73a4606 Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Sat, 14 Feb 2026 00:17:16 +0100 Subject: [PATCH 87/96] Target correct selector --- app/assets/stylesheets/notifications.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/notifications.css b/app/assets/stylesheets/notifications.css index aa54b14ba..ca7a058f4 100644 --- a/app/assets/stylesheets/notifications.css +++ b/app/assets/stylesheets/notifications.css @@ -67,7 +67,7 @@ /* Help /* ------------------------------------------------------------------------ */ .notifications-help { - h2 { + h3 { font-size: var(--text-medium); margin: 0; } From 1572e70b65ebf3281739612d53b099c32a794c0a Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Fri, 13 Feb 2026 17:45:58 -0600 Subject: [PATCH 88/96] Fix typo --- app/views/account/imports/show.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/account/imports/show.html.erb b/app/views/account/imports/show.html.erb index 44d9228e6..88dd75ebe 100644 --- a/app/views/account/imports/show.html.erb +++ b/app/views/account/imports/show.html.erb @@ -12,7 +12,7 @@
    <% when "completed" %>
    -
    Your import was successfull!
    +
    Your import was successful!
    <%= link_to "Go to your account →", landing_url(script_name: @import.account.slug), class: "btn btn--link" %>
    <% when "failed" %> From b742963f7c743510c2d9aa7eb871bd58a63b09e1 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 13 Feb 2026 18:01:53 +0100 Subject: [PATCH 89/96] Add account ownership check to import GID resolution The convert_gids_to_sgids method resolved GIDs globally and minted valid SGIDs without verifying the record belongs to the importing account. This adds the same account_id check that the export path already has, plus RecordNotFound handling for invalid GIDs. Co-Authored-By: Claude Opus 4.6 --- .../action_text_rich_text_record_set.rb | 13 ++++-- .../action_text_rich_text_record_set_test.rb | 40 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/app/models/account/data_transfer/action_text_rich_text_record_set.rb b/app/models/account/data_transfer/action_text_rich_text_record_set.rb index ec2f4e17f..fa1a23cb9 100644 --- a/app/models/account/data_transfer/action_text_rich_text_record_set.rb +++ b/app/models/account/data_transfer/action_text_rich_text_record_set.rb @@ -84,9 +84,16 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer gid = GlobalID.parse(node["gid"]) if gid - record = gid.find - node["sgid"] = record.attachable_sgid - node.remove_attribute("gid") + record = begin + gid.find + rescue ActiveRecord::RecordNotFound + nil + end + + if record&.account_id == account.id + node["sgid"] = record.attachable_sgid + node.remove_attribute("gid") + end end end diff --git a/test/models/account/data_transfer/action_text_rich_text_record_set_test.rb b/test/models/account/data_transfer/action_text_rich_text_record_set_test.rb index ffbd940f5..14a7aeb9c 100644 --- a/test/models/account/data_transfer/action_text_rich_text_record_set_test.rb +++ b/test/models/account/data_transfer/action_text_rich_text_record_set_test.rb @@ -41,4 +41,44 @@ class Account::DataTransfer::ActionTextRichTextRecordSetTest < ActiveSupport::Te tempfile&.unlink importing_account&.destroy end + + test "convert_gids_to_sgids skips GIDs belonging to another account" do + victim_tag = tags(:web) + attacker_account = accounts(:initech) + assert_not_equal attacker_account.id, victim_tag.account_id + + cross_tenant_gid = victim_tag.to_global_id.to_s + html = %() + + record_set = Account::DataTransfer::ActionTextRichTextRecordSet.new(attacker_account) + result = record_set.send(:convert_gids_to_sgids, html) + + assert_no_match(/sgid=/, result, "Cross-tenant GID must not be converted to SGID") + assert_match(/gid=/, result, "Original GID should remain unconverted") + end + + test "convert_gids_to_sgids converts GIDs belonging to the same account" do + own_tag = tags(:web) + own_account = accounts(:"37s") + assert_equal own_account.id, own_tag.account_id + + same_account_gid = own_tag.to_global_id.to_s + html = %() + + record_set = Account::DataTransfer::ActionTextRichTextRecordSet.new(own_account) + result = record_set.send(:convert_gids_to_sgids, html) + + assert_match(/sgid=/, result, "Same-account GID should be converted to SGID") + assert_no_match(/ gid=/, result, "GID should be removed after SGID conversion") + end + + test "convert_gids_to_sgids handles non-existent record GIDs gracefully" do + nonexistent_gid = "gid://fizzy/Tag/00000000000000000000000000" + html = %() + + record_set = Account::DataTransfer::ActionTextRichTextRecordSet.new(accounts(:"37s")) + result = record_set.send(:convert_gids_to_sgids, html) + + assert_no_match(/sgid=/, result, "Non-existent record should not produce SGID") + end end From 95114b1d67b64f2c1e4fe523b80289429eae37fb Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Mon, 16 Feb 2026 16:37:14 +0100 Subject: [PATCH 90/96] Fix race condition between concurrent Event and Mention notifier jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When concurrent NotifyRecipientsJobs process an Event and a Mention for the same user+card, Rails' dirty tracking can skip writing source_type in the UPDATE if it hasn't changed from the stale in-memory value, leaving source_type and source_id mismatched (e.g. source_type='Event' with a Mention's source_id, resulting in a nil source). Force source_type to always be included in the UPDATE via source_type_will_change! to prevent this. Here's a sample timeline of this race condition happening in the real world (with simplified IDs): Two `NotifyRecipientsJob` involving notification `03fklpu`, same card, same user, same comment — enqueued within 50ms of each other: 1. Both jobs load notification `03fklpu` — it has source_type='Mention' (from a previous job) 2. `EventNotifier` writes at 21:22:36.051: ```sql SET source_type='Event', source_id=, unread_count=1 ``` 3. `source_type` included because it changed ('Mention' → 'Event') 4. `MentionNotifier` writes at 21:22:36.057 (~6ms later): ```sql SET source_id=, unread_count=1 ``` 5. No `source_type`! It was `'Mention'` when loaded and `'Mention'` is what it's setting → not dirty → skipped 6. Final DB state: `source_type='Event'` (from step 2, untouched), `source_id=` (from step 3) `Notification.source` now does `Event.find() → nil.` Co-Authored-By: Claude Opus 4.6 --- app/models/notifier.rb | 6 +++ test/models/notifier/mention_notifier_test.rb | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/app/models/notifier.rb b/app/models/notifier.rb index f00987ab8..f6f356fa4 100644 --- a/app/models/notifier.rb +++ b/app/models/notifier.rb @@ -23,6 +23,12 @@ class Notifier end unless notification.previously_new_record? + # Always include source_type in the update to prevent a race condition between + # concurrent Event and Mention notifier jobs: without this, Rails' dirty tracking + # may skip source_type when it hasn't changed from the stale in-memory value, + # even though another job has since modified it in the database, leaving + # source_type and source_id mismatched. + notification.source_type_will_change! notification.update!(source: source, creator: creator, read_at: nil, unread_count: notification.unread_count + 1) end diff --git a/test/models/notifier/mention_notifier_test.rb b/test/models/notifier/mention_notifier_test.rb index 833f55ec8..2d0b274c0 100644 --- a/test/models/notifier/mention_notifier_test.rb +++ b/test/models/notifier/mention_notifier_test.rb @@ -24,4 +24,47 @@ class Notifier::EventNotifierTest < ActiveSupport::TestCase Notifier.for(events(:layout_commented)).notify end end + + test "updates source_type correctly even when a concurrent job modifies it between load and save" do + # Start with a notification sourced from a Mention for kevin on the layout card + notifications(:layout_commented_kevin).destroy + mention = users(:kevin).mentioned_by(users(:david), at: comments(:layout_overflowing_david)) + notification = Notification.create!( + user: users(:kevin), card: cards(:layout), + source: mention, creator: users(:david), unread_count: 1 + ) + + # Override create_or_find_by to simulate a concurrent EventNotifier updating + # source_type in the database after the notification is loaded but before + # the MentionNotifier's update! runs — reproducing the race condition where + # Rails' dirty tracking skips source_type because it hasn't changed from + # the stale in-memory value. + Notification.class_eval do + class << self + alias_method :original_create_or_find_by, :create_or_find_by + + def create_or_find_by(...) + original_create_or_find_by(...).tap do |record| + unless record.previously_new_record? + where(id: record.id).update_all(source_type: "Event") + end + end + end + end + end + + new_mention = users(:kevin).mentioned_by(users(:jz), at: comments(:layout_overflowing_david)) + Notifier.for(new_mention).notify + + notification.reload + assert_equal "Mention", notification.source_type + assert_equal new_mention, notification.source + ensure + Notification.class_eval do + class << self + alias_method :create_or_find_by, :original_create_or_find_by + remove_method :original_create_or_find_by + end + end + end end From 26c380448b7ffb13031696ce52d5c65b75c13afa Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Mon, 16 Feb 2026 10:03:53 -0600 Subject: [PATCH 91/96] Use the correct apostrophe character --- app/views/account/imports/show.html.erb | 4 ++-- app/views/account/settings/_export.html.erb | 2 +- app/views/boards/new.html.erb | 2 +- app/views/users/_data_export.html.erb | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/views/account/imports/show.html.erb b/app/views/account/imports/show.html.erb index 88dd75ebe..745eb7bae 100644 --- a/app/views/account/imports/show.html.erb +++ b/app/views/account/imports/show.html.erb @@ -19,9 +19,9 @@
    Import failed
    <% if @import.failed_due_to_conflict? %> -
    The account you‘re trying to import already exists. Make sure you‘re importing a <%= Fizzy.saas? ? "self-hosted" : "fizzy.do" %> account.
    +
    The account you’re trying to import already exists. Make sure you’re importing a <%= Fizzy.saas? ? "self-hosted" : "fizzy.do" %> account.
    <% elsif @import.failed_due_to_invalid_export? %> -
    The .zip file you uploaded doesn‘t look like a Fizzy account export.
    +
    The .zip file you uploaded doesn’t look like a Fizzy account export.
    <% else %>
    This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export.
    <% end %> diff --git a/app/views/account/settings/_export.html.erb b/app/views/account/settings/_export.html.erb index ad2d8392a..3608e764f 100644 --- a/app/views/account/settings/_export.html.erb +++ b/app/views/account/settings/_export.html.erb @@ -10,7 +10,7 @@

    Export all account data

    This will generate a ZIP archive of all data in this account including all boards, cards, users, and settings.

    -

    We‘ll email you a link to download the file when it‘s ready. The link will expire after 24 hours.

    +

    We’ll email you a link to download the file when it’s ready. The link will expire after 24 hours.

    <%= button_to "Start export", account_exports_path, method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %> diff --git a/app/views/boards/new.html.erb b/app/views/boards/new.html.erb index 5058d6455..c9518ba78 100644 --- a/app/views/boards/new.html.erb +++ b/app/views/boards/new.html.erb @@ -4,7 +4,7 @@
    <%= bridged_form_with model: @board, class: "flex flex-column gap", data: { controller: "form", action: "submit->form#preventEmptySubmit" } do |form| %>

    <%= @page_title %>

    - <%= form.text_field :name, required: true, class: "input full-width", autofocus: true, autocomplete: "off", placeholder: "Name it…", data: { form_target: "input", action: "keydown.esc@document->form#cancel", validation_message: "Board names can‘t be blank" } %> + <%= form.text_field :name, required: true, class: "input full-width", autofocus: true, autocomplete: "off", placeholder: "Name it…", data: { form_target: "input", action: "keydown.esc@document->form#cancel", validation_message: "Board names can’t be blank" } %>