Bläddra i källkod

In-place Edit buttons for task mandate and goal brief

The long-form card fields now edit in place: the task mandate card and the draft goal brief card swap their rendered markdown for a prefilled editor (title/brief/priority on goals) via a shared initLongFormEditors engine in main.html. Statum's stm-attribute.value sets a content attribute, which textareas ignore, so the engine applies state values directly after each render. The mandate field left the task edit expander (short properties only), goal drafts gained their only edit surface (priority included), and starting a goal removes the editor with the update action. New sealed goals update action edit_draft_goal with a server-side draft-only guard; UpdateTaskAction's description-only path is unchanged. Tests: pages 127, goalui 129 checks green.
clanker 1 månad sedan
förälder
incheckning
3785e9f562

+ 37 - 0
src/Services/GoalService.vala

@@ -290,6 +290,43 @@ namespace ClankerSaddle.Services {
             return goal;
         }
 
+
+        /**
+         * Edits a DRAFT goal's title, brief and priority (the brief-card
+         * editor, plan QoL task 12): a draft is the only phase with an
+         * edit surface — starting locks the brief (the run task's
+         * mandate is seeded from it) and every later phase reads it as
+         * history. Each argument is optional (null = leave unchanged),
+         * mirroring {@link TaskService.edit_task}.
+         */
+        public async Goal edit_draft_goal(int64 goal_id, string? title, string? brief,
+                int64? priority) throws Error {
+            var goal = yield require_goal(goal_id);
+            if (((!)goal).status != GOAL_DRAFT) {
+                throw new Error(Quark.from_string("clanker-saddle"), 72,
+                    @"Only draft goals can be edited — $(((!)goal).status) goals have a locked brief");
+            }
+            if (title != null && ((!)title).strip().length == 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 73,
+                    "A goal title is required");
+            }
+            if (brief != null && ((!)brief).strip().length == 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 74,
+                    "A goal brief is required");
+            }
+            if (title != null) {
+                ((!)goal).title = ((!)title).strip();
+            }
+            if (brief != null) {
+                ((!)goal).brief = (!)brief;
+            }
+            if (priority != null) {
+                ((!)goal).priority = ((!)priority).clamp(1, 9);
+            }
+            yield db.update_async<Goal>((!)goal);
+            return goal;
+        }
+
         /**
          * Pauses an active goal: supervision freezes — no goal-agent
          * resumes are scheduled while paused; task events keep

+ 45 - 0
src/actions/GoalActions.vala

@@ -100,6 +100,51 @@ namespace ClankerSaddle {
         }
     }
 
+    /**
+     * Saves a DRAFT goal's title, brief and priority (the brief-card
+     * editor, plan QoL task 12): draft is the only phase with an edit
+     * surface — starting locks the brief and seeds the run task's
+     * mandate from it. Each field is optional (absent = leave
+     * unchanged), mirroring {@link UpdateTaskAction}'s carried-field
+     * semantics, and {@link GoalService.edit_draft_goal} enforces the
+     * draft-only guard server-side.
+     */
+    public class UpdateDraftGoalAction : SealedGoalAction {
+
+        private GoalService goals = inject<GoalService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 goal_id = sealed_goal_id();
+            if (goal_id <= 0) {
+                return directives().navigate("/goals");
+            }
+
+            try {
+                int64? priority = null;
+                string priority_field = Forms.field(request, "priority");
+                if (priority_field.length > 0) {
+                    priority = Forms.integer(request, "priority", 3);
+                }
+                var goal = yield goals.edit_draft_goal(goal_id,
+                    Forms.optional(request, "title"),
+                    request.form != null && request.form.get_field("brief") != null
+                        ? Forms.field_raw(request, "brief") : null,
+                    priority);
+                return directives().notify("success", "Goal saved")
+                    .navigate(@"/goals/$(goal.id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not save the goal: $(e.message)")
+                    .navigate(@"/goals/$(goal_id)");
+            }
+        }
+    }
+
     /** Pauses an active goal: supervision freezes, events queue. */
     public class PauseGoalAction : SealedGoalAction {
 

+ 8 - 0
src/entrypoints/GoalDetailEntrypoint.vala

@@ -186,6 +186,12 @@ namespace ClankerSaddle {
          */
         public string current_activity { get; set; default = ""; }
         public Statum.Model.ActionDto? start { get; set; }
+        /**
+         * The draft brief-card editor's save (title/brief/priority):
+         * authored ONLY while draft — its presence IS the page's
+         * edit-draft gate, so a started goal renders no editor at all.
+         */
+        public Statum.Model.ActionDto? update { get; set; }
         public Statum.Model.ActionDto? pause { get; set; }
         public Statum.Model.ActionDto? resume { get; set; }
         public Statum.Model.ActionDto? cancel { get; set; }
@@ -431,6 +437,7 @@ namespace ClankerSaddle {
                 pub.add_link = ((!)ctx.actions).author_private<AddGoalLinkAction, GoalActionPrivate>(sealed);
                 if (goal.status == GOAL_DRAFT) {
                     pub.start = ((!)ctx.actions).author_private<StartGoalAction, GoalActionPrivate>(sealed);
+                    pub.update = ((!)ctx.actions).author_private<UpdateDraftGoalAction, GoalActionPrivate>(sealed);
                 }
                 if (goal.status == GOAL_ACTIVE) {
                     pub.pause = ((!)ctx.actions).author_private<PauseGoalAction, GoalActionPrivate>(sealed);
@@ -529,6 +536,7 @@ namespace ClankerSaddle {
                 pub.add_link = ((!)ctx.actions).author_private<AddGoalLinkAction, GoalActionPrivate>(sealed);
                 if (goal.status == GOAL_DRAFT) {
                     pub.start = ((!)ctx.actions).author_private<StartGoalAction, GoalActionPrivate>(sealed);
+                    pub.update = ((!)ctx.actions).author_private<UpdateDraftGoalAction, GoalActionPrivate>(sealed);
                 }
                 if (goal.status == GOAL_ACTIVE) {
                     pub.pause = ((!)ctx.actions).author_private<PauseGoalAction, GoalActionPrivate>(sealed);

+ 1 - 0
src/main.vala

@@ -126,6 +126,7 @@ int main(string[] args) {
         statum.action<RefreshTasksDataAction>();
         statum.action<CreateGoalAction>();
         statum.action<StartGoalAction>();
+        statum.action<UpdateDraftGoalAction>();
         statum.action<PauseGoalAction>();
         statum.action<ResumeGoalAction>();
         statum.action<CancelGoalAction>();

+ 34 - 3
src/pages/goal_detail.html

@@ -110,6 +110,11 @@
                 });
             }
 
+            // The brief card's in-place editor (task 12).
+            if (window.initLongFormEditors) {
+                window.initLongFormEditors();
+            }
+
         })();
     }
 </script>
@@ -371,11 +376,37 @@
                 </table>
             </section>
 
-            <!-- The locked brief. -->
-            <section class="card">
+            <!-- The locked brief. Task 12: DRAFT goals swap the
+                 rendered markdown for an in-place editor
+                 (initLongFormEditors in main.html) carrying title,
+                 brief and priority — a draft goal has no other edit
+                 surface (this also unblocks the draft priority, which
+                 nothing else could change). Starting locks the brief:
+                 the editor's stm-if wrapper disappears with the
+                 update action and the card stays the plain read view. -->
+            <section class="card" data-long-form data-field="goal"
+                     data-lf-open="0">
                 <h3>Initial brief
+                    <button type="button" class="inline" stm-if="goal.update != null"
+                            data-lf-toggle>Edit</button>
                     <span class="muted" stm-if="goal.started_display.length > 0">(locked)</span></h3>
-                <div class="md rfc-body goal-body" data-brief-body stm-markdown="goal.brief">brief</div>
+                <div data-lf-view>
+                    <div class="md rfc-body goal-body" data-brief-body stm-markdown="goal.brief">brief</div>
+                </div>
+                <div stm-if="goal.update != null">
+                    <form data-lf-edit hidden stm-action="goal.update">
+                        <div class="form-row"><label>Title</label>
+                            <input type="text" name="title" data-lf-src="goal.title"></div>
+                        <div class="form-row"><label>Brief (markdown)</label>
+                            <textarea name="brief" rows="8"
+                                      data-lf-src="goal.brief">brief</textarea></div>
+                        <div class="form-row"><label>Priority (1–9)</label>
+                            <input type="number" name="priority" min="1" max="9"
+                                   data-lf-src="goal.priority"></div>
+                        <button type="submit">Save</button>
+                        <button type="button" data-lf-toggle>Cancel</button>
+                    </form>
+                </div>
             </section>
 
             <!-- Involved projects. -->

+ 108 - 0
src/pages/main.html

@@ -231,6 +231,114 @@
         }
     };
 
