v1.1: harden recovery and control boundaries

WP-D and WP-E complete the planned v1.1 implementation packages.

WP-D — recovery honesty:
- backups verify the completed copy with PRAGMA integrity_check
- corruption missed by quick_check is detected by the full check
- existing good backups remain protected
- oversized exports are still delivered but declare whether this version can
  import them, while the 20 MB import limit remains unchanged
- backup was exercised through the real browser UI on both the normal campaign
  database and a campaign-shaped database over 100 MB

WP-E — control-boundary contrast:
- interactive control boundaries meet the WCAG 1.4.11 3:1 target
- the contrast audit is now a failing gate rather than an advisory
- rendered browser measurements pass for the composer, controls, tabs and nav
- text contrast and focus visibility remain intact
- owner reviewed and approved the before/after screenshots

Reports:
- planning/reports/v1.1/V1.1-WP-D-REPORT.md
- planning/reports/v1.1/V1.1-WP-E-REPORT.md

All planned v1.1 work packages A-E are now complete. Release validation has not
yet begun.
This commit is contained in:
JesseMarkowitz
2026-09-16 05:37:13 -04:00
parent 59b5ebc2d8
commit 87a40326a2
21 changed files with 2401 additions and 60 deletions
+26 -1
View File
@@ -151,7 +151,32 @@ export const api = {
sendAction: (advId, payload, handlers, signal) =>
streamSSE(`/adventures/${advId}/actions`, payload, handlers, signal),
retry: (advId, handlers, signal) => streamSSE(`/adventures/${advId}/retry`, {}, handlers, signal),
exportAdventure: (id) => request(`/adventures/${id}/export`),
// v1.1 WP-D: the bundle, plus what the server says about importing it back.
// The body is the bundle and nothing else — the browser saves exactly those
// bytes — so the size and the warning come back in headers.
exportAdventure: async (id) => {
const resp = await fetch(`/api/adventures/${id}/export`, {
headers: { 'Content-Type': 'application/json' },
})
if (!resp.ok) {
let detail = resp.statusText
try { detail = (await resp.json()).detail || detail } catch { /* non-JSON */ }
throw new Error(detail)
}
const bundle = await resp.json()
const number = (name) => {
const raw = Number(resp.headers.get(name))
return Number.isFinite(raw) && raw > 0 ? raw : null
}
return {
bundle,
exportBytes: number('X-Export-Bytes'),
importLimitBytes: number('X-Import-Limit-Bytes'),
// Absent header (an older server) means nothing is claimed either way.
importable: resp.headers.get('X-Importable-By-This-Version') !== 'false',
warning: resp.headers.get('X-Export-Warning') || null,
}
},
importAdventure: (bundle) => request('/adventures/import', { method: 'POST', body: JSON.stringify(bundle) }),
// M9. A verified copy of the whole database, which is a different tool from
+4 -2
View File
@@ -66,10 +66,12 @@ export default function Campaigns() {
const exportOne = async (campaign) => {
try {
const bundle = await api.exportAdventure(campaign.id)
const { bundle, warning } = await api.exportAdventure(campaign.id)
const safe = (campaign.title || 'campaign').replace(/[^\w-]+/g, '_').slice(0, 60)
downloadJSON(bundle, `${safe}.json`)
toast('Campaign exported.')
// v1.1 WP-D: the file is written either way. A campaign too large for this
// version to import back says so now, not when it is needed.
toast(warning || 'Campaign exported.', warning ? 'error' : undefined)
} catch (err) {
toast(classifyError(err.message).detail, 'error')
}
@@ -79,10 +79,12 @@ export function CampaignSettingsPanel({ adventure, setAdventure, onError, moment
const exportCampaign = async () => {
try {
const bundle = await api.exportAdventure(adventure.id)
const { bundle, warning } = await api.exportAdventure(adventure.id)
const safe = (adventure.title || 'campaign').replace(/[^\w-]+/g, '_').slice(0, 60)
downloadJSON(bundle, `${safe}.json`)
toast('Campaign exported.')
// v1.1 WP-D: see Campaigns.jsx. The export is delivered; the warning says
// this version could not import the file back.
toast(warning || 'Campaign exported.', warning ? 'error' : undefined)
} catch (err) {
onError(classifyError(err.message).detail)
}
+167
View File
@@ -0,0 +1,167 @@
/* v1.1 WP-D: an export that says whether this version could import it back.
*
* The file is delivered either way — a campaign too large to re-import is not a
* damaged one, and refusing to write it would destroy the copy the reader was
* making. What changes is what they are told, and both reader-facing Export
* controls have to tell them: the one on the campaign card and the one in the
* campaign's own settings.
*
* The server decides. These assert that the page shows what it was given and
* keeps delivering the file, not that it re-derives the size policy.
*/
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '../api'
import * as components from '../components'
import Campaigns from './Campaigns'
import { CampaignSettingsPanel } from './Play/panels/CampaignSettingsPanel'
import { mockModelStatus, renderWith } from '../test/helpers'
const BUNDLE = { format: 'ai-dnd-adventure-v3', title: 'Long Campaign', actions: [] }
const WARNING =
"This export is larger than this version's 20 MB import limit (21,230,000 bytes). "
+ 'The file was exported successfully, but this version cannot import it.'
const CAMPAIGN = {
id: 4, title: 'Long Campaign', action_count: 900,
updated_at: '2026-09-15T10:00:00', snippet: 'Rain over the harbour.',
}
const ADVENTURE = {
id: 4, title: 'Long Campaign', ai_instructions: '', narration_length: 'brief',
canon_rules: [], persona_name: 'Aldric', persona_desc: '',
}
// restoreAllMocks does not undo stubGlobal, and the fetch stub below would
// otherwise outlive its own describe block.
beforeEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
function exportReturns({ warning = null } = {}) {
return vi.spyOn(api, 'exportAdventure').mockResolvedValue({
bundle: BUNDLE,
exportBytes: warning ? 21_230_000 : 12_000,
importLimitBytes: 20 * 1024 * 1024,
importable: !warning,
warning,
})
}
async function library() {
mockModelStatus(api)
vi.spyOn(api, 'listAdventures').mockResolvedValue([CAMPAIGN])
await renderWith(<Campaigns />)
await screen.findByText('Long Campaign')
}
async function settingsPanel() {
mockModelStatus(api)
await renderWith(
<CampaignSettingsPanel adventure={ADVENTURE} setAdventure={vi.fn()}
onError={vi.fn()} moments={900} />,
)
}
describe('reading what the server said', () => {
// The tests below mock api.exportAdventure, so nothing there exercises the
// header names. These do: a typo in one of them would otherwise leave the
// whole suite green and the reader silently uninformed.
function serverSends(headers) {
const body = JSON.stringify(BUNDLE)
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body, {
status: 200,
headers: { 'Content-Type': 'application/json', ...headers },
})))
}
it('reports a warning the server sent, with the sizes it named', async () => {
serverSends({
'X-Export-Bytes': '21230000',
'X-Import-Limit-Bytes': String(20 * 1024 * 1024),
'X-Importable-By-This-Version': 'false',
'X-Export-Warning': WARNING,
})
const result = await api.exportAdventure(4)
expect(result.bundle).toEqual(BUNDLE)
expect(result.warning).toBe(WARNING)
expect(result.importable).toBe(false)
expect(result.exportBytes).toBe(21_230_000)
expect(result.importLimitBytes).toBe(20 * 1024 * 1024)
})
it('claims nothing when an older server sends no headers', async () => {
serverSends({})
const result = await api.exportAdventure(4)
expect(result.bundle).toEqual(BUNDLE)
expect(result.warning).toBeNull()
expect(result.importable).toBe(true)
expect(result.exportBytes).toBeNull()
expect(result.importLimitBytes).toBeNull()
})
it('treats an ordinary export as importable', async () => {
serverSends({
'X-Export-Bytes': '12000',
'X-Import-Limit-Bytes': String(20 * 1024 * 1024),
'X-Importable-By-This-Version': 'true',
})
const result = await api.exportAdventure(4)
expect(result.importable).toBe(true)
expect(result.warning).toBeNull()
expect(result.exportBytes).toBe(12_000)
})
})
describe('the campaign library export', () => {
it('delivers the file and says nothing more when it can be imported back', async () => {
const download = vi.spyOn(components, 'downloadJSON').mockImplementation(() => {})
exportReturns()
await library()
await userEvent.click(screen.getByRole('button', { name: 'Export' }))
await waitFor(() => expect(download).toHaveBeenCalledTimes(1))
expect(download.mock.calls[0][0]).toEqual(BUNDLE)
expect(await screen.findByText('Campaign exported.')).toBeInTheDocument()
expect(screen.queryByText(/cannot import/)).toBeNull()
})
it('still delivers the file when it is too large, and says so', async () => {
const download = vi.spyOn(components, 'downloadJSON').mockImplementation(() => {})
exportReturns({ warning: WARNING })
await library()
await userEvent.click(screen.getByRole('button', { name: 'Export' }))
// The file is written first: the warning is about importing it back, not
// about the export having failed.
await waitFor(() => expect(download).toHaveBeenCalledTimes(1))
expect(download.mock.calls[0][0]).toEqual(BUNDLE)
const notice = await screen.findByText(/cannot import it/)
expect(notice).toBeInTheDocument()
expect(notice.textContent).toContain('20 MB')
expect(notice.textContent).toContain('exported successfully')
expect(screen.queryByText('Campaign exported.')).toBeNull()
})
})
describe('the campaign settings export', () => {
it('delivers the file and confirms it when it can be imported back', async () => {
const download = vi.spyOn(components, 'downloadJSON').mockImplementation(() => {})
exportReturns()
await settingsPanel()
await userEvent.click(screen.getByRole('button', { name: 'Export campaign' }))
await waitFor(() => expect(download).toHaveBeenCalledTimes(1))
expect(await screen.findByText('Campaign exported.')).toBeInTheDocument()
expect(screen.queryByText(/cannot import/)).toBeNull()
})
it('still delivers the file when it is too large, and says so', async () => {
const download = vi.spyOn(components, 'downloadJSON').mockImplementation(() => {})
exportReturns({ warning: WARNING })
await settingsPanel()
await userEvent.click(screen.getByRole('button', { name: 'Export campaign' }))
await waitFor(() => expect(download).toHaveBeenCalledTimes(1))
const notice = await screen.findByText(/cannot import it/)
expect(notice.textContent).toContain('20 MB')
expect(screen.queryByText('Campaign exported.')).toBeNull()
})
})
+16 -1
View File
@@ -59,7 +59,22 @@
.slice-4 { background: #6f9e8c; }
.slice-5 { background: #c48a6a; }
.slice-6 { background: #7c86b8; }
.slice-7 { background: var(--border-bright); }
/* v1.1 WP-E: pinned to the literal value this slice already rendered, instead
of borrowing --border-bright. A chart fill and a control edge have different
jobs: WP-E raised --border-bright to clear WCAG 1.4.11 (3:1) for control
boundaries, and that dragged this slice to #7a7aaa, an OKLab dE of 0.035
from .slice-6 (#7c86b8) — two neighbouring slices the same colour. The
other slices sit 0.100-0.119 from their nearest neighbour; at #3d3d55 this
one sits 0.251, the most separated in the set.
1.4.11's 3:1 does not govern this: it is a proportional fill in a labelled
breakdown, not the boundary of a control, and what it needs is to be
distinguishable from the seven slices beside it. Reassigning it to a freer
hue was considered and rejected — inside the palette's own chroma and
lightness bands the only hues that beat 0.100 are pinks near 14 degrees,
which is --danger's territory and would paint an ordinary prompt section in
the colour this application reserves for failure. */
.slice-7 { background: #3d3d55; }
.ctx-block {
border: 1px solid var(--border);
+9 -2
View File
@@ -3,8 +3,15 @@
--bg-panel: #131320;
--bg-panel-glass: rgba(19, 19, 32, 0.82);
--bg-input: #1a1a2a;
--border: #2b2b3d;
--border-bright: #3d3d55;
/* v1.1 WP-E: control boundaries carry WCAG 1.4.11 (3:1 non-text contrast) on
their own, rather than leaning on the control's text label. The floor is
measured against --bg-input (#1a1a2a), not --bg-panel: inputs and buttons
are drawn on --bg-input (styles/forms.css), and it is the lightest of the
three backgrounds a border sits on, so it is the worst case.
--border 3.21:1 and --border-bright 4.24:1 there; higher on the others
(backend/tools/contrast_audit.py, which now fails below 3:1). */
--border: #676792;
--border-bright: #7a7aaa;
--text: #e2ddd0;
--text-dim: #918c7d;
--accent: #d4a94e;