diff --git a/Gemfile.lock b/Gemfile.lock index 6f4934458..7895feaa6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -174,7 +174,7 @@ GEM argon2-kdf (>= 1) bootsnap (1.25.0) msgpack (~> 1.5) - brakeman (8.0.6) + brakeman (8.1.0) racc browser (6.2.0) builder (3.3.0) diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index d340b7952..c310b4a17 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -69,7 +69,6 @@ @use "components/vega_sprite"; @use "components/visual_novel"; @use "components/buku_x3_reveal"; -@use "components/rocket_progress"; // Pages @use "pages/onboarding" as onboarding_pages; diff --git a/app/assets/stylesheets/components/_buku_x3_status.scss b/app/assets/stylesheets/components/_buku_x3_status.scss index 8cbf70d9f..220ceca75 100644 --- a/app/assets/stylesheets/components/_buku_x3_status.scss +++ b/app/assets/stylesheets/components/_buku_x3_status.scss @@ -1,4 +1,6 @@ .buku-x3-status { + --buku-team-color: var(--color-set-1-fg-secondary); + position: relative; display: grid; align-items: center; @@ -63,8 +65,8 @@ } } - &[data-role-open], - &:has(&__toggle:focus-visible) { + &--interactive[data-role-open], + &--interactive:has(.buku-x3-status__toggle:focus-visible) { .buku-x3-status__identity { visibility: visible; opacity: 1; @@ -76,7 +78,7 @@ } @media (hover: hover) { - &:not(&--compact):hover { + &--interactive:hover { .buku-x3-status__identity { visibility: visible; opacity: 1; diff --git a/app/assets/stylesheets/components/_rocket_progress.scss b/app/assets/stylesheets/components/_rocket_progress.scss deleted file mode 100644 index ce0c0095c..000000000 --- a/app/assets/stylesheets/components/_rocket_progress.scss +++ /dev/null @@ -1,221 +0,0 @@ -// Pinned rocket-repair goal at the top of the home feed (:bukux2). Vega stands -// in front of the strip's left end and rises out of the top of it, and the -// strip's chrome dissolves behind him — the same treatment as the intro -// dialogue's bar, so the track reads as growing out of him rather than as a -// box with a border drawn across his chest. The track is the piece doing the -// talking, so the strip carries no explanatory copy. -// -// Surface follows docs/branding.md §3: set-2 fill, 2px cream highlight border, -// 8px radius. -.rocket-progress { - --rocket-progress-avatar-height: 132px; - --rocket-progress-avatar-inset: var(--space-xs); - // wrench-buddy.png is 1504x1438. - --rocket-progress-avatar-ratio: 1.0459; - // Where the portrait stops and the strip's chrome (and its text) begin. - --rocket-progress-gutter: calc( - var(--rocket-progress-avatar-inset) + var(--rocket-progress-avatar-height) * - var(--rocket-progress-avatar-ratio) + var(--space-m) - ); - - position: relative; - display: flex; - // Top margin reserves the part of him that rises past the strip's top edge. - margin: var(--space-xxl) 0 var(--space-m); - padding: var(--space-m) var(--space-l); - // Floor matches the strip's natural height so the hover growth animates from - // it rather than jumping once min-height passes the content height. - min-height: 5.5rem; - transition: min-height 200ms ease; -} - -// Chrome on its own layer so it can fade out on the left without taking the -// text with it — a mask applies to an element's children too. -.rocket-progress::before { - content: ""; - position: absolute; - inset: 0; - z-index: -1; - background: var(--color-set-2-bg); - border: 2px solid var(--color-brand-highlight); - border-radius: var(--profile-radius); - mask-image: linear-gradient( - to right, - transparent, - #000 var(--rocket-progress-gutter) - ); -} - -.rocket-progress__avatar { - height: var(--rocket-progress-avatar-height); - width: calc( - var(--rocket-progress-avatar-height) * var(--rocket-progress-avatar-ratio) - ); - object-fit: contain; - - position: absolute; - left: var(--rocket-progress-avatar-inset); - // Centered on the strip rather than feet-on-the-bottom-edge — this art - // isn't the tall standing pose the old sprite was. - top: 50%; - transform: translateY(-50%); - filter: drop-shadow(0 6px 14px var(--color-shadow-deep)); -} - -.rocket-progress__avatar, -.rocket-progress__body { - transition: opacity 200ms ease; -} - -.rocket-progress__body { - display: flex; - flex: 1; - flex-direction: column; - gap: var(--space-xs); - // Starts where the chrome has fully faded back in. - margin-left: var(--rocket-progress-gutter); - min-width: 0; -} - -// Explanation copy that swaps in over the strip on hover, in place of the -// hours/progress bar — same footprint, just a different story. -.rocket-progress__hover-copy { - position: absolute; - inset: 0; - display: flex; - align-items: center; - padding: var(--space-l) var(--space-xl); - opacity: 0; - pointer-events: none; - transition: opacity 200ms ease; - - p { - margin: 0; - color: var(--color-space-text); - font-family: var(--font-family-text); - font-size: var(--font-size-m); - line-height: 1.4; - } -} - -// Tall enough for the copy plus its full padding — the strip's natural height -// leaves the text no room to breathe vertically. -.rocket-progress:hover { - min-height: 8rem; - - .rocket-progress__avatar, - .rocket-progress__body { - opacity: 0; - } - - .rocket-progress__hover-copy { - opacity: 1; - } -} - -.rocket-progress__header { - display: flex; - flex-wrap: wrap; - align-items: baseline; - justify-content: space-between; - gap: var(--space-xxs) var(--space-m); -} - -// Small heading per docs/branding.md §2.1 — bold, all caps. -.rocket-progress__eyebrow { - margin: 0; - color: var(--color-brand-highlight); - font-family: var(--font-family-text); - font-size: var(--font-size-m); - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.rocket-progress__count { - margin: 0; - font-family: var(--font-family-text); - line-height: 1.1; -} - -.rocket-progress__hours { - color: var(--color-space-text); - font-size: var(--font-size-xxl); - font-weight: 700; -} - -.rocket-progress__goal { - color: var(--color-set-3-fg-secondary); - font-size: var(--font-size-m); -} - -// A chunky track rather than the 8px strip the shared component defaults to: -// this bar is the whole point of the container. Sunk into the page background -// so the fill reads as sitting in a channel. -.rocket-progress__bar { - height: 22px; - background: var(--color-set-1-bg); - border: 2px solid var(--color-space-surface-soft); - - .progress-bar__fill { - position: relative; - width: var(--rocket-progress-fill); - overflow: hidden; - background: var(--color-brand-highlight); - } -} - -.rocket-progress__contribution { - position: absolute; - inset-block: 0; - right: 0; - width: var(--rocket-progress-contribution); - background: var(--color-brand-mint); - box-shadow: inset 2px 0 var(--color-set-2-bg); -} - -.rocket-progress__contribution-caption { - display: flex; - justify-content: flex-end; - width: var(--rocket-progress-fill); - min-width: max-content; - max-width: 100%; - padding-top: var(--space-xs); -} - -.rocket-progress__contribution-label { - color: var(--color-brand-mint); - font-size: var(--font-size-s); - line-height: 1.4; - white-space: nowrap; - - strong { - font-weight: 700; - } -} - -@media (max-width: 600px) { - .rocket-progress { - --rocket-progress-avatar-height: 104px; - - padding: var(--space-s) var(--space-m); - } - - .rocket-progress__bar { - height: 18px; - } -} - -@media (prefers-reduced-motion: reduce) { - .rocket-progress { - transition: none; - } - - .rocket-progress__bar .progress-bar__fill { - transition: none; - } - - .rocket-progress:hover .rocket-progress__avatar { - animation: none; - } -} diff --git a/app/components/buku_x3_status_component.html.erb b/app/components/buku_x3_status_component.html.erb index a02b8df15..bb1a094d2 100644 --- a/app/components/buku_x3_status_component.html.erb +++ b/app/components/buku_x3_status_component.html.erb @@ -1,20 +1,22 @@ -
<%= 'buku-x3-status--interactive' if role_visible? && !compact? %>" aria-label="<%= compact? ? 'your private event role' : 'bukus versus beans' %>" data-controller="buku-x3-status" data-buku-x3-status-live-hours-value="<%= (compact? ? {} : team_hours).to_json %>" data-action="blackhole:progress@window->buku-x3-status#update"> - <% unless compact? %> + <% if role_visible? && !compact? %> <% end %> -
- <%= image_tag icon, class: "buku-x3-status__icon", alt: "", width: 120, height: 132 %> -
-