+    //
+    // initLongFormEditors (plan QoL task 12): in-place editors for the
+    // long-form card fields (task mandate, goal brief). The rendered
+    // markdown view and the editor <form> BOTH live in the page markup;
+    // a per-card `data-lf-open` flag (kept OUTSIDE statum state, the
+    // plan-remarks pattern) toggles which side shows. Why JS-applied
+    // values and not stm-attribute.value: statum's attribute binding
+    // sets the CONTENT attribute, which the browser ignores on
+    // <textarea> (its value lives in the text node) — so the textarea
+    // is re-prefilled from the card's data-value payload after every
+    // render cycle, while the page state carries the source of truth.
+    // The card contract:
+    //   [data-long-form]            the card root; data-field = slot key
+    //                               whose object carries the payload
+    //   [data-lf-view]              the rendered markdown half
+    //   [data-lf-edit]              the editor half (form)
+    //   textarea/input[data-lf-src] a state path ("task.description")
+    //                               whose current value prefills the
+    //                               control on every render
+    //   [data-lf-toggle]            the Edit / Cancel button
+    // Buttons INSIDE the form (Save) submit through the form's
+    // stm-action as usual; toggling is purely presentational, so no
+    // state round-trip and no flicker.
+    //
+    window.initLongFormEditors = function () {
+        if (!window.statum || !statum.afterEvaluate) return;
+
+        // Resolve a "type.field" state path: statum.read returns the
+        // SLOT's public object, so the first segment is the slot and
+        // the rest walks into it.
+        function readPath(path) {
+            if (!path) return '';
+            var parts = path.split('.');
+            var value = statum.read(parts[0]);
+            for (var i = 1; i < parts.length && value != null; i++) {
+                value = value[parts[i]];
+            }
+            return value === undefined || value === null ? '' : String(value);
+        }
+
+        function syncCard(card) {
+            var open = card.getAttribute('data-lf-open') === '1';
+            var view = card.querySelector('[data-lf-view]');
+            var editor = card.querySelector('[data-lf-edit]');
+            if (view) {
+                if (open) view.setAttribute('hidden', '');
+                else view.removeAttribute('hidden');
+            }
+            if (editor) {
+                if (open) editor.removeAttribute('hidden');
+                else editor.setAttribute('hidden', '');
+            }
+        }
+
+        // Prefill bookkeeping: applied ONCE per open — a statum
+        // re-render (pulse refresh, action response) rebinds
+        // attributes but never textarea VALUES, so re-applying on
+        // every cycle would clobber the user's in-progress typing.
+        // Closing the card clears the flag; the next open re-prefills
+        // from the (possibly updated) state.
+        var prefilled = {};
+        function applyPrefills() {
+            var cards = document.querySelectorAll('[data-long-form]');
+            Array.prototype.forEach.call(cards, function (card) {
+                syncCard(card);
+                var key = card.getAttribute('data-field') || '';
+                var open = card.getAttribute('data-lf-open') === '1';
+                if (open && !prefilled[key]) {
+                    var controls = card.querySelectorAll('[data-lf-src]');
+                    Array.prototype.forEach.call(controls, function (control) {
+                        var value = readPath(control.getAttribute('data-lf-src'));
+                        if (document.activeElement !== control) {
+                            control.value = value;
+                        }
+                    });
+                    prefilled[key] = true;
+                }
+                if (!open) delete prefilled[key];
+            });
+        }
+
+        document.addEventListener('click', function (event) {
+            if (!event.target.closest) return;
+            var button = event.target.closest('[data-lf-toggle]');
+            if (!button) return;
+            var card = button.closest('[data-long-form]');
+            if (!card) return;
+            event.preventDefault();
+            var open = card.getAttribute('data-lf-open') === '1';
+            if (open) {
+                card.setAttribute('data-lf-open', '0');
+                delete prefilled[card.getAttribute('data-field') || ''];
+            } else {
+                card.setAttribute('data-lf-open', '1');
+            }
+            syncCard(card);
+            // Prefill immediately (the render cycle applies the state
+            // bindings; the textarea's value needs this direct pass).
+            applyPrefills();
+            if (!open) {
+                var area = card.querySelector('textarea');
+                if (area && area.focus) area.focus();
+            }
+        });
+
+        statum.afterEvaluate(applyPrefills);
+    };
+
     if (window.statum) {
             (function () {
                 'use strict';

+ 38 - 9
src/pages/task_detail.html

@@ -47,6 +47,11 @@
                     }
                 });
             }
