feat: review ready mobile drafts in one tap (Closes #861)
All checks were successful
CI / lint (pull_request) Successful in 1m46s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-15 01:12:10 +00:00
parent ff41eb1d57
commit c1c4ff8e31
8 changed files with 170 additions and 33 deletions

View File

@ -282,7 +282,7 @@ textarea { resize: vertical; min-height: 120px; }
.draft-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; } .draft-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; }
.draft-preview { color:var(--muted); overflow-wrap:anywhere; } .draft-preview { color:var(--muted); overflow-wrap:anywhere; }
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; } .draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.draft-actions button { min-height:44px; width:100%; } .draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px; width:100%; }
.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; grid-column:1/-1; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; } .delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; grid-column:1/-1; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.delivery-center h3, .delivery-center p { margin:0; } .delivery-center h3, .delivery-center p { margin:0; }
.delivery-center button { min-height:44px; flex:0 0 auto; } .delivery-center button { min-height:44px; flex:0 0 auto; }
@ -290,7 +290,7 @@ textarea { resize: vertical; min-height: 120px; }
.draft-section { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; grid-column:1/-1; } .draft-section { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; grid-column:1/-1; }
.draft-section > h3 { grid-column:1/-1; margin:8px 0 0; } .draft-section > h3 { grid-column:1/-1; margin:8px 0 0; }
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); } .create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; } .create-issue-actions button { min-height:44px; width:100%; }
.create-issue-actions #create-issue-status { grid-column:1/-1; } .create-issue-actions #create-issue-status { grid-column:1/-1; }
.create-issue-estimate { display:grid; gap:6px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#101d31; } .create-issue-estimate { display:grid; gap:6px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#101d31; }
.create-issue-estimate > span:first-of-type { display:flex; align-items:center; gap:8px; } .create-issue-estimate > span:first-of-type { display:flex; align-items:center; gap:8px; }
@ -298,13 +298,11 @@ textarea { resize: vertical; min-height: 120px; }
.draft-filing-session { position:sticky; top:0; z-index:2; display:grid; gap:6px; margin-bottom:12px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); } .draft-filing-session { position:sticky; top:0; z-index:2; display:grid; gap:6px; margin-bottom:12px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); }
.draft-filing-session[hidden] { display:none; } .draft-filing-session[hidden] { display:none; }
.draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; } .draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.draft-filing-session-actions button { min-height:44px; width:100%; }
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); } .my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
.agenda-replan { margin:10px 0; padding:12px; border:1px solid #7c4a1d; border-radius:12px; background:#24170d; } .agenda-replan { margin:10px 0; padding:12px; border:1px solid #7c4a1d; border-radius:12px; background:#24170d; }
.agenda-replan-launch { display:flex; align-items:center; justify-content:space-between; gap:12px; } .agenda-replan-launch { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.agenda-replan-launch p { margin:4px 0 0; } .agenda-replan-launch p { margin:4px 0 0; }
.agenda-replan-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:10px; } .agenda-replan-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:10px; }
.agenda-replan-actions button { min-height:44px; width:100%; }
.agenda-replan-actions label { grid-column:1/-1; } .agenda-replan-actions label { grid-column:1/-1; }
.agenda-replan-actions input { box-sizing:border-box; min-height:44px; width:100%; max-width:100%; } .agenda-replan-actions input { box-sizing:border-box; min-height:44px; width:100%; max-width:100%; }
.protect-today { margin:10px 0; padding:12px; border:1px solid #2f6f9f; border-radius:12px; background:#0d2136; display:flex; align-items:center; justify-content:space-between; gap:12px; } .protect-today { margin:10px 0; padding:12px; border:1px solid #2f6f9f; border-radius:12px; background:#0d2136; display:flex; align-items:center; justify-content:space-between; gap:12px; }

View File

@ -536,9 +536,11 @@
); );
const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)}); const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)});
dFS.attach(qs, { dFS.attach(qs, {
captures:unfiledCaptures, issueCapture, attachment:createIssueAttachmentController, captures:unfiledCaptures, capture:()=>issueCapture, attachment:createIssueAttachmentController,
getLogin:()=>activeFlushLogin, setResumedId:id=>{ rUC = id; }, getLogin:()=>activeFlushLogin, setResumedId:id=>{ rUC = id; },
openSheet:openCreateIssueSheet, setFilingMode:setIssueFilingMode, openSheet:openCreateIssueSheet, setFilingMode:setIssueFilingMode,
review:r=>loadIssueFilingMetadata(r.repository,r)
.then(()=>issueCapture.findDuplicates(currentIssueCaptureDraft())).then(renderIssueDuplicates),
}); });
let rUC = ''; let rUC = '';
let backgroundIssueSync = null; let backgroundIssueSync = null;
@ -2655,19 +2657,16 @@
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open review</button>' + '<button class="draft-resume" data-draft-index="' + index + '" type="button">Open review</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' : '<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
'<button class="draft-resume" data-draft-index="' + index + '" type="button">' + '<button class="draft-resume" data-draft-index="' + index + '" type="button">' +
(isUnfiled ? 'Choose repository' : 'Resume draft') + '</button>' + (isUnfiled ? item.action : 'Resume draft') + '</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>'; '<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
const state = (isOutbox || isUnfiled) ? const state = isOutbox ?
'<span class="pill">' + (item.quarantined ? 'Identity protected' : '<span class="pill">' + draftInbox.deliveryLabel(item) + '</span>' +
(isUnfiled ? 'Needs filing' : item.status === 'completion' ? 'Created · ready to start' :
item.status === 'attention' ? 'Needs attention' : item.status === 'sending' ? 'Sending' :
item.status === 'authorization' ? 'Awaiting authorization' : 'Queued for sync')) + '</span>' +
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : ''; (item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
const attempt = item.last_attempt_error ? '<span class="delivery-attempt small">Last attempt ' + const attempt = item.last_attempt_error ? '<span class="delivery-attempt small">Last attempt ' +
escapeHtml(fmt(item.last_attempt_at)) + ' · ' + escapeHtml(item.last_attempt_error) + '</span>' : ''; escapeHtml(fmt(item.last_attempt_at)) + ' · ' + escapeHtml(item.last_attempt_error) + '</span>' : '';
const captureTarget = isUnfiled ? ' tabindex="-1" data-capture-id="' + escapeHtml(item.capture_id) + '"' : ''; const captureTarget = isUnfiled ? ' tabindex="-1" data-capture-id="' + escapeHtml(item.capture_id) + '"' : '';
return '<article class="my-work-card draft-card"' + captureTarget + '>' + return '<article class="my-work-card draft-card"' + captureTarget + '>' +
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' + '<span class="small">' + escapeHtml([item.label, item.repository, item.details].filter(Boolean).join(' · ')) + '</span>' +
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' + '<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' + state + attempt + '<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' + state + attempt +
'<span class="small">Saved ' + escapeHtml(fmt(item.updated_at)) + '</span>' + '<span class="small">Saved ' + escapeHtml(fmt(item.updated_at)) + '</span>' +
@ -2705,8 +2704,7 @@
if (!item) return; if (!item) return;
if (item.kind === 'unfiled-issue') { if (item.kind === 'unfiled-issue') {
try { try {
dFS.start(item.capture_id); await dFS.open(item.capture_id, item.ready ? button : null);
await dFS.nextCapture();
} catch (error) { qs('#my-work-action-status').textContent = error.message; } } catch (error) { qs('#my-work-action-status').textContent = error.message; }
} else if (item.kind === 'new-issue') openCreateIssueSheet(); } else if (item.kind === 'new-issue') openCreateIssueSheet();
else if (item.route) workRoute.open(item.route); else if (item.route) workRoute.open(item.route);
@ -5326,7 +5324,8 @@
todayCapacity: startCapacity, todayCapacity: startCapacity,
} : {}), } : {}),
}; };
filingReview.open({draft: durableDraft, intent}, event.submitter || qs('#submit-new-issue')); filingReview.open({draft: durableDraft, intent},
dFS.takeTrigger() || event.submitter || qs('#submit-new-issue'));
qs('#create-issue-status').textContent = 'Review the complete payload, then confirm filing.'; qs('#create-issue-status').textContent = 'Review the complete payload, then confirm filing.';
} catch (error) { } catch (error) {
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.'; qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';

View File

@ -1,6 +1,7 @@
function createDraftFilingSession({list}) { function createDraftFilingSession({list}) {
let orderedIds = []; let orderedIds = [];
let currentId = ''; let currentId = '';
let reviewTrigger = null;
function visibleIds() { function visibleIds() {
const available = new Set(list().map(item => item.id)); const available = new Set(list().map(item => item.id));
@ -49,18 +50,42 @@ function createDraftFilingSession({list}) {
const session = {start, current:state, next:()=>move(1), previous:()=>move(-1), removeCurrentAndNext}; const session = {start, current:state, next:()=>move(1), previous:()=>move(-1), removeCurrentAndNext};
session.attach = (qs, dependencies) => { session.attach = (qs, dependencies) => {
const openCapture = async captureId => { const openCapture = async (captureId, trigger = null) => {
const resumed = await dependencies.captures.resume(captureId, dependencies.getLogin()); const resumed = await dependencies.captures.resume(captureId, dependencies.getLogin());
dependencies.issueCapture.saveDraft(resumed); const issueCapture = dependencies.capture?.() || dependencies.issueCapture;
issueCapture.saveDraft(resumed);
dependencies.setResumedId(captureId); dependencies.setResumedId(captureId);
await dependencies.openSheet(); await dependencies.openSheet(false);
const evidence = resumed.attachments || resumed.attachment; const evidence = resumed.attachments || resumed.attachment;
dependencies.attachment[evidence ? 'restore' : 'clear'](evidence); dependencies.attachment[evidence ? 'restore' : 'clear'](evidence);
dependencies.setFilingMode(true); dependencies.setFilingMode(true);
session.render(); session.render();
if (trigger) {
await dependencies.review(resumed);
reviewTrigger = trigger;
const submitter = resumed.completionIntent === 'create-and-start' ?
qs('#create-and-start-issue') : qs('#submit-new-issue');
const disabled = submitter.disabled;
submitter.disabled = false;
qs('#create-issue-form').requestSubmit(submitter);
submitter.disabled = disabled;
}
}; };
return Object.assign(session, { return Object.assign(session, {
nextCapture() { const state = session.current(); return state ? openCapture(state.id) : null; }, nextCapture() { const state = session.current(); return state ? openCapture(state.id) : null; },
reviewCapture(captureId, trigger) {
session.start(captureId);
return openCapture(captureId, trigger);
},
open(captureId, trigger = null) {
session.start(captureId);
return openCapture(captureId, trigger);
},
takeTrigger() {
const trigger = reviewTrigger;
reviewTrigger = null;
return trigger;
},
render() { render() {
const state = session.current(); const state = session.current();
qs('#draft-filing-session').hidden = !state; qs('#draft-filing-session').hidden = !state;

View File

@ -251,7 +251,16 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
return { drafts, deliveries, counts, retryable }; return { drafts, deliveries, counts, retryable };
} }
return { list, discard, partition }; function deliveryLabel(item) {
if (item.quarantined) return 'Identity protected';
if (item.status === 'completion') return 'Created · ready to start';
if (item.status === 'attention') return 'Needs attention';
if (item.status === 'sending') return 'Sending';
if (item.status === 'authorization') return 'Awaiting authorization';
return 'Queued for sync';
}
return { list, discard, partition, deliveryLabel };
} }
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox; if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox;

View File

@ -4,8 +4,18 @@ function withFilingEstimate(draft, value) {
} }
function unfiledDraftSummary(item) { function unfiledDraftSummary(item) {
return {label:item.filingPlan?.repository ? 'Ready to file' : 'Needs filing', const plan = item.filingPlan || {};
repository:item.filingPlan?.repository || ''}; const ready = Boolean(plan.repository);
const startsToday = plan.completionIntent === 'create-and-start';
const evidenceCount = Number(item.attachmentCount || (item.hasAttachment ? 1 : 0));
const evidence = evidenceCount ? `${evidenceCount} screenshot${evidenceCount === 1 ? '' : 's'}` : 'No screenshots';
return {
label:ready ? 'Ready to file' : 'Needs filing',
repository:plan.repository || '', ready,
action:ready ? (startsToday ? 'Review & start' : 'Review & file') : 'Choose repository',
details:[startsToday ? 'Create & start' : 'Create issue',
plan.estimateMinutes ? `${plan.estimateMinutes} min` : '', evidence].filter(Boolean).join(' · '),
};
} }
function unfiledSavedMessage(item) { function unfiledSavedMessage(item) {
@ -409,4 +419,7 @@ function createUnfiledCaptures({
subscribe:listener => (listeners.add(listener), () => listeners.delete(listener))}; subscribe:listener => (listeners.add(listener), () => listeners.delete(listener))};
} }
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures; if (typeof module !== 'undefined' && module.exports) {
module.exports = createUnfiledCaptures;
module.exports.summary = unfiledDraftSummary;
}

View File

@ -270,13 +270,17 @@ const values = new Map([
]); ]);
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const inbox = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}); const inbox = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}});
process.stdout.write(JSON.stringify(inbox.partition())); const partition=inbox.partition();
process.stdout.write(JSON.stringify({{...partition,labels:partition.deliveries.map(inbox.deliveryLabel)}}));
""" """
output = run_node(script) output = run_node(script)
assert len(output["drafts"]) == 1 assert len(output["drafts"]) == 1
assert len(output["deliveries"]) == 5 assert len(output["deliveries"]) == 5
assert output["counts"] == {"waiting": 2, "sending": 1, "attention": 2, "authorization": 0} assert output["counts"] == {"waiting": 2, "sending": 1, "attention": 2, "authorization": 0}
assert output["labels"] == [
"Needs attention", "Identity protected", "Needs attention", "Sending", "Queued for sync"
]
assert [item["outbox_id"] for item in output["retryable"]] == ["waiting"] assert [item["outbox_id"] for item in output["retryable"]] == ["waiting"]
waiting = next(item for item in output["deliveries"] if item["outbox_id"] == "waiting") waiting = next(item for item in output["deliveries"] if item["outbox_id"] == "waiting")
assert waiting["last_attempt_at"] == 90 assert waiting["last_attempt_at"] == 90
@ -293,7 +297,7 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
assert 'class="draft-resume"' in html assert 'class="draft-resume"' in html
assert 'class="draft-discard"' in html assert 'class="draft-discard"' in html
assert 'Discard this unfinished draft?' in html assert 'Discard this unfinished draft?' in html
assert '.draft-actions button { min-height:44px;' in html assert '.draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px;' in html
assert 'createDraftInbox({ storage: localStorage' in html assert 'createDraftInbox({ storage: localStorage' in html
assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html
assert "Verified not posted — retry" in html assert "Verified not posted — retry" in html
@ -316,7 +320,8 @@ async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_w
assert "const deliveryCenter = draftInbox.partition(displayedDrafts);" in html assert "const deliveryCenter = draftInbox.partition(displayedDrafts);" in html
assert "await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)])" in html assert "await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)])" in html
assert "deliveryCenter.retryable.length" in html assert "deliveryCenter.retryable.length" in html
assert "item.status === 'sending' ? 'Sending'" in html assert "draftInbox.deliveryLabel(item)" in html
assert "if (item.status === 'sending') return 'Sending'" in DRAFTS.read_text()
assert "item.last_attempt_error ? '<span class=\"delivery-attempt small\">Last attempt '" in html assert "item.last_attempt_error ? '<span class=\"delivery-attempt small\">Last attempt '" in html
assert '.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;' in html assert '.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;' in html
assert '.delivery-center button { min-height:44px;' in html assert '.delivery-center button { min-height:44px;' in html
@ -330,7 +335,7 @@ async def test_mobile_dashboard_requires_an_explicit_authorize_and_close_gesture
assert '>Authorize &amp; close</button>' in html assert '>Authorize &amp; close</button>' in html
assert "list.querySelectorAll('.draft-authorize')" in html assert "list.querySelectorAll('.draft-authorize')" in html
assert "authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html assert "authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html
assert "item.status === 'authorization' ? 'Awaiting authorization'" in html assert "if (item.status === 'authorization') return 'Awaiting authorization'" in DRAFTS.read_text()
@pytest.mark.anyio @pytest.mark.anyio

View File

@ -4246,7 +4246,7 @@ async def test_every_mobile_repository_selection_uses_the_resilient_metadata_loa
assert "renderLabels(repository, metadata.labels" in source assert "renderLabels(repository, metadata.labels" in source
assert "renderMilestones(repository, metadata.milestones" in source assert "renderMilestones(repository, metadata.milestones" in source
assert "templatePicker.render(repository, metadata.templates" in source assert "templatePicker.render(repository, metadata.templates" in source
assert html.count("loadIssueFilingMetadata(") == 4 assert html.count("loadIssueFilingMetadata(") == 5
assert "loadIssueLabels(repository);\n loadIssueMilestones(repository);" not in html assert "loadIssueLabels(repository);\n loadIssueMilestones(repository);" not in html
assert "loadIssueLabels(event.target.value).then" not in html assert "loadIssueLabels(event.target.value).then" not in html
assert "loadIssueLabels(qs('#create-issue-repository').value" not in html assert "loadIssueLabels(qs('#create-issue-repository').value" not in html

View File

@ -60,6 +60,78 @@ session.start('planned');
assert run_node(script) == [[{"filename": "one.png"}, {"filename": "two.png"}]] assert run_node(script) == [[{"filename": "one.png"}, {"filename": "two.png"}]]
def test_ready_draft_summary_exposes_truthful_mobile_action_and_plan_details():
script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
const planned=createUnfiledCaptures.summary({{
filingPlan:{{repository:'stackchain/dashboard',completionIntent:'create-and-start',estimateMinutes:45}},
attachmentCount:2,
}});
const minimal=createUnfiledCaptures.summary({{}});
process.stdout.write(JSON.stringify({{planned,minimal}}));
"""
assert run_node(script) == {
"planned": {
"label": "Ready to file",
"repository": "stackchain/dashboard",
"ready": True,
"action": "Review & start",
"details": "Create & start · 45 min · 2 screenshots",
},
"minimal": {
"label": "Needs filing",
"repository": "",
"ready": False,
"action": "Choose repository",
"details": "Create issue · No screenshots",
},
}
def test_ready_draft_opens_final_review_with_restored_intent_and_evidence():
script = f"""
const createDraftFilingSession=require({json.dumps(str(DRAFT_SESSION))});
const events=[];
const trigger={{id:'ready-action'}};
const resumed={{repository:'o/r',title:'Planned',body:'Evidence',labelIds:[7],
completionIntent:'create-and-start',estimateMinutes:30,
attachments:[{{filename:'one.png'}},{{filename:'two.png'}}]}};
const elements=new Map();
const qs=selector=>{{
if(!elements.has(selector)) elements.set(selector,{{id:selector.slice(1),hidden:false,disabled:false,textContent:'',addEventListener:()=>{{}},
requestSubmit:button=>events.push(['review',resumed,button.id])}});
return elements.get(selector);
}};
const session=createDraftFilingSession({{list:()=>[{{id:'planned'}}]}});
session.attach(qs,{{
captures:{{resume:async()=>resumed}},
capture:()=>({{saveDraft:value=>events.push(['save',value])}}),
attachment:{{restore:value=>events.push(['evidence',value]),clear:()=>{{}}}},
getLogin:()=>'timmy',setResumedId:id=>events.push(['source',id]),
openSheet:async()=>events.push(['sheet']),setFilingMode:()=>events.push(['filing']),
review:async()=>{{events.push(['metadata']);events.push(['duplicates']);}},
}});
session.start('planned');
(async()=>{{await session.reviewCapture('planned',trigger);process.stdout.write(JSON.stringify({{
events,origin:session.takeTrigger().id
}}));}})();
"""
output = run_node(script)
events = output["events"]
assert [event[0] for event in events] == [
"save", "source", "sheet", "evidence", "filing", "metadata",
"duplicates", "review"
]
assert events[-1][1]["labelIds"] == [7]
assert events[-1][1]["attachments"] == [
{"filename": "one.png"}, {"filename": "two.png"}
]
assert events[-1][2] == "create-and-start-issue"
assert output["origin"] == "ready-action"
def test_unfiled_captures_block_at_capacity_until_oldest_is_explicitly_replaced(): def test_unfiled_captures_block_at_capacity_until_oldest_is_explicitly_replaced():
script = f""" script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))}); const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
@ -599,7 +671,7 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
assert "await unfiledCaptures.completeResume(rUC)" in html assert "await unfiledCaptures.completeResume(rUC)" in html
assert "...(rUC ? { sourceCaptureId: rUC } : {})" in html assert "...(rUC ? { sourceCaptureId: rUC } : {})" in html
assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html
assert "dependencies.issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text() assert "issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text()
assert "item.kind === 'unfiled-issue'" in html assert "item.kind === 'unfiled-issue'" in html
assert '.create-issue-actions button { min-height:44px;' in html assert '.create-issue-actions button { min-height:44px;' in html
assert '.draft-card:focus-visible' in html assert '.draft-card:focus-visible' in html
@ -618,11 +690,28 @@ async def test_mobile_planned_draft_can_be_saved_and_resumed_without_replanning(
assert "qs('#create-issue-estimate').value = captureDraft.estimateMinutes || '';" in html assert "qs('#create-issue-estimate').value = captureDraft.estimateMinutes || '';" in html
assert "qs('#file-new-issue').hidden = enabled" in feature assert "qs('#file-new-issue').hidden = enabled" in feature
assert "qs('.create-issue-capture-actions').hidden = false" in feature assert "qs('.create-issue-capture-actions').hidden = false" in feature
assert "label:item.filingPlan?.repository ? 'Ready to file' : 'Needs filing'" in feature assert "label:ready ? 'Ready to file' : 'Needs filing'" in feature
assert "repository:item.filingPlan?.repository || ''" in feature assert "repository:plan.repository || ''" in feature
assert "Saved planned Draft" in feature assert "Saved planned Draft" in feature
@pytest.mark.anyio
async def test_mobile_ready_draft_card_opens_validated_final_review_in_one_tap():
html = await dashboard()
assert "isUnfiled ? item.action" in html
assert "[item.label, item.repository, item.details].filter(Boolean).join(' · ')" in html
assert "escapeHtml([item.label" in html
assert "await dFS.open(item.capture_id, item.ready ? button : null)" in html
assert "capture:()=>issueCapture" in html
assert "review:r=>loadIssueFilingMetadata(r.repository,r)" in html
assert ".then(()=>issueCapture.findDuplicates(currentIssueCaptureDraft())).then(renderIssueDuplicates)" in html
assert "qs('#create-issue-form').requestSubmit(submitter)" in DRAFT_SESSION.read_text()
assert "dFS.takeTrigger() || event.submitter || qs('#submit-new-issue')" in html
assert '.draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px; width:100%; }' in html
assert '@media(max-width:320px)' in html
@pytest.mark.anyio @pytest.mark.anyio
async def test_mobile_drafts_expose_a_safe_sequential_filing_session(): async def test_mobile_drafts_expose_a_safe_sequential_filing_session():
html = await dashboard() html = await dashboard()
@ -632,13 +721,12 @@ async def test_mobile_drafts_expose_a_safe_sequential_filing_session():
assert 'id="draft-filing-progress"' in html assert 'id="draft-filing-progress"' in html
assert 'id="previous-draft"' in html assert 'id="previous-draft"' in html
assert 'id="skip-draft"' in html assert 'id="skip-draft"' in html
assert "dFS.start(item.capture_id)" in html assert "await dFS.open(item.capture_id, item.ready ? button : null)" in html
assert "await dFS.nextCapture()" in html
assert "await dFS.advance(fS.id)" in html assert "await dFS.advance(fS.id)" in html
feature = DRAFT_SESSION.read_text() feature = DRAFT_SESSION.read_text()
assert "Draft ${state.position} of ${state.total}" in feature assert "Draft ${state.position} of ${state.total}" in feature
assert "qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft'" in feature assert "qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft'" in feature
assert '.draft-filing-session-actions button { min-height:44px;' in html assert '.draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px;' in html
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html