diff --git a/Gemfile b/Gemfile index 6d0c8caebb8..d02b44f1a99 100644 --- a/Gemfile +++ b/Gemfile @@ -428,6 +428,6 @@ gemfiles.each do |file| send(:eval_gemfile, file) if File.readable?(file) end -gem "openproject-octicons", "~>19.34.0" -gem "openproject-octicons_helper", "~>19.34.0" +gem "openproject-octicons", "~>19.35.0" +gem "openproject-octicons_helper", "~>19.35.0" gem "openproject-primer_view_components", "~>0.85.0" diff --git a/Gemfile.lock b/Gemfile.lock index 1f49b6858bd..79772f69dd6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -904,10 +904,10 @@ GEM validate_email validate_url webfinger (~> 2.0) - openproject-octicons (19.34.0) - openproject-octicons_helper (19.34.0) + openproject-octicons (19.35.0) + openproject-octicons_helper (19.35.0) actionview - openproject-octicons (= 19.34.0) + openproject-octicons (= 19.35.0) railties openproject-primer_view_components (0.85.0) actionview (>= 7.2.0) @@ -1685,8 +1685,8 @@ DEPENDENCIES openproject-job_status! openproject-ldap_groups! openproject-meeting! - openproject-octicons (~> 19.34.0) - openproject-octicons_helper (~> 19.34.0) + openproject-octicons (~> 19.35.0) + openproject-octicons_helper (~> 19.35.0) openproject-openid_connect! openproject-primer_view_components (~> 0.85.0) openproject-recaptcha! @@ -2066,8 +2066,8 @@ CHECKSUMS openproject-job_status (1.0.0) openproject-ldap_groups (1.0.0) openproject-meeting (1.0.0) - openproject-octicons (19.34.0) sha256=4efe8a58a2d8051b79c94b37e9a7f04fd242a4da12b50f027c3c7f441a042adc - openproject-octicons_helper (19.34.0) sha256=12eb7af2214e21631369c76464ebaa30de788e1074c4b3bd0fcef7e74cb9edb4 + openproject-octicons (19.35.0) sha256=a5033550d0961b4a8cb0993512a899716d633e17c2b5147bc6a9ed74f3952b38 + openproject-octicons_helper (19.35.0) sha256=c32d142a4bb7fda739b16768aa8846fd88ffc1750509d8056f516056e8767361 openproject-openid_connect (1.0.0) openproject-primer_view_components (0.85.0) sha256=16bc8358ef600f0465488a2e3c86991a9c69ed84580bd450c2dbec6f268eeaca openproject-recaptcha (1.0.0) diff --git a/app/components/open_project/common/border_box_list_component.rb b/app/components/open_project/common/border_box_list_component.rb index 777263a6da1..d317402cf6f 100644 --- a/app/components/open_project/common/border_box_list_component.rb +++ b/app/components/open_project/common/border_box_list_component.rb @@ -37,8 +37,14 @@ module OpenProject # header actions, collapsible behavior, and row rendering. class BorderBoxListComponent < ApplicationComponent include OpPrimer::ComponentHelpers + include Primer::FetchOrFallbackHelper - attr_reader :container, :collapsible, :current_user, :header_id, :footer_id + SCHEME_DEFAULT = :default + SCHEME_OPTIONS = [SCHEME_DEFAULT, :transparent].freeze + HEADER_PADDING_DEFAULT = :inherit + HEADER_PADDING_OPTIONS = [HEADER_PADDING_DEFAULT, :condensed, :default, :spacious].freeze + + attr_reader :container, :scheme, :header_padding, :collapsible, :current_user, :header_id, :footer_id alias_method :collapsible?, :collapsible @@ -163,6 +169,13 @@ module OpenProject # @param container [String, Symbol, Class, Object] value passed to # `dom_target` to derive DOM ids for the list and related controls. + # @param scheme [Symbol] visual scheme. `:default` renders the standard + # BorderBox header. `:transparent` renders a transparent header with no + # separator line. + # @param header_padding [Symbol] optional vertical padding override for + # the header. `:inherit` keeps Primer's padding from the underlying + # BorderBox. `:condensed`, `:default`, and `:spacious` override only + # the header's block padding. # @param interactive [Boolean] whether dynamic list updates should be # announced politely to assistive technology. This affects the counter # and an explicitly configured empty state; it does not create default @@ -172,8 +185,10 @@ module OpenProject # @param current_user [User] user context passed to work-package items. # @param system_arguments [Hash] forwarded to `Primer::Beta::BorderBox`. # Pass `id:` to set the box id; related ids are derived from it. - def initialize( + def initialize( # rubocop:disable Metrics/AbcSize container:, + scheme: SCHEME_DEFAULT, + header_padding: HEADER_PADDING_DEFAULT, interactive: false, collapsible: false, current_user: User.current, @@ -182,6 +197,12 @@ module OpenProject super() @container = container + @scheme = ActiveSupport::StringInquirer.new( + fetch_or_fallback(SCHEME_OPTIONS, scheme, SCHEME_DEFAULT).to_s + ) + @header_padding = ActiveSupport::StringInquirer.new( + fetch_or_fallback(HEADER_PADDING_OPTIONS, header_padding, HEADER_PADDING_DEFAULT).to_s + ) @interactive = interactive @collapsible = collapsible @current_user = current_user @@ -189,6 +210,15 @@ module OpenProject @system_arguments[:id] ||= dom_target(container) @system_arguments[:list_id] = dom_target(@system_arguments[:id], :list) + @system_arguments[:classes] = class_names( + @system_arguments[:classes], + "op-border-box-list", + "op-border-box-list_transparent" => @scheme.transparent?, + "op-border-box-list_header-padding-condensed" => @header_padding.condensed?, + "op-border-box-list_header-padding-default" => @header_padding.default?, + "op-border-box-list_header-padding-spacious" => @header_padding.spacious? + ) + @header_id = dom_target(@system_arguments[:id], :header) @footer_id = dom_target(@system_arguments[:id], :footer) end diff --git a/app/components/open_project/common/border_box_list_component.sass b/app/components/open_project/common/border_box_list_component.sass index 11a8520a032..cfb632efb78 100644 --- a/app/components/open_project/common/border_box_list_component.sass +++ b/app/components/open_project/common/border_box_list_component.sass @@ -8,11 +8,69 @@ // See COPYRIGHT and LICENSE files for more details. //++ +.op-border-box-list_transparent + > .Box-header + background-color: transparent + border-bottom: none + padding-bottom: var(--stack-padding-condensed) + + > ul > .Box-row:first-of-type + border-top: none + +.op-border-box-list + --op-border-box-list-header-row-gap: calc(var(--stack-gap-condensed) / 2) + + &.Box--condensed + --op-border-box-list-header-row-gap: 0 + + &.Box--spacious + --op-border-box-list-header-row-gap: var(--stack-gap-condensed) + + &.op-border-box-list_header-padding-condensed + --op-border-box-list-header-row-gap: 0 + + > .Box-header + padding-block: var(--stack-padding-condensed) + + &.op-border-box-list_header-padding-default + --op-border-box-list-header-row-gap: calc(var(--stack-gap-condensed) / 2) + + > .Box-header + padding-block: var(--stack-padding-normal) + + &.op-border-box-list_header-padding-spacious + --op-border-box-list-header-row-gap: var(--stack-gap-condensed) + + > .Box-header + padding-block: var(--stack-padding-spacious) + + .Box-title + font-size: var(--text-title-size-medium) + + &.op-border-box-list_transparent + &.op-border-box-list_header-padding-default, + &.op-border-box-list_header-padding-spacious + > .Box-header + padding-bottom: var(--stack-padding-condensed) + .op-border-box-list-header display: grid - grid-template-columns: 1fr minmax(5rem, max-content) auto - grid-template-areas: "collapsible actions menu" + grid-template-columns: minmax(0, 1fr) minmax(5rem, max-content) auto + grid-template-areas: "heading actions menu" "description description description" align-items: center + row-gap: var(--op-border-box-list-header-row-gap) + + &_collapsible + grid-template-areas: "heading actions menu" + + &--heading + min-width: 0 + + &--description + display: flex + align-items: center + gap: var(--stack-gap-normal) + color: var(--fgColor-muted) &--actions, &--menu @@ -20,3 +78,11 @@ align-self: flex-start // Unfortunately, the invisible button style bites us here again. margin-top: -6px + + &--heading-line + display: flex + align-items: center + gap: var(--stack-gap-condensed) + + .Box-title + min-width: 0 diff --git a/app/components/open_project/common/border_box_list_component/header.html.erb b/app/components/open_project/common/border_box_list_component/header.html.erb index 3ef158da2d2..8247c66bfbb 100644 --- a/app/components/open_project/common/border_box_list_component/header.html.erb +++ b/app/components/open_project/common/border_box_list_component/header.html.erb @@ -27,56 +27,61 @@ See COPYRIGHT and LICENSE files for more details. ++# %> -<%= grid_layout("op-border-box-list-header", tag: :div) do |grid| %> - <% grid.with_area(:collapsible) do %> - <% if collapsible? %> - <%= - render( - Primer::OpenProject::BorderBox::CollapsibleHeader.new( - collapsible_id:, - collapsed:, - multi_line: true - ) - ) do |collapsible| - %> - <% collapsible.with_title(tag: title_tag) { title } %> - <% if render_count? %> - <% collapsible.with_count(**counter_arguments) %> - <% end %> - <% if description? %> - <% collapsible.with_description do %> - <%= description %> - <% end %> - <% end %> +<%= grid_layout( + "op-border-box-list-header", + tag: :div, + classes: { "op-border-box-list-header_collapsible" => collapsible? } + ) do |grid| %> + <% if collapsible? %> + <% grid.with_area(:heading) do %> + <%= + render( + Primer::OpenProject::BorderBox::CollapsibleHeader.new( + collapsible_id:, + collapsed:, + multi_line: true + ) + ) do |collapsible| + %> + <% collapsible.with_title(tag: title_tag) { title } %> + <% if render_count? %> + <% collapsible.with_count(**counter_arguments) %> <% end %> - <% else %> - <%= render(Primer::BaseComponent.new(tag: :div, classes: "CollapsibleHeader CollapsibleHeader--multi-line")) do %> - <%= render(Primer::BaseComponent.new(tag: :div, classes: "CollapsibleHeader-title-line")) do %> - <%= render(Primer::Beta::Truncate.new(tag: title_tag, classes: "CollapsibleHeader-title Box-title")) { title } %> - <% if render_count? %> - <%= render(Primer::Beta::Counter.new(**counter_arguments, classes: class_names(counter_arguments[:classes], "CollapsibleHeader-count"))) %> - <% end %> - <% end %> - <% if description? %> - <%= render(Primer::Beta::Text.new(color: :subtle, trim: true, classes: "CollapsibleHeader-description")) do %> - <%= description %> - <% end %> + <% if description? %> + <% collapsible.with_description do %> + <%= description %> <% end %> <% end %> <% end %> <% end %> + <% else %> + <% grid.with_area(:heading) do %> + <%= render(Primer::BaseComponent.new(tag: :div, classes: "op-border-box-list-header--heading-line")) do %> + <%= render(Primer::Beta::Truncate.new(tag: title_tag, classes: "Box-title")) { title } %> + <% if render_count? %> + <%= render(Primer::Beta::Counter.new(**counter_arguments)) %> + <% end %> + <% end %> + <% end %> + <% end %> - <% if actions? %> - <% grid.with_area(:actions) do %> - <% actions.each do |action| %> - <%= action %> - <% end %> - <% end %> + <% if description? && !collapsible? %> + <% grid.with_area(:description) do %> + <%= description %> <% end %> + <% end %> - <% if menu? %> - <% grid.with_area(:menu) do %> - <%= menu %> + <% if actions? %> + <% grid.with_area(:actions) do %> + <% actions.each do |action| %> + <%= action %> <% end %> <% end %> + <% end %> + + <% if menu? %> + <% grid.with_area(:menu) do %> + <%= menu %> + <% end %> + <% end %> <% end %> diff --git a/app/controllers/admin/import/jira/instances_controller.rb b/app/controllers/admin/import/jira/instances_controller.rb index d74bc28de4d..f957c881a7d 100644 --- a/app/controllers/admin/import/jira/instances_controller.rb +++ b/app/controllers/admin/import/jira/instances_controller.rb @@ -137,7 +137,7 @@ module Admin::Import::Jira when Import::JiraClient::ApiError then t(:"admin.jira.test.api_error", status: error.status) else Rails.logger.error("Unexpected error testing Jira configuration: #{error.class} - #{error.message}") - t(:"admin.jira.test.error") + "#{t(:"admin.jira.test.error")}: #{error.message}" end render_error_flash_message_via_turbo_stream(message:) end diff --git a/app/services/import/jira_client.rb b/app/services/import/jira_client.rb index e348c0f5b9a..fe5322dafab 100644 --- a/app/services/import/jira_client.rb +++ b/app/services/import/jira_client.rb @@ -268,6 +268,8 @@ module Import raise SsrfError, I18n.t("admin.jira.client.ssrf_blocked") rescue SsrfFilter::Error => e raise ConnectionError, I18n.t("admin.jira.client.connection_error", message: e.message) + rescue OpenSSL::SSL::SSLError => e + raise ConnectionError, I18n.t("admin.jira.client.ssl_error", message: e.message) rescue Timeout::Error => e raise ConnectionError, I18n.t("admin.jira.client.connection_timeout", message: e.message) ensure @@ -292,6 +294,8 @@ module Import raise SsrfError, I18n.t("admin.jira.client.ssrf_blocked") rescue SsrfFilter::Error, SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e raise ConnectionError, I18n.t("admin.jira.client.connection_error", message: e.message) + rescue OpenSSL::SSL::SSLError => e + raise ConnectionError, I18n.t("admin.jira.client.ssl_error", message: e.message) rescue Timeout::Error => e raise ConnectionError, I18n.t("admin.jira.client.connection_timeout", message: e.message) end diff --git a/config/locales/en.yml b/config/locales/en.yml index e521b65a8c8..a555fd904c5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -168,6 +168,7 @@ en: runs on an internal network, allow its IP via the OPENPROJECT_SSRF__PROTECTION__IP__ALLOWLIST environment variable. ssrf_block_doc_link: "Please see our [documentation](docs_url)." + ssl_error: "SSL error connecting to Jira server: %{message}" parse_error: "Failed to parse Jira API response: %{message}" api_error: "Jira API returned error status %{status}" 401_error: "Jira API returned a 401 error. Your authentication token may have expired or lack the required permissions. Please ensure the token belongs to a Jira administrator." diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1cf4c73c8a5..840c1228c2c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -56,7 +56,7 @@ "@ng-select/ng-option-highlight": "^21.8.2", "@ng-select/ng-select": "^21.8.0", "@ngneat/content-loader": "^7.0.0", - "@openproject/octicons-angular": "^19.34.0", + "@openproject/octicons-angular": "^19.35.0", "@openproject/primer-view-components": "^0.85.0", "@openproject/reactivestates": "^3.0.1", "@primer/css": "^22.1.0", @@ -7682,9 +7682,9 @@ "license": "BSD-3-Clause" }, "node_modules/@openproject/octicons-angular": { - "version": "19.34.0", - "resolved": "https://registry.npmjs.org/@openproject/octicons-angular/-/octicons-angular-19.34.0.tgz", - "integrity": "sha512-RsTK48htb8zwb1C4M3quhZG6uGFWYPICR2rO9jckCpww4MgWQZKfFrSCH8r43+uOczjYorwktzn7CIJywGW9Rg==", + "version": "19.35.0", + "resolved": "https://registry.npmjs.org/@openproject/octicons-angular/-/octicons-angular-19.35.0.tgz", + "integrity": "sha512-oN6bkZeOcrWUAJtfuXsMHmHWpuJMxIt1gvToJpsDgOXFY9Wj1DVO2Di/hMYgG/8k+xv2UZ3kAgks43ENImgLmw==", "dependencies": { "tslib": "^2.3.0" }, @@ -31149,9 +31149,9 @@ "integrity": "sha512-iFrvar5SOMtKFOSjYvs4z9UlLqDdJbMx0mgISLcPedv+g0ac5sgeETLGtipHCVIae6HJPclNEH5aCyD1RZaEHw==" }, "@openproject/octicons-angular": { - "version": "19.34.0", - "resolved": "https://registry.npmjs.org/@openproject/octicons-angular/-/octicons-angular-19.34.0.tgz", - "integrity": "sha512-RsTK48htb8zwb1C4M3quhZG6uGFWYPICR2rO9jckCpww4MgWQZKfFrSCH8r43+uOczjYorwktzn7CIJywGW9Rg==", + "version": "19.35.0", + "resolved": "https://registry.npmjs.org/@openproject/octicons-angular/-/octicons-angular-19.35.0.tgz", + "integrity": "sha512-oN6bkZeOcrWUAJtfuXsMHmHWpuJMxIt1gvToJpsDgOXFY9Wj1DVO2Di/hMYgG/8k+xv2UZ3kAgks43ENImgLmw==", "requires": { "tslib": "^2.3.0" } diff --git a/frontend/package.json b/frontend/package.json index fb87931e3c7..0fb47957c10 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -107,7 +107,7 @@ "@ng-select/ng-option-highlight": "^21.8.2", "@ng-select/ng-select": "^21.8.0", "@ngneat/content-loader": "^7.0.0", - "@openproject/octicons-angular": "^19.34.0", + "@openproject/octicons-angular": "^19.35.0", "@openproject/primer-view-components": "^0.85.0", "@openproject/reactivestates": "^3.0.1", "@primer/css": "^22.1.0", diff --git a/lib/open_project/text_formatting/filters/pattern_matcher_filter.rb b/lib/open_project/text_formatting/filters/pattern_matcher_filter.rb index 98096983d48..798601fcafa 100644 --- a/lib/open_project/text_formatting/filters/pattern_matcher_filter.rb +++ b/lib/open_project/text_formatting/filters/pattern_matcher_filter.rb @@ -31,9 +31,6 @@ module OpenProject::TextFormatting module Filters class PatternMatcherFilter < HTML::Pipeline::Filter - # Skip text nodes that are within preformatted blocks - PREFORMATTED_BLOCKS = %w(pre code).to_set - class << self def append_matcher(matcher) matchers << matcher @@ -45,15 +42,35 @@ module OpenProject::TextFormatting end def call + with_matcher_preloads(self.class.matchers.dup) { process_text_nodes } + doc + end + + private + + # Wraps the per-node loop in each matcher's `with_preloaded_resources` + # hook so matchers can warm per-render caches and save/restore them + # around nested `format_text` calls. Opt-in: matchers without the hook + # are skipped. + def with_matcher_preloads(matchers, &) + matcher = matchers.shift + return yield if matcher.nil? + + if matcher.respond_to?(:with_preloaded_resources) + matcher.with_preloaded_resources(doc, context) { with_matcher_preloads(matchers, &) } + else + with_matcher_preloads(matchers, &) + end + end + + def process_text_nodes doc.search(".//text()").each do |node| - next if has_ancestor?(node, PREFORMATTED_BLOCKS) + next if has_ancestor?(node, OpenProject::TextFormatting::PreformattedBlocks::BLOCKS) self.class.matchers.each do |matcher| matcher.call(node, doc:, context:) end end - - doc end end end diff --git a/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb b/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb index f975af718a7..c495ee278f5 100644 --- a/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb +++ b/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb @@ -79,8 +79,8 @@ module OpenProject::TextFormatting::Matchers render_work_package_macro(display_id, detailed: detailed?) else # Plain link needs the WP record for the `formatted_id` label and - # hover-card URL. Unresolved → literal text rather than a broken URL. - wp = WorkPackage.find_by_display_id(display_id) + # hover-card URL. Cache miss → literal text rather than a broken URL. + wp = OpenProject::TextFormatting::Matchers::ResourceLinksMatcher.work_package_for(display_id) return nil unless wp render_work_package_link(wp, fallback_id: display_id) @@ -88,7 +88,7 @@ module OpenProject::TextFormatting::Matchers end def render_for_numeric(wp_id) - wp = WorkPackage.find_by_display_id(wp_id) + wp = OpenProject::TextFormatting::Matchers::ResourceLinksMatcher.work_package_for(wp_id) if quickinfo? # Prefer the resolved WP's display_id so `##1234` typed in semantic diff --git a/lib/open_project/text_formatting/matchers/resource_links_matcher.rb b/lib/open_project/text_formatting/matchers/resource_links_matcher.rb index 1e3b02a49a2..b46708f49a5 100644 --- a/lib/open_project/text_formatting/matchers/resource_links_matcher.rb +++ b/lib/open_project/text_formatting/matchers/resource_links_matcher.rb @@ -69,6 +69,9 @@ module OpenProject::TextFormatting # identifier:version:1.0.0 # identifier:source:some/file class ResourceLinksMatcher < RegexMatcher + WORK_PACKAGES_LOOKUP_KEY = :text_formatting_work_packages_lookup + private_constant :WORK_PACKAGES_LOOKUP_KEY + include ::OpenProject::TextFormatting::Truncation # used for the work package quick links include WorkPackagesHelper @@ -127,6 +130,90 @@ module OpenProject::TextFormatting ] end + # Returns the preloaded WorkPackage for the given identifier (numeric + # or semantic), or nil if no preload is active (classic mode, no `#N` + # references) or the WP couldn't be resolved. + def self.work_package_for(identifier) + RequestStore.store[WORK_PACKAGES_LOOKUP_KEY]&.[](identifier.to_s) + end + + # Doc-level preload called by `PatternMatcherFilter`. Save/restores + # the lookup so a nested `format_text` (e.g. custom-field formatter + # re-entering the pipeline) doesn't clobber the outer render. Classic + # mode skips the load — `display_id` collapses to numeric, so the + # link handler can render from the matched id alone. + def self.with_preloaded_resources(doc, _context) + previous = RequestStore.store[WORK_PACKAGES_LOOKUP_KEY] + + return yield unless Setting::WorkPackageIdentifier.semantic_mode_active? + + identifiers = collect_work_package_identifiers(doc) + return yield if identifiers.empty? + + RequestStore.store[WORK_PACKAGES_LOOKUP_KEY] = build_lookup(identifiers) + yield + ensure + RequestStore.store[WORK_PACKAGES_LOOKUP_KEY] = previous + end + + def self.collect_work_package_identifiers(doc) + identifiers = Set.new + doc.search(".//text()").each do |node| + next if OpenProject::TextFormatting::PreformattedBlocks.ancestor?(node) + + node.to_s.scan(regexp) do + extract_work_package_identifier(Regexp.last_match)&.then { identifiers << it } + end + end + identifiers + end + + # Returns the WP identifier for any `#N` / `##N` / `###N` (or + # semantic-shape) reference. Returns nil for prefixed resource links + # (`version#3`, `message#12`) and `:`-separator resources. Leading-zero + # numerics ("0123") pass through here — the link handler rejects them + # at render time, so a non-resolving cache entry is harmless. + def self.extract_work_package_identifier(match) + parts = parse_match(match) + identifier = parts[:identifier] + return nil unless parts[:prefix].nil? && parts[:sep]&.start_with?("#") && identifier.present? + + identifier + end + + # 1 SELECT in the common case. A second targeted SELECT fires for + # historical aliases — the loaded WP row carries only its current + # identifier, so unmapped inputs must be filled in from + # `WorkPackageSemanticAlias`. The visible-id set passes through + # to the alias fold-in explicitly so each query enforces + # visibility at its own boundary, leaving no implicit trust for + # the cache to leak through. + def self.build_lookup(identifiers) + work_packages = WorkPackage.visible.where_display_id_in(*identifiers).select(:id, :identifier).to_a + lookup = index_by_id_and_identifier(work_packages) + fold_in_alias_keys(lookup, identifiers, visible_wp_ids: work_packages.map(&:id)) + lookup + end + + def self.index_by_id_and_identifier(work_packages) + work_packages.each_with_object({}) do |wp, lookup| + lookup[wp.id.to_s] = wp + lookup[wp.identifier] = wp if wp.identifier.present? + end + end + private_class_method :index_by_id_and_identifier + + def self.fold_in_alias_keys(lookup, identifiers, visible_wp_ids:) + unmapped = identifiers.map(&:to_s) - lookup.keys + return if unmapped.empty? || visible_wp_ids.empty? + + WorkPackageSemanticAlias + .where(work_package_id: visible_wp_ids, identifier: unmapped) + .pluck(:identifier, :work_package_id) + .each { |ident, wp_id| lookup[ident] = lookup[wp_id.to_s] } + end + private_class_method :fold_in_alias_keys + # Flattens the three alternation branches into a single `:sep` / # `:identifier` shape so callers don't branch on which one matched. def self.parse_match(match) diff --git a/lib/open_project/text_formatting/preformatted_blocks.rb b/lib/open_project/text_formatting/preformatted_blocks.rb new file mode 100644 index 00000000000..a9a674dc986 --- /dev/null +++ b/lib/open_project/text_formatting/preformatted_blocks.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module TextFormatting + # `
`/`` ancestry skip. Filters call
+    # `has_ancestor?(node, BLOCKS)` via HTML::Pipeline's instance helper;
+    # matchers without that helper call `ancestor?(node)` here.
+    module PreformattedBlocks
+      BLOCKS = %w[pre code].to_set.freeze
+
+      module_function
+
+      def ancestor?(node)
+        ancestor = node.parent
+        until ancestor.nil? || ancestor.fragment? || ancestor.document?
+          return true if BLOCKS.include?(ancestor.name)
+
+          ancestor = ancestor.parent
+        end
+        false
+      end
+    end
+  end
+end
diff --git a/lookbook/previews/open_project/common/border_box_list_component_preview.rb b/lookbook/previews/open_project/common/border_box_list_component_preview.rb
index 4a406b87055..a8cdee75ff4 100644
--- a/lookbook/previews/open_project/common/border_box_list_component_preview.rb
+++ b/lookbook/previews/open_project/common/border_box_list_component_preview.rb
@@ -32,17 +32,33 @@ module OpenProject
   module Common
     # @logical_path OpenProject/Common
     class BorderBoxListComponentPreview < ViewComponent::Preview
+      DEFAULT_DESCRIPTION = "Coordinate launch work and keep stakeholders aligned."
+      TRANSPARENT_DESCRIPTION = "Sprint goals, scope, and timing for the next iteration."
+      PLAYGROUND_DESCRIPTION =
+        "Preview a longer header description to check wrapping, spacing, and alignment with actions in this list."
+
       # @label Default
+      # @param padding [Symbol] select [default, condensed, spacious]
+      # @param header_padding [Symbol] select [inherit, condensed, default, spacious]
+      # @param description text
       # @param interactive toggle
       # @param collapsible toggle
-      def default(interactive: false, collapsible: false)
+      def default(
+        padding: :default,
+        header_padding: :inherit,
+        description: DEFAULT_DESCRIPTION,
+        interactive: false,
+        collapsible: false
+      )
         render OpenProject::Common::BorderBoxListComponent.new(
           container: "border-box-list-preview",
+          padding:,
+          header_padding:,
           interactive: boolean_preview_param(interactive),
           collapsible: boolean_preview_param(collapsible)
         ) do |list|
           list.with_header(title: "Things we're building", count: true) do |header|
-            header.with_description { "There's lots to look forward to" }
+            header.with_description_content(description)
             header.with_action_button do |button|
               button.with_leading_visual_icon(icon: :pencil)
               "Edit"
@@ -61,15 +77,64 @@ module OpenProject
         end
       end
 
+      # @label Transparent scheme
+      # @param padding [Symbol] select [default, condensed, spacious]
+      # @param header_padding [Symbol] select [inherit, condensed, default, spacious]
+      # @param description text
+      # @param interactive toggle
+      # @param collapsible [Boolean] toggle
+      def transparent(
+        padding: :default,
+        header_padding: :inherit,
+        description: TRANSPARENT_DESCRIPTION,
+        interactive: false,
+        collapsible: false
+      )
+        render OpenProject::Common::BorderBoxListComponent.new(
+          container: "border-box-list-transparent-preview",
+          scheme: :transparent,
+          padding:,
+          header_padding:,
+          interactive: boolean_preview_param(interactive),
+          collapsible: boolean_preview_param(collapsible)
+        ) do |list|
+          list.with_header(title: "Sprint backlog", count: true) do |header|
+            header.with_description_content(description)
+            header.with_action_button do |button|
+              button.with_leading_visual_icon(icon: :rocket)
+              "Start sprint"
+            end
+            header.with_menu(button_aria_label: "Sprint actions") do |menu|
+              menu.with_item(label: "Edit sprint") do |menu_item|
+                menu_item.with_leading_visual_icon(icon: :pencil)
+              end
+            end
+          end
+
+          list.with_item { "User authentication stories" }
+          list.with_item { "Dashboard improvements" }
+          list.with_item { "API documentation" }
+        end
+      end
+
       # @label With work package items
+      # @param padding [Symbol] select [default, condensed, spacious]
+      # @param header_padding [Symbol] select [inherit, condensed, default, spacious]
       # @param interactive toggle
       # @param collapsible toggle
-      def with_work_package_items(interactive: false, collapsible: false)
+      def with_work_package_items(
+        padding: :default,
+        header_padding: :inherit,
+        interactive: false,
+        collapsible: false
+      )
         work_packages = WorkPackage.includes(:project).limit(2).to_a
         return preview_message("No work packages in the database.") if work_packages.empty?
 
         render OpenProject::Common::BorderBoxListComponent.new(
           container: "border-box-list-work-package-preview",
+          padding:,
+          header_padding:,
           interactive: boolean_preview_param(interactive),
           collapsible: boolean_preview_param(collapsible)
         ) do |list|
@@ -79,22 +144,30 @@ module OpenProject
       end
 
       # @label Playground
-      # @param collapsible toggle
       # @param title_tag [Symbol] select [h2, h3, h4, h5]
       # @param count [Symbol] select [inferred, hidden, explicit, zero]
       # @param count_scheme [Symbol] select [primary, secondary]
       # @param hide_zero_count toggle
+      # @param padding [Symbol] select [default, condensed, spacious]
+      # @param header_padding [Symbol] select [inherit, condensed, default, spacious]
+      # @param description text
       # @param interactive toggle
+      # @param collapsible toggle
       def playground(
-        collapsible: false,
         title_tag: :h4,
         count: :inferred,
         count_scheme: :primary,
         hide_zero_count: true,
-        interactive: false
+        padding: :default,
+        header_padding: :inherit,
+        description: PLAYGROUND_DESCRIPTION,
+        interactive: false,
+        collapsible: false
       )
         render OpenProject::Common::BorderBoxListComponent.new(
           container: "border-box-list-playground-preview",
+          padding:,
+          header_padding:,
           interactive: boolean_preview_param(interactive),
           collapsible: boolean_preview_param(collapsible)
         ) do |list|
@@ -108,7 +181,7 @@ module OpenProject
               aria: { label: "Visible list item count" }
             }
           ) do |header|
-            header.with_description { "Advanced header options" }
+            header.with_description_content(description)
           end
 
           list.with_item { "First item" }
@@ -119,11 +192,15 @@ module OpenProject
 
       # @label Empty state
       # List with a header and an empty state (Blankslate), no items.
+      # @param padding [Symbol] select [default, condensed, spacious]
+      # @param header_padding [Symbol] select [inherit, condensed, default, spacious]
       # @param interactive toggle
       # @param collapsible toggle
-      def empty_state(interactive: false, collapsible: false)
+      def empty_state(padding: :default, header_padding: :inherit, interactive: false, collapsible: false)
         render OpenProject::Common::BorderBoxListComponent.new(
           container: "border-box-list-empty-preview",
+          padding:,
+          header_padding:,
           interactive: boolean_preview_param(interactive),
           collapsible: boolean_preview_param(collapsible)
         ) do |list|
diff --git a/modules/backlogs/app/components/backlogs/inbox_component.html.erb b/modules/backlogs/app/components/backlogs/inbox_component.html.erb
index e2f05287ec0..67d0793c704 100644
--- a/modules/backlogs/app/components/backlogs/inbox_component.html.erb
+++ b/modules/backlogs/app/components/backlogs/inbox_component.html.erb
@@ -34,7 +34,9 @@ See COPYRIGHT and LICENSE files for more details.
           container: inbox_container,
           current_user:,
           interactive: true,
+          scheme: :transparent,
           padding: :condensed,
+          header_padding: :spacious,
           test_selector: "backlog-inbox",
           data: {
             generic_drag_and_drop_target: "container",
@@ -44,6 +46,8 @@ See COPYRIGHT and LICENSE files for more details.
           }
         )
       ) do |list| %>
+    <% list.with_header(title: t(".title"), count: work_packages.size) %>
+
     <% list.with_empty_state(
          title: t(".blankslate_title"),
          description: t(".blankslate_description"),
diff --git a/modules/backlogs/app/components/backlogs/sprint_component.html.erb b/modules/backlogs/app/components/backlogs/sprint_component.html.erb
index c798db47e12..a962624e49b 100644
--- a/modules/backlogs/app/components/backlogs/sprint_component.html.erb
+++ b/modules/backlogs/app/components/backlogs/sprint_component.html.erb
@@ -44,17 +44,14 @@ See COPYRIGHT and LICENSE files for more details.
       ) do |list| %>
     <% list.with_header(title: sprint.name) do |header| %>
       <% header.with_description do %>
-        <%= render(
-              Primer::Beta::Text.new(
-                color: :subtle, mr: 3, classes: "velocity",
-                aria: { live: "polite" }
-              )
-            ) do %>
+        <%= render(Backlogs::SprintStatusBadgeComponent.new(sprint:)) %>
+
+        <%= render(Primer::Beta::Text.new(classes: "velocity", aria: { live: "polite" })) do %>
           <%= story_points_total %> <%= t(:"backlogs.points_label", count: story_points_total) %>
         <% end %>
 
         <% if sprint.date_range_set? %>
-          <%= render(Primer::Beta::Text.new(color: :subtle, role: :group)) do %>
+          <%= render(Primer::Beta::Text.new(role: :group, ml: :auto, mr: 2)) do %>
             <%= render(Primer::Beta::Octicon.new(:calendar, size: :small, mr: 1)) %>
             <%= format_date_range([sprint.start_date, sprint.finish_date]) %>
           <% end %>
@@ -63,7 +60,7 @@ See COPYRIGHT and LICENSE files for more details.
 
       <% if show_start_sprint_action? %>
         <% header.with_action_button(**start_sprint_button_arguments) do |button| %>
-          <% button.with_leading_visual_icon(icon: :play) %>
+          <% button.with_leading_visual_icon(icon: :rocket) %>
           <% if start_sprint_disabled_reason.present? %>
             <% button.with_tooltip(
                  text: start_sprint_disabled_reason,
@@ -75,7 +72,7 @@ See COPYRIGHT and LICENSE files for more details.
         <% end %>
       <% elsif show_finish_sprint_action? %>
         <% header.with_action_button(**finish_sprint_button_arguments) do |button| %>
-          <% button.with_leading_visual_icon(icon: :check) %>
+          <% button.with_leading_visual_icon(icon: :"op-flag") %>
           <%= t(".label_complete_sprint") %>
         <% end %>
       <% end %>
diff --git a/modules/backlogs/app/components/backlogs/sprint_status_badge_component.html.erb b/modules/backlogs/app/components/backlogs/sprint_status_badge_component.html.erb
new file mode 100644
index 00000000000..cfcb678e006
--- /dev/null
+++ b/modules/backlogs/app/components/backlogs/sprint_status_badge_component.html.erb
@@ -0,0 +1,5 @@
+<%=
+  render(Primer::Beta::Label.new(size: :medium, font_weight: :bold, **@system_arguments)) do
+    I18n.t(:"activerecord.attributes.sprint.statuses.#{@sprint.status}")
+  end
+%>
diff --git a/modules/backlogs/app/components/backlogs/sprint_status_badge_component.rb b/modules/backlogs/app/components/backlogs/sprint_status_badge_component.rb
new file mode 100644
index 00000000000..01be47a51dc
--- /dev/null
+++ b/modules/backlogs/app/components/backlogs/sprint_status_badge_component.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+module Backlogs
+  class SprintStatusBadgeComponent < ApplicationComponent
+    include OpPrimer::ComponentHelpers
+
+    def initialize(sprint:, **system_arguments)
+      super()
+
+      @sprint = sprint
+      @system_arguments = system_arguments.merge(bg: status_color)
+    end
+
+    private
+
+    def status_color
+      case @sprint.status
+      when "active" then :success
+      when "completed" then :done
+      else :default
+      end
+    end
+  end
+end
diff --git a/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb
index 5122de033ed..df70a099811 100644
--- a/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb
+++ b/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb
@@ -62,12 +62,14 @@ module Backlogs
 
       @system_arguments = system_arguments
       @system_arguments[:padding] = :condensed
+      @system_arguments[:header_padding] = :spacious
       merge_drag_and_drop_data! if drag_and_drop
 
       @list = OpenProject::Common::BorderBoxListComponent.new(
         container:,
         current_user:,
         interactive: true,
+        scheme: :transparent,
         **@system_arguments
       )
     end
diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml
index b0c511eec0e..3c7eece3ae3 100644
--- a/modules/backlogs/config/locales/en.yml
+++ b/modules/backlogs/config/locales/en.yml
@@ -132,6 +132,7 @@ en:
       button_complete_sprint: "Complete sprint"
 
     inbox_component:
+      title: "Inbox"
       blankslate_title: "Backlog inbox is empty"
       blankslate_description: "All open work packages in this project will automatically appear here."
       show_more:
@@ -163,8 +164,8 @@ en:
       blankslate_title: "%{name} is empty"
       blankslate_description: "No items planned yet. Drag items here to add them."
       label_actions: "Sprint actions"
-      label_start_sprint: "Start"
-      label_complete_sprint: "Complete"
+      label_start_sprint: "Start sprint"
+      label_complete_sprint: "Complete sprint"
       start_sprint_disabled_reason_active_sprint: "Another sprint is already active."
       start_sprint_disabled_reason_missing_dates: "Start and finish dates are required in order to start the sprint."
       action_menu:
diff --git a/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb b/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb
index 09c17f7c472..cf5aa4644e5 100644
--- a/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb
+++ b/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb
@@ -75,6 +75,27 @@ RSpec.describe Backlogs::InboxComponent, type: :component do
     end
   end
 
+  describe "header" do
+    let(:work_packages) do
+      [
+        create(:work_package, subject: "First item", project:, story_points: 2, position: 1),
+        create(:work_package, subject: "Second item", project:, story_points: 4, position: 2)
+      ]
+    end
+
+    it "renders the inbox title" do
+      expect(page).to have_heading "Inbox", level: 4
+    end
+
+    it "renders the work-package count" do
+      expect(page).to have_css(
+        ".Counter",
+        text: "2",
+        aria: { label: I18n.t(:label_x_items, count: 2) }
+      )
+    end
+  end
+
   describe "empty state" do
     let(:work_packages) { [] }
 
@@ -139,6 +160,14 @@ RSpec.describe Backlogs::InboxComponent, type: :component do
         expect(page).to have_text("Show #{middle_count} more items")
       end
 
+      it "renders the full work-package count in the header" do
+        expect(page).to have_css(
+          ".Counter",
+          text: total.to_s,
+          aria: { label: I18n.t(:label_x_items, count: total) }
+        )
+      end
+
       it "renders show-more targeting the full backlog turbo frame with all=1" do
         show_link = page.find("##{show_more_id}")
         expect(show_link[:href]).to include("all=1")
diff --git a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb
index 23c6806734b..c6159bd44c6 100644
--- a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb
+++ b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb
@@ -166,6 +166,44 @@ RSpec.describe Backlogs::SprintComponent, type: :component do
       end
     end
 
+    describe "sprint status badge in header description" do
+      context "when the sprint is in planning" do
+        let(:sprint) do
+          create(:sprint, project:, name: "Sprint 1",
+                          start_date: Date.tomorrow, finish_date: Date.tomorrow + 7,
+                          status: "in_planning")
+        end
+
+        it "renders the status badge" do
+          expect(rendered_component).to have_css(".Label", text: "In planning")
+        end
+      end
+
+      context "when the sprint is active" do
+        let(:sprint) do
+          create(:sprint, project:, name: "Sprint 1",
+                          start_date: Date.yesterday, finish_date: Date.tomorrow,
+                          status: "active")
+        end
+
+        it "renders the status badge" do
+          expect(rendered_component).to have_css(".Label", text: "Active")
+        end
+      end
+
+      context "when the sprint is completed" do
+        let(:sprint) do
+          create(:sprint, project:, name: "Sprint 1",
+                          start_date: 2.weeks.ago, finish_date: 1.week.ago,
+                          status: "completed")
+        end
+
+        it "renders the status badge" do
+          expect(rendered_component).to have_css(".Label", text: "Completed")
+        end
+      end
+    end
+
     describe "sprint actions in header" do
       context "when the sprint is in planning with date range set" do
         let(:sprint) do
@@ -175,7 +213,7 @@ RSpec.describe Backlogs::SprintComponent, type: :component do
         end
 
         it "renders the start-sprint link enabled" do
-          expect(rendered_component).to have_link("Start")
+          expect(rendered_component).to have_link("Start sprint")
         end
       end
 
@@ -187,7 +225,7 @@ RSpec.describe Backlogs::SprintComponent, type: :component do
         end
 
         it "renders the start-sprint button as disabled" do
-          expect(rendered_component).to have_selector(:link_or_button, "Start", aria: { disabled: true })
+          expect(rendered_component).to have_selector(:link_or_button, "Start sprint", aria: { disabled: true })
         end
       end
 
@@ -200,7 +238,7 @@ RSpec.describe Backlogs::SprintComponent, type: :component do
         let!(:task_board) { create(:board_grid_with_query, project:, linked: sprint) }
 
         it "renders the complete-sprint link" do
-          expect(rendered_component).to have_link("Complete")
+          expect(rendered_component).to have_link("Complete sprint")
         end
 
         context "when params[:all] is true" do
@@ -210,7 +248,7 @@ RSpec.describe Backlogs::SprintComponent, type: :component do
 
           it "preserves ?all=1 on the complete-sprint link" do
             expect(rendered_component).to have_link(
-              "Complete",
+              "Complete sprint",
               href: finish_project_backlogs_sprint_path(project, sprint, all: 1)
             )
           end
diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb
index 45e6f0a5cfe..9738d662f5c 100644
--- a/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb
+++ b/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb
@@ -139,6 +139,12 @@ RSpec.describe Backlogs::WorkPackageCardListComponent, type: :component do
       expect(rendered_component).to have_css(".Box-header")
     end
 
+    it "keeps condensed row padding with spacious header padding" do
+      expect(rendered_component).to have_css(
+        ".Box.Box--condensed.op-border-box-list_header-padding-spacious"
+      )
+    end
+
     it "renders the provided title" do
       expect(rendered_component).to have_heading "Sprint 1", level: 4
     end
diff --git a/modules/backlogs/spec/support/pages/backlog.rb b/modules/backlogs/spec/support/pages/backlog.rb
index 925fa3a9329..a5f09c1089c 100644
--- a/modules/backlogs/spec/support/pages/backlog.rb
+++ b/modules/backlogs/spec/support/pages/backlog.rb
@@ -77,7 +77,9 @@ module Pages
     end
 
     def sprint_names_in_order
-      page.find_all("#sprint_backlogs_container > section .CollapsibleHeader-title").map(&:text)
+      within_sprint_backlogs do
+        headed_section_titles(id_prefix: "backlogs-sprint-component-")
+      end
     end
 
     def expect_work_packages_in_sprint_in_order(sprint,
@@ -301,7 +303,7 @@ module Pages
 
     def drag_inbox_item_to_sprint(work_package, sprint)
       moved_element = find(draggable_work_package_selector(work_package))
-      target_element = find(sprint_selector(sprint))
+      target_element = find(list_body_selector(sprint_selector(sprint)))
       wait_for_turbo_stream do
         moved_element.native.drag_to(target_element.native, delay: 0.1)
       end
@@ -311,7 +313,7 @@ module Pages
 
     def drag_sprint_item_to_inbox(work_package)
       moved_element = find(draggable_work_package_selector(work_package))
-      target_element = find("#inbox_project_#{project.id}")
+      target_element = find(list_body_selector("#inbox_project_#{project.id}"))
       moved_element.native.drag_to(target_element.native, delay: 0.1)
     rescue Capybara::Cuprite::ObsoleteNode
       retry
@@ -335,7 +337,9 @@ module Pages
     end
 
     def bucket_names_in_order
-      page.find_all("#owner_backlogs_container section .CollapsibleHeader-title").map(&:text)
+      within_owner_backlogs do
+        headed_section_titles(id_prefix: "backlogs-bucket-component-")
+      end
     end
 
     def expect_bucket_names_in_order(*bucket_names)
@@ -423,7 +427,7 @@ module Pages
 
     def drag_work_package_to_backlog_bucket(work_package, bucket)
       moved_element = find(draggable_work_package_selector(work_package))
-      target_element = find(bucket_selector(bucket))
+      target_element = find(list_body_selector(bucket_selector(bucket)))
 
       wait_for_turbo_stream do
         moved_element.native.drag_to(target_element.native, delay: 0.1)
@@ -434,7 +438,7 @@ module Pages
 
     def drag_work_package_to_backlog_inbox(work_package)
       moved_element = find(draggable_work_package_selector(work_package))
-      target_element = find(backlog_inbox_selector)
+      target_element = find(list_body_selector(backlog_inbox_selector))
 
       wait_for_turbo_stream do
         moved_element.native.drag_to(target_element.native, delay: 0.1)
@@ -648,6 +652,17 @@ module Pages
       test_selector("backlog-inbox")
     end
 
+    def list_body_selector(container_selector)
+      "#{container_selector} > ul"
+    end
+
+    def headed_section_titles(id_prefix:)
+      page
+        .all(:section, section_element: :section, heading_level: 4)
+        .select { |section| section[:id].to_s.start_with?(id_prefix) }
+        .map { |section| section.first(:heading, level: 4).text }
+    end
+
     def story_selector(story)
       "#story_#{story.id}"
     end
diff --git a/spec/components/open_project/common/border_box_list_component_preview_spec.rb b/spec/components/open_project/common/border_box_list_component_preview_spec.rb
new file mode 100644
index 00000000000..183597ca7ae
--- /dev/null
+++ b/spec/components/open_project/common/border_box_list_component_preview_spec.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "rails_helper"
+require Rails.root.join("lookbook/previews/open_project/common/border_box_list_component_preview").to_s
+
+RSpec.describe OpenProject::Common::BorderBoxListComponentPreview, type: :component do
+  it "renders a realistic default preview description" do
+    render_preview(:default, from: described_class)
+
+    expect(page).to have_text("Coordinate launch work and keep stakeholders aligned.")
+  end
+
+  it "renders the default preview with the provided description text" do
+    render_preview(
+      :default,
+      from: described_class,
+      params: {
+        description: "Default preview description that demonstrates how text wraps near the header action buttons."
+      }
+    )
+
+    expect(page).to have_text(
+      "Default preview description that demonstrates how text wraps near the header action buttons."
+    )
+  end
+
+  it "renders a realistic transparent preview description" do
+    render_preview(:transparent, from: described_class)
+
+    expect(page).to have_text("Sprint goals, scope, and timing for the next iteration.")
+  end
+
+  it "renders the transparent preview with the provided description text" do
+    render_preview(
+      :transparent,
+      from: described_class,
+      params: {
+        description: "Transparent preview description that demonstrates how text wraps near the sprint action buttons."
+      }
+    )
+
+    expect(page).to have_text(
+      "Transparent preview description that demonstrates how text wraps near the sprint action buttons."
+    )
+  end
+
+  it "renders the playground preview with the provided description text" do
+    render_preview(
+      :playground,
+      from: described_class,
+      params: {
+        description: "A longer playground description that demonstrates wrapping behavior in the list header preview."
+      }
+    )
+
+    expect(page).to have_text(
+      "A longer playground description that demonstrates wrapping behavior in the list header preview."
+    )
+  end
+
+  it "renders the playground preview with a header padding override" do
+    render_preview(
+      :playground,
+      from: described_class,
+      params: {
+        padding: :condensed,
+        header_padding: :default
+      }
+    )
+
+    expect(page).to have_css(".Box.Box--condensed.op-border-box-list_header-padding-default")
+  end
+end
diff --git a/spec/components/open_project/common/border_box_list_component_spec.rb b/spec/components/open_project/common/border_box_list_component_spec.rb
index c072dc586cf..597273e55a8 100644
--- a/spec/components/open_project/common/border_box_list_component_spec.rb
+++ b/spec/components/open_project/common/border_box_list_component_spec.rb
@@ -195,7 +195,7 @@ RSpec.describe OpenProject::Common::BorderBoxListComponent, type: :component do
       end
 
       expect(rendered).to have_heading("My title", level: 4)
-      expect(rendered).to have_text("Some description")
+      expect(rendered).to have_css(".op-border-box-list-header--description", text: "Some description")
     end
 
     it "renders multiple action buttons" do
@@ -766,5 +766,89 @@ RSpec.describe OpenProject::Common::BorderBoxListComponent, type: :component do
 
       expect(rendered).to have_css("collapsible-header")
     end
+
+    it "adds a collapsible modifier without rendering a grid description container" do
+      rendered = render_inline(
+        described_class.new(container: "explicit-collapse", collapsible: true)
+      ) do |list|
+        list.with_header(title: "Collapsible header") do |header|
+          header.with_description { "Collapsible description" }
+        end
+        list.with_item { "row" }
+      end
+
+      expect(rendered).to have_css(
+        ".op-border-box-list-header.op-border-box-list-header_collapsible"
+      )
+      expect(rendered).to have_no_css(".op-border-box-list-header--description")
+      expect(rendered).to have_text("Collapsible description")
+    end
+  end
+
+  describe "scheme" do
+    it "defaults to :default" do
+      rendered = render_inline(
+        described_class.new(container: "scheme-default")
+      ) do |list|
+        list.with_header(title: "Default")
+        list.with_item { "row" }
+      end
+
+      expect(rendered).to have_no_css(".op-border-box-list_transparent")
+    end
+
+    it "applies the transparent CSS class when scheme is :transparent" do
+      rendered = render_inline(
+        described_class.new(container: "scheme-transparent", scheme: :transparent)
+      ) do |list|
+        list.with_header(title: "Transparent")
+        list.with_item { "row" }
+      end
+
+      expect(rendered).to have_css(".Box.op-border-box-list_transparent")
+    end
+
+    it "keeps collapsible independent of the transparent scheme" do
+      rendered = render_inline(
+        described_class.new(container: "transparent-collapse", scheme: :transparent, collapsible: true)
+      ) do |list|
+        list.with_header(title: "Transparent collapsible")
+        list.with_item { "row" }
+      end
+
+      expect(rendered).to have_css(".Box.op-border-box-list_transparent")
+      expect(rendered).to have_css("collapsible-header")
+    end
+  end
+
+  describe "header padding" do
+    it "inherits the underlying BorderBox header padding by default" do
+      rendered = render_inline(
+        described_class.new(container: "header-padding-inherit")
+      ) do |list|
+        list.with_header(title: "Inherited padding")
+        list.with_item { "row" }
+      end
+
+      expect(rendered).to have_css(".Box.op-border-box-list")
+      expect(rendered).to have_no_css("[class*='op-border-box-list_header-padding-']")
+    end
+
+    it "adds a header padding modifier when configured" do
+      rendered = render_inline(
+        described_class.new(container: "header-padding-default", padding: :condensed, header_padding: :default)
+      ) do |list|
+        list.with_header(title: "Default header padding")
+        list.with_item { "row" }
+      end
+
+      expect(rendered).to have_css(".Box.Box--condensed.op-border-box-list_header-padding-default")
+    end
+
+    it "raises for unsupported values in test" do
+      expect do
+        described_class.new(container: "header-padding-unsupported", header_padding: :unsupported)
+      end.to raise_error Primer::FetchOrFallbackHelper::InvalidValueError
+    end
   end
 end
diff --git a/spec/lib/open_project/text_formatting/matchers/link_handlers/work_packages_spec.rb b/spec/lib/open_project/text_formatting/matchers/link_handlers/work_packages_spec.rb
index 0ecf3dc9476..6b96e086e53 100644
--- a/spec/lib/open_project/text_formatting/matchers/link_handlers/work_packages_spec.rb
+++ b/spec/lib/open_project/text_formatting/matchers/link_handlers/work_packages_spec.rb
@@ -72,21 +72,83 @@ RSpec.describe OpenProject::TextFormatting::Matchers::LinkHandlers::WorkPackages
     end
   end
 
-  describe "per-reference query cost",
+  describe ".with_preloaded_resources save/restore semantics",
+           with_flag: { semantic_work_package_ids: true },
+           with_settings: { work_packages_identifier: "semantic" } do
+    # A custom-field formatter or recursive markdown render may invoke the
+    # text-formatting pipeline while an outer render is mid-iteration. The
+    # lookup must save on entry and restore on exit so the outer render's
+    # remaining `#N` matchers still see its WPs after the inner call returns.
+    include_context "with author signed in"
+
+    let(:project) { create(:project, identifier: "NESTED") }
+    let(:outer_wp) { create(:work_package, project:, author:) }
+    let(:inner_wp) { create(:work_package, project:, author:) }
+    let(:matcher) { OpenProject::TextFormatting::Matchers::ResourceLinksMatcher }
+
+    before do
+      outer_wp.allocate_and_register_semantic_id
+      inner_wp.allocate_and_register_semantic_id
+    end
+
+    it "preserves the outer lookup across a nested call" do
+      outer = outer_wp.reload
+      inner = inner_wp.reload
+      outer_doc = Nokogiri::HTML.fragment("##{outer.id}")
+      inner_doc = Nokogiri::HTML.fragment("##{inner.id}")
+
+      matcher.with_preloaded_resources(outer_doc, {}) do
+        expect(matcher.work_package_for(outer.id)).to eq(outer)
+
+        matcher.with_preloaded_resources(inner_doc, {}) do
+          expect(matcher.work_package_for(inner.id)).to eq(inner)
+        end
+
+        expect(matcher.work_package_for(outer.id))
+          .to eq(outer), "outer lookup should be restored after nested call"
+      end
+
+      expect(matcher.work_package_for(outer.id)).to be_nil
+    end
+  end
+
+  describe "classic mode is query-free",
+           with_flag: { semantic_work_package_ids: false },
+           with_settings: { work_packages_identifier: "classic" } do
+    # Rendering a `#N` reference in classic mode must not run any
+    # WorkPackage SELECTs: the preload is a no-op when `display_id` and
+    # `formatted_id` would collapse to the numeric form, so the link
+    # handler can build the link from the matched id alone.
+    include_context "with author signed in"
+    let(:project) { create(:project, identifier: "classicproj") }
+
+    it "does not query work_packages when rendering #N" do
+      wps = create_list(:work_package, 3, project:, author:)
+      ids_text = wps.map { |wp| "##{wp.id}" }.join(" ")
+
+      recorder = ActiveRecord::QueryRecorder.new { format_text(ids_text) }
+      wp_selects = recorder.log.grep(/FROM "work_packages"/i)
+
+      expect(wp_selects).to be_empty,
+                            "classic mode added unexpected WP SELECTs:\n#{wp_selects.join("\n")}"
+    end
+  end
+
+  describe "N+1 query bound",
            with_flag: { semantic_work_package_ids: true },
            with_settings: { work_packages_identifier: "semantic" } do
     include_context "with author signed in"
     let(:project) { create(:project, identifier: "NPLUSONE") }
 