+
+            // The mandate card's in-place editor (task 12).
+            if (window.initLongFormEditors) {
+                window.initLongFormEditors();
+            }
         })();
     }
 </script>
@@ -154,11 +159,35 @@
                 </section>
 
                 <!-- Plan 12: the mandate — the plan-11 "Description"
-                     card, framed as the binding work order. -->
-                <section class="card">
-                    <h3>Mandate</h3>
-                    <div stm-if="task.description.length > 0" class="md" stm-markdown="task.description">description</div>
-                    <p class="muted" stm-if="task.description.length == 0">No mandate.</p>
+                     card, framed as the binding work order. Task 12:
+                     drafts swap the rendered markdown for an in-place
+                     editor (initLongFormEditors in main.html) — the
+                     expander no longer carries the long-form field,
+                     and non-draft/terminal tasks keep the plain read
+                     view. -->
+                <section class="card" data-long-form data-field="task"
+                         data-lf-open="0">
+                    <h3>Mandate
+                        <button type="button" class="inline" stm-if="task.editable_all"
+                                data-lf-toggle>Edit</button></h3>
+                    <div data-lf-view>
+                        <div stm-if="task.description.length > 0" class="md" stm-markdown="task.description">description</div>
+                        <p class="muted" stm-if="task.description.length == 0">No mandate.</p>
+                    </div>
+                    <!-- The stm-if wrapper (not the form itself) owns
+                         draft-gating: statum toggles `hidden` on
+                         stm-if branches, so the form inside keeps its
+                         visibility JS-owned (initLongFormEditors). -->
+                    <div stm-if="task.editable_all">
+                        <form data-lf-edit hidden stm-action="task.update">
+                            <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                            <div class="form-row"><label>Mandate</label>
+                                <textarea name="description" rows="8"
+                                          data-lf-src="task.description">description</textarea></div>
+                            <button type="submit">Save</button>
+                            <button type="button" data-lf-toggle>Cancel</button>
+                        </form>
+                    </div>
                 </section>
             </div>
 
