From 61774dc789261feb8f481e4da450489addccad16 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 10 Sep 2026 03:50:08 +0100 Subject: [PATCH 01/43] Reconcile uncertain personal plan operations before retry --- .../Services/WorkspacePlanService.cs | 2 + .../WorkspacePlanApiTests.cs | 17 ++++++ .../taskdeck-web/src/api/workspacePlanApi.ts | 4 +- .../src/store/workspacePlanStore.ts | 20 +++++-- .../tests/store/workspacePlanStore.spec.ts | 54 +++++++++++++++++-- .../tests/e2e/workspace-plan.spec.ts | 42 +++++++++++++++ 6 files changed, 128 insertions(+), 11 deletions(-) diff --git a/backend/src/Taskdeck.Application/Services/WorkspacePlanService.cs b/backend/src/Taskdeck.Application/Services/WorkspacePlanService.cs index 9769528517..713795c1dd 100644 --- a/backend/src/Taskdeck.Application/Services/WorkspacePlanService.cs +++ b/backend/src/Taskdeck.Application/Services/WorkspacePlanService.cs @@ -35,6 +35,8 @@ public async Task> SaveAsync(Guid actorId, SaveWorkspac public async Task> FocusAsync(Guid actorId, FocusWorkspacePlanDto dto, CancellationToken ct) { + if (dto.BoardId == Guid.Empty || dto.CardId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "Focus requires a board and card."); var preference = await plans.GetAsync(actorId, ct); if (preference.PersonalPlanRevision != dto.ExpectedRevision) return Conflict(); if (!(await ReadCardAsync(actorId, dto.BoardId, dto.CardId, ct)).Available) return Unavailable(); diff --git a/backend/tests/Taskdeck.Api.Tests/WorkspacePlanApiTests.cs b/backend/tests/Taskdeck.Api.Tests/WorkspacePlanApiTests.cs index 8e3539cde3..93d6515b45 100644 --- a/backend/tests/Taskdeck.Api.Tests/WorkspacePlanApiTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/WorkspacePlanApiTests.cs @@ -51,6 +51,23 @@ public async Task PlanFocusAndMakeRoomPersistWithoutChangingCard() actual.Should().BeEquivalentTo(card, "personal planning must not mutate any card fields"); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task EmptyFocusTargetDoesNotChangeSavedPlan(bool emptyBoard) + { + using var client = factory.CreateClient(); + var (board, card) = await Setup(client); + (await client.PutAsJsonAsync(Url, new SaveWorkspacePlanDto(0, [new(board.Id, card.Id, Date)]))).EnsureSuccessStatusCode(); + (await client.PostAsJsonAsync(Url + "/focus", new FocusWorkspacePlanDto(1, board.Id, card.Id))).EnsureSuccessStatusCode(); + var before = (await client.GetFromJsonAsync(Url))!; + var response = await client.PostAsJsonAsync(Url + "/focus", new FocusWorkspacePlanDto( + before.Revision, emptyBoard ? Guid.Empty : board.Id, emptyBoard ? card.Id : Guid.Empty)); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + JsonNode.Parse(await response.Content.ReadAsStringAsync())!["errorCode"]!.GetValue().Should().Be("ValidationError"); + (await client.GetFromJsonAsync(Url)).Should().BeEquivalentTo(before); + } + [Fact] public async Task ViewerPlanIsPrivateAndForeignCardsCannotBeIntroduced() { diff --git a/frontend/taskdeck-web/src/api/workspacePlanApi.ts b/frontend/taskdeck-web/src/api/workspacePlanApi.ts index 196c747dfc..9af176578c 100644 --- a/frontend/taskdeck-web/src/api/workspacePlanApi.ts +++ b/frontend/taskdeck-web/src/api/workspacePlanApi.ts @@ -22,9 +22,9 @@ export interface WorkspacePlan { export const workspacePlanApi = { async get(): Promise { return (await http.get('/workspace/plan')).data }, async save(expectedRevision: number, entries: PlanReference[]): Promise { - return (await http.put('/workspace/plan', { expectedRevision, entries })).data + return (await http.put('/workspace/plan', { expectedRevision, entries }, { skipRetry: true })).data }, async focus(expectedRevision: number, boardId: string, cardId: string): Promise { - return (await http.post('/workspace/plan/focus', { expectedRevision, boardId, cardId })).data + return (await http.post('/workspace/plan/focus', { expectedRevision, boardId, cardId }, { skipRetry: true })).data }, } diff --git a/frontend/taskdeck-web/src/store/workspacePlanStore.ts b/frontend/taskdeck-web/src/store/workspacePlanStore.ts index 452e5c5b7a..cb17cfdb79 100644 --- a/frontend/taskdeck-web/src/store/workspacePlanStore.ts +++ b/frontend/taskdeck-web/src/store/workspacePlanStore.ts @@ -15,8 +15,10 @@ export const useWorkspacePlanStore = defineStore('workspacePlan', () => { const ready = ref(false) const error = ref(null) let generation = 0 + let loadingRequest: Promise | null = null watch(() => session.userId, () => { generation++ + loadingRequest = null plan.value = null ready.value = false loading.value = saving.value = false @@ -25,6 +27,15 @@ export const useWorkspacePlanStore = defineStore('workspacePlan', () => { async function load() { if (!available.value || !session.userId || saving.value) return + if (loadingRequest) return loadingRequest + const request = loadFresh().finally(() => { + if (loadingRequest === request) loadingRequest = null + }) + loadingRequest = request + return request + } + + async function loadFresh() { const current = ++generation loading.value = true ready.value = false @@ -41,7 +52,7 @@ export const useWorkspacePlanStore = defineStore('workspacePlan', () => { } finally { if (current === generation) loading.value = false } } - async function mutate(action: (revision: number) => Promise) { + async function mutate(operation: 'Plan change' | 'Focus', action: (revision: number) => Promise) { if (!available.value || !session.userId || !ready.value || !plan.value || loading.value || saving.value) return false const current = ++generation saving.value = true @@ -55,7 +66,8 @@ export const useWorkspacePlanStore = defineStore('workspacePlan', () => { if (current === generation) { // A response may be lost after commit. Reload before issuing another write. ready.value = false - error.value = getErrorDisplay(failure, 'The plan could not be confirmed. Refresh your plan before trying again.').message + plan.value = null + error.value = `${operation} could not be confirmed. Refresh your plan before trying again. ${getErrorDisplay(failure, 'The request failed.').message}` } return false } finally { if (current === generation) saving.value = false } @@ -63,10 +75,10 @@ export const useWorkspacePlanStore = defineStore('workspacePlan', () => { function save(entries: PlanReference[]) { const material = entries.map(({ boardId, cardId, plannedDate }) => ({ boardId, cardId, plannedDate })) - return mutate(revision => workspacePlanApi.save(revision, material)) + return mutate('Plan change', revision => workspacePlanApi.save(revision, material)) } function focus(boardId: string, cardId: string) { - return mutate(revision => workspacePlanApi.focus(revision, boardId, cardId)) + return mutate('Focus', revision => workspacePlanApi.focus(revision, boardId, cardId)) } return { available, plan, loading, saving, ready, error, load, save, focus } }) diff --git a/frontend/taskdeck-web/src/tests/store/workspacePlanStore.spec.ts b/frontend/taskdeck-web/src/tests/store/workspacePlanStore.spec.ts index 9a0a37b921..bafcfb277e 100644 --- a/frontend/taskdeck-web/src/tests/store/workspacePlanStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/workspacePlanStore.spec.ts @@ -49,18 +49,62 @@ describe('private personal plan', () => { settle({ ...initial, revision: 4 }) expect(await pending).toBe(true) }) - it('requires reload after a lost or conflicting write response', async () => { - vi.mocked(workspacePlanApi.get).mockResolvedValue(initial) - vi.mocked(workspacePlanApi.save).mockRejectedValue(new Error('Conflict')) + it.each(['save', 'focus'] as const)('clears metadata and requires reload after an uncertain %s response', async (operation) => { + const saved: WorkspacePlan = { ...initial, entries: [{ boardId: 'board', cardId: 'card', plannedDate: '2026-09-10', available: true, title: 'Private title', boardName: 'Private board', columnName: null, dueDate: null, isBlocked: false, blockReason: null }] } + vi.mocked(workspacePlanApi.get).mockResolvedValue(saved) + vi.mocked(workspacePlanApi[operation]).mockRejectedValue(new Error('Conflict')) const store = useWorkspacePlanStore() await store.load() - expect(await store.save([])).toBe(false) + expect(store.plan?.entries[0]?.title).toBe('Private title') + expect(await (operation === 'save' ? store.save([]) : store.focus('board', 'card'))).toBe(false) expect(store.ready).toBe(false) - expect(store.error).toBeTruthy() + expect(store.plan).toBeNull() + expect(store.error).toContain(operation === 'save' ? 'Plan change could not be confirmed.' : 'Focus could not be confirmed.') + expect(store.error).toContain('Refresh your plan before trying again.') + vi.mocked(workspacePlanApi.focus).mockClear() expect(await store.focus('board', 'card')).toBe(false) expect(workspacePlanApi.focus).not.toHaveBeenCalled() await store.load() expect(store.ready).toBe(true) + expect(store.plan?.entries[0]?.title).toBe('Private title') + }) + it('shares overlapping Home and plan reads until the request settles', async () => { + let settle!: (value: WorkspacePlan) => void + vi.mocked(workspacePlanApi.get).mockReturnValueOnce(new Promise(resolve => { settle = resolve })) + const store = useWorkspacePlanStore() + const home = store.load() + const plan = store.load() + expect(workspacePlanApi.get).toHaveBeenCalledTimes(1) + expect(store.loading).toBe(true) + settle(initial) + await Promise.all([home, plan]) + expect(store.ready).toBe(true) + expect(store.loading).toBe(false) + vi.mocked(workspacePlanApi.get).mockResolvedValue({ ...initial, revision: 4 }) + await store.load() + expect(workspacePlanApi.get).toHaveBeenCalledTimes(2) + expect(store.plan?.revision).toBe(4) + }) + it('keeps a new-account read shared when the old account request settles', async () => { + let old!: (value: WorkspacePlan) => void + let current!: (value: WorkspacePlan) => void + vi.mocked(workspacePlanApi.get) + .mockReturnValueOnce(new Promise(resolve => { old = resolve })) + .mockReturnValueOnce(new Promise(resolve => { current = resolve })) + const store = useWorkspacePlanStore() + const first = store.load() + session.userId = 'second' + const second = store.load() + old(initial) + await first + expect(store.plan).toBeNull() + expect(store.loading).toBe(true) + const third = store.load() + expect(workspacePlanApi.get).toHaveBeenCalledTimes(2) + current({ ...initial, revision: 8 }) + await Promise.all([second, third]) + expect(store.plan?.revision).toBe(8) + expect(store.ready).toBe(true) }) it('discards a delayed old-account read and clears the current account on signout', async () => { let settle!: (value: WorkspacePlan) => void diff --git a/frontend/taskdeck-web/tests/e2e/workspace-plan.spec.ts b/frontend/taskdeck-web/tests/e2e/workspace-plan.spec.ts index d01baa414b..3a56c62a7a 100644 --- a/frontend/taskdeck-web/tests/e2e/workspace-plan.spec.ts +++ b/frontend/taskdeck-web/tests/e2e/workspace-plan.spec.ts @@ -6,6 +6,48 @@ import { assertOk } from './support/httpAsserts' test.use({ timezoneId: 'America/Los_Angeles', locale: 'en-US' }) +test('uncertain plan writes hide cached entries until explicit refresh reconciles the server', async ({ page, request }) => { + const auth = await registerAndAttachSession(page, request, 'plan-recovery') + const headers = { Authorization: `Bearer ${auth.token}` } + const boardId = await createBoardWithColumn(request, auth, String(Date.now()), { boardNamePrefix: 'Plan recovery', columnNamePrefix: 'Next', description: 'Synthetic recovery proof' }) + const board = await (await request.get(`${API_BASE_URL}/boards/${boardId}`, { headers })).json() + const response = await request.post(`${API_BASE_URL}/boards/${boardId}/cards`, { headers, data: { boardId, columnId: board.columns[0].id, title: 'Recover this private thread' } }) + await assertOk(response, 'seed recovery card') + const card = await response.json() + await assertOk(await request.put(`${API_BASE_URL}/workspace/plan`, { headers, data: { expectedRevision: 0, entries: [{ boardId, cardId: card.id, plannedDate: '2026-09-10' }] } }), 'seed recovery plan') + await page.goto('/workspace/plan') + const entry = page.locator('article').filter({ has: page.getByRole('heading', { name: card.title }) }) + await expect(entry).toBeVisible() + await page.route('**/api/workspace/plan/focus', route => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ errorCode: 'UnexpectedError', message: 'Synthetic uncertain response' }) })) + await entry.getByRole('button', { name: 'Focus', exact: true }).click() + await expect(page.getByRole('alert')).toContainText('Focus could not be confirmed.') + await expect(entry).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Add to plan', exact: true })).toBeDisabled() + await expect(page).toHaveURL(/\/workspace\/plan$/) + await page.unroute('**/api/workspace/plan/focus') + await page.getByRole('button', { name: 'Refresh personal plan', exact: true }).click() + await expect(entry).toBeVisible() + let writes = 0 + await page.route('**/api/workspace/plan', async route => { + if (route.request().method() !== 'PUT') return route.continue() + writes++ + const committed = await route.fetch() + expect(committed.ok()).toBe(true) + await route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ errorCode: 'UnexpectedError', message: 'Synthetic response lost after commit' }) }) + }) + await entry.getByRole('button', { name: 'Make room', exact: true }).click() + await expect(page.getByRole('alert')).toContainText('Plan change could not be confirmed.') + await expect(entry).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Add to plan', exact: true })).toBeDisabled() + await page.getByRole('button', { name: 'Refresh personal plan', exact: true }).click() + await expect(page.getByText('Your plan has room. Choose a card above, then focus on one thread at a time.')).toBeVisible() + const saved = await (await request.get(`${API_BASE_URL}/workspace/plan`, { headers })).json() + expect(saved.entries).toEqual([]) + expect(saved.revision).toBe(2) + expect(saved.lastWorked).toBeNull() + expect(writes).toBe(1) +}) + test('personal plan persists, resumes focus and makes room without rescheduling a card', async ({ page, request }) => { const auth = await registerAndAttachSession(page, request, 'personal-plan') const headers = { Authorization: `Bearer ${auth.token}` } From f31421b079b8f524755b51fb9d753942e70286e1 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 10 Sep 2026 04:02:14 +0100 Subject: [PATCH 02/43] Retain resolved Auto appearance in comparison observations --- docs/IMPLEMENTATION_MASTERPLAN.md | 2 ++ docs/STATUS.md | 2 ++ docs/product/WORKSPACE_OVERHAUL_VALIDATION.md | 7 +++++++ .../build/frontendIdentity.node-check.mjs | 11 +++++++++- .../taskdeck-web/build/frontendIdentity.ts | 2 ++ .../overhaul/WorkspaceExperiencesView.vue | 5 +++-- .../e2e/comparison-compatibility.spec.ts | 20 ++++++++++++------- 7 files changed, 39 insertions(+), 10 deletions(-) diff --git a/docs/IMPLEMENTATION_MASTERPLAN.md b/docs/IMPLEMENTATION_MASTERPLAN.md index 82d7b32a95..752b66417c 100644 --- a/docs/IMPLEMENTATION_MASTERPLAN.md +++ b/docs/IMPLEMENTATION_MASTERPLAN.md @@ -2,6 +2,8 @@ Last Updated: 2026-09-10 +Comparison attribution follow-through (#2808): retain the resolved Auto appearance in the existing portable theme label and exclude known non-shipped test inputs from the frontend fingerprint. Preserve legacy checksums/file semantics and prove live light/night switching, export/import and HTTP LAN compatibility together. + Original-source portability continuation (#2808): hold a store-owned read snapshot across the five source-storage export sections, with deferred SQLite transactions and scoped disposal before later export writes. Prove both account-export routes against a concurrent committed upload and written version. Whole-account point-in-time consistency and restore acceptance remain separate. Studio/Classic planning continuity (#2808): share existing Focus resume on Classic Home and use the existing calendar-day utilities for plan date controls and card deadlines. Browser acceptance includes a western timezone, Today filtering, both Classic renderers and unchanged board-card data. Failed-plan metadata and accepted-navigation focus timestamps remain separate follow-through. diff --git a/docs/STATUS.md b/docs/STATUS.md index cd65d5fa62..4c0ab1d32f 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,6 +2,8 @@ Last Updated: 2026-09-10 +Comparison appearance continuation (#2808): Auto observations retain both the selected mode and the resolved light/night appearance at submission. Existing version-2/3 files keep their original attribution; no historical appearance is invented. Frontend input fingerprints exclude repository test-only sources and build proof scripts, while runtime sources, public assets, build plugins and resolved build options remain attributed. This keeps manual comparisons coherent across proof-only edits without claiming randomized or statistical results. + Source-storage export continuation (#2808): buffered and streamed account exports now hold one deferred SQLite read snapshot across blob objects, references, chunks, representations and audio-answer rows. Concurrent WAL uploads can commit without reserving the writer for the duration of the export; all five sections retain the earlier view until disposal. This is source-storage consistency, not a claim of a single snapshot across every account-export section or a tested restore workflow. Planning continuity follow-through (#2808): Classic Home now offers the same private last-worked Focus resume as the other experiences, in both Paper/Grove and Legacy rendering. Personal-plan due dates use canonical calendar-day formatting; Today and tomorrow use the local calendar date without changing card deadlines. This reduces the work needed to resume a thought after switching versions. diff --git a/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md b/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md index 2d2e5c22f1..818e3796f1 100644 --- a/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md +++ b/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md @@ -369,3 +369,10 @@ PR #2866 now also contains the reviewed source interaction, board overlay, libra Sixteen selected Chromium journeys have passing evidence. The initial combined batch passed fifteen and found one ambiguous memory-title locator; using the checkbox role fixed that test, and both complete contextual/source specs then passed. Coverage includes four experiences, both board renderers, Los Angeles calendar dates, Classic resume, 375 px accessibility/overflow, source permissions, exact original audio, explicit Review/Apply and three production audio-policy cases. This is not a claim that the initial batch was entirely green. Two comparison identity checks, two audio-policy checks, eleven failure-ledger projection tests, documentation links and GitHub governance pass. A bounded fresh-context integration review found no HIGH/CRITICAL interaction defect. Temporary browser services are stopped; all databases are synthetic. Hosted exact-head qualification remains the merge gate. The wider #2808 scope, physical microphones/devices, live processors, restoration and the existing human decisions remain open. + +## Comparison appearance attribution (2026-09-10) + +Auto observations use the existing version-3 theme string to retain both selected mode and resolved appearance at submission: auto (paper) or auto (paper-night). Old auto labels remain unchanged and separate; no version-2 checksum input, file schema or historic identity changes. The UI explains the submission-time scope and asks the observer to note any appearance changes during the task. + +The HTTP LAN compatibility Chromium journey records both live color-scheme settings, confirms the body theme, exports those labels alongside a legacy auto observation, checks three separate result rows, reloads and imports all three without SubtleCrypto or randomUUID. It passes in 14.6 seconds. Node fingerprint checks verify that src/tests and build/*.node-check.mjs changes leave product attribution stable, while runtime source, public assets, build plugin code and build configuration change it. Full frontend/build results belong to the continuation PR; there is no telemetry, randomized assignment or statistical conclusion. +Final local comparison-attribution gate: 6,377 frontend tests passed with three existing skips across 412 files. Production build/typecheck, two Node identity checks, the 14.6-second Chromium journey, documentation links and GitHub governance pass. Independent bounded Luna review is CLEAN. The test-only fingerprint exclusions are exact repository paths; this is still an input fingerprint, not a byte-for-byte bundle hash or proof of the remote server version. Temporary services are stopped; hosted checks remain separate. diff --git a/frontend/taskdeck-web/build/frontendIdentity.node-check.mjs b/frontend/taskdeck-web/build/frontendIdentity.node-check.mjs index 4d2101b316..7049418965 100644 --- a/frontend/taskdeck-web/build/frontendIdentity.node-check.mjs +++ b/frontend/taskdeck-web/build/frontendIdentity.node-check.mjs @@ -22,6 +22,12 @@ test('frontend fingerprint is repeatable and changes with source, public assets const baseline = fingerprintFrontend(root, 'production', { VITE_API_BASE_URL: '/api' }) assert.match(baseline, /^sha256:[a-f0-9]{64}$/) assert.equal(fingerprintFrontend(root, 'production', { VITE_API_BASE_URL: '/api' }), baseline) + mkdirSync(join(root, 'src/tests')) + writeFileSync(join(root, 'src/tests/new.spec.ts'), 'new proof, same product') + writeFileSync(join(root, 'build/example.node-check.mjs'), 'build proof') + assert.equal(fingerprintFrontend(root, 'production', { VITE_API_BASE_URL: '/api' }), baseline) + writeFileSync(join(root, 'src/tests/new.spec.ts'), 'updated proof') + assert.equal(fingerprintFrontend(root, 'production', { VITE_API_BASE_URL: '/api' }), baseline) assert.notEqual(fingerprintFrontend(root, 'production', { VITE_API_BASE_URL: '/other-api' }), baseline) assert.notEqual(fingerprintFrontend(root, 'production', { VITE_API_BASE_URL: '/api' }, { base: '/Taskdeck/' }), baseline) assert.notEqual(fingerprintFrontend(root, 'production', { VITE_API_BASE_URL: '/api' }, { minify: false }), baseline) @@ -29,5 +35,8 @@ test('frontend fingerprint is repeatable and changes with source, public assets const changedSource = fingerprintFrontend(root, 'production', { VITE_API_BASE_URL: '/api' }) assert.notEqual(changedSource, baseline) writeFileSync(join(root, 'public/theme.css'), 'changed theme') - assert.notEqual(fingerprintFrontend(root, 'production', { VITE_API_BASE_URL: '/api' }), changedSource) + const changedAssets = fingerprintFrontend(root, 'production', { VITE_API_BASE_URL: '/api' }) + assert.notEqual(changedAssets, changedSource) + writeFileSync(join(root, 'build/runtime-plugin.ts'), 'plugin affecting output') + assert.notEqual(fingerprintFrontend(root, 'production', { VITE_API_BASE_URL: '/api' }), changedAssets) }) diff --git a/frontend/taskdeck-web/build/frontendIdentity.ts b/frontend/taskdeck-web/build/frontendIdentity.ts index ad7343f277..4ff64faa8f 100644 --- a/frontend/taskdeck-web/build/frontendIdentity.ts +++ b/frontend/taskdeck-web/build/frontendIdentity.ts @@ -13,6 +13,8 @@ export function fingerprintFrontend(root: string, mode: string, publicEnvironmen function directory(relative: string) { for (const entry of readdirSync(join(root, relative), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name, 'en'))) { const name = `${relative}/${entry.name}` + // These repository-owned proof inputs are not shipped in the frontend bundle. + if (name === 'src/tests' || (relative === 'build' && entry.isFile() && entry.name.endsWith('.node-check.mjs'))) continue if (entry.isDirectory()) directory(name) else if (entry.isFile()) add(name, readFileSync(join(root, name))) else throw new Error(`Unsupported frontend input: ${name}`) diff --git a/frontend/taskdeck-web/src/views/overhaul/WorkspaceExperiencesView.vue b/frontend/taskdeck-web/src/views/overhaul/WorkspaceExperiencesView.vue index 0a1f0fa99c..13f087df76 100644 --- a/frontend/taskdeck-web/src/views/overhaul/WorkspaceExperiencesView.vue +++ b/frontend/taskdeck-web/src/views/overhaul/WorkspaceExperiencesView.vue @@ -1,7 +1,7 @@