you're a <%= role_name %>

-

<%= reminder %>

+ <% if role_visible? %> +
+ <%= image_tag icon, class: "buku-x3-status__icon", alt: "", width: 120, height: 132 %> +
+

you're a <%= role_name %>

+

<%= reminder %>

+
-
+ <% end %> <% unless compact? %>
diff --git a/app/components/buku_x3_status_component.rb b/app/components/buku_x3_status_component.rb index 38cb166e7..00dfedc63 100644 --- a/app/components/buku_x3_status_component.rb +++ b/app/components/buku_x3_status_component.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -# Private role reminder. Call sites pass the signed-in viewer, never a profile -# or project owner; do not cache this fragment across users. +# Public team progress with an optional private role reminder. Call sites pass +# the viewer, never a profile/project owner; do not cache across users. class BukuX3StatusComponent < ViewComponent::Base def initialize(user:, compact: false, preview: false, preview_role: "buku") @user = user @@ -11,6 +11,10 @@ def initialize(user:, compact: false, preview: false, preview_role: "buku") end def render? + !compact? || role_visible? + end + + def role_visible? return true if preview? @user.present? && @user.onboarded? && Flipper.enabled?(:bukux3, @user) && diff --git a/app/components/rocket_progress_component.html.erb b/app/components/rocket_progress_component.html.erb deleted file mode 100644 index 0554c718d..000000000 --- a/app/components/rocket_progress_component.html.erb +++ /dev/null @@ -1,39 +0,0 @@ -
- <%= image_tag "rocket_progress/wrench-buddy.png", class: "rocket-progress__avatar", alt: "" %> - -
-
-

