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:
+26
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user