diff --git a/frontend/taskdeck-web/src/locales/en/review.ts b/frontend/taskdeck-web/src/locales/en/review.ts
index a815424d4..a7be01a83 100644
--- a/frontend/taskdeck-web/src/locales/en/review.ts
+++ b/frontend/taskdeck-web/src/locales/en/review.ts
@@ -549,8 +549,22 @@ export default {
title: 'Operation details',
hint: 'Press Space to hide',
loading: 'Loading diff…',
+ // The read-only banner names the content that is ACTUALLY on screen, in the
+ // three modes the pane can be in — #1434 finding 2. The first two are worded
+ // exactly as the Legacy card words them (ReviewProposalCard.vue
+ // `readOnlyDiffBanner`); the third deliberately drops Legacy's trailing
+ // "no stored preview is available" clause, for the reason four lines down.
+ // `storedBannerRecorded` is the common expired path: normal creation flows
+ // never populate `diffPreview`, so the pane synthesizes a listing from the
+ // proposal's own operations. That sentence lives HERE and nowhere else on
+ // the Paper pane, which renders no note under the banner.
storedBanner:
'{status} · read-only — showing the stored preview from the original submission.',
+ storedBannerRecorded: "{status} · read-only — showing the proposal's recorded operations.",
+ // Nothing to show: `storedEmpty` below is the empty state's own sentence and
+ // renders directly under this banner, so the banner states the status and
+ // the read-only fact and does not repeat it.
+ storedBannerNone: '{status} · read-only.',
// Rendered `✎ {lead} {emphasis} {tail}` — the spaces come
// from the template, so `lead` must not carry a trailing space.
revised: {
@@ -689,10 +703,18 @@ export default {
},
// Rendered status labels. The wire values themselves are never keys.
+ // `appliedToBoard` is the read-only diff banner's form of `applied`: the
+ // Legacy card names that status "Applied to board" there (#1434 finding 3),
+ // and its `reviewStatusLabel` is component-local, so the two shells converge
+ // on the wording through this key rather than through a shared helper.
status: {
pendingReview: 'Pending review',
approved: 'Approved',
+ // `appliedToBoard` supersedes `applied` for the read-only diff banner, the
+ // only surface reading this group today; `applied` is kept as the plain
+ // status label for any other surface that extracts into it.
applied: 'Applied',
+ appliedToBoard: 'Applied to board',
rejected: 'Rejected',
failed: 'Failed',
expired: 'Expired',
diff --git a/frontend/taskdeck-web/src/locales/es/review.ts b/frontend/taskdeck-web/src/locales/es/review.ts
index d4c9dae6d..01c040819 100644
--- a/frontend/taskdeck-web/src/locales/es/review.ts
+++ b/frontend/taskdeck-web/src/locales/es/review.ts
@@ -457,6 +457,8 @@ export default {
hint: 'Pulsa Espacio para ocultar',
loading: 'Cargando el diff…',
storedBanner: '{status} · solo lectura — muestra la vista previa guardada del envío original.',
+ storedBannerRecorded: '{status} · solo lectura — muestra las operaciones registradas de la propuesta.',
+ storedBannerNone: '{status} · solo lectura.',
revised: {
lead: 'Esta propuesta se',
emphasis: 'revisó',
@@ -562,6 +564,7 @@ export default {
pendingReview: 'En espera de revisión',
approved: 'Aprobada',
applied: 'Aplicada',
+ appliedToBoard: 'Aplicada al tablero',
rejected: 'Rechazada',
failed: 'Fallida',
expired: 'Caducada',
diff --git a/frontend/taskdeck-web/src/locales/it/review.ts b/frontend/taskdeck-web/src/locales/it/review.ts
index a13b51f88..b42bcb76a 100644
--- a/frontend/taskdeck-web/src/locales/it/review.ts
+++ b/frontend/taskdeck-web/src/locales/it/review.ts
@@ -459,6 +459,9 @@ export default {
loading: 'Caricamento del diff…',
storedBanner:
'{status} · sola lettura — mostra l’anteprima salvata dell’invio originale.',
+ storedBannerRecorded:
+ '{status} · sola lettura — mostra le operazioni registrate della proposta.',
+ storedBannerNone: '{status} · sola lettura.',
revised: {
lead: 'Questa proposta è stata',
emphasis: 'revisionata',
@@ -564,6 +567,7 @@ export default {
pendingReview: 'In attesa di revisione',
approved: 'Approvata',
applied: 'Applicata',
+ appliedToBoard: 'Applicata alla bacheca',
rejected: 'Rifiutata',
failed: 'Non riuscita',
expired: 'Scaduta',
diff --git a/frontend/taskdeck-web/src/tests/views/paper/review/PaperReviewView.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/review/PaperReviewView.spec.ts
index 0ccfc6cfe..385297676 100644
--- a/frontend/taskdeck-web/src/tests/views/paper/review/PaperReviewView.spec.ts
+++ b/frontend/taskdeck-web/src/tests/views/paper/review/PaperReviewView.spec.ts
@@ -12,6 +12,7 @@ import {
import ReviewRevisionEditor from '../../../../views/paper/review/ReviewRevisionEditor.vue'
import { resetProposalDisplayNamesForTests } from '../../../../composables/useProposalDisplayNames'
import enReview from '../../../../locales/en/review'
+import { i18n, DEFAULT_LOCALE } from '../../../../i18n'
// Two sticky rules decide whether the degraded warning is actually visible, and
// both live in scoped stylesheets that vitest never processes, so they have to
@@ -2891,6 +2892,254 @@ describe('PaperReviewView', () => {
wrapper.unmount()
})
+ it('names the stored preview in the read-only banner when one was captured (#1434 finding 2)', async () => {
+ // Parity anchor for the three banner modes below: with a captured
+ // diffPreview on screen the banner keeps the Legacy card's exact sentence
+ // (ReviewProposalCard.vue readOnlyDiffBanner).
+ const wrapper = await mountView([
+ makeProposal({
+ id: 'banner-stored',
+ status: 'Expired',
+ expiresAt: new Date(Date.now() - 60_000).toISOString(),
+ diffPreview: '0. Create card "Archived plan"',
+ }),
+ ])
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', cancelable: true }))
+ await flushPromises()
+
+ const banner = wrapper.find('[data-testid="paper-review-diff-banner"]')
+ expect(banner.exists()).toBe(true)
+ expect(banner.text()).toContain('Expired · read-only')
+ expect(banner.text()).toContain('showing the stored preview from the original submission')
+
+ wrapper.unmount()
+ })
+
+ it('names the recorded operations in the read-only banner when no stored preview was captured (#1434 finding 2)', async () => {
+ // The banner used to claim a "stored preview from the original submission"
+ // even for the synthesized recorded-operations fallback — the COMMON expired
+ // path, since normal creation flows never populate diffPreview. It must name
+ // what is actually on screen, in the Legacy card's wording.
+ const wrapper = await mountView([
+ makeProposal({
+ id: 'banner-ops',
+ status: 'Expired',
+ expiresAt: new Date(Date.now() - 60_000).toISOString(),
+ diffPreview: null,
+ }),
+ ])
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', cancelable: true }))
+ await flushPromises()
+
+ expect(wrapper.find('[data-testid="paper-review-diff-stored-operations"]').exists()).toBe(true)
+ const banner = wrapper.find('[data-testid="paper-review-diff-banner"]')
+ expect(banner.exists()).toBe(true)
+ expect(banner.text()).toContain('Expired · read-only')
+ expect(banner.text()).toContain("showing the proposal's recorded operations")
+ expect(banner.text()).not.toContain('stored preview')
+ // Said ONCE: the Paper pane renders no separate note under the banner, so
+ // the banner alone carries the sentence (the LOW recorded on the Legacy
+ // side, where the banner repeats the note directly below it).
+ const section = wrapper.find('[data-testid="paper-review-diff"]')
+ expect(
+ section.text().split("showing the proposal's recorded operations").length - 1,
+ ).toBe(1)
+
+ wrapper.unmount()
+ })
+
+ it('does not claim a stored preview in the read-only banner when nothing was captured (#1434 finding 2)', async () => {
+ // Nothing to show at all: the banner states the status and that the record
+ // is read-only, and the empty state below keeps its own sentence. The banner
+ // must neither claim a preview exists nor repeat that sentence.
+ const wrapper = await mountView([
+ makeProposal({
+ id: 'banner-none',
+ status: 'Expired',
+ expiresAt: new Date(Date.now() - 60_000).toISOString(),
+ diffPreview: null,
+ operations: [],
+ }),
+ ])
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', cancelable: true }))
+ await flushPromises()
+
+ const banner = wrapper.find('[data-testid="paper-review-diff-banner"]')
+ expect(banner.exists()).toBe(true)
+ expect(banner.text()).toContain('Expired · read-only')
+ expect(banner.text()).not.toContain('stored preview')
+ expect(banner.text()).not.toContain('showing')
+ const storedEmpty = wrapper.find('[data-testid="paper-review-diff-stored-empty"]')
+ expect(storedEmpty.exists()).toBe(true)
+ expect(storedEmpty.text()).toContain('No stored preview is available for this proposal.')
+ const section = wrapper.find('[data-testid="paper-review-diff"]')
+ expect(section.text().toLowerCase().split('no stored preview').length - 1).toBe(1)
+
+ wrapper.unmount()
+ })
+
+ it('keeps the revised caveat consistent with the recorded-operations banner (#1434 finding 2)', async () => {
+ // Banner and caveat must describe the same content: the fallback tail already
+ // says "recorded operations", so the banner above it may not say "stored
+ // preview" for the same pane.
+ const now = new Date().toISOString()
+ mocks.getRevisions.mockResolvedValue([
+ {
+ id: 'rev-1',
+ proposalId: 'banner-ops-revised',
+ revisionNumber: 1,
+ editorUserId: 'u-1',
+ revisedPayload: '{"operations":[]}',
+ revisedAt: now,
+ reason: 'edit',
+ createdAt: now,
+ },
+ ])
+ const wrapper = await mountView([
+ makeProposal({
+ id: 'banner-ops-revised',
+ status: 'Expired',
+ expiresAt: new Date(Date.now() - 60_000).toISOString(),
+ diffPreview: null,
+ }),
+ ])
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', cancelable: true }))
+ await flushPromises()
+
+ const banner = wrapper.find('[data-testid="paper-review-diff-banner"]')
+ expect(banner.exists()).toBe(true)
+ expect(banner.text()).toContain("showing the proposal's recorded operations")
+ expect(banner.text()).not.toContain('stored preview')
+ const revisedNote = wrapper.find('[data-testid="paper-review-diff-revised-note"]')
+ expect(revisedNote.exists()).toBe(true)
+ expect(revisedNote.text()).toContain('recorded operations')
+ expect(revisedNote.text()).not.toContain('stored preview')
+
+ wrapper.unmount()
+ })
+
+ it('converges the read-only banner status on the Legacy card\'s "Applied to board" wording (#1434 finding 3)', async () => {
+ // Legacy renders Applied through reviewStatusLabel as "Applied to board"
+ // (ReviewProposalCard.vue); Paper's previewReadOnlyLabel rendered the bare
+ // normalized status. reviewStatusLabel is component-local — not a composable
+ // or util this view may import — so the convergence is the catalog key the
+ // banner reads.
+ expect(enReview.status.appliedToBoard).toBe('Applied to board')
+
+ // The preview key is inert on an applied record (useReviewKeymap's
+ // `isActionEnabled` allows only onReject while `activeAppliedProposal` is
+ // set), so the banner cannot be OPENED on one — asserted below. It is
+ // reached the other way instead: a pane opened before the apply converts to
+ // the stored presentation when the apply lands, which the next two specs
+ // drive end to end.
+ const wrapper = await mountView([
+ makeProposal({
+ id: 'banner-applied',
+ status: 'Applied',
+ summary: 'Applied banner probe',
+ diffPreview: '0. Create card "Stored"',
+ appliedAt: new Date(Date.now() - 5 * 60_000).toISOString(),
+ }),
+ ])
+
+ const row = wrapper
+ .findAll('.paper-review-recent__row')
+ .find((button) => button.text().includes('Applied banner probe'))!
+ await row.trigger('click')
+ await flushPromises()
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', cancelable: true }))
+ await flushPromises()
+
+ expect(mocks.getProposalDiff).not.toHaveBeenCalled()
+ expect(wrapper.find('[data-testid="paper-review-diff"]').exists()).toBe(false)
+
+ wrapper.unmount()
+ })
+
+ // The route that renders the Applied banner: open the live pane on a pending
+ // proposal, then approve and apply it (#1942's two clicks plus the phase-2
+ // dialog). Nothing on that path clears the pane — the proposal-change watcher
+ // sees the same id and a revision identity that only went to null, and the
+ // decision receipt keeps the applied proposal active — so the #1397 LOW-5
+ // watcher converts the open live pane to the stored read-only presentation.
+ async function applyWithOpenLivePane(id: string) {
+ mocks.getProposalDiff.mockResolvedValueOnce('0. Create card "Live"')
+ mocks.approveProposal.mockResolvedValueOnce(
+ makeProposal({ id, status: 'Approved', diffPreview: '0. Create card "Stored"' }),
+ )
+ mocks.executeProposal.mockResolvedValueOnce(
+ makeProposal({
+ id,
+ status: 'Applied',
+ diffPreview: '0. Create card "Stored"',
+ appliedAt: new Date().toISOString(),
+ }),
+ )
+ const wrapper = await mountView([
+ makeProposal({ id, status: 'PendingReview', diffPreview: '0. Create card "Stored"' }),
+ ])
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', cancelable: true }))
+ await flushPromises()
+ // The pane is live and actionable at this point: no read-only banner yet.
+ expect(wrapper.find('[data-testid="paper-review-diff-pre"]').text()).toContain('Live')
+ expect(wrapper.find('[data-testid="paper-review-diff-banner"]').exists()).toBe(false)
+
+ await wrapper.find('[data-testid="decision-apply"]').trigger('click')
+ await flushPromises()
+ await wrapper.find('[data-testid="decision-apply"]').trigger('click')
+ await flushPromises()
+ await confirmApplyDialog()
+ await wrapper.vm.$nextTick()
+
+ return wrapper
+ }
+
+ it('renders the Legacy "Applied to board" wording when the apply converts an open pane (#1434 finding 3)', async () => {
+ const wrapper = await applyWithOpenLivePane('banner-applied-live')
+
+ const banner = wrapper.find('[data-testid="paper-review-diff-banner"]')
+ expect(banner.exists()).toBe(true)
+ expect(banner.text()).toContain('Applied to board · read-only')
+ expect(banner.text()).toContain('showing the stored preview from the original submission')
+ // The conversion also swapped the live diff for the decision-time stored
+ // preview, which is the content the banner now names.
+ expect(wrapper.find('[data-testid="paper-review-diff-pre"]').text()).toContain('Stored')
+
+ wrapper.unmount()
+ })
+
+ it.each([
+ ['it', 'Applicata alla bacheca', 'sola lettura'],
+ ['es', 'Aplicada al tablero', 'solo lectura'],
+ ] as const)(
+ 'renders the %s appliedToBoard label in the converted banner (#1434 finding 3)',
+ async (locale, label, readOnly) => {
+ // The banner label is the one string this catalog key reaches the DOM
+ // through, so the translated forms are proven here rather than only in the
+ // catalog-parity guard.
+ i18n.global.locale.value = locale
+ try {
+ const wrapper = await applyWithOpenLivePane(`banner-applied-${locale}`)
+
+ const banner = wrapper.find('[data-testid="paper-review-diff-banner"]')
+ expect(banner.exists()).toBe(true)
+ expect(banner.text()).toContain(label)
+ expect(banner.text()).toContain(readOnly)
+ expect(banner.text()).not.toContain('Applied to board')
+
+ wrapper.unmount()
+ } finally {
+ i18n.global.locale.value = DEFAULT_LOCALE
+ }
+ },
+ )
+
it('renders the invalid verdict with the backend reason (not a toast) when /diff 400s for a pending proposal (#1397)', async () => {
mocks.getProposalDiff.mockRejectedValueOnce({
response: {
diff --git a/frontend/taskdeck-web/src/views/paper/PaperReviewView.vue b/frontend/taskdeck-web/src/views/paper/PaperReviewView.vue
index da4231acb..8e19ba014 100644
--- a/frontend/taskdeck-web/src/views/paper/PaperReviewView.vue
+++ b/frontend/taskdeck-web/src/views/paper/PaperReviewView.vue
@@ -520,11 +520,21 @@ function clearPreviewDiff() {
// The read-only banner label for the active proposal's stored preview: 'Expired'
// when the clock/domain says so, otherwise its terminal status.
+//
+// #1434 finding 3: Applied is spelled "Applied to board" here, the way the
+// Legacy card's `reviewStatusLabel` spells it (ReviewProposalCard.vue). That
+// helper is component-local — not a composable or util this view may import —
+// so the two shells converge through the catalog key instead. Every other
+// status already matched. The Applied case reaches the screen through the
+// #1397 LOW-5 conversion — a pane opened before the apply, not a key press,
+// since the preview key is inert once the record is applied.
const previewReadOnlyLabel = computed(() => {
const p = activeProposal.value
if (!p) return ''
if (isProposalExpired(p)) return t('review.status.expired')
- return t(`review.status.${statusKeySuffix(normalizeProposalStatus(p.status))}`)
+ const status = normalizeProposalStatus(p.status)
+ if (status === 'Applied') return t('review.status.appliedToBoard')
+ return t(`review.status.${statusKeySuffix(status)}`)
})
// Read-only fallback when the proposal never captured a `diffPreview` (normal
@@ -546,6 +556,24 @@ const storedOperationsFallback = computed(() => {
)
.join('\n')
})
+
+// The read-only banner names the content actually on screen, the way the Legacy
+// card's `readOnlyDiffBanner` does (ReviewProposalCard.vue): the captured stored
+// preview, the recorded-operations fallback synthesized above (the COMMON
+// expired path — normal creation flows never populate `diffPreview`), or
+// neither. Claiming a "stored preview from the original submission" for the
+// synthesized listing was inaccurate, and claiming one when nothing was captured
+// contradicted the empty state right below it (#1434 finding 2).
+//
+// The recorded-operations sentence lives on the banner ALONE. Unlike the Legacy
+// card, which renders a note under its banner saying the same thing, this pane
+// has no such note — so the fact is stated once.
+const previewReadOnlyBanner = computed(() => {
+ const status = previewReadOnlyLabel.value
+ if (previewDiff.value) return t('review.diff.storedBanner', { status })
+ if (storedOperationsFallback.value) return t('review.diff.storedBannerRecorded', { status })
+ return t('review.diff.storedBannerNone', { status })
+})
// Guards against a double-click firing two feedback POSTs (the backend is idempotent as a backstop).
const reportingProposalId = ref(null)
@@ -2805,14 +2833,15 @@ async function onClearBoardScope() {