<%= heading %>

-

- <%= display_hours %> - / <%= display_goal %> hours -

-
- -
-
-
- <% if contribution? %> - - <% end %> -
-
- <% if contribution? %> -
- - You contributed <%= display_contribution %> h - -
- <% end %> -
-
- - -
diff --git a/app/components/rocket_progress_component.rb b/app/components/rocket_progress_component.rb deleted file mode 100644 index 9525d2675..000000000 --- a/app/components/rocket_progress_component.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -# Pinned strip at the top of the home feed: how many approved hours the program -# has banked toward repairing Vega's rocket. Same story as the :bukux2 intro -# dialogue, so it hides with that flag and the home page is untouched without -# it. -# -# The figure comes from RocketProgress, which derives it from approved YSWS -# submissions whose ship landed inside the campaign window — nothing here -# writes anything. -class RocketProgressComponent < ViewComponent::Base - attr_reader :user - - def initialize(user:) - @user = user - end - - def render? = Flipper.enabled?(:bukux2, user) - - def progress = @progress ||= RocketProgress.snapshot(user: user) - - def hours = progress.hours - def goal = progress.goal_hours - def remaining = progress.remaining_hours - def complete? = progress.complete? - - def heading - complete? && !Flipper.enabled?(:bukux3, user) ? "The rocket is being built..." : "Rocket repair" - end - - def contribution? = progress.user_hours.positive? - def fill_percent = [ hours / goal.to_f * 100, 100 ].min.round(4) - def contribution_percent = hours.positive? ? (progress.user_hours.to_f / hours * 100).clamp(0, 100).round(4) : 0 - def display_contribution = helpers.number_with_precision(progress.user_hours, precision: 2, strip_insignificant_zeros: true, delimiter: ",") - - def progress_label - label = "#{display_hours} of #{display_goal} hours logged toward fixing the rocket" - contribution? ? "#{label}, including #{display_contribution} hours from you" : label - end - - # Whole hours read better on a 5000-hour goal; the exact figure still goes to - # the progress bar's aria-valuenow. - def display_hours = helpers.number_with_delimiter(hours.round) - def display_goal = helpers.number_with_delimiter(goal) - def display_remaining = helpers.number_with_delimiter(remaining.ceil) -end diff --git a/app/javascript/controllers/blackhole_controller.js b/app/javascript/controllers/blackhole_controller.js index 22d63804d..46aa13bb3 100644 --- a/app/javascript/controllers/blackhole_controller.js +++ b/app/javascript/controllers/blackhole_controller.js @@ -2,7 +2,7 @@ import { Controller } from "@hotwired/stimulus"; import { Delaunay } from "d3"; const CARD_SURFACES = - ".feed-post-card, .feed-composer, .rocket-progress, .rail-widget, .raffle-widget, .phantom-promo, .sidebar__logo-img, .sidebar__user-card"; + ".feed-post-card, .feed-composer, .rail-widget, .raffle-widget, .phantom-promo, .sidebar__logo-img, .sidebar__user-card"; const MEDIA_CONTENT = "img, video, iframe, svg"; const SIDEBAR_SURFACES = [ "#primary-nav", @@ -441,6 +441,7 @@ export default class extends Controller { priority: element.style.getPropertyPriority("clip-path"), seed: this.surfaceSeed(element), card: cards.includes(element), + proportional: element.matches(".sidebar__logo-img"), }); element.style.setProperty("clip-path", `url(#${clip.id})`); this.surfaceResize.observe(element); @@ -494,6 +495,7 @@ export default class extends Controller { protectedRects, excludedRects, surface.card, + surface.proportional, ); if (surface.lastPath !== path) { surface.path.setAttribute("d", path); @@ -840,6 +842,7 @@ export default class extends Controller { protectedRects = [], excludedRects = [], card = true, + proportional = false, ) { const cells = this.fragmentCells(rect, size, seed); this.fragmentFields ||= new Map(); @@ -851,6 +854,7 @@ export default class extends Controller { cached.height === rect.height && cached.size === size && cached.card === card && + cached.proportional === proportional && cached.protectedRects === protectedRects && cached.excludedRects === excludedRects ) @@ -877,6 +881,12 @@ export default class extends Controller { bounds.top < safe.bottom && bounds.bottom > safe.top; if (excludedRects.some(overlaps)) continue; + // The logo dissolves throughout the whole event, independently of the + // delayed image/text stages. Stable uniform thresholds track damage. + if (proportional) { + field.push({ cell, start: random(x, y, seed + 12) * 0.93 }); + continue; + } const protectedContent = protectedRects.some(overlaps); const imageContent = protectedRects.some( (area) => area.kind === "image" && overlaps(area), @@ -922,6 +932,7 @@ export default class extends Controller { height: rect.height, size, card, + proportional, protectedRects, excludedRects, field, @@ -936,6 +947,7 @@ export default class extends Controller { protectedRects = [], excludedRects = [], card = true, + proportional = false, ) { const holes = []; for (const entry of this.fragmentField( @@ -945,10 +957,13 @@ export default class extends Controller { protectedRects, excludedRects, card, + proportional, )) { const { cell, start } = entry; const { cx, cy, vertices } = cell; let erosion = clamp((this.level - start) / 0.07); + // Polygon area scales quadratically; keep proportional damage linear. + if (proportional) erosion = Math.sqrt(erosion); erosion *= 1 - this.repairStrength(cell, rect); if (!erosion) continue; if (entry.erosion !== erosion) { diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb index 3a6feff20..22188a1b1 100644 --- a/app/views/home/index.html.erb +++ b/app/views/home/index.html.erb @@ -8,12 +8,7 @@
<%= turbo_frame_tag "buku_x3_home_status", class: ("buku-x3-home-preview" if Rails.env.development?), data: { controller: "buku-x3-status", action: "buku-x3-reveal:complete@window->buku-x3-status#refresh" } do %> <% preview_role = params[:buku_preview].presence_in(%w[buku bean]) if Rails.env.development? %> - <% event_status = BukuX3StatusComponent.new(user: current_user, preview: preview_role.present?, preview_role: preview_role) %> - <% if event_status.render? %> - <%= render event_status %> - <% else %> - <%= render RocketProgressComponent.new(user: current_user) %> - <% end %> + <%= render BukuX3StatusComponent.new(user: current_user, preview: preview_role.present?, preview_role: preview_role) %> <% end %> <% if current_user.present? && Flipper.enabled?(:mihimode, current_user) %> <%= render ActionButtonComponent.new( diff --git a/test/components/buku_x3_status_component_test.rb b/test/components/buku_x3_status_component_test.rb index c9771ecd3..ec7d11164 100644 --- a/test/components/buku_x3_status_component_test.rb +++ b/test/components/buku_x3_status_component_test.rb @@ -75,7 +75,7 @@ class BukuX3StatusComponentTest < ViewComponent::TestCase BukuX3::Assignment.stub(:buku?, ->(_) { flunk "premature role disclosure" }) do render_inline BukuX3StatusComponent.new(user: @user) end - assert_no_selector ".buku-x3-status" + assert_public_progress_only end end @@ -86,20 +86,40 @@ class BukuX3StatusComponentTest < ViewComponent::TestCase @event.update!(unlocked_at: 1.minute.ago) Flipper.disable(:bukux3) render_inline BukuX3StatusComponent.new(user: @user) - assert_no_selector ".buku-x3-status" + assert_public_progress_only end test "signed out and incomplete accounts cannot see a role" do - render_inline BukuX3StatusComponent.new(user: nil) + BukuX3::Assignment.stub(:buku?, ->(_) { flunk "must not compute an undisclosed role" }) do + render_inline BukuX3StatusComponent.new(user: nil) + assert_public_progress_only + @user.update!(onboarded_at: nil) + render_inline BukuX3StatusComponent.new(user: @user) + assert_public_progress_only + end + end + + test "public progress works without the event flag or an event record" do + Flipper.disable(:bukux3) + BukuX3::Event.stub(:current, nil) do + render_inline BukuX3StatusComponent.new(user: nil) + end + assert_public_progress_only + assert_selector "[role='meter'][aria-valuenow='25']" + assert_selector ".buku-x3-status__hours", text: "0 h shipped", count: 2 + end + + test "compact private reminder stays hidden for guests and incomplete accounts" do + render_inline BukuX3StatusComponent.new(user: nil, compact: true) assert_no_selector ".buku-x3-status" @user.update!(onboarded_at: nil) - render_inline BukuX3StatusComponent.new(user: @user) + render_inline BukuX3StatusComponent.new(user: @user, compact: true) assert_no_selector ".buku-x3-status" end test "panel preview cannot bypass gates outside development" do render_inline BukuX3StatusComponent.new(user: nil, preview: true, preview_role: "bean") - assert_no_selector ".buku-x3-status" + assert_public_progress_only end test "development preview uses an explicit role without assigning an account" do @@ -112,4 +132,12 @@ class BukuX3StatusComponentTest < ViewComponent::TestCase assert_no_selector ".buku-x3-status__preview" assert_selector "img[src*='events/bukux3/bean']" end + + private + + def assert_public_progress_only + assert_selector ".buku-x3-status [role='meter']" + assert_no_selector ".buku-x3-status__identity, .buku-x3-status__toggle, .buku-x3-status--interactive" + assert_no_selector ".buku-x3-status--buku, .buku-x3-status--bean" + end end diff --git a/test/components/rocket_progress_component_test.rb b/test/components/rocket_progress_component_test.rb deleted file mode 100644 index be4c7dde4..000000000 --- a/test/components/rocket_progress_component_test.rb +++ /dev/null @@ -1,45 +0,0 @@ -require "test_helper" - -class RocketProgressComponentTest < ViewComponent::TestCase - test "personal hours occupy the end of the filled total without adding to it" do - render_progress(hours: 2750, user_hours: 150) - - assert_selector "[role='progressbar'][aria-valuenow='2750']" - assert_selector ".rocket-progress__contribution-label", text: "You contributed 150 h" - assert_selector ".rocket-progress__meter[style*='--rocket-progress-fill: 55.0%']" - assert_selector ".rocket-progress__meter[style*='--rocket-progress-contribution: 5.4545%']" - end - - test "no contribution segment or label when the user has not contributed" do - render_progress(hours: 0, user_hours: 0) - - assert_no_selector ".rocket-progress__contribution" - assert_no_selector ".rocket-progress__contribution-label" - end - - test "small contributions retain fractional hours and completed goals stay capped" do - render_progress(hours: 5250, user_hours: 0.1) - - assert_selector "[role='progressbar'][aria-valuenow='5000']" - assert_selector ".rocket-progress__contribution-label", text: "You contributed 0.1 h" - assert_selector ".rocket-progress__meter[style*='--rocket-progress-fill: 100%']" - end - - test "flag still hides the entire component" do - Flipper.stub(:enabled?, false) do - render_inline RocketProgressComponent.new(user: users(:one)) - assert_no_selector ".rocket-progress" - end - end - - private - - def render_progress(hours:, user_hours:) - snapshot = RocketProgress::Snapshot.new(hours: hours, goal_hours: 5000, user_hours: user_hours) - Flipper.stub(:enabled?, true) do - RocketProgress.stub(:snapshot, snapshot) do - render_inline RocketProgressComponent.new(user: users(:one)) - end - end - end -end diff --git a/test/controllers/buku_x3/reveals_controller_test.rb b/test/controllers/buku_x3/reveals_controller_test.rb index 6129e6e3e..23db14e8d 100644 --- a/test/controllers/buku_x3/reveals_controller_test.rb +++ b/test/controllers/buku_x3/reveals_controller_test.rb @@ -67,7 +67,8 @@ class BukuX3::RevealsControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_select ".visual-novel[data-visual-novel-dismiss-thing-value='bukux3_intro']", count: 1 assert_select "dialog.buku-x3-reveal", count: 0 - assert_select ".buku-x3-status", count: 0 + assert_select ".buku-x3-status [role='meter']", count: 1 + assert_select ".buku-x3-status__identity, .buku-x3-status__toggle", count: 0 end test "reveal endpoint requires the intro to be completed" do @@ -118,7 +119,8 @@ class BukuX3::RevealsControllerTest < ActionDispatch::IntegrationTest get home_path(buku_preview: "bean") end assert_response :success - assert_select ".buku-x3-status", count: 0 + assert_select ".buku-x3-status [role='meter']", count: 1 + assert_select ".buku-x3-status__identity, .buku-x3-status__toggle", count: 0 assert_select "turbo-frame#buku_x3_home_status", count: 1 end end diff --git a/test/controllers/home_controller_test.rb b/test/controllers/home_controller_test.rb index 934cfa1a6..6e475125e 100644 --- a/test/controllers/home_controller_test.rb +++ b/test/controllers/home_controller_test.rb @@ -38,6 +38,34 @@ class HomeControllerTest < ActionDispatch::IntegrationTest assert_equal [ 1, 4, 5 ], selected.map(&:id) end + test "guests see team progress without a role hover or legacy rocket bar" do + Flipper.enable(:bukux2) + BukuX3::Assignment.stub(:buku?, ->(_) { flunk "guest must not receive a role" }) do + get home_path + end + assert_response :success + assert_select ".buku-x3-status [role='meter']", count: 1 + assert_select ".buku-x3-status__toggle, .buku-x3-status__identity, .rocket-progress", count: 0 + ensure + Flipper.disable(:bukux2) + end + + test "unlinked accounts see team progress instead of rocket repair" do + @user.update!(onboarded_at: nil, things_dismissed: []) + sign_in @user + Flipper.enable(:bukux2) + Flipper.enable(:bukux3) + BukuX3::Assignment.stub(:buku?, ->(_) { flunk "unlinked account must not receive a role" }) do + get home_path + end + assert_response :success + assert_select ".buku-x3-status [role='meter']", count: 1 + assert_select ".buku-x3-status__toggle, .buku-x3-status__identity, .rocket-progress", count: 0 + ensure + Flipper.disable(:bukux2) + Flipper.disable(:bukux3) + end + private def create_devlog(body:) diff --git a/test/javascript/blackhole_logo_test.mjs b/test/javascript/blackhole_logo_test.mjs new file mode 100644 index 000000000..2ec687cac --- /dev/null +++ b/test/javascript/blackhole_logo_test.mjs @@ -0,0 +1,100 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import BlackholeController from "../../app/javascript/controllers/blackhole_controller.js"; + +const rect = { left: 0, top: 0, width: 240, height: 100 }; +const image = [{ left: 0, top: 0, right: 240, bottom: 100, kind: "image" }]; +const excluded = []; +const intact = "M-50000,-50000H50000V50000H-50000Z"; + +function controller() { + return Object.assign(Object.create(BlackholeController.prototype), { + width: 1200, + height: 800, + level: 0.25, + fringe: [], + pointer: null, + repairRadius: 145, + repairTime: 0, + reducedMotion: { matches: false }, + }); +} + +function mask(c, proportional = true) { + return c.fragmentPath(rect, 9, -62, image, excluded, true, proportional); +} + +test("logo damage starts immediately, tracks the damage range, and is reversible", () => { + const c = controller(); + const snapshots = new Map(); + for (const level of [0, 0.25, 0.5, 0.75, 1]) { + c.level = level; + snapshots.set(level, mask(c)); + const entries = c.fragmentFields + .get(-62) + .field.filter( + ({ cell }) => + cell.cx >= 0 && + cell.cx <= rect.width && + cell.cy >= 0 && + cell.cy <= rect.height, + ); + const damage = + entries.reduce((sum, { erosion = 0 }) => sum + erosion ** 2, 0) / + entries.length; + assert.ok( + Math.abs(damage - level) < 0.07, + `${level}: actual damage ${damage}`, + ); + } + assert.equal(snapshots.get(0), intact); + assert.notEqual(snapshots.get(0.25), intact); + for (const level of [0.75, 0.5, 0.25, 0]) { + c.level = level; + assert.equal(mask(c), snapshots.get(level)); + } +}); + +test("proportional logo fields are cached separately from staged image decay", () => { + const c = controller(); + const stagedPath = mask(c, false); + const staged = c.fragmentFields.get(-62).field; + assert.ok( + staged + .filter( + ({ cell }) => + cell.cx >= 0 && + cell.cx <= rect.width && + cell.cy >= 0 && + cell.cy <= rect.height, + ) + .every(({ start }) => start >= 0.6), + "ordinary images still survive early damage", + ); + const path = mask(c); + const proportional = c.fragmentFields.get(-62).field; + assert.notEqual(proportional, staged); + assert.notEqual(path, intact); + assert.equal(mask(c), path); + assert.equal(c.fragmentFields.get(-62).field, proportional); + assert.equal(mask(c, false), stagedPath); +}); + +test("logo fragments use the shared cursor repair and decay back after it leaves", () => { + const c = controller(); + c.level = 1; + const damaged = mask(c); + c.pointer = { x: 120, y: 50 }; + const repaired = mask(c); + assert.notEqual(repaired, damaged); + const center = c.fragmentFields + .get(-62) + .field.find(({ cell }) => Math.hypot(cell.cx - 120, cell.cy - 50) < 15); + assert.equal(center.cell.repair, 1); + c.pointer = null; + c.repairTime = 100; + mask(c); + assert.ok(center.cell.repair > 0 && center.cell.repair < 1); + c.repairTime = 10000; + assert.equal(mask(c), damaged); +});