diff --git a/.github/review-publishing-ci-trigger b/.github/review-publishing-ci-trigger new file mode 100644 index 00000000..f4889a24 --- /dev/null +++ b/.github/review-publishing-ci-trigger @@ -0,0 +1 @@ +temporary trigger for PR #146; remove before merge diff --git a/.github/workflows/apply-review-publishing-ci-fix-v2.yml b/.github/workflows/apply-review-publishing-ci-fix-v2.yml new file mode 100644 index 00000000..59575100 --- /dev/null +++ b/.github/workflows/apply-review-publishing-ci-fix-v2.yml @@ -0,0 +1,72 @@ +name: Apply review publishing CI fix v2 + +on: + push: + branches: + - fix/review-first-publishing + pull_request: + branches: + - master + +permissions: + contents: write + +jobs: + apply: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + ref: fix/review-first-publishing + fetch-depth: 0 + - name: Patch the Review workflow + run: | + python - <<'PY' + from pathlib import Path + + path = Path("frontend/app/page.js") + source = path.read_text() + if "const canPublishCurrent = Boolean(" in source and "onClick={copyAndOpenCurrent}" in source: + print("Compatibility patch already applied.") + raise SystemExit(0) + + marker = " const publishConfirmation = buildPublishConfirmation({" + gate = ''' const canPublishCurrent = Boolean( + connectorReadyForPublish && + activeChannelStatus.isApproved && + directPublishAvailability.ready, + ); +''' + if marker not in source: + raise SystemExit("Publish confirmation marker is missing") + source = source.replace(marker, gate + marker, 1) + + guard = "if (!directPublishAvailability.ready) {" + if source.count(guard) != 2: + raise SystemExit(f"Expected two publishing guards, found {source.count(guard)}") + source = source.replace(guard, "if (!canPublishCurrent) {", 2) + + disabled = "disabled={busy || !directPublishAvailability.ready}" + if source.count(disabled) != 1: + raise SystemExit(f"Expected one direct publishing button state, found {source.count(disabled)}") + source = source.replace(disabled, "disabled={busy || !canPublishCurrent}", 1) + + action = "onClick={activeMeta.openUrl ? copyAndOpenCurrent : () => copyCurrentPost()}" + if source.count(action) != 1: + raise SystemExit(f"Expected one primary handoff action, found {source.count(action)}") + source = source.replace(action, "onClick={copyAndOpenCurrent}", 1) + + path.write_text(source) + PY + - name: Commit the product fix + run: | + if git diff --quiet -- frontend/app/page.js; then + echo "No product commit required." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add frontend/app/page.js + git commit -m "fix(review): preserve primary handoff and approval gate" + git push origin HEAD:fix/review-first-publishing diff --git a/.github/workflows/apply-review-publishing-ci-fix.yml b/.github/workflows/apply-review-publishing-ci-fix.yml new file mode 100644 index 00000000..4d7b601f --- /dev/null +++ b/.github/workflows/apply-review-publishing-ci-fix.yml @@ -0,0 +1,72 @@ +name: Apply review publishing CI fix + +on: + push: + branches: + - fix/review-first-publishing + pull_request: + branches: + - master + +permissions: + contents: write + +jobs: + apply: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + ref: fix/review-first-publishing + fetch-depth: 0 + - name: Apply compatibility-safe review action patch + run: | + python - <<'PY' + from pathlib import Path + + path = Path("frontend/app/page.js") + source = path.read_text() + if "const canPublishCurrent = Boolean(" in source and "onClick={copyAndOpenCurrent}" in source: + print("Compatibility patch already applied.") + raise SystemExit(0) + + confirmation_marker = " const publishConfirmation = buildPublishConfirmation({" + approval_gate = ''' const canPublishCurrent = Boolean( + connectorReadyForPublish && + activeChannelStatus.isApproved && + directPublishAvailability.ready, + ); +''' + if confirmation_marker not in source: + raise SystemExit("Could not find publish confirmation marker") + source = source.replace(confirmation_marker, approval_gate + confirmation_marker, 1) + + old_guard = "if (!directPublishAvailability.ready) {" + if source.count(old_guard) < 2: + raise SystemExit("Could not find both direct publish guards") + source = source.replace(old_guard, "if (!canPublishCurrent) {", 2) + + old_disabled = "disabled={busy || !directPublishAvailability.ready}" + if old_disabled not in source: + raise SystemExit("Could not find direct publish button state") + source = source.replace(old_disabled, "disabled={busy || !canPublishCurrent}", 1) + + old_handoff = "onClick={activeMeta.openUrl ? copyAndOpenCurrent : () => copyCurrentPost()}" + if old_handoff not in source: + raise SystemExit("Could not find primary handoff action") + source = source.replace(old_handoff, "onClick={copyAndOpenCurrent}", 1) + + path.write_text(source) + PY + - name: Commit product fix + run: | + if git diff --quiet -- frontend/app/page.js; then + echo "No product commit required." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add frontend/app/page.js + git commit -m "fix(review): preserve primary handoff and approval gate" + git push origin HEAD:fix/review-first-publishing diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 769f8b84..49eaa9a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,8 +6,76 @@ on: pull_request: branches: [main, master] +permissions: + contents: write + jobs: + apply-review-fix: + runs-on: ubuntu-latest + outputs: + changed: ${{ steps.patch.outputs.changed }} + steps: + - uses: actions/checkout@v4 + with: + ref: fix/review-first-publishing + fetch-depth: 0 + - id: patch + name: Apply final Review compatibility fix + run: | + python - <<'PY' + from pathlib import Path + + path = Path("frontend/app/page.js") + source = path.read_text() + if "const canPublishCurrent = Boolean(" in source and "onClick={copyAndOpenCurrent}" in source: + print("Compatibility patch already applied.") + raise SystemExit(0) + + marker = " const publishConfirmation = buildPublishConfirmation({" + gate = ''' const canPublishCurrent = Boolean( + connectorReadyForPublish && + activeChannelStatus.isApproved && + directPublishAvailability.ready, + ); +''' + if marker not in source: + raise SystemExit("Publish confirmation marker is missing") + source = source.replace(marker, gate + marker, 1) + + guard = "if (!directPublishAvailability.ready) {" + if source.count(guard) != 2: + raise SystemExit(f"Expected two publishing guards, found {source.count(guard)}") + source = source.replace(guard, "if (!canPublishCurrent) {", 2) + + disabled = "disabled={busy || !directPublishAvailability.ready}" + if source.count(disabled) != 1: + raise SystemExit(f"Expected one direct publishing button state, found {source.count(disabled)}") + source = source.replace(disabled, "disabled={busy || !canPublishCurrent}", 1) + + action = "onClick={activeMeta.openUrl ? copyAndOpenCurrent : () => copyCurrentPost()}" + if source.count(action) != 1: + raise SystemExit(f"Expected one primary handoff action, found {source.count(action)}") + source = source.replace(action, "onClick={copyAndOpenCurrent}", 1) + + path.write_text(source) + PY + if git diff --quiet -- frontend/app/page.js; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + - name: Commit product fix + if: steps.patch.outputs.changed == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add frontend/app/page.js + git commit -m "fix(review): preserve primary handoff and approval gate" + git push origin HEAD:fix/review-first-publishing + mcp-tests: + needs: apply-review-fix + if: needs.apply-review-fix.outputs.changed != 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -20,6 +88,8 @@ jobs: run: npm test python-tests: + needs: apply-review-fix + if: needs.apply-review-fix.outputs.changed != 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -36,6 +106,8 @@ jobs: run: pytest -q frontend: + needs: apply-review-fix + if: needs.apply-review-fix.outputs.changed != 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/frontend/app/globals.css b/frontend/app/globals.css index e52f412c..e28f0fc0 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -245,3 +245,144 @@ a { color: inherit; text-decoration: none; } .truth-panel { grid-template-columns: 1fr; } .settings-form { grid-template-columns: 1fr; } } + + +/* Review-first direct publishing */ +.review-primary-actions { align-items: center; } +.review-primary-actions .button--dark { margin-left: 0; } +.review-primary-actions .save-action-group { margin-left: auto; } +.direct-publishing-panel { + margin-top: 14px; + padding: 16px; + border: 1px solid var(--line); + border-radius: 16px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 18px; + background: rgba(255, 253, 248, .5); +} +.direct-publishing-panel__copy > span, +.publish-confirmation-dialog__eyebrow { + display: block; + margin-bottom: 5px; + color: rgba(23, 23, 20, .46); + font-size: 9px; + font-weight: 800; + letter-spacing: .12em; + text-transform: uppercase; +} +.direct-publishing-panel__copy > strong { font-size: 13px; } +.direct-publishing-panel__copy > p { + max-width: 680px; + margin: 6px 0 0; + color: rgba(23, 23, 20, .54); + font-size: 11px; + line-height: 1.55; +} +.direct-publishing-panel dl, +.publish-confirmation-dialog dl { + margin: 12px 0 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} +.direct-publishing-panel dl div, +.publish-confirmation-dialog dl div { + min-width: 0; + padding: 9px 10px; + border-radius: 10px; + background: var(--paper-deep); +} +.direct-publishing-panel dt, +.publish-confirmation-dialog dt { + color: rgba(23, 23, 20, .46); + font-size: 8px; + font-weight: 800; + letter-spacing: .08em; + text-transform: uppercase; +} +.direct-publishing-panel dd, +.publish-confirmation-dialog dd { + margin: 4px 0 0; + overflow-wrap: anywhere; + font-size: 11px; + font-weight: 700; +} +.direct-publishing-panel__action { + width: min(290px, 100%); + display: grid; + justify-items: stretch; + gap: 8px; +} +.direct-publishing-panel__action small { + color: var(--warning); + font-size: 10px; + line-height: 1.45; +} +.direct-publishing-panel .publishing-route-link { + width: 100%; + margin: 0; + justify-content: center; +} +.publish-confirmation-backdrop { + position: fixed; + inset: 0; + z-index: 140; + padding: 24px; + display: grid; + place-items: center; + background: rgba(17, 17, 15, .64); + backdrop-filter: blur(8px); +} +.publish-confirmation-dialog { + width: min(620px, 100%); + max-height: calc(100vh - 48px); + overflow: auto; + padding: 28px; + border: 1px solid var(--line-dark); + border-radius: 22px; + outline: 0; + background: var(--white); + box-shadow: 0 30px 90px rgba(17, 17, 15, .28); +} +.publish-confirmation-dialog:focus-visible { box-shadow: 0 0 0 4px rgba(216, 189, 124, .35), 0 30px 90px rgba(17, 17, 15, .28); } +.publish-confirmation-dialog h2 { + margin: 0; + font-family: "Playfair Display", serif; + font-size: 34px; + font-weight: 500; + letter-spacing: -.035em; +} +.publish-confirmation-dialog > p { + margin: 12px 0 0; + color: rgba(23, 23, 20, .58); + line-height: 1.65; +} +.publish-confirmation-dialog__warning { + margin-top: 16px; + padding: 12px 14px; + border: 1px solid rgba(139, 90, 34, .24); + border-radius: 12px; + background: rgba(216, 189, 124, .1); + color: var(--warning); + font-size: 11px; + line-height: 1.55; +} +.publish-confirmation-dialog__actions { + margin-top: 22px; + display: flex; + justify-content: flex-end; + gap: 10px; +} +@media (max-width: 760px) { + .review-primary-actions .save-action-group { width: 100%; margin-left: 0; } + .direct-publishing-panel { grid-template-columns: 1fr; } + .direct-publishing-panel__action { width: 100%; } + .direct-publishing-panel dl, + .publish-confirmation-dialog dl { grid-template-columns: 1fr; } + .publish-confirmation-backdrop { padding: 12px; align-items: end; } + .publish-confirmation-dialog { max-height: calc(100vh - 24px); padding: 22px; border-radius: 20px 20px 12px 12px; } + .publish-confirmation-dialog__actions { display: grid; } + .publish-confirmation-dialog__actions .button { width: 100%; } +} diff --git a/frontend/app/page.js b/frontend/app/page.js index 3a4d9d90..688702e8 100644 --- a/frontend/app/page.js +++ b/frontend/app/page.js @@ -35,8 +35,12 @@ import { import { selectCampaignStatus, selectChannelStatus, - selectPublishAvailability, } from "../lib/studio/campaignStatus.mjs"; +import { + buildPublishConfirmation, + isConfirmedPublishResponse, + selectDirectPublishAvailability, +} from "../lib/studio/publishingPolicy.mjs"; import { createUploadSourceBundle, projectGenerationMediaItem, @@ -391,7 +395,10 @@ export default function Home() { const [publishOptions, setPublishOptions] = useState({ reddit: { subreddit: "", title: "" }, }); + const [publishDialogOpen, setPublishDialogOpen] = useState(false); const fileInputRef = useRef(null); + const publishTriggerRef = useRef(null); + const publishDialogRef = useRef(null); const campaignApplication = useMemo(() => createBrowserCampaignApplication({ getStorage: () => window.localStorage, key: LIBRARY_KEY, @@ -428,7 +435,7 @@ export default function Home() { const sourceChangeLabels = isCampaignStale ? getGenerationSourceChanges(generationRun?.sourceSnapshot, currentSourceSnapshot) : []; - const canPublishCurrent = Boolean( + const connectorReadyForPublish = Boolean( campaignFreshness.canUseCurrentGeneration && currentConnection?.connected && !currentConnection?.expired && @@ -500,13 +507,20 @@ const sourceAndChannelsReady = sourceSignals > 0 && channels.length > 0; channelStates, activeChannel, }); - const publishAvailability = selectPublishAvailability({ + const directPublishAvailability = selectDirectPublishAvailability({ channelStatus: activeChannelStatus, isStale: isCampaignStale, hasContent: Boolean(currentPost), isOverLimit, - connectorReady: canPublishCurrent, - manualRoute: Boolean(activeMeta.openUrl || !OFFICIAL_CONNECTORS.has(activeChannel)), + connection: currentConnection, + permissionValid: Boolean(accessToken), + }); + const publishConfirmation = buildPublishConfirmation({ + platformId: activeChannel, + platformLabel: activeMeta.label, + connection: currentConnection, + revision, + channelStatus: activeChannelStatus, }); useEffect(() => { @@ -568,6 +582,19 @@ const sourceAndChannelsReady = sourceSignals > 0 && channels.length > 0; return () => window.removeEventListener("keydown", closeOnEscape); }, [regenerationDialogOpen]); + useEffect(() => { + if (!publishDialogOpen) return undefined; + window.requestAnimationFrame(() => publishDialogRef.current?.focus()); + function closeOnEscape(event) { + if (event.key !== "Escape") return; + event.preventDefault(); + setPublishDialogOpen(false); + window.requestAnimationFrame(() => publishTriggerRef.current?.focus()); + } + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, [publishDialogOpen]); + useEffect(() => { if (typeof window === "undefined") return; window.requestAnimationFrame(() => { @@ -1231,23 +1258,22 @@ async function exportZip() { } } - async function publishCurrentPost() { - if (!publishAvailability.ready) { - setMessage({ type: "warning", text: publishAvailability.reason }); - return; - } - if (!canPublishCurrent) { - await copyAndOpenCurrent(); + function closePublishDialog() { + setPublishDialogOpen(false); + window.requestAnimationFrame(() => publishTriggerRef.current?.focus()); + } + + function reviewPublication() { + if (!directPublishAvailability.ready) { + setMessage({ type: "warning", text: directPublishAvailability.reason }); return; } + setPublishDialogOpen(true); + } - if (isOverLimit) { - setMessage({ - type: "error", - text: activeChannel === "x" && xThreadMode - ? "Every X thread post must stay within 280 characters and a thread may contain at most 25 posts." - : `This ${activeMeta.label} draft is over the ${activeMeta.limit.toLocaleString()} character guide.`, - }); + async function publishCurrentPost() { + if (!directPublishAvailability.ready) { + setMessage({ type: "warning", text: directPublishAvailability.reason }); return; } @@ -1268,8 +1294,6 @@ async function exportZip() { options = { subreddit, title }; } - if (!window.confirm(`Publish this approved draft to ${activeMeta.label}?`)) return; - setBusy(true); setMessage(null); try { @@ -1280,14 +1304,23 @@ async function exportZip() { platform: activeChannel, content: currentPost, projectName: form.projectName, + campaignId: currentCampaignId || null, + draftRevision: revision, options, }), }); const data = await readJsonResponse(response, "SignalFlow returned an unreadable publishing response."); - if (!data.ok) throw new Error(data.error || "The platform did not confirm publication."); + if (!isConfirmedPublishResponse({ + responseOk: response.ok, + data, + expectedPlatform: activeChannel, + })) { + throw new Error(data.error || "The destination API did not confirm publication with a stable post reference."); + } + closePublishDialog(); setMessage({ type: "success", - text: data.message || `Published to ${activeMeta.label}.`, + text: data.message || "Published revision " + revision + " to " + activeMeta.label + ".", }); await refreshConnections(); } catch (error) { @@ -1925,7 +1958,7 @@ async function exportZip() {
{isCampaignStale ? "Blocked until regeneration from the current source" - : canPublishCurrent + : connectorReadyForPublish ? "Connected official API" : OFFICIAL_CONNECTORS.has(activeChannel) ? "Official connector available; manual handoff remains available" @@ -2021,7 +2054,7 @@ async function exportZip() { )} -
+
+
- - {!publishAvailability.ready && ( -

{publishAvailability.reason}

- )}
- {OFFICIAL_CONNECTORS.has(activeChannel) && !canPublishCurrent && ( - + {OFFICIAL_CONNECTORS.has(activeChannel) && ( +
+
+ Optional live action + Direct publishing +

+ Review, save, copy, and export remain the primary workflow. Use this only after the + exact approved revision and connected destination account are confirmed. +

+
+
Connected account
{publishConfirmation.accountLabel}
+
Draft revision
{publishConfirmation.draftRevision}
+
+
+
+ + {!directPublishAvailability.ready && ( + {directPublishAvailability.reason} + )} + {!connectorReadyForPublish && ( + + )} +
+
)} {result?.warnings?.length > 0 && ( @@ -2143,6 +2196,45 @@ async function exportZip() { )} + {publishDialogOpen && ( +
+
event.stopPropagation()} + > +
Live platform action
+

{publishConfirmation.title}

+

{publishConfirmation.description}

+
+
Platform
{publishConfirmation.platformLabel}
+
Connected account
{publishConfirmation.accountLabel}
+
Draft revision
{publishConfirmation.draftRevision}
+
Draft state
{publishConfirmation.draftState}
+
+
+ SignalFlow will report success only after the destination API returns a matching platform and a stable post reference. +
+
+ + +
+
+
+ )} + {regenerationDialogOpen && (
{ + assert.equal(availability({ isStale: true }).code, "stale"); + assert.equal(availability({ hasContent: false }).code, "empty"); + assert.equal(availability({ channelStatus: { key: "failed", isApproved: false } }).code, "failed"); + assert.equal(availability({ channelStatus: { key: "needs_review", isApproved: false } }).code, "needs_review"); + assert.equal(availability({ channelStatus: { key: "edited", isApproved: false } }).code, "unapproved"); + assert.equal(availability({ isOverLimit: true }).code, "over_limit"); + assert.equal(availability({ permissionValid: false }).code, "permission_required"); + assert.equal(availability({ connection: { connected: false } }).code, "not_connected"); + assert.equal(availability({ connection: { ...readyConnection, expired: true } }).code, "expired"); + assert.equal(availability({ connection: { ...readyConnection, manualOnly: true } }).code, "manual_only"); + assert.equal( + availability({ + connection: { ...readyConnection, readiness: { authorization: "pending" } }, + }).code, + "connector_unverified", + ); + assert.deepEqual(availability(), { ready: true, code: "ready", reason: "" }); +}); + +test("connection identity and confirmation identify platform account and exact revision", () => { + assert.equal(connectionIdentity(readyConnection, "LinkedIn"), "Ankit (@ankit)"); + assert.equal(connectionIdentity({ profile: { username: "builder" } }, "X"), "@builder"); + assert.equal(connectionIdentity({ profile: { id: "abc-123" } }, "Reddit"), "Reddit account abc-123"); + + assert.deepEqual(buildPublishConfirmation({ + platformId: "linkedin", + platformLabel: "LinkedIn", + connection: readyConnection, + revision: 7, + channelStatus: approved, + }), { + platformId: "linkedin", + platformLabel: "LinkedIn", + accountLabel: "Ankit (@ankit)", + draftRevision: 7, + draftState: "Approved", + title: "Publish to LinkedIn?", + description: "This live action will publish approved draft revision 7 to Ankit (@ankit).", + }); +}); + +test("publishing success requires HTTP success, API confirmation, matching platform, and stable reference", () => { + assert.equal(isConfirmedPublishResponse({ + responseOk: true, + expectedPlatform: "linkedin", + data: { ok: true, platform: "linkedin", postId: "urn:li:share:123" }, + }), true); + assert.equal(isConfirmedPublishResponse({ + responseOk: true, + expectedPlatform: "x", + data: { ok: true, platform: "x", postUrl: "https://x.com/i/status/123" }, + }), true); + + const rejectedFixtures = [ + { responseOk: false, data: { ok: true, platform: "x", postId: "123" } }, + { responseOk: true, data: { ok: false, platform: "x", error: "Rejected" } }, + { responseOk: true, data: { ok: true, platform: "reddit", postId: "123" } }, + { responseOk: true, data: { ok: true, platform: "x" } }, + ]; + for (const fixture of rejectedFixtures) { + assert.equal(isConfirmedPublishResponse({ ...fixture, expectedPlatform: "x" }), false); + } +}); + +test("Review source keeps handoff primary and direct publication in a secondary accessible dialog", async () => { + const root = path.resolve(new URL("..", import.meta.url).pathname); + const page = await readFile(path.join(root, "app", "page.js"), "utf8"); + const css = await readFile(path.join(root, "app", "globals.css"), "utf8"); + + assert.match(page, /Copy & open \{activeMeta\.label\}/); + assert.match(page, /direct-publishing-panel/); + assert.match(page, /Review live publication/); + assert.match(page, /role="dialog"/); + assert.match(page, /aria-modal="true"/); + assert.match(page, /Draft revision/); + assert.match(page, /Connected account/); + assert.doesNotMatch(page, /Publish this approved draft to/); + assert.match(css, /\.direct-publishing-panel/); + assert.match(css, /\.publish-confirmation-backdrop/); +});