@@ -178,8 +207,10 @@
             <!-- The edit expander sits UNDER the "Show history" expander
                  (plan 11 task D): draft tasks edit every field except
                  status; non-draft non-terminal tasks edit priority
-                 only; terminal tasks edit nothing. The description
-                 arrives pre-filled whenever it is editable. -->
+                 only; terminal tasks edit nothing. Task 12: the
+                 long-form mandate moved to its own in-place editor on
+                 the mandate card — the expander keeps the short
+                 properties only. -->
             <details class="edit-expander" stm-if="task.editable_all || task.editable_priority">
                 <summary><strong>Edit task</strong>
                     <span class="muted" stm-if="task.editable_all">— every field except status while in draft</span>
@@ -188,8 +219,6 @@
                     <input type="hidden" name="task_id" stm-attribute.value="task.id">
                     <div class="form-row"><label>Title</label>
                         <input type="text" name="title" stm-attribute.value="task.title"></div>
-                    <div class="form-row"><label>Mandate</label>
-                        <textarea name="description" rows="4" stm-attribute.value="task.description"></textarea></div>
                     <div class="form-row" stm-if="!task.is_goal_run"><label>Mode</label>
                         <select name="mode" stm-attribute.value="task.mode">
                             <option value="implement">implement — build the change on a task branch</option>

+ 61 - 0
test/bundles/goalui.py

@@ -722,3 +722,64 @@ def run(ctx):
           % s2_wrapped)
     check(goal_state(s2_goal)["status"] == "planning",
           "the feedback rejection returns the agent to planning")
