From 98845280bbab404d4153371e22012af2278bbe78 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Wed, 21 Oct 2026 12:07:46 -0400 Subject: [PATCH 01/23] fix(#2873): tolerate stale slugs in auto-dispatched rebuild filter An auto-dispatched QA/content rebuild carrying a retired slug in its TUTORIAL_SLUGS payload (e.g. teched2025-genaihub-basic-trial) hard-failed the entire run via the #433 unknown-slug guard. Now resolveSlugFilter() drops unknown slugs with a warning and proceeds with the valid ones; only an all-unknown filter (real typo / fully-stale batch) stays fatal, preserving #433 fail-fast for interactive author typos. --- .../fetch-tutorials-slug-filter.test.ts | 27 +++++++++- scripts/fetch-tutorials.ts | 53 ++++++++++++++++--- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/scripts/__tests__/fetch-tutorials-slug-filter.test.ts b/scripts/__tests__/fetch-tutorials-slug-filter.test.ts index cc9ccc090..880969a95 100644 --- a/scripts/__tests__/fetch-tutorials-slug-filter.test.ts +++ b/scripts/__tests__/fetch-tutorials-slug-filter.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { parseSlugFilter, targetedSourceErrors } from '../fetch-tutorials' +import { parseSlugFilter, resolveSlugFilter, targetedSourceErrors } from '../fetch-tutorials' describe('parseSlugFilter', () => { it('returns null when both inputs are empty/undefined', () => { @@ -95,3 +95,28 @@ describe('targetedSourceErrors', () => { expect(targetedSourceErrors(errors, filter).map(e => e.slug)).toEqual(['a', 'c']) }) }) + +describe('resolveSlugFilter', () => { + const discovered = new Set(['alpha', 'beta', 'gamma']) + + it('keeps all slugs when every requested slug exists', () => { + const r = resolveSlugFilter(new Set(['alpha', 'beta']), discovered) + expect(r.fatal).toBe(false) + expect([...r.valid].sort()).toEqual(['alpha', 'beta']) + expect(r.dropped).toEqual([]) + }) + + it('drops a stale slug but keeps the valid ones (partial unknown is non-fatal)', () => { + const r = resolveSlugFilter(new Set(['alpha', 'teched2025-genaihub-basic-trial']), discovered) + expect(r.fatal).toBe(false) + expect([...r.valid]).toEqual(['alpha']) + expect(r.dropped).toEqual(['teched2025-genaihub-basic-trial']) + }) + + it('is FATAL only when EVERY requested slug is unknown (real typo / empty run)', () => { + const r = resolveSlugFilter(new Set(['nope', 'also-nope']), discovered) + expect(r.fatal).toBe(true) + expect(r.valid.size).toBe(0) + expect(r.dropped.sort()).toEqual(['also-nope', 'nope']) + }) +}) diff --git a/scripts/fetch-tutorials.ts b/scripts/fetch-tutorials.ts index cfd2c833e..378840fe5 100644 --- a/scripts/fetch-tutorials.ts +++ b/scripts/fetch-tutorials.ts @@ -238,6 +238,38 @@ export function parseSlugFilter(slug?: string, slugs?: string): Set | nu return set.size > 0 ? set : null } +/** + * Resolve a requested slug filter against the set of slugs discovery actually + * returned, tolerating stale/retired slugs. + * + * The old guard (#433) hard-failed if ANY requested slug was unknown — correct + * for an interactive author typo, but wrong for an AUTO-DISPATCHED rebuild whose + * `TUTORIAL_SLUGS` payload still names a tutorial that has since been retired + * from source (e.g. `teched2025-genaihub-basic-trial`). One stale slug would nuke + * the whole QA rebuild on every run until the external dispatch was corrected. + * + * Rule: drop unknown slugs with a warning and proceed with the valid ones. + * Only when EVERY requested slug is unknown (real typo, or the whole batch is + * stale → nothing to do) is it `fatal` — the caller exits non-zero as before. + * + * @param filter — the requested slugs (non-null `Set`) + * @param discoveredSlugs — slugs discovery returned this run + * @returns `valid` (requested ∩ discovered), `dropped` (requested − discovered), + * and `fatal` (true iff `valid` is empty) + */ +export function resolveSlugFilter( + filter: Set, + discoveredSlugs: Set, +): { valid: Set; dropped: string[]; fatal: boolean } { + const valid = new Set() + const dropped: string[] = [] + for (const slug of filter) { + if (discoveredSlugs.has(slug)) valid.add(slug) + else dropped.push(slug) + } + return { valid, dropped, fatal: valid.size === 0 } +} + /** * Plan the Phase 2 GitHub metadata prefetch — one task per repo, with the * per-repo slug list narrowed to `tutorialSlugFilter` when set. @@ -723,7 +755,7 @@ async function main() { const totalStart = performance.now() const regenerateMode = process.argv.includes('--regenerate') const discoverOnly = process.argv.includes('--discover-only') - const tutorialSlugFilter = parseSlugFilter(process.env.TUTORIAL_SLUG, process.env.TUTORIAL_SLUGS) + let tutorialSlugFilter = parseSlugFilter(process.env.TUTORIAL_SLUG, process.env.TUTORIAL_SLUGS) const target = parseTarget(process.argv) const channel = parseChannel(process.argv) // QA channel: discover only -Contribution repos via inverse filter in github.ts. @@ -892,15 +924,24 @@ async function main() { // Validate slug filter and bust its markdown cache so it gets re-fetched. // Other tutorials will be regenerated from cached markdown (see Phase 3). if (tutorialSlugFilter) { - // Validate every requested slug exists; fail-fast and list ALL unknowns - // so the author fixes typos in one rerun (per #433 spec). + // Resolve the requested slugs against what discovery actually returned. + // A stale/retired slug in an auto-dispatched TUTORIAL_SLUGS payload is + // dropped with a warning so the rest of the rebuild proceeds (#2873); + // only an ALL-unknown filter (real typo / fully-stale batch) is fatal, + // preserving the #433 fail-fast for author typos. const discoveredSlugs = new Set(allTutorials.map(t => t.slug)) - const unknown = [...tutorialSlugFilter].filter(s => !discoveredSlugs.has(s)) - if (unknown.length > 0) { - console.error(`ERROR: ${unknown.length} unknown slug(s) in filter: ${unknown.join(', ')}`) + const { valid, dropped, fatal } = resolveSlugFilter(tutorialSlugFilter, discoveredSlugs) + if (dropped.length > 0) { + console.warn(`WARNING: dropping ${dropped.length} unknown slug(s) from filter: ${dropped.join(', ')}`) + console.warn(` Discovery returned ${allTutorials.length} slugs from source: ${discovery.source}`) + } + if (fatal) { + console.error(`ERROR: no known slug(s) in filter (all ${tutorialSlugFilter.size} unknown): ${[...tutorialSlugFilter].join(', ')}`) console.error(` Discovery returned ${allTutorials.length} slugs from source: ${discovery.source}`) process.exit(1) } + // Narrow the filter to the valid slugs for all downstream use. + tutorialSlugFilter = valid // Source-attributed log line so authors can see how their inputs combined. const fromSlug = (process.env.TUTORIAL_SLUG ?? '').trim() const fromSlugs = (process.env.TUTORIAL_SLUGS ?? '') From ebe31185ee1dea1515d6948ba855de726f5ac955 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Wed, 21 Oct 2026 13:37:13 -0400 Subject: [PATCH 02/23] fix(#2866): keep redo tutorial URL active for out-of-window completions A tutorial completed before the event window is downgraded to COMPLETED_OUT_OF_WINDOW, which correctly blocks the prize and shows the redo message. But the tutorial link was also disabled, because the link affordance was gated by isItemUnlocked (strict === 'COMPLETED'). The user needs to reach the tutorial to reset progress and redo it during the event. Extract the step-timeline gating into app-space-gating.ts and add isTutorialOpenable: a COMPLETED_OUT_OF_WINDOW tutorial stays clickable without advancing the track or unlocking the prize. Wire the click handler and both clickable affordances to it. Prize gating and the redo hint are unchanged. Add unit coverage proving a gated redo item is not unlocked but is openable. --- hugo-apps/src/app-space/AppSpace.vue | 30 +++----- hugo-apps/src/app-space/app-space-gating.ts | 53 ++++++++++++++ test/unit/app-space-gating.test.js | 81 +++++++++++++++++++++ 3 files changed, 146 insertions(+), 18 deletions(-) create mode 100644 hugo-apps/src/app-space/app-space-gating.ts create mode 100644 test/unit/app-space-gating.test.js diff --git a/hugo-apps/src/app-space/AppSpace.vue b/hugo-apps/src/app-space/AppSpace.vue index 5f6d9bb24..061e0b78a 100644 --- a/hugo-apps/src/app-space/AppSpace.vue +++ b/hugo-apps/src/app-space/AppSpace.vue @@ -3,6 +3,7 @@ import { ref, computed, reactive, onMounted, onUnmounted, watch } from 'vue' import { useRealtimeProgress } from './useRealtimeProgress' import { useConfetti } from '../composables/useConfetti' import { checkpointBarcodeUrl as _checkpointBarcodeUrl, shouldShowCheckpointBarcode as _shouldShowCheckpointBarcode } from './checkpoint-barcode' +import { isItemUnlocked as _isItemUnlocked, isTutorialOpenable as _isTutorialOpenable } from './app-space-gating' // ── Configuration ───────────────────────────────────────────────── const eventId = ref(null) @@ -242,21 +243,14 @@ function progressPercent(track: AppSpaceTrack): number { } function isItemUnlocked(track: AppSpaceTrack, index: number): boolean { - const item = track.items[index] - if (item.type === 'TUTORIAL') { - if (index === 0) return true - const prev = track.items[index - 1] - return prev.status === 'COMPLETED' - } - if (item.type === 'CHECKPOINT') { - const prevTuts = track.items.slice(0, index).filter(i => i.type === 'TUTORIAL') - return prevTuts.every(i => i.status === 'COMPLETED') - } - if (item.type === 'PRIZE') { - const allTuts = track.items.filter(i => i.type === 'TUTORIAL') - return allTuts.every(i => i.status === 'COMPLETED') - } - return false + return _isItemUnlocked(track.items, index) +} + +// #2866: thin wrapper over the extracted gating helper; keeps a redo +// (COMPLETED_OUT_OF_WINDOW) tutorial's link live without letting it advance the +// track or unlock the prize. See app-space-gating.ts. +function isTutorialOpenable(track: AppSpaceTrack, index: number): boolean { + return _isTutorialOpenable(track.items, index) } function itemStatusClass(track: AppSpaceTrack, index: number): string { @@ -288,7 +282,7 @@ function startLogin() { } function handleItemClick(track: AppSpaceTrack, index: number, item: AppSpaceItem) { - if (item.type === 'TUTORIAL' && isItemUnlocked(track, index) && item.url) { + if (item.type === 'TUTORIAL' && isTutorialOpenable(track, index) && item.url) { window.open(item.url, '_blank') } } @@ -504,7 +498,7 @@ const emptyStateMessage = computed(() => {
{ {{ formatTime(item.timeToComplete) }}

{{ item.title }}

{{ item.description }}

diff --git a/hugo-apps/src/app-space/app-space-gating.ts b/hugo-apps/src/app-space/app-space-gating.ts new file mode 100644 index 000000000..648b858b2 --- /dev/null +++ b/hugo-apps/src/app-space/app-space-gating.ts @@ -0,0 +1,53 @@ +/** + * Pure gating helpers for the App Space step timeline. + * Extracted to a sibling module (same pattern as checkpoint-barcode.ts) so both + * AppSpace.vue and tests can import them without mounting the full component. + */ + +export interface GatingItem { + type: 'TUTORIAL' | 'CHECKPOINT' | 'PRIZE' + status: string + [key: string]: unknown +} + +/** + * Whether an item is "unlocked" for PROGRESS / PRIZE gating. + * + * Strict: a TUTORIAL unlocks only when the previous item is exactly + * 'COMPLETED', and a CHECKPOINT/PRIZE unlocks only when every preceding + * tutorial is exactly 'COMPLETED'. #2866 statuses such as + * 'COMPLETED_OUT_OF_WINDOW' deliberately do NOT count here — an out-of-window + * completion must not advance the track or unlock the prize. + */ +export function isItemUnlocked(items: GatingItem[], index: number): boolean { + const item = items[index] + if (!item) return false + if (item.type === 'TUTORIAL') { + if (index === 0) return true + const prev = items[index - 1] + return prev.status === 'COMPLETED' + } + if (item.type === 'CHECKPOINT') { + const prevTuts = items.slice(0, index).filter(i => i.type === 'TUTORIAL') + return prevTuts.every(i => i.status === 'COMPLETED') + } + if (item.type === 'PRIZE') { + const allTuts = items.filter(i => i.type === 'TUTORIAL') + return allTuts.every(i => i.status === 'COMPLETED') + } + return false +} + +/** + * Whether a TUTORIAL's link should stay live. Distinct from isItemUnlocked + * (progress/prize gating): a COMPLETED_OUT_OF_WINDOW tutorial still does NOT + * count toward the track or unlock the prize, but the user must be able to + * reach it to reset progress and redo it during the event (#2866) — so its URL + * stays clickable even though it isn't "unlocked" for gating purposes. + */ +export function isTutorialOpenable(items: GatingItem[], index: number): boolean { + const item = items[index] + if (!item || item.type !== 'TUTORIAL') return false + if (item.status === 'COMPLETED_OUT_OF_WINDOW') return true + return isItemUnlocked(items, index) +} diff --git a/test/unit/app-space-gating.test.js b/test/unit/app-space-gating.test.js new file mode 100644 index 000000000..456dbe5bf --- /dev/null +++ b/test/unit/app-space-gating.test.js @@ -0,0 +1,81 @@ +// test/unit/app-space-gating.test.js +// +// Unit tests for App Space step-timeline gating (#2866 follow-up). +// +// Covers the distinction between: +// isItemUnlocked — progress/prize gating, strict === 'COMPLETED' +// isTutorialOpenable — whether a TUTORIAL's link stays live +// +// The #2866 bug: a tutorial COMPLETED *before* the event window (status +// 'COMPLETED_OUT_OF_WINDOW') had its URL disabled, so the user couldn't reach +// the tutorial to reset their progress and redo it during the event. The fix +// keeps the link live for redo items WITHOUT advancing the track or unlocking +// the prize. + +import { describe, it, expect } from 'vitest'; +import { isItemUnlocked, isTutorialOpenable } from '../../hugo-apps/src/app-space/app-space-gating.ts'; + +const tut = (status) => ({ type: 'TUTORIAL', status }); +const checkpoint = (status = 'LOCKED') => ({ type: 'CHECKPOINT', status }); +const prize = (status = 'LOCKED') => ({ type: 'PRIZE', status }); + +describe('isItemUnlocked — progress/prize gating (strict COMPLETED)', () => { + it('first tutorial is always unlocked', () => { + expect(isItemUnlocked([tut('AVAILABLE')], 0)).toBe(true); + }); + + it('later tutorial unlocks only when the previous is exactly COMPLETED', () => { + const items = [tut('COMPLETED'), tut('AVAILABLE')]; + expect(isItemUnlocked(items, 1)).toBe(true); + }); + + it('out-of-window completion does NOT unlock the next tutorial', () => { + const items = [tut('COMPLETED_OUT_OF_WINDOW'), tut('AVAILABLE')]; + expect(isItemUnlocked(items, 1)).toBe(false); + }); + + it('out-of-window completion does NOT unlock the prize', () => { + const items = [tut('COMPLETED_OUT_OF_WINDOW'), prize()]; + expect(isItemUnlocked(items, 1)).toBe(false); + }); + + it('prize unlocks only when every tutorial is exactly COMPLETED', () => { + expect(isItemUnlocked([tut('COMPLETED'), tut('COMPLETED'), prize()], 2)).toBe(true); + expect(isItemUnlocked([tut('COMPLETED'), tut('COMPLETED_OUT_OF_WINDOW'), prize()], 2)).toBe(false); + }); + + it('checkpoint unlocks only when preceding tutorials are exactly COMPLETED', () => { + expect(isItemUnlocked([tut('COMPLETED'), checkpoint()], 1)).toBe(true); + expect(isItemUnlocked([tut('COMPLETED_OUT_OF_WINDOW'), checkpoint()], 1)).toBe(false); + }); +}); + +describe('isTutorialOpenable — keeps the tutorial link live (#2866)', () => { + it('an out-of-window completed tutorial stays openable (the bug fix)', () => { + // Even though it is NOT unlocked for gating, its URL must stay reachable so + // the user can reset progress and redo it during the event. + const items = [tut('COMPLETED_OUT_OF_WINDOW'), tut('AVAILABLE')]; + expect(isItemUnlocked(items, 0)).toBe(true); // first item, unlocked anyway + expect(isTutorialOpenable(items, 0)).toBe(true); + + // And a redo item that is NOT the first (so not unlocked) still opens. + const gated = [tut('AVAILABLE'), tut('COMPLETED_OUT_OF_WINDOW')]; + expect(isItemUnlocked(gated, 1)).toBe(false); + expect(isTutorialOpenable(gated, 1)).toBe(true); + }); + + it('an unlocked, in-window tutorial is openable', () => { + const items = [tut('COMPLETED'), tut('AVAILABLE')]; + expect(isTutorialOpenable(items, 1)).toBe(true); + }); + + it('a genuinely locked tutorial is NOT openable', () => { + const items = [tut('AVAILABLE'), tut('LOCKED')]; + expect(isTutorialOpenable(items, 1)).toBe(false); + }); + + it('a non-tutorial item is never openable', () => { + expect(isTutorialOpenable([prize('EARNED')], 0)).toBe(false); + expect(isTutorialOpenable([checkpoint('AVAILABLE')], 0)).toBe(false); + }); +}); From f07c66ecbe48b3db2dfce3b9afca3c1d46c06dbd Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Wed, 21 Oct 2026 14:10:24 -0400 Subject: [PATCH 03/23] feat(#2865): drawing service skeleton + listEvents (Admin-gated) --- srv/drawing-service.cds | 12 ++++++ srv/drawing-service.js | 28 +++++++++++++ test/drawing-service.test.js | 81 ++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 srv/drawing-service.cds create mode 100644 srv/drawing-service.js create mode 100644 test/drawing-service.test.js diff --git a/srv/drawing-service.cds b/srv/drawing-service.cds new file mode 100644 index 000000000..d98ce538d --- /dev/null +++ b/srv/drawing-service.cds @@ -0,0 +1,12 @@ +using { com.sap.developers.ims as ims } from '../db/schema'; + +@path: '/drawings' +@requires: 'Admin' +service DrawingService { + + function listEvents() returns many { + legacyId : Integer; + name : String; + startDate : Timestamp; + }; +} diff --git a/srv/drawing-service.js b/srv/drawing-service.js new file mode 100644 index 000000000..43b573032 --- /dev/null +++ b/srv/drawing-service.js @@ -0,0 +1,28 @@ +import cds from '@sap/cds'; + +export default class DrawingService extends cds.ApplicationService { + + async init() { + const { Events, PrizeRecords } = cds.entities('com.sap.developers.ims'); + + this.on('listEvents', async () => { + // Find distinct event IDs that have at least one CLAIMED PrizeRecord + const claimed = await SELECT.distinct.from(PrizeRecords) + .columns('event_ID') + .where({ status: 'CLAIMED' }); + + if (!claimed.length) return []; + + const eventIds = claimed.map(r => r.event_ID); + + const events = await SELECT.from(Events) + .columns('legacyId', 'name', 'startDate') + .where({ ID: { in: eventIds } }) + .orderBy({ startDate: 'desc' }); + + return events; + }); + + await super.init(); + } +} diff --git a/test/drawing-service.test.js b/test/drawing-service.test.js new file mode 100644 index 000000000..baa838ba4 --- /dev/null +++ b/test/drawing-service.test.js @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import cds from '@sap/cds'; + +const project = cds.test('serve', '--project', '.', '--in-memory'); + +const adminAuth = { auth: { username: 'admin', password: 'admin' } }; +const devAuth = { auth: { username: 'developer', password: 'developer' } }; + +describe('DrawingService', () => { + + describe('Authorization', () => { + it('rejects non-Admin users', async () => { + const { status } = await project.get('/drawings/listEvents()', { + ...devAuth, validateStatus: () => true + }); + expect(status).toBe(403); + }); + }); + + describe('listEvents', () => { + beforeAll(async () => { + const { Events, Users, PrizeRecords } = cds.entities('com.sap.developers.ims'); + + await INSERT.into(Events).entries([ + { + ID: 'bbbbbbbb-2222-0000-0000-000000000001', + name: 'Drawing Event A', + startDate: '2026-06-01T00:00:00Z', + endDate: '2026-06-03T23:59:59Z', + legacyId: 7001 + }, + { + ID: 'bbbbbbbb-2222-0000-0000-000000000002', + name: 'Drawing Event B', + startDate: '2026-07-01T00:00:00Z', + endDate: '2026-07-03T23:59:59Z', + legacyId: 7002 + } + ]); + + await INSERT.into(Users).entries([ + { ID: 'bbbbbbbb-2222-0000-0000-000000000011', uuid: 'draw-u1', legacyId: 7011, displayName: 'Winner1' }, + { ID: 'bbbbbbbb-2222-0000-0000-000000000012', uuid: 'draw-u2', legacyId: 7012, displayName: 'Winner2' }, + { ID: 'bbbbbbbb-2222-0000-0000-000000000013', uuid: 'draw-u3', legacyId: 7013, displayName: 'Winner3' } + ]); + + // Only event 7001 has a CLAIMED PrizeRecord + // Event 7002 has no CLAIMED records (only PENDING) + await INSERT.into(PrizeRecords).entries([ + { + ID: 'bbbbbbbb-2222-0000-0000-000000000021', + user_ID: 'bbbbbbbb-2222-0000-0000-000000000011', + event_ID: 'bbbbbbbb-2222-0000-0000-000000000001', + status: 'CLAIMED', + legacyId: 7021 + }, + { + ID: 'bbbbbbbb-2222-0000-0000-000000000022', + user_ID: 'bbbbbbbb-2222-0000-0000-000000000012', + event_ID: 'bbbbbbbb-2222-0000-0000-000000000001', + status: 'CLAIMED', + legacyId: 7022 + }, + { + ID: 'bbbbbbbb-2222-0000-0000-000000000023', + user_ID: 'bbbbbbbb-2222-0000-0000-000000000013', + event_ID: 'bbbbbbbb-2222-0000-0000-000000000002', + status: 'PENDING', + legacyId: 7023 + } + ]); + }); + + it('listEvents returns only events with CLAIMED records, newest-first', async () => { + const { status, data } = await project.get('/drawings/listEvents()', adminAuth); + expect(status).toBe(200); + expect(data.value).toHaveLength(1); + expect(data.value[0].legacyId).toBe(7001); + }); + }); +}); From 69423b26333e95ef1ded6fcdbd63b48b7c2bee42 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Wed, 21 Oct 2026 14:14:56 -0400 Subject: [PATCH 04/23] test(#2865): strengthen listEvents ordering assertion Add event 7003 (2026-08-01, CLAIMED) so the newest-first sort is exercised with two eligible events; assert 7003 before 7001. --- test/drawing-service.test.js | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/test/drawing-service.test.js b/test/drawing-service.test.js index baa838ba4..183b2a4aa 100644 --- a/test/drawing-service.test.js +++ b/test/drawing-service.test.js @@ -35,6 +35,13 @@ describe('DrawingService', () => { startDate: '2026-07-01T00:00:00Z', endDate: '2026-07-03T23:59:59Z', legacyId: 7002 + }, + { + ID: 'bbbbbbbb-2222-0000-0000-000000000003', + name: 'Drawing Event C', + startDate: '2026-08-01T00:00:00Z', + endDate: '2026-08-03T23:59:59Z', + legacyId: 7003 } ]); @@ -44,8 +51,8 @@ describe('DrawingService', () => { { ID: 'bbbbbbbb-2222-0000-0000-000000000013', uuid: 'draw-u3', legacyId: 7013, displayName: 'Winner3' } ]); - // Only event 7001 has a CLAIMED PrizeRecord - // Event 7002 has no CLAIMED records (only PENDING) + // Events 7001 and 7003 have CLAIMED PrizeRecords; event 7002 has only PENDING. + // This ensures the filter (only CLAIMED events) AND the sort (newest-first) are both exercised. await INSERT.into(PrizeRecords).entries([ { ID: 'bbbbbbbb-2222-0000-0000-000000000021', @@ -67,6 +74,13 @@ describe('DrawingService', () => { event_ID: 'bbbbbbbb-2222-0000-0000-000000000002', status: 'PENDING', legacyId: 7023 + }, + { + ID: 'bbbbbbbb-2222-0000-0000-000000000024', + user_ID: 'bbbbbbbb-2222-0000-0000-000000000011', + event_ID: 'bbbbbbbb-2222-0000-0000-000000000003', + status: 'CLAIMED', + legacyId: 7024 } ]); }); @@ -74,8 +88,10 @@ describe('DrawingService', () => { it('listEvents returns only events with CLAIMED records, newest-first', async () => { const { status, data } = await project.get('/drawings/listEvents()', adminAuth); expect(status).toBe(200); - expect(data.value).toHaveLength(1); - expect(data.value[0].legacyId).toBe(7001); + // 7003 (2026-08-01) and 7001 (2026-06-01) have CLAIMED records; 7002 (PENDING only) is excluded + expect(data.value).toHaveLength(2); + expect(data.value[0].legacyId).toBe(7003); // newest first + expect(data.value[1].legacyId).toBe(7001); }); }); }); From de4d9827902640f8eab8083e5cd0162f296d89d6 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Wed, 21 Oct 2026 14:25:03 -0400 Subject: [PATCH 05/23] feat(#2865): pool + draw functions, anonymized-user exclusion --- srv/drawing-service.cds | 13 ++++ srv/drawing-service.js | 53 ++++++++++++- test/drawing-service.test.js | 147 +++++++++++++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 1 deletion(-) diff --git a/srv/drawing-service.cds b/srv/drawing-service.cds index d98ce538d..6c03d40de 100644 --- a/srv/drawing-service.cds +++ b/srv/drawing-service.cds @@ -9,4 +9,17 @@ service DrawingService { name : String; startDate : Timestamp; }; + + function pool(eventId: Integer) returns { + eventId : Integer; + eligibleCount : Integer; + }; + + function draw(eventId: Integer) returns { + winner : { + displayName : String; + email : String; + }; + eligibleCount : Integer; + }; } diff --git a/srv/drawing-service.js b/srv/drawing-service.js index 43b573032..b7bae60ad 100644 --- a/srv/drawing-service.js +++ b/srv/drawing-service.js @@ -3,7 +3,31 @@ import cds from '@sap/cds'; export default class DrawingService extends cds.ApplicationService { async init() { - const { Events, PrizeRecords } = cds.entities('com.sap.developers.ims'); + const { Events, PrizeRecords, Users } = cds.entities('com.sap.developers.ims'); + + // Resolve an event by legacyId; rejects with 404 if not found (mirrors display-service.js pattern). + async function resolveEvent(eventLegacyId, req) { + const event = await SELECT.one.from(Events).where({ legacyId: eventLegacyId }); + if (!event) { req.reject(404, `Event not found: ${eventLegacyId}`); return null; } + return event; + } + + // Returns distinct non-anonymized Users who have at least one CLAIMED PrizeRecord for the given event UUID. + async function eligibleUserIds(eventID) { + const claimed = await SELECT.from(PrizeRecords) + .columns('user_ID') + .where({ event_ID: eventID, status: 'CLAIMED' }); + + const ids = [...new Set(claimed.map(r => r.user_ID))]; + if (!ids.length) return []; + + const users = await SELECT.from(Users) + .columns('ID', 'displayName', 'email') + .where({ ID: { in: ids } }); + + // Exclude anonymized users + return users.filter(u => u.displayName !== 'ANONYMIZED'); + } this.on('listEvents', async () => { // Find distinct event IDs that have at least one CLAIMED PrizeRecord @@ -23,6 +47,33 @@ export default class DrawingService extends cds.ApplicationService { return events; }); + this.on('pool', async (req) => { + const { eventId } = req.data; + const event = await resolveEvent(eventId, req); + if (!event) return; + + const eligible = await eligibleUserIds(event.ID); + return { eventId, eligibleCount: eligible.length }; + }); + + this.on('draw', async (req) => { + const { eventId } = req.data; + const event = await resolveEvent(eventId, req); + if (!event) return; + + const eligible = await eligibleUserIds(event.ID); + + if (!eligible.length) { + return { winner: null, eligibleCount: 0 }; + } + + const winner = eligible[Math.floor(Math.random() * eligible.length)]; + return { + winner: { displayName: winner.displayName, email: winner.email ?? null }, + eligibleCount: eligible.length + }; + }); + await super.init(); } } diff --git a/test/drawing-service.test.js b/test/drawing-service.test.js index 183b2a4aa..c5bc166de 100644 --- a/test/drawing-service.test.js +++ b/test/drawing-service.test.js @@ -94,4 +94,151 @@ describe('DrawingService', () => { expect(data.value[1].legacyId).toBe(7001); }); }); + + // --------------------------------------------------------------------------- + // pool and draw + // --------------------------------------------------------------------------- + // Seed layout (uses dedicated events 7010/7011/7012 to avoid contamination + // from the listEvents seed above which already inserted CLAIMED rows for 7001): + // Event 7010 (ID: ...010): + // User A (ID: ...031, displayName:'UserA', email:'a@test.com') — 2 CLAIMED rows (counts once) + // User B (ID: ...032, displayName:'UserB', email:null) — 1 CLAIMED row + // User C (ID: ...033, displayName:'ANONYMIZED', email:null) — 1 CLAIMED row (excluded) + // User D (ID: ...034, displayName:'UserD', email:'d@test.com') — 1 PENDING row only (excluded) + // Event 7011: no CLAIMED rows — pool = 0 + // Event 7012: only User B CLAIMED — deterministic draw with null email + // --------------------------------------------------------------------------- + describe('pool and draw', () => { + beforeAll(async () => { + const { Events, Users, PrizeRecords } = cds.entities('com.sap.developers.ims'); + + // Dedicated events for pool/draw tests + await INSERT.into(Events).entries([ + { + ID: 'bbbbbbbb-2222-0000-0000-000000000010', + name: 'Pool Draw Event A', + startDate: '2026-09-01T00:00:00Z', + endDate: '2026-09-03T23:59:59Z', + legacyId: 7010 + }, + { + ID: 'bbbbbbbb-2222-0000-0000-000000000020', + name: 'Pool Draw Event B (empty)', + startDate: '2026-09-04T00:00:00Z', + endDate: '2026-09-06T23:59:59Z', + legacyId: 7011 + }, + { + ID: 'bbbbbbbb-2222-0000-0000-000000000030', + name: 'Pool Draw Event C (B only)', + startDate: '2026-09-07T00:00:00Z', + endDate: '2026-09-09T23:59:59Z', + legacyId: 7012 + } + ]); + + // Users A-D for pool/draw tests + await INSERT.into(Users).entries([ + { ID: 'bbbbbbbb-2222-0000-0000-000000000031', uuid: 'draw-pool-u1', legacyId: 8031, displayName: 'UserA', email: 'a@test.com' }, + { ID: 'bbbbbbbb-2222-0000-0000-000000000032', uuid: 'draw-pool-u2', legacyId: 8032, displayName: 'UserB', email: null }, + { ID: 'bbbbbbbb-2222-0000-0000-000000000033', uuid: 'draw-pool-u3', legacyId: 8033, displayName: 'ANONYMIZED', email: null }, + { ID: 'bbbbbbbb-2222-0000-0000-000000000034', uuid: 'draw-pool-u4', legacyId: 8034, displayName: 'UserD', email: 'd@test.com' } + ]); + + await INSERT.into(PrizeRecords).entries([ + // Event 7010 — User A, 2 CLAIMED rows (should count as 1 distinct) + { + ID: 'bbbbbbbb-2222-0000-0000-000000000041', + user_ID: 'bbbbbbbb-2222-0000-0000-000000000031', + event_ID: 'bbbbbbbb-2222-0000-0000-000000000010', + status: 'CLAIMED', + legacyId: 8041 + }, + { + ID: 'bbbbbbbb-2222-0000-0000-000000000042', + user_ID: 'bbbbbbbb-2222-0000-0000-000000000031', + event_ID: 'bbbbbbbb-2222-0000-0000-000000000010', + status: 'CLAIMED', + legacyId: 8042 + }, + // Event 7010 — User B, 1 CLAIMED, email null + { + ID: 'bbbbbbbb-2222-0000-0000-000000000043', + user_ID: 'bbbbbbbb-2222-0000-0000-000000000032', + event_ID: 'bbbbbbbb-2222-0000-0000-000000000010', + status: 'CLAIMED', + legacyId: 8043 + }, + // Event 7010 — User C (ANONYMIZED), 1 CLAIMED — must be excluded from pool + { + ID: 'bbbbbbbb-2222-0000-0000-000000000044', + user_ID: 'bbbbbbbb-2222-0000-0000-000000000033', + event_ID: 'bbbbbbbb-2222-0000-0000-000000000010', + status: 'CLAIMED', + legacyId: 8044 + }, + // Event 7010 — User D, PENDING only — must be excluded from pool + { + ID: 'bbbbbbbb-2222-0000-0000-000000000045', + user_ID: 'bbbbbbbb-2222-0000-0000-000000000034', + event_ID: 'bbbbbbbb-2222-0000-0000-000000000010', + status: 'PENDING', + legacyId: 8045 + }, + // Event 7012 — only User B (null email) — ensures draw works with null email + { + ID: 'bbbbbbbb-2222-0000-0000-000000000046', + user_ID: 'bbbbbbbb-2222-0000-0000-000000000032', + event_ID: 'bbbbbbbb-2222-0000-0000-000000000030', + status: 'CLAIMED', + legacyId: 8046 + } + ]); + }); + + it('pool counts distinct non-anonymized claimers', async () => { + const { status, data } = await project.get('/drawings/pool(eventId=7010)', adminAuth); + expect(status).toBe(200); + // UserA (counted once despite 2 rows) + UserB = 2; UserC (ANONYMIZED) and UserD (PENDING) excluded + expect(data.eligibleCount).toBe(2); + expect(data.eventId).toBe(7010); + }); + + it('pool is 0 for event with no claims', async () => { + const { status, data } = await project.get('/drawings/pool(eventId=7011)', adminAuth); + expect(status).toBe(200); + expect(data.eligibleCount).toBe(0); + }); + + it('pool 404s for unknown event', async () => { + const { status } = await project.get('/drawings/pool(eventId=99999)', { + ...adminAuth, validateStatus: () => true + }); + expect(status).toBe(404); + }); + + it('draw returns a pool member', async () => { + const { status, data } = await project.get('/drawings/draw(eventId=7010)', adminAuth); + expect(status).toBe(200); + expect(data.eligibleCount).toBe(2); + expect(['UserA', 'UserB']).toContain(data.winner.displayName); + }); + + it('draw returns null winner for empty pool', async () => { + const { status, data } = await project.get('/drawings/draw(eventId=7011)', adminAuth); + expect(status).toBe(200); + expect(data.winner).toBeNull(); + expect(data.eligibleCount).toBe(0); + }); + + it('draw can return a winner with null email', async () => { + // Event 7012 has only UserB (email:null) as eligible claimer + const { status, data } = await project.get('/drawings/draw(eventId=7012)', adminAuth); + expect(status).toBe(200); + expect(data.winner.displayName).toBe('UserB'); + expect(data.winner.email).toBeNull(); + expect(data.eligibleCount).toBe(1); + }); + }); }); + From 129500be11edee98504bf4782e63b2a9b26a69fc Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Wed, 21 Oct 2026 14:32:41 -0400 Subject: [PATCH 06/23] test(#2865): add draw 404-on-unknown-event test --- test/drawing-service.test.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/drawing-service.test.js b/test/drawing-service.test.js index c5bc166de..6f88fe637 100644 --- a/test/drawing-service.test.js +++ b/test/drawing-service.test.js @@ -217,6 +217,13 @@ describe('DrawingService', () => { expect(status).toBe(404); }); + it('draw 404s for unknown event', async () => { + const { status } = await project.get('/drawings/draw(eventId=99999)', { + ...adminAuth, validateStatus: () => true + }); + expect(status).toBe(404); + }); + it('draw returns a pool member', async () => { const { status, data } = await project.get('/drawings/draw(eventId=7010)', adminAuth); expect(status).toBe(200); From e6131bcd9a6ff918b62e86d81bd1b4f9cfed9ac1 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Wed, 21 Oct 2026 14:56:13 -0400 Subject: [PATCH 07/23] feat(#2865): scaffold drawings Vue display app --- app/display-app-drawings/index.html | 16 + app/display-app-drawings/package-lock.json | 1397 ++++++++++++++++++++ app/display-app-drawings/package.json | 19 + app/display-app-drawings/public/README.md | 9 + app/display-app-drawings/src/App.vue | 5 + app/display-app-drawings/src/env.d.ts | 5 + app/display-app-drawings/src/main.ts | 5 + app/display-app-drawings/src/style.css | 2 + app/display-app-drawings/tsconfig.json | 17 + app/display-app-drawings/vite.config.ts | 23 + 10 files changed, 1498 insertions(+) create mode 100644 app/display-app-drawings/index.html create mode 100644 app/display-app-drawings/package-lock.json create mode 100644 app/display-app-drawings/package.json create mode 100644 app/display-app-drawings/public/README.md create mode 100644 app/display-app-drawings/src/App.vue create mode 100644 app/display-app-drawings/src/env.d.ts create mode 100644 app/display-app-drawings/src/main.ts create mode 100644 app/display-app-drawings/src/style.css create mode 100644 app/display-app-drawings/tsconfig.json create mode 100644 app/display-app-drawings/vite.config.ts diff --git a/app/display-app-drawings/index.html b/app/display-app-drawings/index.html new file mode 100644 index 000000000..48208cb2b --- /dev/null +++ b/app/display-app-drawings/index.html @@ -0,0 +1,16 @@ + + + + + + + Tutorial Drawings + + + + + +
+ + + diff --git a/app/display-app-drawings/package-lock.json b/app/display-app-drawings/package-lock.json new file mode 100644 index 000000000..4fa21e01b --- /dev/null +++ b/app/display-app-drawings/package-lock.json @@ -0,0 +1,1397 @@ +{ + "name": "tutorial-drawings", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tutorial-drawings", + "version": "1.0.0", + "dependencies": { + "vue": "^3.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.6.0", + "vite": "^6.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.12.tgz", + "integrity": "sha512-Z8lz+5UOiCJLonjeqwJlhUtHA/Sr6R1AidkccQxdWF0nigNzP81aLoU+udgOaGfL5s1uzFXzcux/pCwGAMu0lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.12.tgz", + "integrity": "sha512-+tKxNa2psFmiTb0KfXlXs4Yj7vkEhzAvZ0DrQ5+0gWHQJM10jSleQ3l1FIVm5uvD53YM8lHfM4mgCLDDN9f1Gg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.12.tgz", + "integrity": "sha512-iiripdfvyRBugV55/Dk7GplNXQtN/PG85OcKeveU1z2UkTg0MofbBF1qk//qgPWiqJbVvQcXAVCfE56oDGO4EQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.12.tgz", + "integrity": "sha512-BmATib8swSMAagWNIfDZMnb+k34RXR4bbGOUfZgK8lLlniuMyVoSDDXCOGkdhtUmBXL4aygDh7bhjQ+TmhTvIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.12.tgz", + "integrity": "sha512-GzrO0CiUq8vcAI/hYJuIcp6wNm3T3YhOpumYtf9Z3Fz+cfN1VESCVfy7EPcM3/m2K8l+Gn2U+4XtNZjwhUXctQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.12.tgz", + "integrity": "sha512-Dpta6gigm4o6SYioXtTkZBlGLA/Sl6OpzgLqY+51uafJE2blPejAtR1w82CVLPN+F9cvo1+/em92x/zcOgY0uQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.12.tgz", + "integrity": "sha512-YpeDwQ5uFaogRvytNC8RK0keDO65kI5wNEhGSs+4GiJc3KHhxj4j5cnQh2nCu9ybJj6RP0/ycB6bkN0yFJ9I+w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.12.tgz", + "integrity": "sha512-LQrB3/sImIr1ycrlEyaApOLH1rAy8JV4/dH4eoK817aWATdcs4fESXz53/w72NwDnwPM5zEUpoL7socH4ZDnwA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.12.tgz", + "integrity": "sha512-iAF2iYu9S4SBfNsb4k0ewNBjO83WxHEltIuKQBGMzEKFgrYfmJn+gatBDXR2t/0zJN0UW0kzR4rfvQ1UHzkZ1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.12.tgz", + "integrity": "sha512-0MQdRtdbirJCXPIVeW/hd/LXLR8Qz96pc+2EBptuuV5q/zSGOzND7SZt8gBTE58NC9cUG/8UxcUXQiKRZ2WzzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.12.tgz", + "integrity": "sha512-yGDEwn0AGnrY/KCz2oY8R9KKNiQnTcwNuCWXeSXF0VLxXzlqoCgaS5QHsf1lbQvKWwPuN2Czkjk1wXRCBONTOQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.12.tgz", + "integrity": "sha512-AjlbD0efZEhHaJ3bsNjxSFDoguNq6VZvC+FKGJGBCA+ZN7BrZq2TuuSWFa6AI8IpOeuvhp2LoYcP7jiZwmtU0Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.12.tgz", + "integrity": "sha512-sDW0CiPoUCEEUV25uBG5sJ2gKe75DJqLpj91m1eK1vKffywyY7v4i6q1450ZuR6QW4NArXg7JpbMGgKU8di3HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.12.tgz", + "integrity": "sha512-VTQYUhMjl8kv999VuYqScgv7P9cR9KuxI/6bkUsEa3bsswqG0+otFjuLZmJ1U+ffEP4IAz0jxOeV0DX6NRBhjg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.12.tgz", + "integrity": "sha512-ppsKwpSjNrzJ8hge3Z3uiCcoRgAAQk2tU7auy3MFF348ffWRldFPnWIT/a/enK7huijlTfnwn4DYpDoapEb45A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.12.tgz", + "integrity": "sha512-Gc3cVRC1trFH+GFWWTsJz6YuD4u75fzr/q99jqQGimonN/zvN6Wecck5WGzllOHj+XYXHcq0Ibor0tanGjfI6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.12.tgz", + "integrity": "sha512-pjmDTFStvwFE3shTeUDvSDiSp8/qAf5bQXejcEUDDavWz4jEhTIpCdpS06G0PEwSCSNK0Lngjz2v9l4hk3Z74A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.12.tgz", + "integrity": "sha512-mkbjxPqpR91u1JD7xY4aLZhr/Mqka4/RqGcG4WNGgN/lC1o7TzyCyqJoSJXp9qVVxMwpJGFlEF1VlERb+Ghw1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.12.tgz", + "integrity": "sha512-vIeut2LkuVc3gcsawLJPahNAzvzBcFRhu2O+yVb//BuQKz7PIItMF0piXRbN/lIDO3d68TSDd368Vg6YwgTybw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.12.tgz", + "integrity": "sha512-3Sx+D3r9oPkTdNUcPrIRM2Am+vzEV6VGWoImHG4ZVK2jJeOW+eDjLeTm6nvu/z/m0fiX2YmGLXIypblc336MXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.12.tgz", + "integrity": "sha512-Nn2xh0teFnl71EiiUrKvECWcdGXtWF8q0uGclYwXGVw6QClGAcME0/bJSGdK+xIvd6MLEKYmjkTUvCBV+7m5Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.12.tgz", + "integrity": "sha512-n1/0H9TS+kBa4xp6n9aV7yiynBNfmerjkj2nAMIfY0ZHv1zShWRXnsAuAPAH0sfEK4ZSMK+z24q2RjAPFLCN1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.12.tgz", + "integrity": "sha512-AdlDbQpsLfGlsHr1NfLZFtdnvxCKNp9TwlYVYExs8JJLWjLafUQEKJk0r9zUbmU57mw9nglMEvzqPHuLWYmaKw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.12.tgz", + "integrity": "sha512-u5SSUJBHod51UamjkigSr+JPre6xMy5vaWTTV2K0SnFG4RuKyYpf66dTSLFKX2cwaldpwyBurXPUCxq8riQMEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.12.tgz", + "integrity": "sha512-j5rXD4R/4DM5+V991AA9xOnG2Rwu2g3p5TPgu1Eu7apju0NUrLl+Bn/KpWc6wYJtlXqzwz1r+LNV1JeBU4VGvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.44.tgz", + "integrity": "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.9", + "@vue/shared": "3.5.44", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.2" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.44.tgz", + "integrity": "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.44.tgz", + "integrity": "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.9", + "@vue/compiler-core": "3.5.44", + "@vue/compiler-dom": "3.5.44", + "@vue/compiler-ssr": "3.5.44", + "@vue/shared": "3.5.44", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.29", + "source-map-js": "^1.2.2" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.44.tgz", + "integrity": "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.44.tgz", + "integrity": "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.44.tgz", + "integrity": "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.44.tgz", + "integrity": "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.44", + "@vue/runtime-core": "3.5.44", + "@vue/shared": "3.5.44", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.44.tgz", + "integrity": "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.44", + "@vue/runtime-dom": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.44.tgz", + "integrity": "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.12.tgz", + "integrity": "sha512-GhAu8ZglZ61VWSX/ZW3K/qkazgURBIQL0/Yy51QsS8DPyebR5Cf/Z2PANJFpTU0aVZ9byJcyqR6xvBbkXhdGYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.12", + "@rollup/rollup-android-arm64": "4.64.12", + "@rollup/rollup-darwin-arm64": "4.64.12", + "@rollup/rollup-darwin-x64": "4.64.12", + "@rollup/rollup-freebsd-arm64": "4.64.12", + "@rollup/rollup-freebsd-x64": "4.64.12", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.12", + "@rollup/rollup-linux-arm-musleabihf": "4.64.12", + "@rollup/rollup-linux-arm64-gnu": "4.64.12", + "@rollup/rollup-linux-arm64-musl": "4.64.12", + "@rollup/rollup-linux-loong64-gnu": "4.64.12", + "@rollup/rollup-linux-loong64-musl": "4.64.12", + "@rollup/rollup-linux-ppc64-gnu": "4.64.12", + "@rollup/rollup-linux-ppc64-musl": "4.64.12", + "@rollup/rollup-linux-riscv64-gnu": "4.64.12", + "@rollup/rollup-linux-riscv64-musl": "4.64.12", + "@rollup/rollup-linux-s390x-gnu": "4.64.12", + "@rollup/rollup-linux-x64-gnu": "4.64.12", + "@rollup/rollup-linux-x64-musl": "4.64.12", + "@rollup/rollup-openbsd-x64": "4.64.12", + "@rollup/rollup-openharmony-arm64": "4.64.12", + "@rollup/rollup-win32-arm64-msvc": "4.64.12", + "@rollup/rollup-win32-ia32-msvc": "4.64.12", + "@rollup/rollup-win32-x64-gnu": "4.64.12", + "@rollup/rollup-win32-x64-msvc": "4.64.12", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz", + "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.44.tgz", + "integrity": "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.44", + "@vue/compiler-sfc": "3.5.44", + "@vue/runtime-dom": "3.5.44", + "@vue/server-renderer": "3.5.44", + "@vue/shared": "3.5.44" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/app/display-app-drawings/package.json b/app/display-app-drawings/package.json new file mode 100644 index 000000000..872a802bd --- /dev/null +++ b/app/display-app-drawings/package.json @@ -0,0 +1,19 @@ +{ + "name": "tutorial-drawings", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.6.0", + "vite": "^6.0.0" + } +} diff --git a/app/display-app-drawings/public/README.md b/app/display-app-drawings/public/README.md new file mode 100644 index 000000000..97865b056 --- /dev/null +++ b/app/display-app-drawings/public/README.md @@ -0,0 +1,9 @@ +# Mascot Assets + +## kasimir-tuxedo.png + +**PLACEHOLDER** — This is a minimal 1x1 transparent PNG created as a build-safe placeholder. + +Replace with the real tuxedo-Kasimir mascot image (issue #2865). + +The app references this asset at `/drawings-ui/kasimir-tuxedo.png`. diff --git a/app/display-app-drawings/src/App.vue b/app/display-app-drawings/src/App.vue new file mode 100644 index 000000000..7fd437c2b --- /dev/null +++ b/app/display-app-drawings/src/App.vue @@ -0,0 +1,5 @@ + + + diff --git a/app/display-app-drawings/src/env.d.ts b/app/display-app-drawings/src/env.d.ts new file mode 100644 index 000000000..2b97bd961 --- /dev/null +++ b/app/display-app-drawings/src/env.d.ts @@ -0,0 +1,5 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/app/display-app-drawings/src/main.ts b/app/display-app-drawings/src/main.ts new file mode 100644 index 000000000..fe5bae398 --- /dev/null +++ b/app/display-app-drawings/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './style.css' + +createApp(App).mount('#app') diff --git a/app/display-app-drawings/src/style.css b/app/display-app-drawings/src/style.css new file mode 100644 index 000000000..952f4b15f --- /dev/null +++ b/app/display-app-drawings/src/style.css @@ -0,0 +1,2 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } +html, body { width: 100%; height: 100%; overflow: hidden; } diff --git a/app/display-app-drawings/tsconfig.json b/app/display-app-drawings/tsconfig.json new file mode 100644 index 000000000..ff5009d38 --- /dev/null +++ b/app/display-app-drawings/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "noEmit": true, + "paths": { "@/*": ["./src/*"] } + }, + "include": ["src/**/*.ts", "src/**/*.vue"] +} diff --git a/app/display-app-drawings/vite.config.ts b/app/display-app-drawings/vite.config.ts new file mode 100644 index 000000000..afbe62195 --- /dev/null +++ b/app/display-app-drawings/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + base: '/drawings-ui/', + build: { + outDir: 'dist', + emptyOutDir: true, + }, + server: { + proxy: { + '/rest': { + target: 'http://localhost:4004', + changeOrigin: true, + }, + '/drawings': { + target: 'http://localhost:4004', + changeOrigin: true, + }, + }, + }, +}) From ddaa00ad780b33b3730f8e3e47a40212f6936988 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Wed, 21 Oct 2026 14:56:44 -0400 Subject: [PATCH 08/23] fix(options): indent code boxes trailing an OPTION block (#2820) Code fences that trail an [OPTION] group within the same numbered substep rendered flush-left (margin-left: 0) while the surrounding prose/figures sat at the 1.5rem list-content column, producing the stair-stepped indentation reported on tutorials-qa/hana-clients-hdbsql step 5 substep 2. The OPTION shortcode's block
closes the surrounding
    , so trailing content becomes a column-0 sibling of the list. The trailing-sibling indent rule re-indented ~ p/figure/pre/blockquote, but tutorial code fences render as
    (render-codeblock.html) with the
     nested
    inside, so ~ pre never matched them. Add ~ .code-block to the rule.
    
    Verified in-browser against the QA DOM: injecting the rule moves all visible
    code boxes in the affected substep from gap 0 to the 1.5rem content column.
    
    Co-authored-by: Ording 
    ---
     hugo/assets/css/sap-fundamental.css           |  8 ++++
     hugo/assets/css/sap-fundamental.src.css       |  8 ++++
     .../option-codeblock-indent-css.test.ts       | 48 +++++++++++++++++++
     3 files changed, 64 insertions(+)
     create mode 100644 test/parsers/option-codeblock-indent-css.test.ts
    
    diff --git a/hugo/assets/css/sap-fundamental.css b/hugo/assets/css/sap-fundamental.css
    index 78603fbaa..46da934cb 100644
    --- a/hugo/assets/css/sap-fundamental.css
    +++ b/hugo/assets/css/sap-fundamental.css
    @@ -2849,11 +2849,19 @@ ui5-li .step-badge-row {
     .step-content > .os-options ~ p,
     .step-content > .os-options ~ figure,
     .step-content > .os-options ~ pre,
    +.step-content > .os-options ~ .code-block,
     .step-content > .os-options ~ blockquote,
     .step-content > .option-tabs ~ p,
     .step-content > .option-tabs ~ figure,
     .step-content > .option-tabs ~ pre,
    +.step-content > .option-tabs ~ .code-block,
     .step-content > .option-tabs ~ blockquote {
    +  /* #2820 — tutorial code fences render as `
    ` (the + * `
    ` is nested inside, so the `~ pre` entry above never matches them);
    +   * a code box that TRAILS an OPTION group within the same substep must get
    +   * the same content-column indent as the trailing prose/figures, otherwise it
    +   * sits flush-left (0px) while the surrounding blocks sit at 1.5rem — the
    +   * stair-stepped indentation reported in #2820 (hdbsql step 5 substep 2). */
       margin-left: 1.5rem;
     }
     
    diff --git a/hugo/assets/css/sap-fundamental.src.css b/hugo/assets/css/sap-fundamental.src.css
    index e653454d2..20cb30780 100644
    --- a/hugo/assets/css/sap-fundamental.src.css
    +++ b/hugo/assets/css/sap-fundamental.src.css
    @@ -1357,11 +1357,19 @@ ui5-li .step-badge-row {
     .step-content > .os-options ~ p,
     .step-content > .os-options ~ figure,
     .step-content > .os-options ~ pre,
    +.step-content > .os-options ~ .code-block,
     .step-content > .os-options ~ blockquote,
     .step-content > .option-tabs ~ p,
     .step-content > .option-tabs ~ figure,
     .step-content > .option-tabs ~ pre,
    +.step-content > .option-tabs ~ .code-block,
     .step-content > .option-tabs ~ blockquote {
    +  /* #2820 — tutorial code fences render as `
    ` (the + * `
    ` is nested inside, so the `~ pre` entry above never matches them);
    +   * a code box that TRAILS an OPTION group within the same substep must get
    +   * the same content-column indent as the trailing prose/figures, otherwise it
    +   * sits flush-left (0px) while the surrounding blocks sit at 1.5rem — the
    +   * stair-stepped indentation reported in #2820 (hdbsql step 5 substep 2). */
       margin-left: 1.5rem;
     }
     
    diff --git a/test/parsers/option-codeblock-indent-css.test.ts b/test/parsers/option-codeblock-indent-css.test.ts
    new file mode 100644
    index 000000000..ab465bb05
    --- /dev/null
    +++ b/test/parsers/option-codeblock-indent-css.test.ts
    @@ -0,0 +1,48 @@
    +import { describe, it, expect } from 'vitest'
    +import { readFileSync } from 'node:fs'
    +
    +// Regression test for issue #2820 — code boxes that TRAIL an [OPTION] block
    +// within the same numbered substep rendered flush-left (margin-left: 0) while
    +// the surrounding prose/figures sat at the 1.5rem list-content column, giving
    +// the stair-stepped indentation Tom reported on tutorials-qa/hana-clients-hdbsql
    +// step 5 substep 2 (the `SELECT …` / `hdbsql …` boxes after the OS OPTION group).
    +//
    +// Root cause: the OPTION shortcode's block 
    closes the surrounding
      , +// so trailing content becomes a column-0 sibling of the list. The trailing- +// sibling indent rule re-indents `~ p`, `~ figure`, `~ pre`, `~ blockquote`, +// but tutorial code fences render as `
      ` (render- +// codeblock.html) — the `
      ` is nested inside — so `~ pre` never matched
      +// them. Fix: add `~ .code-block` to the trailing-sibling indent set.
      +//
      +// Verified live: injecting exactly this rule into the QA DOM moved all three
      +// trailing code boxes from gap 0 to gap 24px (the list content column).
      +
      +const src = 'hugo/assets/css/sap-fundamental.src.css'
      +const built = 'hugo/assets/css/sap-fundamental.css'
      +
      +describe('#2820 — trailing code-box indent after OPTION blocks', () => {
      +  it('source indents .code-block that trails an os-options / option-tabs group', () => {
      +    const t = readFileSync(src, 'utf8')
      +    expect(t).toContain('.step-content > .os-options ~ .code-block')
      +    expect(t).toContain('.step-content > .option-tabs ~ .code-block')
      +  })
      +
      +  it('the trailing-sibling rule still covers prose, figures, pre and blockquote', () => {
      +    // Guard against a refactor dropping the sibling types the earlier fixes added.
      +    const t = readFileSync(src, 'utf8')
      +    for (const sel of [
      +      '.step-content > .os-options ~ p',
      +      '.step-content > .os-options ~ figure',
      +      '.step-content > .os-options ~ pre',
      +      '.step-content > .os-options ~ blockquote',
      +    ]) {
      +      expect(t).toContain(sel)
      +    }
      +  })
      +
      +  it('built css was regenerated with the code-block selector', () => {
      +    const t = readFileSync(built, 'utf8')
      +    expect(t).toContain('.os-options ~ .code-block')
      +    expect(t).toContain('.option-tabs ~ .code-block')
      +  })
      +})
      
      From 153345495de071c425e3388611a97c8289d713de Mon Sep 17 00:00:00 2001
      From: Thomas Jung 
      Date: Wed, 21 Oct 2026 15:00:05 -0400
      Subject: [PATCH 09/23] fix(#2865): add .gitignore exception for PNG, create
       index.template.html
      
      ---
       .gitignore                                    |   1 +
       app/display-app-drawings/index.template.html  |  20 ++++++++++++++++++
       .../public/kasimir-tuxedo.png                 | Bin 0 -> 68 bytes
       3 files changed, 21 insertions(+)
       create mode 100644 app/display-app-drawings/index.template.html
       create mode 100644 app/display-app-drawings/public/kasimir-tuxedo.png
      
      diff --git a/.gitignore b/.gitignore
      index 944678f5f..ca2e51dc0 100644
      --- a/.gitignore
      +++ b/.gitignore
      @@ -56,6 +56,7 @@ hugo/static-qa/
       !hugo/static/images/devtoberfest/selfie/thumbnails/*.png
       !hugo/static/images/devtoberfest/selfie/stickers/*.png
       !hugo/static/images/devtoberfest/selfie/backgrounds/*.png
      +!app/display-app-drawings/public/*.png
       hugo/static/js/*
       !hugo/static/js/joule.js
       !hugo/static/js/joule-render.js
      diff --git a/app/display-app-drawings/index.template.html b/app/display-app-drawings/index.template.html
      new file mode 100644
      index 000000000..26bbb92c2
      --- /dev/null
      +++ b/app/display-app-drawings/index.template.html
      @@ -0,0 +1,20 @@
      +
      +
      +
      +
      +  
      +  
      +  Tutorial Drawings
      +  
      +  
      +  
      +  
      +
      +
      +  
      + + + diff --git a/app/display-app-drawings/public/kasimir-tuxedo.png b/app/display-app-drawings/public/kasimir-tuxedo.png new file mode 100644 index 0000000000000000000000000000000000000000..35a340b6565bfc92ef9d53ef01e30267f65d4ec2 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k8}blZci7-kcv6U2@FgO46F={RdK8B Pfx--)u6{1-oD!M Date: Wed, 21 Oct 2026 15:08:11 -0400 Subject: [PATCH 10/23] =?UTF-8?q?feat(#2865):=20drawings=20UI=20=E2=80=94?= =?UTF-8?q?=20event=20picker,=20draw,=20winner=20reveal=20with=20Kasimir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/display-app-drawings/public/README.md | 5 + app/display-app-drawings/src/App.vue | 596 +++++++++++++++++- .../src/composables/useConfetti.ts | 58 ++ 3 files changed, 657 insertions(+), 2 deletions(-) create mode 100644 app/display-app-drawings/src/composables/useConfetti.ts diff --git a/app/display-app-drawings/public/README.md b/app/display-app-drawings/public/README.md index 97865b056..b981f85ab 100644 --- a/app/display-app-drawings/public/README.md +++ b/app/display-app-drawings/public/README.md @@ -7,3 +7,8 @@ Replace with the real tuxedo-Kasimir mascot image (issue #2865). The app references this asset at `/drawings-ui/kasimir-tuxedo.png`. + +## Post-deploy smoke check + +The post-deploy `e2e` job should hit `/drawings-ui/` to verify the app loads; it is an XSUAA-protected operator screen so full interaction requires auth. + diff --git a/app/display-app-drawings/src/App.vue b/app/display-app-drawings/src/App.vue index 7fd437c2b..a3f477e9b 100644 --- a/app/display-app-drawings/src/App.vue +++ b/app/display-app-drawings/src/App.vue @@ -1,5 +1,597 @@ - + + + diff --git a/app/display-app-drawings/src/composables/useConfetti.ts b/app/display-app-drawings/src/composables/useConfetti.ts new file mode 100644 index 000000000..bdfba1b7a --- /dev/null +++ b/app/display-app-drawings/src/composables/useConfetti.ts @@ -0,0 +1,58 @@ +import { ref } from 'vue' + +export interface ConfettiParticle { + id: number + x: number + y: number + color: string + size: number + rotation: number + delay: number + shape: 'square' | 'circle' | 'strip' +} + +function getThemeColors(): string[] { + const el = document.documentElement + const style = getComputedStyle(el) + return [ + style.getPropertyValue('--d-confetti-1').trim() || '#0070f2', + style.getPropertyValue('--d-confetti-2').trim() || '#0064d9', + style.getPropertyValue('--d-confetti-3').trim() || '#107e3e', + ] +} + +export function useConfetti() { + const particles = ref([]) + const active = ref(false) + let idCounter = 0 + + function burst() { + const colors = getThemeColors() + const shapes: ConfettiParticle['shape'][] = ['square', 'circle', 'strip'] + const count = 70 + Math.floor(Math.random() * 30) + const newParticles: ConfettiParticle[] = [] + + for (let i = 0; i < count; i++) { + newParticles.push({ + id: ++idCounter, + x: 10 + Math.random() * 80, + y: -10 - Math.random() * 20, + color: colors[Math.floor(Math.random() * colors.length)], + size: 6 + Math.random() * 12, + rotation: Math.random() * 360, + delay: Math.random() * 0.8, + shape: shapes[Math.floor(Math.random() * shapes.length)], + }) + } + + particles.value = newParticles + active.value = true + + setTimeout(() => { + particles.value = [] + active.value = false + }, 4000) + } + + return { particles, active, burst } +} From 85c364bbb2fcc14143e14bf600df3b536998c8c7 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Wed, 21 Oct 2026 15:11:46 -0400 Subject: [PATCH 11/23] revert(#2864): remove mission-header-level temporarilyUnavailable (wrong level) --- app/admin-annotations.cds | 5 - db/last-dev/csn.json | 12 - db/schema.cds | 2 - ....developers.ims.Missions.hdbmigrationtable | 8 +- db/views.cds | 3 +- .../plans/2026-10-20-mission-disable-path.md | 323 ------------------ .../2026-10-20-mission-disable-path-design.md | 180 ---------- .../__tests__/fixtures/browse-page-1.html | 8 +- .../fixtures/card-mission.expected.html | 3 +- .../__tests__/fixtures/cards.fixtures.json | 3 +- hugo-apps/src/shared/cards/MissionCard.vue | 1 - hugo-apps/src/shared/types.ts | 2 - .../browse/_partials/card-mission.html | 1 - .../fetch-tutorials-browse-channel.test.ts | 26 -- scripts/fetch-tutorials.ts | 4 - scripts/parsers/types.ts | 2 - srv/lib/branch/mission-detail.js | 5 - srv/lib/build-catalog.js | 2 - srv/lib/navigator-catalog.js | 2 +- .../srv/build-catalog-unavailable.test.js | 65 ---- .../srv/mission-detail-unavailable.test.js | 115 ------- .../srv/navigator-catalog-unavailable.test.js | 271 --------------- 22 files changed, 15 insertions(+), 1028 deletions(-) delete mode 100644 docs/superpowers/plans/2026-10-20-mission-disable-path.md delete mode 100644 docs/superpowers/specs/2026-10-20-mission-disable-path-design.md delete mode 100644 test/unit/srv/build-catalog-unavailable.test.js delete mode 100644 test/unit/srv/mission-detail-unavailable.test.js delete mode 100644 test/unit/srv/navigator-catalog-unavailable.test.js diff --git a/app/admin-annotations.cds b/app/admin-annotations.cds index 32ccef007..6a3af0837 100644 --- a/app/admin-annotations.cds +++ b/app/admin-annotations.cds @@ -127,8 +127,6 @@ annotate AdminService.Missions with { missionType @Common.Label: 'Type' @Common.ValueListWithFixedValues; event @Common.Label: 'Event'; published @Common.Label: 'Published'; - temporarilyUnavailable @Common.Label: 'Temporarily Unavailable'; - unavailableReason @Common.Label: 'Unavailable Reason' @UI.MultiLineText; status @Common.Label: 'Status' @Common.ValueListWithFixedValues; averageTimeToComplete @Common.Label: 'Avg Time (min)'; qaPreviewUrl @Common.Label: 'QA Preview' @Common.FieldControl: #ReadOnly; @@ -151,7 +149,6 @@ annotate AdminService.Missions with @UI: { { Value: experienceTag }, { Value: primaryTagRef.name, Label: 'Primary Tag' }, { Value: published, @UI.Importance: #High }, - { Value: temporarilyUnavailable }, { Value: status } ], Facets: [ @@ -169,8 +166,6 @@ annotate AdminService.Missions with @UI: { { Value: experienceTag }, { Value: missionType }, { Value: published, @UI.FieldControl: publishedFieldControl }, - { Value: temporarilyUnavailable }, - { Value: unavailableReason }, { Value: status }, { $Type: 'UI.DataFieldWithUrl', Value: qaPreviewLabel, Url: qaPreviewUrl, Label: 'QA Preview' }, { $Type: 'UI.DataFieldWithUrl', Value: mainPreviewLabel, Url: mainPreviewUrl, Label: 'Live Mission' } diff --git a/db/last-dev/csn.json b/db/last-dev/csn.json index e6721594b..5755c9e22 100644 --- a/db/last-dev/csn.json +++ b/db/last-dev/csn.json @@ -481,18 +481,6 @@ }, "@cds.persistence.name": "PUBLISHED" }, - "temporarilyUnavailable": { - "type": "cds.Boolean", - "default": { - "val": false - }, - "@cds.persistence.name": "TEMPORARILYUNAVAILABLE" - }, - "unavailableReason": { - "type": "cds.String", - "length": 500, - "@cds.persistence.name": "UNAVAILABLEREASON" - }, "group_ID": { "type": "cds.String", "length": 36, diff --git a/db/schema.cds b/db/schema.cds index 2ad6bc6ce..9857b9fad 100644 --- a/db/schema.cds +++ b/db/schema.cds @@ -86,8 +86,6 @@ entity Missions : TaskBase, LinkHealth { // toggles published to true. Replaces AEM's role of curating which missions // surface on the website. Write-guard at admin-service.js:788. Issue #348. published : Boolean default false; - temporarilyUnavailable : Boolean default false; - unavailableReason : String(500); primaryTagRef : Association to Tags; group : Association to Groups; event : Association to Events; diff --git a/db/src/com.sap.developers.ims.Missions.hdbmigrationtable b/db/src/com.sap.developers.ims.Missions.hdbmigrationtable index 48ce752f5..8cf761f1b 100644 --- a/db/src/com.sap.developers.ims.Missions.hdbmigrationtable +++ b/db/src/com.sap.developers.ims.Missions.hdbmigrationtable @@ -1,4 +1,4 @@ -== version=9 +== version=10 COLUMN TABLE com_sap_developers_ims_Missions ( ID NVARCHAR(36) NOT NULL, createdAt TIMESTAMP, @@ -23,14 +23,16 @@ COLUMN TABLE com_sap_developers_ims_Missions ( sourceKgCommunityFingerprint NVARCHAR(64), missionType NVARCHAR(20), published BOOLEAN DEFAULT FALSE, - temporarilyUnavailable BOOLEAN DEFAULT FALSE, - unavailableReason NVARCHAR(500), group_ID NVARCHAR(36), event_ID NVARCHAR(36), groupOrder INTEGER DEFAULT 0, PRIMARY KEY(ID) ) +== migration=10 +-- revert(#2864): drop mission-level disable columns (moved to path-item level) +ALTER TABLE com_sap_developers_ims_Missions DROP (temporarilyUnavailable, unavailableReason); + == migration=9 -- generated by cds-compiler version 7.0.1 ALTER TABLE com_sap_developers_ims_Missions ADD (temporarilyUnavailable BOOLEAN DEFAULT FALSE, unavailableReason NVARCHAR(500)); diff --git a/db/views.cds b/db/views.cds index e466c768e..8e6d25edd 100644 --- a/db/views.cds +++ b/db/views.cds @@ -93,8 +93,7 @@ view NavigatorCatalog as item.taskType } where item.taskType = 'TUTORIAL' and tut.slug is not null - and mission.published = true - and (mission.temporarilyUnavailable is null or mission.temporarilyUnavailable = false); + and mission.published = true; view SearchableItems as SELECT from ims.Tutorials as t diff --git a/docs/superpowers/plans/2026-10-20-mission-disable-path.md b/docs/superpowers/plans/2026-10-20-mission-disable-path.md deleted file mode 100644 index aa9bb1a08..000000000 --- a/docs/superpowers/plans/2026-10-20-mission-disable-path.md +++ /dev/null @@ -1,323 +0,0 @@ -# Mission "Temporarily Unavailable" Toggle — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a manual per-mission "Temporarily Unavailable" switch that badges the mission's cards and removes its child path-item links, while leaving the mission's own content reachable. - -**Architecture:** New `temporarilyUnavailable` boolean + `unavailableReason` string on `Missions` in CDS. The flag is threaded through the build-time card pipeline (`/build/catalog` → `Mission` type → `browse.json` → both card templates) to show a badge and is used by the navigator/mission-detail feeds to suppress child links. Admin sets it via the Fiori Mission Editor. Propagation is rebuild-gated (same as every other mission edit). - -**Tech Stack:** SAP CAP (Node.js, CDS), Fiori Elements (annotations), TypeScript build pipeline, Hugo partials + Vue 3 (vitest). - -**Spec:** `docs/superpowers/specs/2026-10-20-mission-disable-path-design.md` - -## Global Constraints - -- Field names, verbatim: `temporarilyUnavailable : Boolean default false;` and `unavailableReason : String(500);` on `com.sap.developers.ims.Missions`. -- Badge copy, verbatim: **Temporarily Unavailable**. -- `temporarilyUnavailable` must NOT be added to any publish-style write guard — a normal Admin writes it. `_guardPublished` (`srv/admin-service.js:2473-2501`) is field-specific to `published`; leave it untouched. -- A disabled mission stays `published: true`; the new flag is an additional gate, never a replacement for `published`. -- The two mission-card templates (`hugo/layouts/partials/browse/_partials/card-mission.html` and `hugo-apps/src/shared/cards/MissionCard.vue`) must stay parity-equal (post-canonicalization: `data-v-*`, HTML comments, `·`, and whitespace are normalized away — only element/class/text content must match). Any badge markup goes in BOTH, and the fixture `card-mission.expected.html` must be regenerated. -- Badge/link scope is the **build-time** path only (`browse.json`, served by `/browse/` and `/tutorial-navigator/` SSR). Do NOT extend the runtime `MissionRef` plumbing (builder call — see Task 4 note). -- Suppressing links must NOT empty the tutorial set used to derive the card's `level`/`time`/`tutorialCount`/`primaryTag` — those come from bucketed `TutorialNavEntry` rows, independent of the flag. The flag only adds a badge property and gates the link feeds. -- The mission's own detail page must still load — only its child `items[]` are emptied. - -## Review Focus - -- **Disabled mission with a direct URL still loads (content visible).** Owned by Task 5 — its test asserts the mission-detail handler returns `200` with `items: []`, not `404`. -- **Disabled mission's nested-group child links don't leak through the navigator.** Owned by Task 6 — `navigator-catalog.js` has a *second* GROUP-link path beyond the view; its test asserts a disabled mission contributes no nested-group links. -- **`temporarilyUnavailable: false`/absent behaves exactly as today (no badge, links present).** Owned by Task 4 — card test asserts no badge property/markup when the flag is falsy. -- **A normal (non-SuperAdmin) Admin can set the flag.** Owned by Task 3 — note in the task verifies the guard is not extended; no code change means no new restriction (covered by inspection, not a new auth test, since the guard is field-name-specific). -- **`unavailableReason` round-trips through the catalog without breaking existing card output.** Owned by Task 4 — parity test + existing browse-channel tests must still pass with the new optional fields absent. - ---- - -### Task 1: Add schema fields - -**Files:** -- Modify: `db/schema.cds:70-95` (entity `Missions`, adjacent to `published` at `:88`) - -**Interfaces:** -- Produces: two new columns on `com.sap.developers.ims.Missions` — `temporarilyUnavailable : Boolean` (default `false`) and `unavailableReason : String(500)`. Consumed by Tasks 2, 3, 5, 6. - -- [ ] **Step 1: Add the fields** — in `entity Missions`, immediately after `published : Boolean default false;` (line 88), add: - -```cds - temporarilyUnavailable : Boolean default false; - unavailableReason : String(500); -``` - -- [ ] **Step 2: Verify the model compiles** — Run: `npx cds compile db/schema.cds --to sql > /dev/null` (wrap in `scripts/quiet-run.sh` if noisy). Expected: no error; exit 0. - -- [ ] **Step 3: Commit** - -```bash -git add db/schema.cds -git commit -m "feat(#2864): add temporarilyUnavailable + unavailableReason to Missions" -``` - ---- - -### Task 2: Emit the flag from /build/catalog and the Mission type - -**Files:** -- Modify: `scripts/parsers/types.ts:268-278` (the `Mission` interface) -- Modify: `srv/lib/build-catalog.js:53-63` (`missionList.map`) -- Test: `test/unit/srv/build-catalog-unavailable.test.js` (new; match the harness used by sibling `srv/lib` unit tests — check `test/unit/srv/` for an existing build-catalog test to mirror; if none, use the project's default unit runner as configured in `package.json` `test` script) - -**Interfaces:** -- Consumes: schema fields from Task 1. -- Produces: `Mission` type gains `temporarilyUnavailable?: boolean` and `unavailableReason?: string`; `/build/catalog` mission objects carry both. Consumed by Task 4 (card assembly reads `mMeta.temporarilyUnavailable`). - -- [ ] **Step 1: Write the failing test** — assert the catalog handler maps the flag. Build a published mission with `temporarilyUnavailable: true, unavailableReason: 'maintenance'` in the test DB, call the catalog build, and assert the emitted mission object has `temporarilyUnavailable === true` and `unavailableReason === 'maintenance'`. (Mirror the setup of the nearest existing `build-catalog` unit test; if the handler isn't unit-testable in isolation, test the pure `missionList.map` shape by extracting/asserting on a representative row.) - -```js -// shape of the assertion -expect(missionObj.temporarilyUnavailable).toBe(true) -expect(missionObj.unavailableReason).toBe('maintenance') -``` - -- [ ] **Step 2: Run it, verify it fails** — Run the new test. Expected: FAIL (field undefined). - -- [ ] **Step 3: Add the fields to the `Mission` interface** (`scripts/parsers/types.ts`), after `categorySlugs?: string[]`: - -```ts - temporarilyUnavailable?: boolean - unavailableReason?: string -``` - -- [ ] **Step 4: Thread them through `missionList.map`** (`srv/lib/build-catalog.js:53-63`) — add to the mapped object: - -```js - temporarilyUnavailable: m.temporarilyUnavailable === true, - unavailableReason: m.unavailableReason || undefined, -``` - -- [ ] **Step 5: Run the test, verify it passes.** - -- [ ] **Step 6: Commit** - -```bash -git add scripts/parsers/types.ts srv/lib/build-catalog.js test/unit/srv/build-catalog-unavailable.test.js -git commit -m "feat(#2864): carry temporarilyUnavailable through /build/catalog + Mission type" -``` - ---- - -### Task 3: Surface the toggle in the Admin Mission Editor - -**Files:** -- Modify: `app/admin-annotations.cds:117-136` (label block) and `:138-173` (`@UI` block) - -**Interfaces:** -- Consumes: schema fields from Task 1 (auto-projected through `srv/admin-service.cds:152` `*`). -- Produces: editable `temporarilyUnavailable` + `unavailableReason` on the Missions ObjectPage, and a `temporarilyUnavailable` list column. - -- [ ] **Step 1: Add labels** — in `annotate AdminService.Missions with { … }`, after the `published @Common.Label: 'Published';` line (`:129`): - -```cds - temporarilyUnavailable @Common.Label: 'Temporarily Unavailable'; - unavailableReason @Common.Label: 'Unavailable Reason' @UI.MultiLineText; -``` - -- [ ] **Step 2: Add the list column** — in `@UI.LineItem`, after `{ Value: published, @UI.Importance: #High }` (`:151`): - -```cds - { Value: temporarilyUnavailable }, -``` - -- [ ] **Step 3: Add the form fields** — in `@UI.FieldGroup#General.Data`, after `{ Value: published, @UI.FieldControl: publishedFieldControl }` (`:168`): - -```cds - { Value: temporarilyUnavailable }, - { Value: unavailableReason }, -``` - -- [ ] **Step 4: Confirm the write guard is NOT extended** — inspect `srv/admin-service.js:2473-2501` (`_guardPublished`). Verify it only acts on `'published' in req.data`; confirm no change is needed so a normal Admin can write the new flag. (No code change; this is a verification step.) - -- [ ] **Step 5: Verify annotations compile** — Run: `npx cds compile srv --to edmx > /dev/null` (quiet-run if noisy). Expected: exit 0, no annotation error referencing `temporarilyUnavailable`/`unavailableReason`. - -- [ ] **Step 6: Commit** - -```bash -git add app/admin-annotations.cds -git commit -m "feat(#2864): expose Temporarily Unavailable toggle in Mission Editor" -``` - ---- - -### Task 4: Badge on the card (build-time + both templates + parity) - -**Files:** -- Modify: `scripts/fetch-tutorials.ts:2021-2038` (`BrowseCardItem`) and `:2204-2230` (mission literal) -- Modify: `hugo-apps/src/shared/types.ts:20-37` (`CardItem`) -- Modify: `hugo/layouts/partials/browse/_partials/card-mission.html` -- Modify: `hugo-apps/src/shared/cards/MissionCard.vue` -- Modify: `hugo-apps/src/browse/__tests__/fixtures/cards.fixtures.json` (add flag to `mission` fixture) -- Regenerate: `hugo-apps/src/browse/__tests__/fixtures/card-mission.expected.html` (via `tools/regen-card-parity-fixtures.sh`) -- Test: `scripts/__tests__/fetch-tutorials-browse-channel.test.ts` (add cases) + `hugo-apps/src/browse/__tests__/card-template-parity.test.ts` (fixture already exercises both templates) - -**Interfaces:** -- Consumes: `Mission.temporarilyUnavailable` / `unavailableReason` from Task 2 (`mMeta` lookup in `buildAllCards`). -- Produces: `BrowseCardItem` / `CardItem` gain `temporarilyUnavailable?: boolean` and `unavailableReason?: string`; mission cards render a badge element when the flag is truthy. - -**Builder note (runtime scope):** The runtime navigator (`TutorialNavigator.vue`, mission literal `:119-141`) builds its card from a `MissionRef` (`{id,slug,title}` only — it doesn't even carry `categorySlugs`). Do NOT extend `MissionRef`/the navigator seed to thread the flag at runtime. The badge is baked into `browse.json` and rendered server-side on first paint for both `/browse/` and `/tutorial-navigator/`; the Vue `MissionCard.vue` must still render the badge from `item.temporarilyUnavailable` so the parity test holds and hydration is identical. This is the minimal correct layer given rebuild-gated propagation. - -- [ ] **Step 1: Write the failing build-time test** — in `fetch-tutorials-browse-channel.test.ts`, add a case: construct `const mission: Mission = { imsId: 1, slug: 'x', title: 'X', description: '', level: 'beginner', time: 60, icon: '', tasksCount: 1, temporarilyUnavailable: true, unavailableReason: 'maint' }` and `makeTut({ missionId: 1, missionTitle: 'X' })`, call `buildAllCards([tut],[mission],noHierarchies,noGroups,emptyMetaMap)`, assert: - -```ts - const mCard = cards.find(c => c.type === 'mission') - expect(mCard?.temporarilyUnavailable).toBe(true) - expect(mCard?.unavailableReason).toBe('maint') -``` - - And a negative case: a mission without the flag yields `mCard?.temporarilyUnavailable` falsy. - -- [ ] **Step 2: Run it, verify it fails.** - -- [ ] **Step 3: Add fields to `BrowseCardItem`** (`scripts/fetch-tutorials.ts:2021-2038`) and **`CardItem`** (`hugo-apps/src/shared/types.ts:20-37`), both after `createdAt?`/`updatedAt?`: - -```ts - temporarilyUnavailable?: boolean - unavailableReason?: string -``` - -- [ ] **Step 4: Populate them in the mission literal** (`scripts/fetch-tutorials.ts:2204-2230`), after `categorySlugs`: - -```ts - temporarilyUnavailable: mMeta?.temporarilyUnavailable === true, - unavailableReason: mMeta?.unavailableReason, -``` - -- [ ] **Step 5: Run the build-time test, verify it passes.** - -- [ ] **Step 6: Add the badge to both templates.** In `MissionCard.vue`, immediately after the `nav-card__type--mission` div: - -```html - -``` - - In `card-mission.html`, in the same position (after the `MISSION` type div): - -```html - {{- if $item.temporarilyUnavailable }}{{ end -}} -``` - - Add a `.nav-card__unavailable-badge` rule to `hugo-apps/src/shared/cards/card.css` (reuse existing badge/chip visual conventions; builder's CSS call). - -- [ ] **Step 7: Add the flag to the mission parity fixture** — in `cards.fixtures.json`, set `"temporarilyUnavailable": true` on the `mission` object so the parity test exercises the badge branch. - -- [ ] **Step 8: Regenerate the Hugo fixture** — Run: `bash tools/regen-card-parity-fixtures.sh` (requires `hugo` on PATH). Expected: `card-mission.expected.html` updated to include the badge element. - -- [ ] **Step 9: Run the parity + browse-channel tests, verify they pass** — Run the vitest suites for `card-template-parity.test.ts` and `fetch-tutorials-browse-channel.test.ts`. Expected: PASS (Vue and Hugo render the badge identically post-canonicalization). - -- [ ] **Step 10: Commit** - -```bash -git add scripts/fetch-tutorials.ts hugo-apps/src/shared/types.ts hugo/layouts/partials/browse/_partials/card-mission.html hugo-apps/src/shared/cards/MissionCard.vue hugo-apps/src/shared/cards/card.css hugo-apps/src/browse/__tests__/fixtures/cards.fixtures.json hugo-apps/src/browse/__tests__/fixtures/card-mission.expected.html scripts/__tests__/fetch-tutorials-browse-channel.test.ts -git commit -m "feat(#2864): Temporarily Unavailable badge on mission cards" -``` - ---- - -### Task 5: Suppress links in the mission-detail feed (content stays visible) - -**Files:** -- Modify: `srv/lib/branch/mission-detail.js:37-38` (right after the mission load) -- Test: `test/unit/srv/mission-detail-unavailable.test.js` (new; mirror an existing `srv/lib/branch` or mission-detail test harness if one exists) - -**Interfaces:** -- Consumes: `mission.temporarilyUnavailable` (already selected — `SELECT.one.from(Missions)` has no `.columns`, so the field is present once Task 1 lands). -- Produces: for a disabled mission, the feed returns `{ missionSlug, items: [] }` with HTTP 200 (page shell reachable, no onward links). - -- [ ] **Step 1: Write the failing test** — a disabled mission (`temporarilyUnavailable: true`) with at least one completion path + tutorial item returns status 200 and `body.items` length 0; a normal mission returns its items as today. - -```js -// disabled mission -expect(res.statusCode).toBe(200) -expect(body).toEqual({ missionSlug: 'x', items: [] }) -``` - -- [ ] **Step 2: Run it, verify it fails** (currently returns the tutorial items). - -- [ ] **Step 3: Add the guard** — in `mission-detail.js`, immediately after the `if (!mission) return res.status(404)…` line (`:38`): - -```js - if (mission.temporarilyUnavailable) { - res.setHeader('Cache-Control', 'no-store'); - return res.json({ missionSlug: slug, items: [] }); - } -``` - -- [ ] **Step 4: Run the test, verify it passes.** - -- [ ] **Step 5: Commit** - -```bash -git add srv/lib/branch/mission-detail.js test/unit/srv/mission-detail-unavailable.test.js -git commit -m "feat(#2864): mission-detail feed returns no items for disabled missions" -``` - ---- - -### Task 6: Suppress links in the navigator (view + nested-group path) - -**Files:** -- Modify: `db/views.cds:76-95` (`NavigatorCatalog` — project the flag, filter it) -- Modify: `srv/lib/navigator-catalog.js:221-224` (nested-group mission SELECT) -- Test: `test/unit/srv/navigator-catalog-unavailable.test.js` (new) - -**Interfaces:** -- Consumes: schema fields from Task 1. -- Produces: a disabled mission contributes no tutorial links (view `where`) and no nested-group links (JS filter) to the navigator catalog. - -- [ ] **Step 1: Write the failing test** — seed a published-but-`temporarilyUnavailable` mission with a tutorial item and a nested GROUP item; call the navigator catalog builder; assert no catalog entry references that mission's slug and no nested-group link flows from it. A normal published mission still produces its entries. - -- [ ] **Step 2: Run it, verify it fails** (links currently flow — only `published` is filtered). - -- [ ] **Step 3: Filter the view** — in `db/views.cds` `NavigatorCatalog`, extend the `where` clause: - -```cds - where item.taskType = 'TUTORIAL' and tut.slug is not null - and mission.published = true and mission.temporarilyUnavailable = false; -``` - - (If any view row needs the flag value, also add `mission.temporarilyUnavailable as missionTemporarilyUnavailable` to the projection — only if the test shows it's needed; otherwise the `where` is sufficient.) - -- [ ] **Step 4: Filter the nested-group path** — in `navigator-catalog.js:221-224`, add the flag to the mission SELECT's `.where`: - -```js - ? await SELECT.from(Missions).columns('ID', 'legacyId', 'title', 'slug', 'published', 'temporarilyUnavailable').where({ ID: { in: missionIds }, published: true, temporarilyUnavailable: false }) -``` - - Confirm the `missionById` map then excludes disabled missions so their nested-group refs are dropped. (Verify whether `temporarilyUnavailable: false` matches rows where the column is `null` on legacy data — if the default didn't backfill, use `{ '!=': true }` instead. Builder checks against a migrated row.) - -- [ ] **Step 5: Run the test, verify it passes.** - -- [ ] **Step 6: Commit** - -```bash -git add db/views.cds srv/lib/navigator-catalog.js test/unit/srv/navigator-catalog-unavailable.test.js -git commit -m "feat(#2864): navigator suppresses links for disabled missions" -``` - ---- - -### Task 7: Full test sweep + cds build - -**Files:** none (verification task). - -- [ ] **Step 1: Run the unit suite** — Run: `scripts/quiet-run.sh npm test`. Expected: PASS (new tests green, no regressions). Drill into `.quiet-logs/` only if the summary shows a failure. - -- [ ] **Step 2: Production build sanity** — Run: `scripts/quiet-run.sh npx cds build --production`. Expected: exit 0 (schema + annotations + views compile for deploy). - -- [ ] **Step 3: Confirm line endings unchanged** (Windows worktree) — Run: `git diff --stat` and spot-check with `file db/schema.cds db/views.cds srv/lib/navigator-catalog.js`. Expected: no CRLF flip on edited LF files; normalize via Node if any flipped. - -- [ ] **Step 4: Commit any fixups**, then the branch is ready for PR to `DEV`. - ---- - -## Notes for the executor - -- PRs target `DEV` (not `main`); open with `gh pr create`. -- This is model + build-pipeline + annotations only — no deploy is required to merge. The badge/link behavior goes live on the next content rebuild after deploy (rebuild-gated, by design). -- Groups have a parallel `published` field (`db/schema.cds:102`) — the issue is mission-path-level only; do NOT add the flag to Groups unless asked. diff --git a/docs/superpowers/specs/2026-10-20-mission-disable-path-design.md b/docs/superpowers/specs/2026-10-20-mission-disable-path-design.md deleted file mode 100644 index 170b50383..000000000 --- a/docs/superpowers/specs/2026-10-20-mission-disable-path-design.md +++ /dev/null @@ -1,180 +0,0 @@ -# Mission "Temporarily Unavailable" toggle — design - -**Issue:** [sap-tutorials/tutorials-ims#2864](https://github.com/sap-tutorials/tutorials-ims/issues/2864) -**Date:** 2026-10-20 -**Author:** design captured by Claude with Tom - -## Intent and why - -When a mission (path) breaks or needs maintenance, there is currently no fast way -to pull it out of circulation short of unpublishing it. Unpublishing is a heavier, -SuperAdmin-guarded action with publish semantics; it also fully removes the mission. -Operators want a **reactive quick-disable**: flip one switch in the Mission Editor so -the mission stops being promoted, its card shows "Temporarily Unavailable", and its -links to child path items disappear — while the content itself stays reachable so -anyone mid-path (or with a direct link) can still read it. - -The moment this serves: "a path is down or broken, and we need it out of the way in -seconds, then back just as fast once it's fixed." - -## What it does (behavior) - -A new per-mission boolean, **`temporarilyUnavailable`** (default `false`), with an -optional free-text **`unavailableReason`**. When `true`: - -1. **Card badge** — the mission card shows a "Temporarily Unavailable" badge/label. - Mission cards render on **`/browse/`** and **`/tutorial-navigator/`** only; the - homepage `/` renders no mission cards, so it is out of scope. (Decided with Tom.) -2. **Links removed** — the mission's child path-item links (the tutorials/groups it - points to) are suppressed from card/navigator navigation, so the path can't be - entered through the normal links. -3. **Content stays visible** — the mission's own detail page still loads at its direct - URL. Disabling is about promotion/linking, not deletion. (Decided with Tom.) - -When `false`, everything behaves exactly as today. - -Set **manually** via an on/off toggle in the Admin UI Mission Editor. **No scheduled -window** — this is a reactive maintenance switch, not a calendar feature. (Decided with -Tom; a scheduled window can be added later if ever needed.) - -**Propagation is rebuild-gated** (decided with Tom). `Missions` is `@odata.draft.enabled`, -so disabling is edit-draft → activate, and the badge + link removal take effect on the -**next content rebuild** (the `gh workflow run rebuild-content.yml` path, ~2–10 min) — -the same way every other mission edit propagates to the baked site. This is NOT a -near-instant runtime toggle; near-instant was explicitly deferred as out of scope. - -This is orthogonal to `published`: a mission must still be `published: true` to appear -at all; `temporarilyUnavailable` is an additional gate applied to already-published -missions. - -## Goals - -- One-switch disable/enable from the Mission Editor, takes effect on the next content - rebuild/catalog refresh. -- Disabled mission's card is clearly badged "Temporarily Unavailable" (on `/browse/` - and `/tutorial-navigator/`). -- Disabled mission's child path-item links are removed from navigation. -- Disabled mission's own content page still loads by direct URL. -- Optional operator-facing reason note stored alongside the flag. - -## Non-goals - -- No scheduled / timed auto-re-enable. -- No change to `published` semantics or its SuperAdmin guard. -- No disabling at the individual tutorial or group level (mission-path level only, per - the issue title). -- No end-user notification/email, no analytics event for disable. - -## Anti-goals (would make this a failure even if it "works") - -- Making the disabled mission's content **inaccessible** by direct URL — the issue is - explicit that content remains visible. A hard block is wrong. -- Coupling this to the publish workflow such that disabling unpublishes, or such that - re-enabling requires SuperAdmin. Quick-disable must be quick. -- Letting a disabled mission still surface its child links anywhere (card meta, - navigator, mission-detail feed) — that defeats the "pull it out of circulation" point. - -## Constraints - -- CAP Node.js + CDS; model change lands in `db/schema.cds` on `Missions`. -- Admin UI is Fiori Elements driven by annotations in `app/admin-annotations.cds`; a new - editable field surfaces by adding a `@Common.Label` and a `FieldGroup`/`LineItem` entry - — no manifest change needed for a plain field. -- Mission cards have **two parallel implementations kept in sync by a parity test**: the - Hugo SSR partial `hugo/layouts/partials/browse/_partials/card-mission.html` and the Vue - source `hugo-apps/src/shared/cards/MissionCard.vue`. The parity test compares *after* - canonicalization (not literal byte-equivalence), and a regenerated fixture - (`card-mission.expected.html`) backs it — both templates plus the fixture change - together. -- The baked SSR cards and the hydrated Vue cards both read `hugo/data/browse.json`, built - at fetch time by `scripts/fetch-tutorials.ts` (`buildAllCards`/`writeBrowseData`). The - flag must flow through *that* build-time assembly — NOT `/build/catalog`, which feeds a - different (navigator/hydration) path. The mission-detail feed - (`srv/lib/branch/mission-detail.js`) and the two navigator link paths - (`db/views.cds` `NavigatorCatalog` + `srv/lib/navigator-catalog.js` nested-group loop) - are the link-suppression touch points. -- `temporarilyUnavailable` must be writable by a normal admin (not gated like - `published`), so quick-disable stays quick. - -## The "how" decided so far - -- **Field name:** `temporarilyUnavailable : Boolean default false;` plus - `unavailableReason : String(500);` on `Missions` in `db/schema.cds` (matching the - `deletionReason : String(500)` sibling on `TaskBase`). -- **Card source — the correct file.** The baked SSR mission cards are NOT built from - `/build/catalog`. They are assembled at build time by `buildAllCards()` in - `scripts/fetch-tutorials.ts` (mission cards at ~`:2204–2230`) → `writeBrowseData()` - (~`:2276–2316`) → `hugo/data/browse.json`, which is what **both** card templates read - (SSR partial inlines it; Vue hydrates from it). `build-catalog.js` feeds the - `/build/navigator` + hydration path, not the card payload. **So the flag must be - carried into the mission source that `fetch-tutorials.ts` reads and added to the - mission-card object it pushes** — this is a build-time fetch-pipeline change, not a - `build-catalog.js` change. -- **Badge WITHOUT stripping display fields.** A mission card's `primaryTag`, `level`, - `time`, and `tutorialCount` are derived from its child tutorials - (`fetch-tutorials.ts:2220–2223`). Link suppression must NOT empty the child set used to - compute those, or the card loses its tag/level/count. Suppress the *navigation links*, - keep the derived display fields, and add the badge flag as a separate card property. -- **Link suppression — two navigator paths.** `db/views.cds` `NavigatorCatalog` - (`:76–95`) gates only on `mission.published = true`; AND `srv/lib/navigator-catalog.js` - has a second link-producing path — the nested-group loop (~`:196–288`, re-queries - `CompletionPathItems where taskType='GROUP'`, filters only by `published:true` ~`:223`). - **Both** must also filter out `temporarilyUnavailable` missions, or a disabled - mission's nested-group child links still leak (violates the anti-goal). -- **Mission detail feed:** `srv/lib/branch/mission-detail.js` loads the mission by slug - and does NOT gate on published — it emits the child `items[]`. For a disabled mission it - should emit `items: []` (or an `unavailable` marker) so the page shell stays reachable - but offers no onward links. This is the correct "content visible, links suppressed" - point. -- **Badge:** net-new label element added to **both** card templates, shown when - `temporarilyUnavailable` is truthy. Copy: "Temporarily Unavailable". Reuse an existing - chip/tag pattern (`card-category-chip` / `nav-card__tag`) where it fits. -- **Card parity is NOT byte-equivalent.** The parity test - (`hugo-apps/src/browse/__tests__/card-template-parity.test.ts`) compares *after* - canonicalization (strips `data-v-*`, HTML comments, normalizes `·`, collapses - whitespace). There is also a fixture (`fixtures/card-mission.expected.html`) regenerated - via `tools/regen-card-parity-fixtures.sh` — the builder must **regen the fixture**, not - just edit the test. -- **Admin UI:** add the toggle + reason to the Mission Editor via `app/admin-annotations.cds` - (`@Common.Label`, `FieldGroup#General`, and a `LineItem` column). Field flows - automatically through the `*` projection at `srv/admin-service.cds:152`. A normal Admin - can write it — the publish guard `_guardPublished` (`srv/admin-service.js:2473–2501`) is - field-specific to `published` and does not touch the new field, so quick-disable stays - quick with no extra work. - -## Relationship to automated link-health (not a conflict) - -`Missions` already composes a `LinkHealth` aspect (`db/schema.cds:29–33`) with -job-written `linkStatus` (OK/SLOW/BROKEN) and an admin-pinned `linkStatusOverride` from -the url-health-check job (#2787). That is the *automated* "is this path's content -reachable" signal. `temporarilyUnavailable` is deliberately a separate *manual, reactive* -operator switch — it does not read or write link-health, and a broken `linkStatus` does -not auto-set it. Kept distinct on purpose. - -## Left to the builder - -- Whether the badge also appears on the mission's own detail-page header (issue only - requires the card badge; builder's call, lean minimal). -- Precise CSS/markup for the badge within existing `nav-card` styling. -- The exact field the flag rides on into `fetch-tutorials.ts` (the mission source / - `Mission` type it reads) — builder traces it and adds the property at the minimal layer. -- Test coverage: unit test for the suppressed-links + badge-flag card output, navigator - nested-group suppression, mission-detail empty-items, and the regen'd parity fixture; - follow existing patterns. - -## Key file touch points (from codebase recon) - -| Concern | File:line | -|---|---| -| `Missions` entity / new field | `db/schema.cds:69–95` (next to `published` at `:88`) | -| Admin projection (auto `*`) | `srv/admin-service.cds:152` | -| Admin UI field/column annotations | `app/admin-annotations.cds` (Missions block ~`:117–212`) | -| Publish guard (field-specific, untouched) | `srv/admin-service.js:2473–2501` | -| **Card source (SSR + Vue read this)** | `scripts/fetch-tutorials.ts` (mission cards ~`:2204–2230`, `writeBrowseData` ~`:2276–2316`) → `hugo/data/browse.json` | -| Mission-detail feed (child links) | `srv/lib/branch/mission-detail.js` | -| Navigator links — view | `db/views.cds:76–95` (`NavigatorCatalog`, gates `published` only) | -| Navigator links — nested-group path | `srv/lib/navigator-catalog.js` ~`:196–288` (filters `published` only) | -| Mission card (SSR) | `hugo/layouts/partials/browse/_partials/card-mission.html` | -| Mission card (Vue, parity source) | `hugo-apps/src/shared/cards/MissionCard.vue` | -| Card parity test + fixture | `hugo-apps/src/browse/__tests__/card-template-parity.test.ts`, `fixtures/card-mission.expected.html` (regen via `tools/regen-card-parity-fixtures.sh`) | -| Card surfaces (badge shows here) | `/browse/` (`hugo/layouts/browse/list.html:95`), `/tutorial-navigator/` (`TutorialNavigator.vue`) | diff --git a/hugo-apps/src/browse/__tests__/fixtures/browse-page-1.html b/hugo-apps/src/browse/__tests__/fixtures/browse-page-1.html index 47f7c64d4..c2a1e6aaa 100644 --- a/hugo-apps/src/browse/__tests__/fixtures/browse-page-1.html +++ b/hugo-apps/src/browse/__tests__/fixtures/browse-page-1.html @@ -82,7 +82,7 @@ - + @@ -953,7 +953,8 @@

      Recommended for you

      Show all →