-    it "issues one work_packages SELECT per `#N` reference" do
+    it "loads referenced work packages with a single SELECT regardless of count" do
       wps = create_list(:work_package, 5, project:, author:)
       ids_text = wps.map { |wp| "##{wp.id}" }.join(" ")
 
       recorder = ActiveRecord::QueryRecorder.new { format_text(ids_text) }
       wp_selects = recorder.log.grep(/FROM "work_packages"/i)
 
-      expect(wp_selects.size).to eq(5),
-                                 "expected one SELECT per reference, got #{wp_selects.size}:\n#{wp_selects.join("\n")}"
+      expect(wp_selects.size).to eq(1),
+                                 "expected exactly one work_packages SELECT, got #{wp_selects.size}:\n#{wp_selects.join("\n")}"
     end
   end
 
@@ -140,7 +202,7 @@ RSpec.describe OpenProject::TextFormatting::Matchers::LinkHandlers::WorkPackages
     end
 
     context "with mixed numeric and semantic references in one render" do
-      it "resolves both with one work_packages SELECT per reference" do
+      it "resolves both with a single work_packages SELECT" do
         wps = create_list(:work_package, 2, project:, author:)
         wps.each(&:allocate_and_register_semantic_id)
         loaded = wps.map(&:reload)
@@ -150,8 +212,8 @@ RSpec.describe OpenProject::TextFormatting::Matchers::LinkHandlers::WorkPackages
         recorder = ActiveRecord::QueryRecorder.new { rendered = format_text(text) }
         wp_selects = recorder.log.grep(/FROM "work_packages"/i)
 