+
+
+    # =====================================================================
+    # Task 12 (goal half): the draft brief-card editor.
+    # =====================================================================
+    print("== task 12: the draft goal brief editor ==")
+    # A fresh DRAFT goal (the fixtures above are all started): the
+    # brief card carries the in-place editor with title/brief/priority
+    # prefilled from state, goal.update saves through the sealed
+    # action, and START removes the editor (the brief locks).
+    act(auth, slot_public(auth, "/goals", "goals")["create"], {
+        "project_id": "1", "title": "Editable draft goal",
+        "brief": "The original brief.",
+        "branch_name": "goal/editable-draft"})
+    conn = db()
+    edit_goal = conn.execute(
+        "select id from goals where title = 'Editable draft goal'"
+    ).fetchone()[0]
+    conn.close()
+
+    draft = goal_state(edit_goal)
+    check("update" in draft,
+          "a draft goal's state carries the update action (the editor's save)")
+    goal_page = http_get_page(auth, "/goals/%d" % edit_goal)
+    check('stm-if="goal.update != null"' in goal_page
+          and 'data-lf-toggle>Edit</button>' in goal_page,
+          "the brief card renders the draft-only Edit button")
+    check('data-lf-src="goal.brief"' in goal_page
+          and 'data-lf-src="goal.title"' in goal_page
+          and 'data-lf-src="goal.priority"' in goal_page,
+          "the editor prefills title, brief and priority from state"
+          " (stm-attribute.value cannot carry a textarea)")
+
+    act(auth, draft["update"], {"title": "Editable draft goal (edited)",
+                                "brief": "The edited brief.",
+                                "priority": "7"})
+    saved = goal_state(edit_goal)
+    check(saved["title"] == "Editable draft goal (edited)"
+          and saved["brief"] == "The edited brief."
+          and saved["priority"] == 7,
+          "the draft edit round-trips title, brief and priority"
+          " (draft priority is finally changeable)")
+
+    # The draft-only guard is server-side: start the goal, then the
+    # update action is gone from state (the editor with it).
+    act(auth, saved["start"], {})
+    check(wait_for(lambda: goal_state(edit_goal)["status"] != "draft"
+                   or False, what="editable draft goal starts"),
+          "the edited draft goal starts")
+    check("update" not in goal_state(edit_goal),
+          "a started goal's state carries no update action (the brief locked)")
+
+    # The server-side guard: replaying the (still-valid) sealed
+    # update action against the now-started goal must refuse — the
+    # editor's disappearance is UI, the refusal is enforcement.
+    replay = act(auth, draft["update"], {"brief": "Sneaked brief."})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error"
+              for d in replay),
+          "the draft-only guard refuses an edit replay against a started goal")
+    check(goal_state(edit_goal)["brief"] == "The edited brief.",
+          "the refused replay left the brief unchanged")

+ 87 - 6
test/bundles/pages.py

@@ -348,10 +348,9 @@ def run(ctx):
           and page.index("<h3>Progress</h3>") < page.index("status-head"),
           "the progress feed renders above the status card")
     check(page.index("grid-2") < page.index("status-head")
-          and page.index("status-head") < page.index("<h3>Mandate</h3>"),
+          and page.index("status-head") < page.index('data-long-form data-field="task"'),
           "status and description sit side by side inside the grid (status first)")
-    check("<h3>Mandate</h3>" in page
-          and "plan-expander" not in page,
+    check("Mandate" in page and "plan-expander" not in page,
           "the task page carries no plan expander (plans live on the goal)")
     # Answered blocks fold behind the history expander (this fixture's
     # three blocks are all answered by now).
@@ -775,10 +774,13 @@ def run(ctx):
     act(auth, task_state(auth, keeper)["update"], {"priority": "2"})
     check("Keep this mandate intact." == task_state(auth, keeper)["description"],
           "a priority-only edit leaves the mandate untouched")
-    # And the full edit form prefills the mandate (carried fix).
+    # Task 12: the mandate's edit surface is the in-place card
+    # editor (the expander no longer carries the field); the editor
+    # prefills from state, not stm-attribute.value (a content
+    # attribute, which textareas ignore).
     task_page_html = http_get_page(auth, "/tasks/%d" % keeper)
-    check('stm-attribute.value="task.description"' in task_page_html,
-          "the edit expander pre-populates the mandate textarea")
+    check('data-lf-src="task.description"' in task_page_html,
+          "the mandate card editor prefills the textarea from state")
 
     print("== plan 13 B4: comments display as footnotes, body-first ==")
     # A quoted agent comment on the keeper task drives the display
@@ -846,3 +848,82 @@ def run(ctx):
               if c["body"].startswith("Order probe")]
     check(len(bodies) == 2 and bodies[0] == "Order probe new remark",
           "the task comment thread leads with the newest remark")
