diff --git a/AGENTS.md b/AGENTS.md index 69443be..07d2fd2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,31 @@ Most of the application logic lives in the **CoPlan Rails engine** (`engine/`), - No npm packages — everything through importmaps or inline - Views use Turbo Frames for partial page updates, Turbo Streams for realtime broadcasts - Keep it simple: no React, no Vue, no component libraries + +### HARD REQUIREMENT: native feel, secretly HTML + +The entire goal of this stack is an app that is plain HTML over the wire but +**feels indistinguishable from a native app**. Server-rendered HTML is the +implementation, never the excuse. Concretely, every interaction must meet +this bar: + +- **No layout jiggle between navigations.** Page width and shared chrome must + not shift when moving between pages (`scrollbar-gutter: stable` guards the + scrollbar case; keep shared containers dimensionally consistent). +- **No scroll jumps or full-page reloads for in-page actions.** Submitting a + form, uploading a file, toggling state — the result appears in place + (Turbo Streams / fetch + partial swap), with progress feedback during the + wait. `redirect_to` + full reload for something the user did mid-page is a + bug, not a fallback. +- **Immediate feedback for every action** — optimistic UI, busy/progress + states, pulses. Nothing may silently wait on a server round-trip. +- **Instant-feeling navigation.** Real navigations use Turbo Drive with hover + prefetch; anything that can be preloaded on intent (hover, spring pulses) + should be. + +If an interaction scrolls, flashes, reflows, or stalls in a way a native app +wouldn't, treat it as a bug — regardless of how idiomatic the Rails behind +it is. - **Prefer server-rendered HTML with standard Stimulus bindings** (`data-controller`, `data-action`, `data-*-target`) — direct `addEventListener` is a last resort, only when elements are created dynamically in JS and Stimulus can't bind to them (e.g., inline text highlights wrapping arbitrary DOM ranges). Even these cases should be revisited for server-side alternatives when practical. ## Testing diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index e98e73b..47d6028 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -4,7 +4,9 @@ color-scheme: light; /* Colors */ - --color-bg: #fafafa; + /* Slightly cool and dark for a page background: white cards need + something to stand out against, or light mode reads as fog. */ + --color-bg: #eef0f5; --color-bg-muted: #f3f4f6; --color-surface: #ffffff; --color-surface-muted: #f9fafb; @@ -108,7 +110,11 @@ --glass-edge: rgba(255, 255, 255, 0.75); --glass-blur: blur(16px) saturate(1.6); --shadow-glass: inset 0 1px 0 rgba(255, 255, 255, 0.7), 0 1px 2px rgba(15, 23, 42, 0.04), 0 10px 30px rgba(15, 23, 42, 0.08); - --surface-translucent: rgba(255, 255, 255, 0.6); + --surface-translucent: rgba(255, 255, 255, 0.75); + /* Card outline. The white --glass-edge reads as a specular highlight on + dark backdrops but disappears white-on-white — cards and rows use this + faintly dark line instead so light mode has real definition. */ + --card-edge: rgba(24, 33, 54, 0.10); } @media (prefers-color-scheme: dark) { @@ -184,6 +190,7 @@ --glass-blur: blur(16px) saturate(1.4); --shadow-glass: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 1px 2px rgba(0, 0, 0, 0.3), 0 12px 32px rgba(0, 0, 0, 0.4); --surface-translucent: rgba(21, 32, 51, 0.55); + --card-edge: rgba(148, 163, 184, 0.16); } } @@ -260,6 +267,7 @@ --glass-blur: blur(16px) saturate(1.4); --shadow-glass: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 1px 2px rgba(0, 0, 0, 0.3), 0 12px 32px rgba(0, 0, 0, 0.4); --surface-translucent: rgba(21, 32, 51, 0.55); + --card-edge: rgba(148, 163, 184, 0.16); } /* Forced light theme — overrides system dark preference */ @@ -281,6 +289,11 @@ html { color: var(--color-text); background-color: var(--color-bg); -webkit-font-smoothing: antialiased; + /* Native-feel hard requirement: page width must NOT change between + navigations. Without a stable gutter, the scrollbar appearing or + disappearing between a long page and a short one re-centers the whole + layout — a "jiggle" on every workspace → plan hop. */ + scrollbar-gutter: stable; } body { @@ -494,6 +507,20 @@ img, svg { flex-direction: row-reverse; } +/* Each avatar is wrapped in a profile link — the link owns the stacking + overlap and the tooltip (::after can't render on an ). */ +.plan-viewers__avatar-link { + display: flex; + margin-left: -0.5rem; + border-radius: 50%; + position: relative; +} + +.plan-viewers__avatar-link:hover { + z-index: 1; + transform: translateY(-1px); +} + .plan-viewers__avatar { display: flex; align-items: center; @@ -506,9 +533,6 @@ img, svg { font-size: 0.7rem; font-weight: 600; border: 2px solid var(--color-surface); - margin-left: -0.5rem; - cursor: default; - position: relative; object-fit: cover; } @@ -516,7 +540,7 @@ img, svg { background: var(--color-success); } -.plan-viewers__avatar::after { +.plan-viewers__avatar-link::after { content: attr(data-tooltip); position: absolute; bottom: calc(100% + 6px); @@ -535,7 +559,7 @@ img, svg { z-index: 10; } -.plan-viewers__avatar:hover::after { +.plan-viewers__avatar-link:hover::after { opacity: 1; } @@ -653,7 +677,7 @@ img, svg { background: var(--glass-bg-strong); -webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur); - border: 1px solid var(--glass-edge); + border: 1px solid var(--card-edge); border-radius: var(--radius-lg); padding: var(--space-lg); box-shadow: var(--shadow-glass); @@ -2077,6 +2101,12 @@ img.avatar { width: 220px; flex-shrink: 0; max-height: calc(100vh - var(--nav-height) - var(--space-xl) * 2); + /* The nav is the scroll container — a long outline must not push the + back-matter links (References/Attachments) below the sticky bound + where no amount of page scrolling reaches them. */ + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--color-border) transparent; transition: width 0.2s ease, opacity 0.2s ease; margin-right: var(--space-lg); } @@ -2125,10 +2155,8 @@ img.avatar { list-style: none; padding: 0; margin: 0; - overflow-y: auto; - max-height: calc(100vh - var(--nav-height) - 8rem); - scrollbar-width: thin; - scrollbar-color: var(--color-border) transparent; + /* No inner scroll region: the whole nav scrolls (see .content-nav), so + the outline and the back-matter links share one scroll. */ } .content-nav__item { @@ -2522,27 +2550,26 @@ img.avatar { /* Plan document + footnotes: one page, no tabs. The document is the main glass card; references and attachments follow like back matter — same page, quieter surface. */ +/* Back matter lives inside the content column, closing out the document + after a rule — no card-within-card, no space below the sidebar. Empty + sections are a single quiet line. */ .plan-footnotes { - margin-top: var(--space-xl); + margin-top: var(--space-2xl); + padding-top: var(--space-lg); + border-top: 1px solid var(--color-border); display: flex; flex-direction: column; gap: var(--space-lg); } .plan-footnote { - /* Set apart from the document proper: flatter, edge-lit, no heavy card. */ - background: var(--surface-translucent); - border: 1px solid var(--glass-edge); - border-left: 3px solid var(--color-border); - border-radius: var(--radius); - scroll-margin-top: var(--space-lg); + scroll-margin-top: calc(var(--nav-height) + var(--space-lg)); } .plan-footnote__header { display: flex; align-items: center; gap: var(--space-sm); - padding: var(--space-md) var(--space-lg) 0; } .plan-footnote__title { @@ -2721,8 +2748,17 @@ img.avatar { } /* References section */ -.references-section { - padding: var(--space-lg); +/* Section bodies hang under the header, indented past the round "+" so + content lines up with the section title. */ +.references-section, +.attachments-section { + padding: 0 0 0 calc(24px + var(--space-sm)); +} + +.plan-footnote__empty { + margin: var(--space-xs) 0 0; + font-size: var(--text-sm); + color: var(--color-text-muted); } .references-list { @@ -2926,10 +2962,6 @@ img.avatar { } /* Attachments section */ -.attachments-section { - padding: var(--space-lg); -} - .attachments-section__header { margin-bottom: var(--space-md); } @@ -2965,7 +2997,26 @@ img.avatar { .dropzone--busy { pointer-events: none; - opacity: 0.7; + opacity: 0.85; +} + +/* Live upload progress — fills as bytes actually leave the machine. */ +.dropzone__progress { + width: min(18rem, 100%); + height: 5px; + margin-top: var(--space-xs); + border-radius: 999px; + background: color-mix(in srgb, var(--color-primary) 18%, transparent); + overflow: hidden; +} + +.dropzone__progress-fill { + display: block; + height: 100%; + width: 0; + border-radius: inherit; + background: var(--color-primary); + transition: width 0.15s ease; } .dropzone__icon { @@ -4001,36 +4052,39 @@ img.avatar { font-size: var(--text-xs); } -/* The header bookmark: save/move/remove via the folder navigator. */ -.plan-shelves__bookmark { +/* 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; - gap: 4px; - font-size: var(--text-xs); - font-family: inherit; - padding: 3px 8px; - border: 1px dashed var(--color-border); - border-radius: 9999px; +} + +.plan-bookmark { + display: inline-flex; + align-items: center; + padding: 4px; + border: none; + border-radius: var(--radius); background: transparent; color: var(--color-text-muted); cursor: pointer; - transition: color 0.12s ease, border-color 0.12s ease; + transition: color 0.12s ease, background 0.12s ease; } -.plan-shelves__bookmark:hover { +.plan-bookmark:hover { color: var(--color-primary); - border-color: var(--color-primary); + background: var(--color-primary-light); } -.plan-shelves__bookmark--saved { +.plan-bookmark--saved { color: var(--color-primary); - border-style: solid; } /* Saved = a second click removes; hint at that on hover. */ -.plan-shelves__bookmark--saved:hover { +.plan-bookmark--saved:hover { color: var(--color-danger, #e0637a); - border-color: var(--color-danger, #e0637a); + background: color-mix(in srgb, var(--color-danger, #e0637a) 10%, transparent); } /* Library browsing page (read-only shelves) */ @@ -4501,6 +4555,12 @@ img.avatar { top: calc(var(--nav-height) + var(--space-md)); font-size: var(--text-sm); min-width: 0; + /* A sticky sidebar taller than the viewport pins its overflow below the + fold forever — page scroll can't reach it. Scroll the sidebar itself. */ + max-height: calc(100vh - var(--nav-height) - var(--space-md) * 2); + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--color-border) transparent; } .workspace__sidebar-sections { @@ -4689,6 +4749,8 @@ img.avatar { color: var(--color-text); text-decoration: none; min-width: 0; + -webkit-user-select: none; + user-select: none; } /* Leaf nodes (no
) keep the same left padding so labels align. */ @@ -4707,12 +4769,86 @@ img.avatar { font-weight: 500; } -/* Live drop target under the cursor — folder links and group headings. */ +/* Live drop target under the cursor — folder links and group headings. + Loud on purpose: a solid ring, a glow, and primary-tinted contents, so + there's no squinting about where the drop will land. */ .dnd-drop { - outline: 2px dashed var(--color-primary); + outline: 2px solid var(--color-primary); + outline-offset: -2px; + background: var(--color-primary-light); + border-radius: var(--radius); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-primary) 22%, transparent); + color: var(--color-primary); +} + +.dnd-drop .folder-row__icon, +.dnd-drop .folder-row__name, +.dnd-drop .folder-row__count, +.dnd-drop .folder-tree__name, +.dnd-drop .sidebar__count { + color: var(--color-primary); +} + +/* Drop accepted, PATCH + refresh in flight — the target pulses until the + page re-renders so the move never feels like it silently stalled. */ +.dnd-drop--busy { + outline: 2px solid var(--color-primary); outline-offset: -2px; background: var(--color-primary-light); border-radius: var(--radius); + animation: dnd-busy-pulse 0.8s ease-in-out infinite; +} + +@keyframes dnd-busy-pulse { + 50% { opacity: 0.45; } +} + +.workspace--moving, +.workspace--moving * { + cursor: progress; +} + +/* The row that was just dropped stays ghosted (dragend strips the + --dragging class before the server round-trip finishes). */ +.dnd-source--moving { + opacity: 0.4; + pointer-events: none; +} + +/* Spring-loading, Finder-style: keep the drag hovering on a folder and it + pulses twice, then springs open. */ +.dnd-spring { + animation: dnd-spring-pulse 0.3s ease-in-out 2; +} + +@keyframes dnd-spring-pulse { + 50% { + box-shadow: 0 0 0 8px color-mix(in srgb, var(--color-primary) 38%, transparent); + transform: scale(1.012); + } +} + +/* Tunneled pane: springing a main-pane folder swaps the pane to that + folder's real level view mid-drag. A quick slide-in sells the "you just + went somewhere" feel each level down. */ +.workspace__main--tunneled { + animation: dnd-tunnel-in 0.18s ease-out; +} + +@keyframes dnd-tunnel-in { + from { + opacity: 0.4; + transform: translateY(4px); + } +} + +/* The pane itself is the fallback drop target ("file into the folder + you're looking at"). Ring it quietly and don't tint its text — the loud + treatment belongs to specific rows/crumbs. */ +.workspace__main.dnd-drop { + color: inherit; + box-shadow: none; + outline-style: dashed; } .folder-tree__link--dragging { @@ -4734,6 +4870,16 @@ img.avatar { outline-offset: -1px; } +/* While a drag is live, the pane grows a runway below the level's + contents: an empty (or short) folder — especially one just tunneled + into — otherwise offers almost nothing to drop on. Padding, not + margin: padding is inside the element, so the runway IS the pane's + drop target. */ +.workspace--dragging .workspace__main { + padding-bottom: 10rem; + min-height: 60vh; +} + /* Needs attention strip */ .attention { margin-bottom: var(--space-lg); @@ -4786,17 +4932,18 @@ img.avatar { history, quieter than the warning-toned attention strip. */ .recent-updates { margin-bottom: var(--space-lg); - padding: var(--space-sm) var(--space-md); - border: 1px solid var(--glass-edge); + padding: var(--space-md); + border: 1px solid var(--card-edge); border-radius: var(--radius); background: var(--surface-translucent); + box-shadow: var(--shadow); } .recent-updates__heading { display: flex; align-items: center; gap: var(--space-xs); - margin: 0 0 var(--space-xs); + margin: 0 0 var(--space-sm); font-size: 0.8rem; font-weight: 600; text-transform: uppercase; @@ -4810,7 +4957,8 @@ img.avatar { padding: 0; display: flex; flex-direction: column; - gap: 2px; + /* Roomy enough to scan — this read as one bunched block at 2px. */ + gap: var(--space-sm); } .recent-updates__item { @@ -4900,12 +5048,15 @@ img.avatar { gap: var(--space-sm); padding: var(--space-sm) var(--space-md); background: var(--surface-translucent); - border: 1px solid var(--glass-edge); + border: 1px solid var(--card-edge); border-radius: var(--radius); margin-bottom: var(--space-xs); + box-shadow: var(--shadow); color: var(--color-text); text-decoration: none; transition: background 0.12s ease, box-shadow 0.12s ease; + -webkit-user-select: none; + user-select: none; } .folder-row:hover { @@ -4942,10 +5093,16 @@ img.avatar { /* Translucent without backdrop blur — rows are numerous and per-row blur is a GPU tax; the mesh still glows through faintly. */ background: var(--surface-translucent); - border: 1px solid var(--glass-edge); + border: 1px solid var(--card-edge); border-radius: var(--radius); margin-bottom: var(--space-xs); + box-shadow: var(--shadow); transition: background 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease; + /* Selectable text hijacks press-and-drag: the browser starts a text + selection instead of the row drag, and the mouseup then *navigates* + the title link. Rows are chrome, not prose — don't select. */ + -webkit-user-select: none; + user-select: none; } /* The file icon spans both text rows — a Drive/Finder file entry. */ @@ -4963,6 +5120,26 @@ img.avatar { background: var(--glass-bg-strong); box-shadow: var(--shadow-pop); transform: translateY(-1px); + cursor: pointer; +} + +/* Whole-card click target: the title link stretches over the card, so + clicking (and hover-prefetching) works anywhere on the row. Every other + interactive element in the row rises above the stretch. */ +.plan-row { + position: relative; +} + +.plan-row__title::after { + content: ""; + position: absolute; + inset: 0; +} + +.plan-row a:not(.plan-row__title), +.plan-row button { + position: relative; + z-index: 1; } .plan-row--dragging { @@ -5007,8 +5184,15 @@ img.avatar { text-decoration: none; } -.plan-row__spacer { - flex: 1; +/* Byline pinned to the card's top-right corner: it must not ride along + with wrapping titles or drift to a second line mid-card. */ +.plan-row__meta { + display: flex; + align-items: center; + gap: var(--space-sm); + margin-left: auto; + align-self: flex-start; + flex-shrink: 0; } .plan-row__time { @@ -5030,40 +5214,6 @@ img.avatar { overflow: hidden; } -/* Row bookmark — the keyboard/no-drag door into the folder navigator. */ -.plan-row__save { - display: inline-flex; - align-items: center; - padding: 4px; - background: none; - border: none; - border-radius: var(--radius); - color: var(--color-text-muted); - cursor: pointer; - opacity: 0; - transition: opacity 0.12s ease, color 0.12s ease; -} - -.plan-row:hover .plan-row__save, -.plan-row__save:focus-visible, -.plan-row__save--saved { - opacity: 1; -} - -.plan-row__save:hover { - color: var(--color-primary); - background: var(--color-bg-muted); -} - -.plan-row__save--saved { - color: var(--color-primary); -} - -/* Saved = a second click removes; hint at that on hover. */ -.plan-row__save--saved:hover { - color: var(--color-danger, #e0637a); -} - /* Toasts (drag & drop feedback) */ .toasts { position: fixed; @@ -5091,6 +5241,8 @@ img.avatar { tap to expand, out of the way otherwise. */ .workspace__sidebar { position: static; + max-height: none; + overflow: visible; } .workspace__sidebar-toggle { @@ -5185,10 +5337,13 @@ img.avatar { overflow-wrap: anywhere; } - .plan-document, + .plan-document { + padding: var(--space-md); + } + .references-section, .attachments-section { - padding: var(--space-md); + padding-left: 0; } .plan-actions { diff --git a/engine/app/controllers/coplan/attachments_controller.rb b/engine/app/controllers/coplan/attachments_controller.rb index d9a33c1..7456538 100644 --- a/engine/app/controllers/coplan/attachments_controller.rb +++ b/engine/app/controllers/coplan/attachments_controller.rb @@ -9,7 +9,10 @@ def create files = Array(params[:files]).reject(&:blank?) if files.empty? - return redirect_to plan_path(@plan, anchor: "footnote-attachments"), alert: "Choose at least one file to upload." + return respond_to do |format| + format.turbo_stream { render turbo_stream: toast_stream("Choose at least one file to upload.", "alert") } + format.html { redirect_to plan_path(@plan, anchor: "footnote-attachments"), alert: "Choose at least one file to upload." } + end end errors = [] @@ -19,10 +22,18 @@ def create end uploaded = files.size - errors.size - flash_options = {} - flash_options[:notice] = "#{uploaded} #{"file".pluralize(uploaded)} uploaded." if uploaded.positive? - flash_options[:alert] = errors.join(" ") if errors.any? - redirect_to plan_path(@plan, anchor: "footnote-attachments"), flash_options + notice = "#{uploaded} #{"file".pluralize(uploaded)} uploaded." if uploaded.positive? + alert = errors.join(" ") if errors.any? + + respond_to do |format| + # In-place: swap the attachments section where it stands and toast — + # never bounce the reader to the top of the plan they're in. + format.turbo_stream { render_attachments_update(notice: notice, alert: alert) } + format.html do + redirect_to plan_path(@plan, anchor: "footnote-attachments"), + { notice: notice, alert: alert }.compact + end + end end def destroy @@ -43,7 +54,12 @@ def destroy metadata: { content_type: content_type } ) - redirect_to plan_path(@plan, anchor: "footnote-attachments"), notice: "Attachment removed." + respond_to do |format| + format.turbo_stream { render_attachments_update(notice: "Attachment removed.") } + format.html do + redirect_to plan_path(@plan, anchor: "footnote-attachments"), notice: "Attachment removed." + end + end end private @@ -51,5 +67,37 @@ def destroy def set_plan @plan = Plan.find(params[:plan_id]) end + + # Re-renders #plan-attachments in place plus a bottom-corner toast per + # message — the whole point is that the reader's scroll position never + # moves. + def render_attachments_update(notice: nil, alert: nil) + attachments = @plan.attachments_attachments.includes(:blob).order(created_at: :desc) + streams = [ + turbo_stream.replace("plan-attachments", + partial: "coplan/plans/attachments", + locals: { plan: @plan, attachments: attachments }), + # The count shows in the footnote header AND the document outline — + # both were rendered by the redirect this stream response replaced. + count_stream("attachments-count", attachments.size), + count_stream("nav-attachments-count", attachments.size) + ] + streams << toast_stream(notice, "notice") if notice + streams << toast_stream(alert, "alert") if alert + render turbo_stream: streams + end + + 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/references_controller.rb b/engine/app/controllers/coplan/references_controller.rb index a1dfc77..415df3b 100644 --- a/engine/app/controllers/coplan/references_controller.rb +++ b/engine/app/controllers/coplan/references_controller.rb @@ -88,6 +88,12 @@ def render_references_stream turbo_stream.replace( "references-count", html: helpers.content_tag(:span, references.size, class: "section-count", id: "references-count") + ), + # The document outline shows the same count; without this it goes + # stale the moment a reference is added or removed. + turbo_stream.replace( + "nav-references-count", + html: helpers.content_tag(:span, references.size, class: "section-count", id: "nav-references-count") ) ] end diff --git a/engine/app/helpers/coplan/plans_helper.rb b/engine/app/helpers/coplan/plans_helper.rb index 3fd2c29..3fb4458 100644 --- a/engine/app/helpers/coplan/plans_helper.rb +++ b/engine/app/helpers/coplan/plans_helper.rb @@ -32,11 +32,11 @@ def plan_state_badge(plan) flags << hidden_state_flag("Archived", "Hidden from lists unless filtered for") if plan.archived? return "".html_safe if flags.empty? - safe_join([" ", safe_join(flags, " ")]) + safe_join([ " ", safe_join(flags, " ") ]) end def hidden_state_flag(label, title) - content_tag(:span, safe_join([HIDDEN_EYE_ICON, label]), class: "state-flag", title: title) + content_tag(:span, safe_join([ HIDDEN_EYE_ICON, label ]), class: "state-flag", title: title) end # Built-in icon set for plan types (lucide outlines, matching the rest @@ -53,7 +53,7 @@ def hidden_state_flag(label, title) "map" => %(), "flask" => %(), "shield" => %(), - "wrench" => %(), + "wrench" => %() }.freeze # How many tint classes exist in CSS (.plan-type-icon--0 … --N-1). @@ -89,8 +89,8 @@ def plan_content_preview(plan, limit: 200) # full Commonmarker + Nokogiri parse per plan without an AI summary. # The document body itself is fetched only on a cache miss — list pages # preload just the stub (id + sha), never the MEDIUMTEXT columns. - cache_key = ["coplan/plan-preview", MarkdownHelper::RENDER_CACHE_VERSION, plan.id, - stub.content_sha256 || plan.current_revision] + cache_key = [ "coplan/plan-preview", MarkdownHelper::RENDER_CACHE_VERSION, plan.id, + stub.content_sha256 || plan.current_revision ] plain = Rails.cache.fetch(cache_key) do markdown_to_plain_text(PlanVersion.where(id: stub.id).pick(:content_markdown)) end diff --git a/engine/app/javascript/controllers/coplan/dropzone_controller.js b/engine/app/javascript/controllers/coplan/dropzone_controller.js index 5237550..2adde14 100644 --- a/engine/app/javascript/controllers/coplan/dropzone_controller.js +++ b/engine/app/javascript/controllers/coplan/dropzone_controller.js @@ -1,10 +1,23 @@ import { Controller } from "@hotwired/stimulus" // Styled replacement for a native file input, attached to the upload form: -// click the zone (or drag files onto it) and the form submits as soon as +// click the zone (or drag files onto it) and the upload starts as soon as // files are chosen — no separate Upload button. +// +// Uploads go over XHR (fetch has no upload progress) so the zone can show a +// real progress bar, then the server's Turbo Stream response swaps the +// attachments section in place — the page never reloads or scrolls. export default class extends Controller { - static targets = ["input", "zone", "label"] + static targets = ["input", "zone", "label", "progress", "fill"] + + disconnect() { + // Turbo navigation mid-upload: every plan page has the same + // #plan-attachments target, so a late response from plan A would + // happily replace plan B's section. Abort — the upload belongs to a + // page that no longer exists. + this.xhr?.abort() + this.xhr = null + } open() { this.inputTarget.click() @@ -33,10 +46,74 @@ export default class extends Controller { submit() { const count = this.inputTarget.files.length - if (this.hasLabelTarget) { - this.labelTarget.textContent = `Uploading ${count} ${count === 1 ? "file" : "files"}…` - } + const noun = count === 1 ? "file" : "files" + this.originalLabel ??= this.hasLabelTarget ? this.labelTarget.textContent : null + if (this.hasLabelTarget) this.labelTarget.textContent = `Uploading ${count} ${noun}… 0%` this.zoneTarget.classList.add("dropzone--busy") - this.element.requestSubmit() + this.#setProgress(0) + + const xhr = new XMLHttpRequest() + xhr.open("POST", this.element.action) + xhr.setRequestHeader("Accept", "text/vnd.turbo-stream.html, text/html") + const token = document.querySelector('meta[name="csrf-token"]')?.content + if (token) xhr.setRequestHeader("X-CSRF-Token", token) + + xhr.upload.addEventListener("progress", event => { + if (!event.lengthComputable) return + const percent = Math.round((event.loaded / event.total) * 100) + this.#setProgress(percent) + if (this.hasLabelTarget) { + this.labelTarget.textContent = percent < 100 + ? `Uploading ${count} ${noun}… ${percent}%` + : "Processing…" + } + }) + + xhr.addEventListener("load", () => { + this.xhr = null + const type = xhr.getResponseHeader("Content-Type") || "" + if (xhr.status < 300 && type.includes("turbo-stream") && window.Turbo) { + // Replaces #plan-attachments (this form included) and toasts — + // nothing to reset here, the fresh render is the reset. + window.Turbo.renderStreamMessage(xhr.responseText) + } else if (xhr.status < 400 && window.Turbo) { + // Non-stream success (redirect followed to HTML): fall back to a + // proper visit of wherever we landed. + window.Turbo.visit(xhr.responseURL || window.location.href) + } else { + this.#fail(`Upload failed (${xhr.status}). Try again.`) + } + }) + + xhr.addEventListener("error", () => { + this.xhr = null + this.#fail("Upload failed — check your connection and try again.") + }) + + this.xhr = xhr + xhr.send(new FormData(this.element)) + } + + #setProgress(percent) { + if (!this.hasProgressTarget) return + this.progressTarget.hidden = false + if (this.hasFillTarget) this.fillTarget.style.width = `${percent}%` + } + + #fail(message) { + this.zoneTarget.classList.remove("dropzone--busy") + if (this.hasProgressTarget) this.progressTarget.hidden = true + if (this.hasLabelTarget && this.originalLabel) this.labelTarget.textContent = this.originalLabel + // Clear the selection so picking the same file again re-fires change. + this.inputTarget.value = "" + + const container = document.getElementById("coplan-toasts") + if (!container) return alert(message) + const toast = document.createElement("div") + toast.className = "flash flash--alert toasts__toast" + toast.setAttribute("role", "status") + toast.dataset.controller = "coplan--toast" + toast.textContent = message + container.appendChild(toast) } } diff --git a/engine/app/javascript/controllers/coplan/folder_dnd_controller.js b/engine/app/javascript/controllers/coplan/folder_dnd_controller.js index dd3305d..b8d368f 100644 --- a/engine/app/javascript/controllers/coplan/folder_dnd_controller.js +++ b/engine/app/javascript/controllers/coplan/folder_dnd_controller.js @@ -14,11 +14,69 @@ import { Controller } from "@hotwired/stimulus" const PLAN_MIME = "application/x-coplan-plan" const FOLDER_MIME = "application/x-coplan-folder" const DROP_CLASS = "dnd-drop" +const BUSY_CLASS = "dnd-drop--busy" + +// Spring-loaded folders, Finder-style: hover a drag over a folder, it +// pulses twice, then springs open. +// +// - Sidebar: a collapsed tree branch expands in place (and snaps shut when +// the drag moves off it), so you can dive multiple levels while dragging. +// - Main pane: springing a folder row (or a breadcrumb, to go up) TUNNELS — +// the pane temporarily becomes that folder's real level view, exactly as +// if you'd navigated, prefetched during the pulses. Keep tunneling deeper +// through its folder rows; drop anywhere in the pane (dead space included) +// to file right there; end the drag without dropping and the original +// pane is restored untouched. +const SPRING_CLASS = "dnd-spring" +const SPRING_DELAY = 650 // two 0.3s pulses, then open export default class extends Controller { + connect() { + // Swallow the click some browsers fire on the source link right after a + // drag — press-drag-release must never read as "open the plan". + this.element.addEventListener("click", this.#clickGuard, true) + // Watch where the drag actually is, workspace-wide: a pending spring is + // cancelled and sprung-open sidebar branches snap shut as soon as the + // cursor moves somewhere they don't contain. Capture phase: targets + // stop dragover propagation (innermost target wins), which must not + // starve this sweep. + this.element.addEventListener("dragover", this.#springSweep, true) + this.springs = [] + } + + disconnect() { + this.element.removeEventListener("click", this.#clickGuard, true) + this.element.removeEventListener("dragover", this.#springSweep, true) + this.#cancelPendingSpring() + this.#collapseAllSprings() + this.#restoreTunnel() + } + + #clickGuard = (event) => { + if (!this.dragHappened) return + event.preventDefault() + event.stopPropagation() + } + + #springSweep = (event) => { + const over = event.target + if (over === this.lastSweepTarget) return + this.lastSweepTarget = over + if (this.springPendingEl && !this.springPendingEl.contains(over)) this.#cancelPendingSpring() + // Close sprung folders newest-first until one still holds the cursor. + while (this.springs.length) { + const top = this.springs[this.springs.length - 1] + if (top.keepAlive.some(el => el.contains(over))) break + this.springs.pop().close() + } + } + dragStart(event) { const row = event.currentTarget + this.dragHappened = true + this.dragActive = true + this.dragSourceEl = row event.dataTransfer.effectAllowed = "move" // The move URL and current folder ride along in the drag payload // (readable on drop) — the folder so a drop where the plan already @@ -35,6 +93,9 @@ export default class extends Controller { dragEnd(event) { event.currentTarget.classList.remove("plan-row--dragging") this.element.classList.remove("workspace--dragging") + // The stray post-drag click (when it comes at all) fires before timers, + // so a 0ms clear still covers it without eating the next real click. + this.#dragCleanup() } folderDragStart(event) { @@ -42,6 +103,9 @@ export default class extends Controller { // bubble into any other draggable ancestor. event.stopPropagation() const node = event.currentTarget + this.dragHappened = true + this.dragActive = true + this.dragSourceEl = node event.dataTransfer.effectAllowed = "move" event.dataTransfer.setData(FOLDER_MIME, JSON.stringify({ url: node.dataset.reparentUrl, @@ -56,22 +120,192 @@ export default class extends Controller { folderDragEnd(event) { event.currentTarget.classList.remove("folder-tree__link--dragging") this.element.classList.remove("workspace--dragging") + this.#dragCleanup() } dragOver(event) { if (!this.#accepts(event)) return event.preventDefault() + // Innermost target wins: a folder row inside the pane highlights alone, + // without also lighting up the pane-wide target behind it. + event.stopPropagation() event.dataTransfer.dropEffect = "move" - event.currentTarget.classList.add(DROP_CLASS) + const target = event.currentTarget + // Exactly one live target at a time (also mops up any highlight a + // missed dragleave left behind). + this.element.querySelectorAll(`.${DROP_CLASS}`).forEach(el => { + if (el !== target) el.classList.remove(DROP_CLASS) + }) + target.classList.add(DROP_CLASS) + this.#springHover(target) } dragLeave(event) { + // dragleave also fires when the cursor moves onto a *child* of the + // target (the folder name, the icon) — dropping the highlight there + // makes it flicker while you're hovering dead-center on the folder. + if (event.currentTarget.contains(event.relatedTarget)) return event.currentTarget.classList.remove(DROP_CLASS) + if (this.springPendingEl === event.currentTarget) this.#cancelPendingSpring() + } + + // ----- Spring-loading ----- + + #springHover(el) { + if (this.springPendingEl === el) return // pulses already running + if (this.springs.some(s => s.owner === el)) return // already sprung open + + this.#cancelPendingSpring() + const action = this.#springAction(el) + if (!action) return + + action.prefetch?.() + this.springPendingEl = el + el.classList.add(SPRING_CLASS) + this.springTimer = setTimeout(() => { + this.#cancelPendingSpring() + const entry = action.open() + if (entry) this.springs.push(entry) + }, SPRING_DELAY) + } + + // What springing this element would do — or null if nothing's behind it. + // Sidebar branches return a {owner, keepAlive, close} entry (they revert + // as soon as the drag leaves them); tunnels manage their own lifetime + // (they persist until the drag ends). + #springAction(el) { + // Sidebar: the summary link of a collapsed
branch. + if (el.classList.contains("folder-tree__link")) { + const details = el.closest("summary")?.parentElement + if (!details || details.open) return null + return { + open: () => { + details.open = true + return { owner: el, keepAlive: [details], close: () => { details.open = false } } + } + } + } + + // Main pane: folder rows tunnel in, breadcrumbs tunnel back up — the + // pane temporarily becomes that folder's real level view. Any folder + // (empty included: tunnel in, drop on the empty pane to file there). + if (el.classList.contains("folder-row") || + (el.classList.contains("workspace-crumbs__crumb") && + !el.classList.contains("workspace-crumbs__crumb--current"))) { + const url = el.href + if (!url) return null + return { + prefetch: () => this.#prefetchPane(url), + open: () => { this.#tunnel(url); return null } + } + } + + return null + } + + // ----- Tunneling (temporary in-drag navigation of the main pane) ----- + + #prefetchPane(url) { + if (this.paneCache?.url === url) return this.paneCache.promise + const promise = fetch(url, { headers: { "Accept": "text/html" } }) + .then(response => { + if (!response.ok) throw new Error(`prefetch ${response.status}`) + return response.text() + }) + .then(html => new DOMParser().parseFromString(html, "text/html")) + // Swallow here so an abandoned prefetch never surfaces as an unhandled + // rejection; #tunnel re-awaits the cached promise and handles failure. + promise.catch(() => {}) + this.paneCache = { url, promise } + return promise + } + + async #tunnel(url) { + let doc + try { doc = await this.#prefetchPane(url) } catch { return } + // The world may have moved on while the fetch ran. + if (!this.dragActive) return + const main = this.element.querySelector(".workspace__main") + const incoming = doc.querySelector(".workspace__main") + if (!main || !incoming) return + + if (!this.tunnelHome) { + // Keep the dragged row's node in the document — removing the drag + // source mid-drag makes browsers skip dragend entirely, leaking the + // whole drag state. It waits out the tunnel in a hidden stash. + if (this.dragSourceEl && main.contains(this.dragSourceEl)) { + this.sourceHome = { parent: this.dragSourceEl.parentNode, next: this.dragSourceEl.nextSibling } + this.#stash().appendChild(this.dragSourceEl) + } + this.tunnelHome = { nodes: Array.from(main.childNodes) } + this.homeFolderId = main.dataset.folderId + } + main.replaceChildren(...incoming.childNodes) + // Swap the pane's own drop identity too — dead-space drops now file + // into the tunneled folder. + main.dataset.folderId = incoming.dataset.folderId || "" + this.tunnelUrl = url + // Restart the entrance animation for each level. + main.classList.remove("workspace__main--tunneled") + void main.offsetWidth + main.classList.add("workspace__main--tunneled") + } + + #restoreTunnel() { + if (!this.tunnelHome) return + const main = this.element.querySelector(".workspace__main") + if (main) { + main.replaceChildren(...this.tunnelHome.nodes) + main.dataset.folderId = this.homeFolderId ?? main.dataset.folderId + main.classList.remove("workspace__main--tunneled") + } + if (this.dragSourceEl && this.sourceHome?.parent?.isConnected) { + this.sourceHome.parent.insertBefore(this.dragSourceEl, this.sourceHome.next) + } + this.tunnelHome = null + this.tunnelUrl = null + this.sourceHome = null + } + + #stash() { + if (!this.stashEl) { + this.stashEl = document.createElement("div") + this.stashEl.style.display = "none" + this.element.appendChild(this.stashEl) + } + return this.stashEl + } + + #cancelPendingSpring() { + clearTimeout(this.springTimer) + this.springPendingEl?.classList.remove(SPRING_CLASS) + this.springPendingEl = null + } + + #collapseAllSprings() { + while (this.springs.length) this.springs.pop().close() + } + + #dragCleanup() { + this.dragActive = false + this.#cancelPendingSpring() + this.paneCache = null + // A committed drop keeps the tunneled pane and its pulsing target on + // screen until the refresh lands (or the move fails); an abandoned + // drag snaps everything back to where it started. + if (!this.dropCommitted) { + this.#collapseAllSprings() + this.#restoreTunnel() + } + setTimeout(() => { this.dragHappened = false }, 0) } drop(event) { if (!this.#accepts(event)) return event.preventDefault() + // Same innermost-target-wins rule as dragOver: a drop on a folder row + // must not also fire the pane-wide "file here" target behind it. + event.stopPropagation() const target = event.currentTarget target.classList.remove(DROP_CLASS) @@ -85,14 +319,14 @@ export default class extends Controller { // no-op, not a PATCH + reload + toast. if (payload.id && payload.id === targetFolderId) return if ((payload.parentId || "") === targetFolderId) return - this.#patch(payload.url, { parent_id: targetFolderId }, "Folder moved.") + this.#patch(payload.url, { parent_id: targetFolderId }, "Folder moved.", target) } else { let payload = {} try { payload = JSON.parse(event.dataTransfer.getData(PLAN_MIME)) } catch {} if (!payload.url) return // Same quiet no-op when the plan is already filed right here. if ((payload.folderId || "") === targetFolderId) return - this.#patch(payload.url, { folder_id: targetFolderId }, "Plan moved.") + this.#patch(payload.url, { folder_id: targetFolderId }, "Plan moved.", target) } } @@ -101,7 +335,16 @@ export default class extends Controller { return types.includes(PLAN_MIME) || types.includes(FOLDER_MIME) } - #patch(url, body, fallbackMessage) { + #patch(url, body, fallbackMessage, target) { + // The PATCH + page refresh takes a beat — show it immediately: the drop + // target pulses, the dragged row stays ghosted, the cursor says busy. + // (drop fires before dragend, so the --dragging source is still marked.) + this.dropCommitted = true + target?.classList.add(BUSY_CLASS) + this.element.classList.add("workspace--moving") + this.element.querySelector(".plan-row--dragging, .folder-tree__link--dragging") + ?.classList.add("dnd-source--moving") + const token = document.querySelector('meta[name="csrf-token"]')?.content fetch(url, { method: "PATCH", @@ -120,12 +363,31 @@ export default class extends Controller { const message = data.message || fallbackMessage if (window.Turbo) { document.addEventListener("turbo:load", () => this.#toast(message, "notice"), { once: true }) - window.Turbo.visit(window.location.href, { action: "replace" }) + // A drop inside a tunneled folder lands you there for real — + // Finder leaves you in the window you sprang into. + if (this.tunnelUrl) { + window.Turbo.visit(this.tunnelUrl, { action: "advance" }) + } else { + window.Turbo.visit(window.location.href, { action: "replace" }) + } } else { this.#toast(message, "notice") + this.#clearBusy(target) } }) - .catch(error => this.#toast(error.message, "alert")) + .catch(error => { + this.#clearBusy(target) + this.#toast(error.message, "alert") + }) + } + + #clearBusy(target) { + this.dropCommitted = false + target?.classList.remove(BUSY_CLASS) + this.element.classList.remove("workspace--moving") + this.element.querySelector(".dnd-source--moving")?.classList.remove("dnd-source--moving") + this.#collapseAllSprings() + this.#restoreTunnel() } #toast(message, kind) { diff --git a/engine/app/javascript/controllers/coplan/plan_bookmark_controller.js b/engine/app/javascript/controllers/coplan/plan_bookmark_controller.js new file mode 100644 index 0000000..594b64c --- /dev/null +++ b/engine/app/javascript/controllers/coplan/plan_bookmark_controller.js @@ -0,0 +1,30 @@ +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