From 4f9cfa94b7d5fa43242ba970982e1c0a4d225725 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Fri, 6 Feb 2026 12:30:28 +0100 Subject: [PATCH 01/33] 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 8f95a6f228f8b1dc130f1adab6829c3292fd5770 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Fri, 6 Feb 2026 17:32:55 +0100 Subject: [PATCH 02/33] 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 03/33] 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 2bfd54437ce834f92d9108d02e52c5019c47490d Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Mon, 9 Feb 2026 09:31:50 +0100 Subject: [PATCH 04/33] 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 5785e2a5c1d3eaf2d6e73b54604c55d1e7a7d83e Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Tue, 10 Feb 2026 17:22:20 +0100 Subject: [PATCH 05/33] Adjust space between title, description and steps --- app/assets/stylesheets/card-perma.css | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index b3d37d68a..c5afd757d 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -68,7 +68,7 @@ @media (max-width: 639px) { flex-direction: column; - padding-block-end: calc(var(--card-padding-block) * 1.5); + padding-block: var(--card-padding-block) calc(var(--card-padding-block) * 1.5); } } @@ -90,12 +90,24 @@ } &:has(textarea) { - margin-block-end: 0; + @media (min-width: 640px) { + margin-block-end: 0; + } @supports not (field-sizing: content) { text-wrap: unset; /* Safari is annoying if you have text-wrap: balance in textareas */ } } + + @media (max-width: 639px) { + margin-block-end: 0.75ch; + } + } + + .card__description { + @media (max-width: 639px) { + margin-block-end: 1ch; + } } .card__meta, From c992ac1be555e5759741a552cb5f6dfbcc68640b Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Tue, 10 Feb 2026 17:22:31 +0100 Subject: [PATCH 06/33] Extend background color --- app/assets/stylesheets/native.css | 6 ++++++ app/views/cards/drafts/_container.html.erb | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/native.css b/app/assets/stylesheets/native.css index c67d05d14..34259e9c6 100644 --- a/app/assets/stylesheets/native.css +++ b/app/assets/stylesheets/native.css @@ -66,6 +66,12 @@ translate: unset; } + .card-perma--draft { + .card-perma__bg { + box-shadow: 0 calc(100vh + 100px) 0 100vh var(--color-container); + } + } + /* Search /* ------------------------------------------------------------------------ */ diff --git a/app/views/cards/drafts/_container.html.erb b/app/views/cards/drafts/_container.html.erb index 409621b21..fa7134b55 100644 --- a/app/views/cards/drafts/_container.html.erb +++ b/app/views/cards/drafts/_container.html.erb @@ -1,4 +1,4 @@ -
+
<% cache card do %>
<%= render "cards/container/image", card: card %> From c84d783b2a79531f75f63294aebf124c711c527a Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 12 Feb 2026 08:07:51 +0100 Subject: [PATCH 07/33] 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 08/33] 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 09/33] 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 10/33] 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 11/33] 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 12/33] 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 13/33] 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 14/33] 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 15/33] 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 16/33] 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 17/33] 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 18/33] 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 19/33] 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 22276a25c82bb8202381681876584de607171acd Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 12 Feb 2026 11:36:35 +0100 Subject: [PATCH 20/33] 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 21/33] 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 22/33] 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 1506624cd3de856415dae1b449507e7492daf48a Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 12 Feb 2026 15:35:26 +0000 Subject: [PATCH 23/33] 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 851f13a93439ea75dfa40766934ae744a7365498 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 12 Feb 2026 12:07:40 -0500 Subject: [PATCH 24/33] 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 33/33] 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 %>