+
+
+    # =====================================================================
+    # Task 12 (task half): the mandate-card in-place editor.
+    # =====================================================================
+    print("== task 12: the mandate card editor ==")
+    act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+        "project_id": "1", "title": "Mandate editor task", "priority": "4",
+        "description": "# The original mandate\n\nWith a body."})
+    conn = db()
+    mandate_task = conn.execute(
+        "select id from tasks where title = 'Mandate editor task'"
+    ).fetchone()[0]
+    conn.close()
+
+    detail = task_state(auth, mandate_task)
+    check(detail.get("editable_all") is True and detail.get("draft") is True,
+          "the mandate editor task is a draft (every field editable)")
+    task_page = http_get_page(auth, "/tasks/%d" % mandate_task)
+    card_at = task_page.index('data-long-form data-field="task"')
+    edit_at = task_page.index('data-lf-toggle>Edit</button>', card_at)
+    area_at = task_page.index('data-lf-src="task.description"', card_at)
+    check(card_at >= 0 and edit_at > card_at and area_at > card_at,
+          "the mandate card carries the in-place Edit button and editor")
+    check('name="description" rows="8"' in task_page,
+          "the editor's textarea is the wide in-place one")
+    # The expander no longer carries the mandate field (short
+    # properties only) — and no other description textarea exists.
+    expander_at = task_page.index('class="edit-expander"')
+    expander_end = task_page.index('</details>', expander_at)
+    expander_html = task_page[expander_at:expander_end]
+    check('name="description"' not in expander_html,
+          "the edit expander no longer carries the mandate field")
+
+    # The PREFILL contract: the editor reads task.description from
+    # state at open (JS-applied — stm-attribute.value is a no-op on
+    # textarea). The compiled page therefore carries the state path,
+    # and the save is a description-only update.
+    check(task_page.count('data-lf-src="task.description"') == 1,
+          "exactly one mandate editor textarea exists, keyed to state")
+    act(auth, detail["update"], {"task_id": str(mandate_task),
+                                 "description": "# The edited mandate\n\nSecond."})
+    saved = task_state(auth, mandate_task)
+    check(saved["description"] == "# The edited mandate\n\nSecond."
+          and saved["title"] == "Mandate editor task",
+          "the description-only save round-trips and leaves the title alone")
+
+    # Editability rules unchanged: an ACTIVE task keeps the read-only
+    # mandate (no Edit button, no editor textarea) while priority
+    # remains editable in the expander.
+    conn = db()
+    conn.execute("insert into tasks (project_id, title, status, priority,"
+                 " created_at) values (1, 'Active mandate task', 'working', 5,"
+                 " strftime('%s','now'))")
+    conn.commit()
+    active_task = conn.execute(
+        "select id from tasks where title = 'Active mandate task'"
+    ).fetchone()[0]
+    conn.close()
+    active = task_state(auth, active_task)
+    check(active.get("editable_all") is False
+          and active.get("editable_priority") is True,
+          "an active task edits priority only")
+    # The compiled page is state-independent markup: statum gates the
+    # editor through stm-if, which HIDES (never removes) the branch.
+    # What the raw page can promise: the Edit button and the editor
+    # form are BOTH inside task.editable_all-gated branches (statum
+    # hides them at render), while the rendered mandate view carries
+    # no gate at all.
+    active_page = http_get_page(auth, "/tasks/%d" % active_task)
+    card_at = active_page.find('data-long-form data-field="task"')
+    card_end = active_page.find('</section>', card_at)
+    card_html = active_page[card_at:card_end]
+    toggle_at = card_html.find('data-lf-toggle')
+    editor_at = card_html.find('data-lf-edit')
+    check(toggle_at < 0 or 'stm-if="task.editable_all"' in card_html,
+          "an active task's Edit button is draft-gated (stm-if hides it)")
+    check(editor_at < 0 or card_html.rfind('stm-if="task.editable_all"', 0, editor_at) > 0,
+          "an active task's editor form sits inside the draft-gated branch")