-        expect(wp_selects.size).to eq(2),
-                                   "expected one SELECT per reference, got #{wp_selects.size}:\n#{wp_selects.join("\n")}"
+        expect(wp_selects.size).to eq(1),
+                                   "expected exactly one work_packages SELECT, got #{wp_selects.size}:\n#{wp_selects.join("\n")}"
 
         # Both render with the user-facing display_id, regardless of which
         # form the user typed.
@@ -161,7 +223,7 @@ RSpec.describe OpenProject::TextFormatting::Matchers::LinkHandlers::WorkPackages
     end
 
     context "with a historical alias reference" do
-      it "resolves via the alias table without a separate alias round-trip" do
+      it "resolves via the alias table with two round-trips total" do
         wp = work_package.reload
         # Simulate a project rename: the WP keeps its current MACROPROJ-N
         # identifier on the row, but a historical OLD-prefix alias row
@@ -172,14 +234,13 @@ RSpec.describe OpenProject::TextFormatting::Matchers::LinkHandlers::WorkPackages
         rendered = nil
         recorder = ActiveRecord::QueryRecorder.new { rendered = format_text("see #OLDPROJ-1") }
 
-        # `find_by_display_id` runs one WP SELECT whose WHERE clause is
-        # `identifier = ? OR EXISTS (SELECT 1 FROM aliases …)`, so the
-        # alias table is consulted in-line — no separate alias-only SELECT.
-        wp_selects = recorder.log.grep(/FROM "work_packages"/i)
-        alias_only_selects = recorder.log.grep(/FROM "work_package_semantic_aliases"/i)
-                                     .grep_v(/FROM "work_packages"/i)
-        expect(wp_selects.size).to eq(1)
-        expect(alias_only_selects).to be_empty
+        # Two database round-trips: (1) `where_display_id_in` runs a
+        # single WP SELECT whose WHERE clause includes an EXISTS
+        # subquery against the alias table; (2) a sidecar alias pluck
+        # maps the historical input string back to its WP for the
+        # cache. A third statement would indicate an N+1 regression.
+        expect(recorder.log.size).to eq(2)
+        expect(recorder.log.last).to match(/FROM "work_package_semantic_aliases"/)
 
         # Renders against the WP's CURRENT display_id, not the historical
         # alias the user typed — old content stays alive but points at the
