diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 47d6028..ce3621f 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -337,6 +337,10 @@ img, svg { z-index: 100; } +/* Three zones: brand + primary links cluster on the left (they're one + thought — "where am I?"), search floats in the middle, controls hold + the right edge. The auto margins on links/right split the free space, + so nothing drifts into detached islands at wide widths. */ .site-nav__inner { max-width: var(--max-width); margin: 0 auto; @@ -344,7 +348,7 @@ img, svg { height: 100%; display: flex; align-items: center; - justify-content: space-between; + gap: var(--space-md); } .site-nav__brand { @@ -355,6 +359,7 @@ img, svg { display: flex; align-items: center; gap: var(--space-sm); + flex-shrink: 0; } .site-nav__logo { @@ -363,15 +368,15 @@ img, svg { border-radius: 6px; } -.env-badge { - font-size: 10px; - font-weight: 600; - color: var(--color-text-inverse); - padding: 1px 6px; - border-radius: 4px; - text-transform: uppercase; - letter-spacing: 0.5px; - line-height: 1.4; +/* Non-production environment marker: a colored ring on the logo (favicon + palette) — layout-neutral, so dev and staging reproduce production's + nav spacing exactly. */ +.site-nav__logo--development { + box-shadow: 0 0 0 2px #10b981; +} + +.site-nav__logo--staging { + box-shadow: 0 0 0 2px #f59e0b; } .site-nav__brand:hover { @@ -384,7 +389,9 @@ img, svg { align-items: center; gap: var(--space-lg); list-style: none; - margin-left: var(--space-lg); + margin-left: var(--space-sm); + margin-right: auto; + flex-shrink: 0; } .site-nav__links a { @@ -413,10 +420,21 @@ img, svg { color: var(--color-text); } +/* The controls may compress as a group (the user's name truncates), but + the icons themselves never shrink — a gear crushed to a sliver, or a + bell whose badge outlives it, reads as breakage. */ .site-nav__right { display: flex; align-items: center; gap: var(--space-lg); + margin-left: auto; + min-width: 0; +} + +.site-nav__icon-link, +.site-nav__bell, +.inbox-dropdown { + flex-shrink: 0; } .site-nav__user { @@ -426,6 +444,13 @@ img, svg { color: var(--color-text-muted); font-size: var(--text-sm); white-space: nowrap; + min-width: 0; +} + +.site-nav__profile-link { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } /* Main content */ @@ -450,17 +475,64 @@ img, svg { font-weight: 700; } -/* Plan page header: file icon + title, byline underneath — compact, the - document itself is the point. */ -.page-header--plan { - margin-bottom: var(--space-sm); - align-items: flex-start; +/* The plan masthead: two visual rows. Row one is the title (wrapping + naturally, as wide as it needs). Row two is the byline on the left and + the viewer-relative side column (presence + toolbar) on the right — + bottom-aligning the side column against the header makes the toolbar + sit level with the byline, so the whole block reads as two bands even + though it's two DOM subtrees (broadcasts own the left, the request + owns the right). */ +.plan-masthead { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-sm) var(--space-lg); + flex-wrap: wrap; + margin-bottom: var(--space-md); } -.page-header__title-row { +.plan-masthead__side { display: flex; align-items: center; + gap: var(--space-md); + margin-left: auto; + flex-shrink: 0; + /* Level the toolbar pill's baseline band with the byline text. */ + margin-bottom: -2px; +} + +/* Plan page header: one lockup — the file icon beside a text stack + (title over byline) that shares a single left edge. The icon anchors + to the title's first line (36px icon inside a ~38px line box), so it + reads as the document's mark whether or not the title wraps, and the + byline hangs in the title's text column instead of being fake-indented. + Everything here is broadcast-safe; viewer-relative chrome (Save, owner + toolbar) lives in the masthead's side column, outside this subtree. */ +.page-header--plan { + display: flex; + align-items: flex-start; + justify-content: flex-start; gap: var(--space-sm); + margin-bottom: 0; + flex: 1; + min-width: min(24rem, 100%); +} + +.page-header--plan .plan-type-icon { + flex-shrink: 0; + margin-top: 1px; +} + +/* Center the back arrow (24px hit area) against the title's first line. */ +.page-header--plan .history-back { + margin-top: 7px; +} + +.page-header__text { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; } .page-header__title { @@ -473,26 +545,7 @@ img, svg { align-items: center; gap: var(--space-xs); flex-wrap: wrap; - margin-top: var(--space-xs); -} - -/* Shelf chips (left) + owner actions (right) share one compact row. */ -.plan-meta-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-md); - flex-wrap: wrap; - margin-bottom: var(--space-md); -} - -.plan-meta-row .plan-shelves, -.plan-meta-row .plan-actions { - margin-bottom: 0; -} - -.plan-meta-row .plan-actions { - margin-left: auto; + min-width: 0; } /* Plan viewers / presence */ @@ -1037,12 +1090,6 @@ img.avatar { word-wrap: break-word; } -/* Page header actions */ -.page-header__actions { - display: flex; - gap: var(--space-sm); -} - /* Version list */ /* History split view */ .history-split { @@ -1151,6 +1198,26 @@ img.avatar { height: 200px; } +/* On narrow screens the side-by-side split would crush the diff to a + word per line — stack instead: a short scrollable version list on + top, the detail below it at full width. */ +@media (max-width: 720px) { + .history-split { + grid-template-columns: 1fr; + } + + .history-split__list { + border-right: none; + border-bottom: 1px solid var(--color-border); + max-height: 260px; + } + + .history-split__detail { + padding: var(--space-md); + max-height: none; + } +} + /* Version diff preview */ .version-diff-preview__header { display: flex; @@ -2350,16 +2417,17 @@ img.avatar { padding: 0 4px; } -/* Floating inbox panel */ +/* Floating inbox panel. Solid, not glass: it lives inside the nav, + whose own backdrop-filter blocks a descendant's backdrop-filter from + sampling the page — translucency here would just let the page bleed + through unblurred. */ .inbox-panel { position: absolute; top: calc(100% + var(--space-sm)); right: 0; width: 420px; max-height: 480px; - background: var(--glass-bg-strong); - -webkit-backdrop-filter: var(--glass-blur); - backdrop-filter: var(--glass-blur); + background: var(--color-surface); border: 1px solid var(--glass-edge); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); @@ -2635,27 +2703,7 @@ img.avatar { text-decoration: none; } -/* The visibility eye: slash appears when Private (matching the state - flag's crossed eye); armed = mid-flip, waiting for the confirming - second click. */ -.visibility-toggle .visibility-toggle__slash { - display: none; -} - -.visibility-toggle--hidden .visibility-toggle__slash { - display: initial; -} - -.visibility-toggle--armed { - color: var(--color-primary); - animation: visibility-pulse 1.2s ease-in-out infinite; -} - -@keyframes visibility-pulse { - 50% { opacity: 0.45; } -} - -/* Folder-picker navigator (the bookmark's popover) */ +/* Folder-picker navigator (the Save / Move-to-folder popover) */ .folder-picker__plan { margin: 0 0 var(--space-sm); } @@ -2720,6 +2768,33 @@ img.avatar { font-weight: 600; } +/* "Remove from library" — shown in the navigator only when the trigger + was already saved. Letting go is a labeled, deliberate choice down + here, never a surprise toggle on the Save button itself. */ +.folder-picker__remove { + display: flex; + align-items: center; + gap: var(--space-xs); + width: 100%; + margin-top: var(--space-sm); + padding: 5px var(--space-sm); + border: none; + border-top: 1px solid var(--color-border); + border-radius: 0 0 var(--radius) var(--radius); + background: none; + color: var(--color-text-muted); + font-size: var(--text-sm); + font-family: inherit; + text-align: left; + cursor: pointer; + padding-top: calc(5px + var(--space-xs)); +} + +.folder-picker__remove:hover { + color: var(--color-danger); + background: var(--color-danger-soft); +} + /* History page (the plan's time machine) */ .history-page { padding: var(--space-lg); @@ -3960,14 +4035,28 @@ img.avatar { background: var(--color-surface); } -/* Plan owner actions (show page): quiet icon buttons tucked at the top - right of the document, out of the content's way. */ +/* Plan owner actions (show page): one glass pill at the top right of the + document, so the controls read as a single toolbar rather than icons + scattered along the edge. Falls back to a translucent solid where + backdrop-filter is unsupported (the tokens carry alpha regardless). */ .plan-actions { display: flex; align-items: center; justify-content: flex-end; - gap: var(--space-sm); - margin-bottom: var(--space-sm); + gap: var(--space-xs); + padding: 3px; + border: 1px solid var(--glass-edge); + border-radius: 9999px; + background: var(--glass-bg); + -webkit-backdrop-filter: var(--glass-blur); + backdrop-filter: var(--glass-blur); + box-shadow: var(--shadow-glass); +} + +/* Inside the pill, buttons go fully round so the container's curve and + the controls' curves agree. */ +.plan-actions .btn { + border-radius: 9999px; } /* Square icon-only button. */ @@ -3980,111 +4069,94 @@ img.avatar { display: block; } -/* Archive sits slightly apart from the constructive actions — same - corner, small gap (a full-width split reads as a stray icon). */ -.plan-actions__archive { - margin-left: var(--space-sm); +/* The Save control (readers only): a labeled bookmark. Saved = filled + icon + accent, and clicking it opens the navigator to move or remove — + it never unsaves on the spot. */ +.plan-save--saved { + color: var(--color-primary); } -/* Full-width state banner on the plan page (e.g. archived notice). */ -.plan-banner { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-md); - padding: var(--space-sm) var(--space-md); - margin-bottom: var(--space-md); - border: 1px solid var(--color-border); - border-radius: var(--radius); - background: var(--color-status-abandoned-bg); - color: var(--color-text-muted); - font-size: var(--text-sm); +/* Dropdown menu on the native Popover API — the toolbar's ⋯ overflow. + Same glass as the pill it hangs from. Position is set inline by + menu_controller (popovers open centered by default). */ +.menu { + min-width: 13.5rem; + padding: var(--space-xs); + border: 1px solid var(--glass-edge); + border-radius: var(--radius-lg); + background: var(--glass-bg-strong); + -webkit-backdrop-filter: var(--glass-blur); + backdrop-filter: var(--glass-blur); + box-shadow: var(--shadow-glass); + color: var(--color-text); } -/* Archived rows stay legible but visually recede. */ -.plan-row--archived { - opacity: 0.65; +/* button_to wraps its button in a form — flatten it out of the layout. */ +.menu__form { + display: contents; } -/* Folder-jump strip on the plan page: which library shelves hold this - plan, plus the "add to your library" quick control. */ -.plan-shelves { +.menu__item { display: flex; align-items: center; - flex-wrap: wrap; - gap: var(--space-xs) var(--space-sm); - margin-bottom: var(--space-md); - font-size: var(--text-sm); -} - -/* Drive-style folder chip: small folder glyph + the folder's name. */ -.plan-shelves__chip { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 3px 12px 3px 9px; - border: 1px solid var(--color-border); - border-radius: 9999px; - background: var(--color-surface); + gap: var(--space-sm); + width: 100%; + padding: 6px var(--space-sm); + border: none; + border-radius: var(--radius); + background: none; color: var(--color-text); - text-decoration: none; font-size: var(--text-sm); - line-height: 1.4; - transition: background 0.12s ease, border-color 0.12s ease; + font-family: inherit; + text-align: left; + text-decoration: none; + cursor: pointer; } -.plan-shelves__chip:hover { +.menu__item:hover { background: var(--color-bg-muted); - border-color: var(--color-border-strong); + text-decoration: none; } -.plan-shelves__chip-icon { - color: var(--color-text-muted); +.menu__item svg { flex-shrink: 0; + color: var(--color-text-muted); } -.plan-shelves__chip--theirs .plan-shelves__chip-icon { - color: var(--color-agent); +/* Destructive actions read as such before you commit to them. */ +.menu__item--danger, +.menu__item--danger svg { + color: var(--color-danger); } -.plan-shelves__chip-owner { - color: var(--color-text-muted); - font-size: var(--text-xs); +.menu__item--danger:hover { + background: var(--color-danger-soft); } -/* The save bookmark sits beside the plan title (mounted into - #plan-bookmark-slot by plan_bookmark_controller) — a quiet icon that - fills once the plan is on your shelf, like a bookmark star. */ -.plan-bookmark-slot { - display: inline-flex; - align-items: center; +.menu__separator { + height: 1px; + margin: var(--space-xs) calc(-1 * var(--space-xs)); + background: var(--color-border); } -.plan-bookmark { - display: inline-flex; +/* Full-width state banner on the plan page (e.g. archived notice). */ +.plan-banner { + display: flex; align-items: center; - padding: 4px; - border: none; + justify-content: space-between; + gap: var(--space-md); + padding: var(--space-sm) var(--space-md); + margin-bottom: var(--space-md); + border: 1px solid var(--color-border); border-radius: var(--radius); - background: transparent; + background: var(--color-status-abandoned-bg); color: var(--color-text-muted); - cursor: pointer; - transition: color 0.12s ease, background 0.12s ease; -} - -.plan-bookmark:hover { - color: var(--color-primary); - background: var(--color-primary-light); -} - -.plan-bookmark--saved { - color: var(--color-primary); + font-size: var(--text-sm); } -/* Saved = a second click removes; hint at that on hover. */ -.plan-bookmark--saved:hover { - color: var(--color-danger, #e0637a); - background: color-mix(in srgb, var(--color-danger, #e0637a) 10%, transparent); +/* Archived rows stay legible but visually recede. */ +.plan-row--archived { + opacity: 0.65; } /* Library browsing page (read-only shelves) */ @@ -4349,7 +4421,7 @@ img.avatar { } .home__day { - margin-bottom: var(--space-xl); + margin-bottom: var(--space-lg); } .home__day-heading { @@ -4377,7 +4449,7 @@ img.avatar { display: flex; align-items: flex-start; gap: var(--space-md); - padding: var(--space-md) 0; + padding: var(--space-sm) 0; border-bottom: 1px solid var(--color-border); } @@ -4395,11 +4467,17 @@ img.avatar { flex: 1; } +/* The item's first line flows as text — icon, title, and the activity + rollup sit inline, so a long title wraps like a sentence instead of + shattering the row (icon orphaned above, "new" stranded below). */ .home__item-line { - display: flex; - align-items: baseline; - gap: var(--space-sm); - flex-wrap: wrap; + min-width: 0; +} + +.home__item-line .plan-type-icon { + display: inline-flex; + vertical-align: text-bottom; + margin-right: var(--space-xs); } .home__item-title { @@ -4417,6 +4495,7 @@ img.avatar { font-size: var(--text-xs); color: var(--color-text-muted); white-space: nowrap; + margin-left: var(--space-xs); } /* A plan's first appearance gets a whisper of accent — not a badge. */ @@ -5261,6 +5340,34 @@ img.avatar { } } +/* ---- Medium screens ---- + The first thing the nav gives up is the search field's prose: the + placeholder and "/" hint fold into a round icon button before anything + else is allowed to compress. The name gets a truncation budget so the + controls never fight it for room. */ +@media (max-width: 900px) { + .site-nav__search-label, + .site-nav__search-kbd { + display: none; + } + + .site-nav__search { + flex: 0 0 auto; + gap: 0; + padding: 8px; + margin: 0; + border-radius: 9999px; + } + + .site-nav__right { + gap: var(--space-md); + } + + .site-nav__profile-link { + max-width: 9rem; + } +} + /* ---- Phone-sized screens ---- Reviewing your agents' work from a phone is a first-class flow — reading, commenting, and finding the right doc all have to work at @@ -5271,13 +5378,9 @@ img.avatar { gap: var(--space-sm); } - /* Icons over labels: brand text, env badge, search placeholder, the - "/" hint, the settings gear, and the sign-out link all yield to the - things a phone visit is actually for. */ + /* Icons over labels: brand text, the settings gear, and the sign-out + link all yield to the things a phone visit is actually for. */ .site-nav__brand-text, - .env-badge, - .site-nav__search-label, - .site-nav__search-kbd, .site-nav__icon-link, .site-nav__user a[data-turbo-method="delete"] { display: none; @@ -5288,39 +5391,8 @@ img.avatar { gap: var(--space-md); } - .site-nav__search { - flex: 0 0 auto; - gap: 0; - padding: 8px; - margin: 0; - margin-left: auto; - border-radius: 9999px; - } - - .site-nav__right { - gap: var(--space-md); - } - - /* Nothing in the chrome gets crushed to make room for the user's full - name — the name is what truncates. */ - .site-nav__brand, - .site-nav__logo, - .site-nav__bell, - .inbox-dropdown { - flex-shrink: 0; - } - - .site-nav__user { - min-width: 0; - } - .site-nav__profile-link { - display: inline-block; max-width: 6.5rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - vertical-align: bottom; } .main-content { @@ -5337,6 +5409,11 @@ img.avatar { overflow-wrap: anywhere; } + /* At phone widths the byline can wrap under itself freely. */ + .page-header__byline { + flex-wrap: wrap; + } + .plan-document { padding: var(--space-md); } @@ -5370,7 +5447,18 @@ img.avatar { flex-basis: 100%; } + /* Narrow screens: the timestamp stops being a side column (it was + squeezing titles to a word per line) and tucks under the byline, + indented to the body's left edge (2rem avatar + md gap). */ + .home__item { + flex-wrap: wrap; + } + .home__item-time { + order: 3; + flex-basis: 100%; + padding-top: 0; + margin-left: calc(2rem + var(--space-md)); font-size: 0.75rem; } } diff --git a/engine/app/controllers/coplan/application_controller.rb b/engine/app/controllers/coplan/application_controller.rb index d6c29b1..eb04095 100644 --- a/engine/app/controllers/coplan/application_controller.rb +++ b/engine/app/controllers/coplan/application_controller.rb @@ -84,6 +84,17 @@ def allowed_to?(record, action) end helper_method :allowed_to? + # A bottom-corner toast as a Turbo Stream — append it to any + # turbo_stream response so in-place actions get feedback without a + # top-of-page flash (which would mean scrolling the reader away). + def toast_stream(message, kind) + turbo_stream.append("coplan-toasts", + helpers.content_tag(:div, message, + class: "flash flash--#{kind} toasts__toast", + role: "status", + data: { controller: "coplan--toast" })) + end + # Fires once per successful, signed-in HTML GET. Skips Turbo Frame # requests (those are partial reloads within an already-counted page), # non-2xx responses, agent/API traffic, and anything that isn't HTML. diff --git a/engine/app/controllers/coplan/attachments_controller.rb b/engine/app/controllers/coplan/attachments_controller.rb index 7456538..2371145 100644 --- a/engine/app/controllers/coplan/attachments_controller.rb +++ b/engine/app/controllers/coplan/attachments_controller.rb @@ -91,13 +91,5 @@ def count_stream(id, count) turbo_stream.replace(id, html: helpers.content_tag(:span, count, class: "section-count", id: id)) end - - def toast_stream(message, kind) - turbo_stream.append("coplan-toasts", - helpers.content_tag(:div, message, - class: "flash flash--#{kind} toasts__toast", - role: "status", - data: { controller: "coplan--toast" })) - end end end diff --git a/engine/app/controllers/coplan/plans_controller.rb b/engine/app/controllers/coplan/plans_controller.rb index 128f36d..23e1692 100644 --- a/engine/app/controllers/coplan/plans_controller.rb +++ b/engine/app/controllers/coplan/plans_controller.rb @@ -142,9 +142,8 @@ def show # Old ?tab=history links: history is its own page now (the other # former tabs are same-page sections). return redirect_to history_plan_path(@plan) if params[:tab] == "history" - # Folder-jump discovery: every shelf this plan sits on. The plan - # itself is already authorized above, and placements inherit the - # plan's visibility — a shelf never reveals more than the plan does. + # The viewer's own placement (if any) drives the toolbar's + # Save/Saved state and the folder navigator's current-folder mark. @shelf_placements = @plan.placements .includes(:library, folder: { parent: :parent }) .order(:created_at) @@ -257,9 +256,9 @@ def preview render html: html, layout: false end - # Publishing is the one-way door out of draft: explicit, confirmed in - # the UI, and irreversible by design (archive is the tool for "done - # with this", not unpublish). + # One direction of the header visibility toggle: share a private plan + # with the whole org. The other direction is #hide — visibility is a + # two-way switch (archive is the tool for "done with this"). def publish authorize!(@plan, :publish?) @plan.update!(visibility: "published") @@ -279,6 +278,10 @@ def publish via: "web" ) respond_to do |format| + # The toggle fetches this: the header re-render carries the new + # state flag, so the page repaints even when the ActionCable + # broadcast can't reach this browser. + format.turbo_stream { render turbo_stream: visibility_streams("Shared with everyone in the org.") } format.json { render json: { visibility: @plan.visibility } } format.html { redirect_to plan_path(@plan), notice: "Plan published — everyone can see it now." } end @@ -306,17 +309,25 @@ def hide via: "web" ) respond_to do |format| + format.turbo_stream { render turbo_stream: visibility_streams("Private again — hidden from lists and search.") } format.json { render json: { visibility: @plan.visibility } } format.html { redirect_to plan_path(@plan), notice: "Plan is private again — hidden from lists and search." } end end + # Archiving happens in place: the banner appears (with Restore — the + # undo), the toolbar's menu loses its Archive entry, and a toast + # confirms. No navigation, so the consequence is visible right where + # the action happened. def archive authorize!(@plan, :archive?) @plan.update!(archived_at: Time.current) broadcast_plan_update(@plan) Plans::LogEvent.call(plan: @plan, actor: current_user, event_type: "archived") - redirect_to plan_path(@plan), notice: "Plan archived. It's hidden from lists unless someone filters for archived plans." + respond_to do |format| + format.turbo_stream { render turbo_stream: archive_streams("Archived — hidden from lists, still readable at this URL.") } + format.html { redirect_to plan_path(@plan), notice: "Plan archived. It's hidden from lists unless someone filters for archived plans." } + end end def unarchive @@ -324,7 +335,10 @@ def unarchive @plan.update!(archived_at: nil) broadcast_plan_update(@plan) Plans::LogEvent.call(plan: @plan, actor: current_user, event_type: "unarchived") - redirect_to plan_path(@plan), notice: "Plan restored." + respond_to do |format| + format.turbo_stream { render turbo_stream: archive_streams("Plan restored.") } + format.html { redirect_to plan_path(@plan), notice: "Plan restored." } + end end def toggle_checkbox @@ -663,5 +677,30 @@ def set_plan def broadcast_plan_update(plan) Broadcaster.replace_to(plan, target: "plan-header", partial: "coplan/plans/header", locals: { plan: plan }) end + + # Turbo Streams for a visibility change: re-render the header (the + # byline's Private flag) and the toolbar (whose menu offers the + # opposite direction now) in the acting browser, and confirm with a + # toast. + def visibility_streams(message) + [ + turbo_stream.replace("plan-header", partial: "coplan/plans/header", locals: { plan: @plan }), + turbo_stream.replace("plan-toolbar", partial: "coplan/plans/toolbar", locals: { plan: @plan }), + toast_stream(message, "notice") + ] + end + + # Turbo Streams for archive/restore: the banner slot is the loud, + # visible consequence (it appears with a Restore button — the undo), + # the header's byline picks up/drops the Archived flag, and the + # toolbar re-renders so its menu tracks the new state. + def archive_streams(message) + [ + turbo_stream.replace("plan-header", partial: "coplan/plans/header", locals: { plan: @plan }), + turbo_stream.replace("plan-banner-slot", partial: "coplan/plans/banner", locals: { plan: @plan }), + turbo_stream.replace("plan-toolbar", partial: "coplan/plans/toolbar", locals: { plan: @plan }), + toast_stream(message, "notice") + ] + end end end diff --git a/engine/app/helpers/coplan/application_helper.rb b/engine/app/helpers/coplan/application_helper.rb index be653b2..6553691 100644 --- a/engine/app/helpers/coplan/application_helper.rb +++ b/engine/app/helpers/coplan/application_helper.rb @@ -66,12 +66,20 @@ def user_avatar(user, size: "sm") end end - def coplan_environment_badge + # Non-production environments mark the nav logo instead of adding a + # badge next to the brand — a badge changes the nav's layout, so dev + # and staging would never reproduce production's spacing. The logo + # gets a colored ring (same palette as the favicon) and a tooltip. + def coplan_environment_logo_modifier + return "" if Rails.env.production? + + " site-nav__logo--#{Rails.env}" + end + + def coplan_environment_logo_title return if Rails.env.production? - label = Rails.env.capitalize - colors = FAVICON_COLORS.fetch(Rails.env, FAVICON_COLORS["development"]) - tag.span(label, class: "env-badge", style: "background: #{colors[:start]};") + "#{Rails.env.capitalize} environment" end end end diff --git a/engine/app/javascript/controllers/coplan/folder_picker_controller.js b/engine/app/javascript/controllers/coplan/folder_picker_controller.js index 97776c6..9ab7dc3 100644 --- a/engine/app/javascript/controllers/coplan/folder_picker_controller.js +++ b/engine/app/javascript/controllers/coplan/folder_picker_controller.js @@ -1,13 +1,13 @@ import { Controller } from "@hotwired/stimulus" -// The "save to library" bookmark. Behaves like a browser bookmark star: -// unsaved, a click opens the folder navigator popover (the viewer's folder -// tree, a little filesystem) to pick where it goes; saved, a click just -// removes it — no dialog, no toast — and you can re-add if you want. One -// shared popover per page — triggers carry the plan's move URL, current -// folder, and saved state so rows and the plan header can all reuse it. +// The folder navigator popover: the viewer's folder tree, a little +// filesystem. Every trigger opens it — Save on someone's plan, "Move to +// folder…" in the plan menu, workspace row fallbacks. Triggers carry the +// plan's move URL, current folder, and saved state; already-saved triggers +// get a "Remove from library" row inside the navigator, so removal is a +// deliberate labeled choice instead of a surprise toggle on the trigger. export default class extends Controller { - static targets = ["modal", "title"] + static targets = ["modal", "title", "heading", "remove", "removeLabel"] open(event) { event.preventDefault() @@ -15,12 +15,17 @@ export default class extends Controller { this._moveUrl = trigger.dataset.moveUrl this._currentFolderId = trigger.dataset.currentFolderId || "" - // Already saved: the second click unbookmarks, quietly. - if (trigger.dataset.saved === "true") { - this._patch("", { quiet: true }) - return + if (this.hasHeadingTarget) { + this.headingTarget.textContent = trigger.dataset.pickerHeading || "Save to library" + } + if (this.hasRemoveTarget) { + this.removeTarget.hidden = trigger.dataset.saved !== "true" + if (this.hasRemoveLabelTarget) { + // "Remove" means different things per trigger: a reader letting a + // saved plan go entirely vs. an owner unfiling their own plan. + this.removeLabelTarget.textContent = trigger.dataset.removeLabel || "Remove from library" + } } - if (this.hasTitleTarget && trigger.dataset.planTitle) { this.titleTarget.textContent = trigger.dataset.planTitle } @@ -45,6 +50,13 @@ export default class extends Controller { this._patch(folderId) } + // The "Remove from library" row inside the navigator (only shown when + // the trigger was already saved). + remove() { + if (!this._moveUrl) return + this._patch("") + } + _patch(folderId, { quiet = false } = {}) { const token = document.querySelector('meta[name="csrf-token"]')?.content fetch(this._moveUrl, { diff --git a/engine/app/javascript/controllers/coplan/menu_controller.js b/engine/app/javascript/controllers/coplan/menu_controller.js new file mode 100644 index 0000000..70222fe --- /dev/null +++ b/engine/app/javascript/controllers/coplan/menu_controller.js @@ -0,0 +1,41 @@ +import { Controller } from "@hotwired/stimulus" + +// A small dropdown menu on the native Popover API (light-dismiss and Esc +// for free). Popovers open centered by default; this anchors the menu +// under its trigger's right edge on every open, and closes it on scroll +// so it never floats away from the button. +export default class extends Controller { + static targets = ["trigger", "menu"] + + reposition(event) { + if (event.newState !== "open") { + this.#stopWatchingScroll() + return + } + const rect = this.triggerTarget.getBoundingClientRect() + const menu = this.menuTarget + menu.style.position = "fixed" + menu.style.margin = "0" + menu.style.top = `${rect.bottom + 6}px` + menu.style.left = "auto" + menu.style.right = `${Math.max(window.innerWidth - rect.right, 8)}px` + this.#watchScroll() + } + + close() { + try { this.menuTarget.hidePopover() } catch {} + } + + disconnect() { + this.#stopWatchingScroll() + } + + #watchScroll() { + this.scrollHandler ||= () => this.close() + window.addEventListener("scroll", this.scrollHandler, { passive: true, once: true }) + } + + #stopWatchingScroll() { + if (this.scrollHandler) window.removeEventListener("scroll", this.scrollHandler) + } +} diff --git a/engine/app/javascript/controllers/coplan/plan_bookmark_controller.js b/engine/app/javascript/controllers/coplan/plan_bookmark_controller.js deleted file mode 100644 index 594b64c..0000000 --- a/engine/app/javascript/controllers/coplan/plan_bookmark_controller.js +++ /dev/null @@ -1,30 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -// Mounts the save bookmark into the plan header's title row. The bookmark -// is viewer-relative (saved state, move URL) so it can't be rendered inside -// the broadcast-replaced #plan-header partial — instead the show view -// renders it into a