@@ -234,4 +295,78 @@ RSpec.describe OpenProject::TextFormatting::Matchers::LinkHandlers::WorkPackages
       expect(rendered).to include("version#PROJ-1")
     end
   end
+
+  describe "visibility scoping",
+           with_flag: { semantic_work_package_ids: true },
+           with_settings: { work_packages_identifier: "semantic" } do
+    # The lookup cache must scope through `WorkPackage.visible` —
+    # anything it surfaces ends up in the rendered link, so an
+    # unscoped cache would let any user read back the project
+    # identifier of a WP just by guessing its primary key, semantic
+    # identifier, or historical alias.
+    include_context "with author signed in"
+
+    let(:project) { create(:project, identifier: "VISIBLE") }
+    let(:hidden_project) { create(:project, identifier: "HIDDEN") }
+    let(:visible_wp) { create(:work_package, project:, author:) }
+    let(:hidden_wp) { create(:work_package, project: hidden_project) }
+
+    before do
+      visible_wp.allocate_and_register_semantic_id
+      hidden_wp.allocate_and_register_semantic_id
+    end
+
+    context "with a semantic-shaped ref to an inaccessible work package" do
+      it "renders literal text and never surfaces the WP's display id" do
+        wp = hidden_wp.reload
+        rendered = format_text("see ##{wp.display_id} here")
+
+        expect(rendered).to include("##{wp.display_id}")
+        expect(rendered).not_to include(%(href="/work_packages/#{wp.display_id}"))
+        expect(rendered).not_to include("opce-macro-wp-quickinfo")
+      end
+    end
+
+    context "with a numeric ref to an inaccessible work package in semantic mode" do
+      it "renders the numeric label and href without upgrading to the semantic identifier" do
+        wp = hidden_wp.reload
+        rendered = format_text("see ##{wp.id} here")
+
+        # The link still renders — `#42` was already in the user's input —
+        # but the upgrade to the WP's `formatted_id` / `display_id` (which
+        # would leak the project identifier) does not happen.
+        expect(rendered).to include(%(href="/work_packages/#{wp.id}"))
+        expect(rendered).to include(">##{wp.id}<")
+        expect(rendered).not_to include(%(href="/work_packages/#{wp.display_id}"))
+        expect(rendered).not_to include(">#{wp.formatted_id}<")
+      end
+    end
+
+    context "with a historical alias for an inaccessible work package" do
+      it "renders literal text and does not resolve via the alias table" do
+        wp = hidden_wp.reload
+        WorkPackageSemanticAlias.create!(work_package_id: wp.id, identifier: "OLDHIDDEN-1")
+
+        rendered = format_text("see #OLDHIDDEN-1 here")
+
+        expect(rendered).to include("#OLDHIDDEN-1")
+        expect(rendered).not_to include(%(href="/work_packages/#{wp.display_id}"))
+        expect(rendered).not_to include(">#{wp.formatted_id}<")
+      end
+    end
+
+    context "with visible and invisible refs mixed in one input" do
+      it "renders the visible ref normally and falls back to literal text for the invisible one" do
+        visible = visible_wp.reload
+        hidden = hidden_wp.reload
+        rendered = format_text("see ##{visible.display_id} and ##{hidden.display_id}")
+
+        expect(rendered).to include(%(href="/work_packages/#{visible.display_id}"))
+        expect(rendered).to include(">#{visible.formatted_id}<")
+
+        expect(rendered).not_to include(%(href="/work_packages/#{hidden.display_id}"))
+        expect(rendered).to include("##{hidden.display_id}")
+      end
+    end
+  end
 end