@pie-players/pie-assessment-toolkit 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +159 -1
  2. package/dist/index.d.ts +16 -2
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +9 -2
  5. package/dist/index.js.map +1 -1
  6. package/dist/player/AssessmentPlayer.d.ts +9 -0
  7. package/dist/player/AssessmentPlayer.d.ts.map +1 -1
  8. package/dist/player/AssessmentPlayer.js +13 -4
  9. package/dist/player/AssessmentPlayer.js.map +1 -1
  10. package/dist/services/AnnotationToolbarConfig.d.ts +134 -0
  11. package/dist/services/AnnotationToolbarConfig.d.ts.map +1 -0
  12. package/dist/services/AnnotationToolbarConfig.js +100 -0
  13. package/dist/services/AnnotationToolbarConfig.js.map +1 -0
  14. package/dist/services/HighlightCoordinator.d.ts +23 -10
  15. package/dist/services/HighlightCoordinator.d.ts.map +1 -1
  16. package/dist/services/HighlightCoordinator.js +87 -40
  17. package/dist/services/HighlightCoordinator.js.map +1 -1
  18. package/dist/services/PNPToolResolver.d.ts +171 -15
  19. package/dist/services/PNPToolResolver.d.ts.map +1 -1
  20. package/dist/services/PNPToolResolver.js +324 -32
  21. package/dist/services/PNPToolResolver.js.map +1 -1
  22. package/dist/services/RangeSerializer.d.ts +119 -0
  23. package/dist/services/RangeSerializer.d.ts.map +1 -0
  24. package/dist/services/RangeSerializer.js +199 -0
  25. package/dist/services/RangeSerializer.js.map +1 -0
  26. package/dist/services/TTSService.d.ts +4 -1
  27. package/dist/services/TTSService.d.ts.map +1 -1
  28. package/dist/services/TTSService.js +58 -3
  29. package/dist/services/TTSService.js.map +1 -1
  30. package/dist/services/ToolRegistry.d.ts +225 -0
  31. package/dist/services/ToolRegistry.d.ts.map +1 -0
  32. package/dist/services/ToolRegistry.js +248 -0
  33. package/dist/services/ToolRegistry.js.map +1 -0
  34. package/dist/services/ToolkitCoordinator.d.ts +22 -0
  35. package/dist/services/ToolkitCoordinator.d.ts.map +1 -1
  36. package/dist/services/ToolkitCoordinator.js +44 -0
  37. package/dist/services/ToolkitCoordinator.js.map +1 -1
  38. package/dist/services/createDefaultToolRegistry.d.ts +72 -0
  39. package/dist/services/createDefaultToolRegistry.d.ts.map +1 -0
  40. package/dist/services/createDefaultToolRegistry.js +147 -0
  41. package/dist/services/createDefaultToolRegistry.js.map +1 -0
  42. package/dist/services/interfaces.d.ts +8 -0
  43. package/dist/services/interfaces.d.ts.map +1 -1
  44. package/dist/services/interfaces.js.map +1 -1
  45. package/dist/services/pnp-provenance.d.ts +167 -0
  46. package/dist/services/pnp-provenance.d.ts.map +1 -0
  47. package/dist/services/pnp-provenance.js +230 -0
  48. package/dist/services/pnp-provenance.js.map +1 -0
  49. package/dist/services/pnp-standard-features.d.ts +240 -0
  50. package/dist/services/pnp-standard-features.d.ts.map +1 -0
  51. package/dist/services/pnp-standard-features.js +342 -0
  52. package/dist/services/pnp-standard-features.js.map +1 -0
  53. package/dist/services/tool-context.d.ts +150 -0
  54. package/dist/services/tool-context.d.ts.map +1 -0
  55. package/dist/services/tool-context.js +157 -0
  56. package/dist/services/tool-context.js.map +1 -0
  57. package/dist/services/tool-providers/ToolProviderRegistry.d.ts.map +1 -1
  58. package/dist/services/tool-providers/ToolProviderRegistry.js +7 -7
  59. package/dist/services/tool-providers/ToolProviderRegistry.js.map +1 -1
  60. package/dist/tools/registrations/accessibility-tools.d.ts +43 -0
  61. package/dist/tools/registrations/accessibility-tools.d.ts.map +1 -0
  62. package/dist/tools/registrations/accessibility-tools.js +230 -0
  63. package/dist/tools/registrations/accessibility-tools.js.map +1 -0
  64. package/dist/tools/registrations/calculator.d.ts +21 -0
  65. package/dist/tools/registrations/calculator.d.ts.map +1 -0
  66. package/dist/tools/registrations/calculator.js +98 -0
  67. package/dist/tools/registrations/calculator.js.map +1 -0
  68. package/dist/tools/registrations/interaction-tools.d.ts +28 -0
  69. package/dist/tools/registrations/interaction-tools.d.ts.map +1 -0
  70. package/dist/tools/registrations/interaction-tools.js +124 -0
  71. package/dist/tools/registrations/interaction-tools.js.map +1 -0
  72. package/dist/tools/registrations/measurement-tools.d.ts +25 -0
  73. package/dist/tools/registrations/measurement-tools.d.ts.map +1 -0
  74. package/dist/tools/registrations/measurement-tools.js +112 -0
  75. package/dist/tools/registrations/measurement-tools.js.map +1 -0
  76. package/dist/tools/registrations/subject-specific-tools.d.ts +28 -0
  77. package/dist/tools/registrations/subject-specific-tools.d.ts.map +1 -0
  78. package/dist/tools/registrations/subject-specific-tools.js +118 -0
  79. package/dist/tools/registrations/subject-specific-tools.js.map +1 -0
  80. package/dist/tools/registrations/tts.d.ts +20 -0
  81. package/dist/tools/registrations/tts.d.ts.map +1 -0
  82. package/dist/tools/registrations/tts.js +86 -0
  83. package/dist/tools/registrations/tts.js.map +1 -0
  84. package/dist/tools/tool-tag-map.d.ts +24 -0
  85. package/dist/tools/tool-tag-map.d.ts.map +1 -0
  86. package/dist/tools/tool-tag-map.js +38 -0
  87. package/dist/tools/tool-tag-map.js.map +1 -0
  88. package/package.json +15 -7
  89. package/src/README.md +25 -11
  90. package/src/components/PNPProfileTester.example.svelte +188 -0
  91. package/src/components/PNPProfileTester.svelte +397 -0
  92. package/src/components/PNPProvenanceViewer.svelte +260 -0
  93. package/src/components/QuestionToolBar.svelte +160 -83
  94. package/src/components/ToolButton.svelte +146 -0
  95. package/src/components/ToolButtonGroup.svelte +109 -0
  96. package/src/reference-layout/ReferenceLayout.svelte +5 -13
  97. package/dist/services/PNPMapper.d.ts +0 -71
  98. package/dist/services/PNPMapper.d.ts.map +0 -1
  99. package/dist/services/PNPMapper.js +0 -98
  100. package/dist/services/PNPMapper.js.map +0 -1
@@ -0,0 +1,188 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Example usage of PNPProfileTester component
4
+ *
5
+ * This demonstrates how to use the PNP Profile Tester for development and testing.
6
+ * Shows integration with PNPToolResolver and PNPProvenanceViewer.
7
+ */
8
+ import PNPProfileTester from './PNPProfileTester.svelte';
9
+ import PNPProvenanceViewer from './PNPProvenanceViewer.svelte';
10
+ import { PNPToolResolver } from '../services/PNPToolResolver';
11
+ import { createDefaultToolRegistry } from '../services/createDefaultToolRegistry';
12
+ import type { PersonalNeedsProfile } from '@pie-players/pie-players-shared/types';
13
+ import type { PNPResolutionProvenance } from '../services/pnp-provenance';
14
+
15
+ // Mock assessment for testing
16
+ const mockAssessment = {
17
+ id: 'test-assessment-1',
18
+ name: 'Example Assessment',
19
+ personalNeedsProfile: undefined as PersonalNeedsProfile | undefined,
20
+ settings: {
21
+ // Example district policy
22
+ districtPolicy: {
23
+ blockedTools: [], // No blocks in this example
24
+ requiredTools: [], // No requirements in this example
25
+ },
26
+ },
27
+ };
28
+
29
+ // Create resolver with default tool registry
30
+ const toolRegistry = createDefaultToolRegistry();
31
+ const resolver = new PNPToolResolver(toolRegistry, true);
32
+
33
+ // State
34
+ let currentProfile = $state<PersonalNeedsProfile | null>(null);
35
+ let resolutionResult = $state<{
36
+ toolIds: string[];
37
+ provenance: PNPResolutionProvenance;
38
+ } | null>(null);
39
+ let showProvenance = $state(true);
40
+
41
+ // Update resolution when profile changes
42
+ function handleProfileChange(profile: PersonalNeedsProfile | null) {
43
+ currentProfile = profile;
44
+
45
+ // Use the override method to test with this profile
46
+ const result = resolver.resolveWithOverride(mockAssessment as any, profile);
47
+
48
+ resolutionResult = {
49
+ toolIds: result.tools.filter((t) => t.enabled).map((t) => t.id),
50
+ provenance: result.provenance,
51
+ };
52
+ }
53
+ </script>
54
+
55
+ <div class="container mx-auto p-4 max-w-6xl">
56
+ <div class="prose mb-6">
57
+ <h1>PNP Profile Tester - Example</h1>
58
+ <p>
59
+ This component allows developers and testers to experiment with different PNP
60
+ (Personal Needs Profile) configurations in real-time.
61
+ </p>
62
+ </div>
63
+
64
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
65
+ <!-- Left: Profile Tester -->
66
+ <div>
67
+ <PNPProfileTester onProfileChange={handleProfileChange} />
68
+ </div>
69
+
70
+ <!-- Right: Resolution Results -->
71
+ <div class="space-y-4">
72
+ <!-- Enabled Tools -->
73
+ <div class="card bg-base-100 shadow-sm">
74
+ <div class="card-body">
75
+ <h3 class="card-title text-sm">Resolved Tools</h3>
76
+ {#if resolutionResult && resolutionResult.toolIds.length > 0}
77
+ <div class="space-y-2">
78
+ {#each resolutionResult.toolIds as toolId}
79
+ <div class="badge badge-primary">{toolId}</div>
80
+ {/each}
81
+ </div>
82
+ {:else}
83
+ <p class="text-sm text-base-content/50">
84
+ No tools enabled. Select a profile or add features to see results.
85
+ </p>
86
+ {/if}
87
+ </div>
88
+ </div>
89
+
90
+ <!-- Profile JSON -->
91
+ <div class="card bg-base-100 shadow-sm">
92
+ <div class="card-body">
93
+ <h3 class="card-title text-sm">Current Profile JSON</h3>
94
+ <div class="mockup-code">
95
+ <pre><code>{JSON.stringify(currentProfile, null, 2)}</code></pre>
96
+ </div>
97
+ </div>
98
+ </div>
99
+
100
+ <!-- Provenance Toggle -->
101
+ <div class="form-control">
102
+ <label class="label cursor-pointer">
103
+ <span class="label-text">Show Provenance</span>
104
+ <input
105
+ type="checkbox"
106
+ class="toggle toggle-primary"
107
+ bind:checked={showProvenance}
108
+ />
109
+ </label>
110
+ </div>
111
+ </div>
112
+ </div>
113
+
114
+ <!-- Full-width Provenance Viewer -->
115
+ {#if showProvenance && resolutionResult}
116
+ <div class="mt-6">
117
+ <PNPProvenanceViewer provenance={resolutionResult.provenance} />
118
+ </div>
119
+ {/if}
120
+
121
+ <!-- Usage Examples -->
122
+ <div class="prose mt-8">
123
+ <h2>Integration Examples</h2>
124
+
125
+ <h3>Basic Usage</h3>
126
+ <pre><code>{`<script>
127
+ import { PNPProfileTester } from '@pie-players/pie-assessment-toolkit/components';
128
+
129
+ let currentProfile;
130
+ </script>
131
+
132
+ <PNPProfileTester
133
+ onProfileChange={(profile) => {
134
+ currentProfile = profile;
135
+ // Apply to your assessment player
136
+ }}
137
+ />`}</code></pre>
138
+
139
+ <h3>With PNPToolResolver Override</h3>
140
+ <pre><code>{`<script>
141
+ import { PNPProfileTester } from '@pie-players/pie-assessment-toolkit/components';
142
+ import { PNPToolResolver, createDefaultToolRegistry } from '@pie-players/pie-assessment-toolkit';
143
+
144
+ const registry = createDefaultToolRegistry();
145
+ const resolver = new PNPToolResolver(registry);
146
+
147
+ function handleProfileChange(profile) {
148
+ // Test with override profile
149
+ const result = resolver.resolveWithOverride(assessment, profile);
150
+
151
+ // Use resolved tools
152
+ const toolIds = result.tools.filter(t => t.enabled).map(t => t.id);
153
+
154
+ // Show provenance
155
+ console.log(result.provenance);
156
+ }
157
+ </script>
158
+
159
+ <PNPProfileTester onProfileChange={handleProfileChange} />`}</code></pre>
160
+
161
+ <h3>Compact Mode in Sidebar</h3>
162
+ <pre><code>{`<PNPProfileTester compact={true} onProfileChange={handleProfileChange} />`}</code></pre>
163
+
164
+ <h3>Without Custom Features</h3>
165
+ <pre><code>{`<PNPProfileTester
166
+ allowCustomFeatures={false}
167
+ onProfileChange={handleProfileChange}
168
+ />`}</code></pre>
169
+ </div>
170
+ </div>
171
+
172
+ <style>
173
+ .prose {
174
+ max-width: 100%;
175
+ }
176
+
177
+ .prose code {
178
+ background-color: hsl(var(--b2));
179
+ padding: 0.125rem 0.375rem;
180
+ border-radius: 0.25rem;
181
+ font-size: 0.875em;
182
+ }
183
+
184
+ .prose pre code {
185
+ background-color: transparent;
186
+ padding: 0;
187
+ }
188
+ </style>
@@ -0,0 +1,397 @@
1
+ <script lang="ts">
2
+ // PNP Profile Tester Component
3
+ // Development and testing tool for overriding PNP profiles in real-time.
4
+ // See PNP_TESTING.md for usage documentation.
5
+ import type { PersonalNeedsProfile } from '@pie-players/pie-players-shared/types';
6
+ import {
7
+ EXAMPLE_PNP_CONFIGURATIONS,
8
+ QTI_STANDARD_ACCESS_FEATURES,
9
+ getFeatureCategory,
10
+ } from '../services/pnp-standard-features';
11
+
12
+ interface Props {
13
+ /** Callback when profile changes */
14
+ onProfileChange?: (profile: PersonalNeedsProfile | null) => void;
15
+ /** Initial profile (optional) */
16
+ initialProfile?: PersonalNeedsProfile | null;
17
+ /** Show compact view */
18
+ compact?: boolean;
19
+ /** Allow custom feature entry */
20
+ allowCustomFeatures?: boolean;
21
+ }
22
+
23
+ let {
24
+ onProfileChange = () => {},
25
+ initialProfile = null,
26
+ compact = false,
27
+ allowCustomFeatures = true
28
+ }: Props = $props();
29
+
30
+ // Default profile with all tools enabled
31
+ const DEFAULT_ALL_TOOLS_PROFILE = [
32
+ 'calculator',
33
+ 'calculatorScientific',
34
+ 'calculatorGraphing',
35
+ 'graphingTool',
36
+ 'periodicTable',
37
+ 'protractor',
38
+ 'ruler',
39
+ 'readingGuide',
40
+ 'magnification',
41
+ 'screenMagnifier',
42
+ 'textToSpeech',
43
+ 'answerEliminator'
44
+ ];
45
+
46
+ // Current state - initialize with default profile if no initialProfile provided
47
+ let activeFeatures = $state<Set<string>>(
48
+ new Set(initialProfile?.supports || DEFAULT_ALL_TOOLS_PROFILE),
49
+ );
50
+ let selectedPreset = $state<string | null>(initialProfile ? null : 'default');
51
+ let customFeature = $state('');
52
+ let showCategoryFilter = $state<string | null>(null);
53
+ let searchQuery = $state('');
54
+ let statusMessage = $state('');
55
+
56
+ // Computed profile
57
+ $effect(() => {
58
+ const profile: PersonalNeedsProfile = {
59
+ supports: Array.from(activeFeatures),
60
+ };
61
+ onProfileChange(activeFeatures.size > 0 ? profile : null);
62
+ });
63
+
64
+ // Get all standard features as array
65
+ const allStandardFeatures = Object.entries(QTI_STANDARD_ACCESS_FEATURES).flatMap(
66
+ ([category, features]) =>
67
+ Object.entries(features).map(([key, value]) => ({
68
+ id: value,
69
+ name: key,
70
+ category,
71
+ })),
72
+ );
73
+
74
+ // Filter features
75
+ let filteredFeatures = $derived.by(() => {
76
+ let filtered = allStandardFeatures;
77
+
78
+ if (showCategoryFilter) {
79
+ filtered = filtered.filter((f) => f.category === showCategoryFilter);
80
+ }
81
+
82
+ if (searchQuery) {
83
+ const query = searchQuery.toLowerCase();
84
+ filtered = filtered.filter(
85
+ (f) =>
86
+ f.id.toLowerCase().includes(query) ||
87
+ f.name.toLowerCase().includes(query),
88
+ );
89
+ }
90
+
91
+ return filtered;
92
+ });
93
+
94
+ function applyPreset(presetKey: string) {
95
+ const preset = EXAMPLE_PNP_CONFIGURATIONS[presetKey as keyof typeof EXAMPLE_PNP_CONFIGURATIONS];
96
+ if (!preset) return;
97
+
98
+ selectedPreset = presetKey;
99
+ // Create a new Set to trigger Svelte 5 reactivity
100
+ activeFeatures = new Set(preset.features);
101
+ statusMessage = `Profile changed to ${preset.label.replace('Example: ', '')}. ${preset.features.length} features enabled.`;
102
+ }
103
+
104
+ function toggleFeature(featureId: string) {
105
+ // Create a new Set to trigger Svelte 5 reactivity
106
+ const newFeatures = new Set(activeFeatures);
107
+ const wasEnabled = newFeatures.has(featureId);
108
+ if (wasEnabled) {
109
+ newFeatures.delete(featureId);
110
+ } else {
111
+ newFeatures.add(featureId);
112
+ }
113
+ activeFeatures = newFeatures;
114
+ selectedPreset = null; // Clear preset when manually changing
115
+ statusMessage = `${featureId} ${wasEnabled ? 'disabled' : 'enabled'}. ${activeFeatures.size} features active.`;
116
+ }
117
+
118
+ function addCustomFeature() {
119
+ if (customFeature.trim()) {
120
+ const newFeatures = new Set(activeFeatures);
121
+ const featureId = customFeature.trim();
122
+ newFeatures.add(featureId);
123
+ activeFeatures = newFeatures;
124
+ customFeature = '';
125
+ selectedPreset = null;
126
+ statusMessage = `Custom feature ${featureId} added. ${activeFeatures.size} features active.`;
127
+ }
128
+ }
129
+
130
+ function clearAll() {
131
+ activeFeatures = new Set();
132
+ selectedPreset = null;
133
+ statusMessage = 'All features cleared.';
134
+ }
135
+
136
+ function exportProfile() {
137
+ const profile: PersonalNeedsProfile = {
138
+ supports: Array.from(activeFeatures),
139
+ };
140
+ const json = JSON.stringify(profile, null, 2);
141
+ const blob = new Blob([json], { type: 'application/json' });
142
+ const url = URL.createObjectURL(blob);
143
+ const a = document.createElement('a');
144
+ a.href = url;
145
+ a.download = 'pnp-profile.json';
146
+ a.click();
147
+ URL.revokeObjectURL(url);
148
+ }
149
+
150
+ function importProfile(event: Event) {
151
+ const input = event.target as HTMLInputElement;
152
+ const file = input.files?.[0];
153
+ if (!file) return;
154
+
155
+ const reader = new FileReader();
156
+ reader.onload = (e) => {
157
+ try {
158
+ const profile = JSON.parse(e.target?.result as string) as PersonalNeedsProfile;
159
+ if (profile.supports && Array.isArray(profile.supports)) {
160
+ activeFeatures = new Set(profile.supports);
161
+ selectedPreset = null;
162
+ }
163
+ } catch (err) {
164
+ console.error('Failed to import PNP profile:', err);
165
+ }
166
+ };
167
+ reader.readAsText(file);
168
+ }
169
+
170
+ const categories = Object.keys(QTI_STANDARD_ACCESS_FEATURES);
171
+ </script>
172
+
173
+ <div class="pnp-profile-tester" class:compact>
174
+ <!-- Header -->
175
+ <div class="flex items-center gap-2 mb-3">
176
+ <h3 class="text-sm font-semibold">PNP Profile Tester</h3>
177
+ <div class="badge badge-neutral badge-sm">
178
+ {activeFeatures.size} feature{activeFeatures.size !== 1 ? 's' : ''}
179
+ </div>
180
+ </div>
181
+
182
+ <!-- Preset Selector and Search Row -->
183
+ <div class="grid grid-cols-1 {compact ? '' : 'md:grid-cols-2'} gap-2 mb-3">
184
+ <!-- Preset Selector -->
185
+ <div class="form-control">
186
+ <label for="preset-selector" class="label label-text text-xs font-semibold py-1">
187
+ Example Profiles
188
+ </label>
189
+ <select
190
+ id="preset-selector"
191
+ class="select select-sm select-bordered w-full"
192
+ value={selectedPreset || ''}
193
+ aria-label="Select an example accessibility profile"
194
+ onchange={(e) => {
195
+ const value = (e.target as HTMLSelectElement).value;
196
+ if (value === 'default') {
197
+ activeFeatures = new Set(DEFAULT_ALL_TOOLS_PROFILE);
198
+ selectedPreset = 'default';
199
+ statusMessage = `Default profile applied. ${activeFeatures.size} features enabled.`;
200
+ } else if (value === '') {
201
+ clearAll();
202
+ } else {
203
+ applyPreset(value);
204
+ }
205
+ }}
206
+ >
207
+ <option value="default">Default - All Tools</option>
208
+ <option value="">None (Clear All)</option>
209
+ <optgroup label="Example Profiles">
210
+ {#each Object.entries(EXAMPLE_PNP_CONFIGURATIONS) as [key, preset]}
211
+ <option value={key}>{preset.label.replace('Example: ', '')}</option>
212
+ {/each}
213
+ </optgroup>
214
+ </select>
215
+ </div>
216
+
217
+ <!-- Search -->
218
+ {#if !compact}
219
+ <div class="form-control">
220
+ <label for="feature-search" class="label label-text text-xs font-semibold py-1">
221
+ Search Features
222
+ </label>
223
+ <input
224
+ id="feature-search"
225
+ type="text"
226
+ placeholder="Search features..."
227
+ class="input input-bordered input-sm w-full"
228
+ bind:value={searchQuery}
229
+ aria-label="Search accessibility features by name or ID"
230
+ />
231
+ </div>
232
+ {/if}
233
+ </div>
234
+
235
+ <!-- Category Filter -->
236
+ {#if !compact}
237
+ <div class="mb-3">
238
+ <div class="text-xs font-semibold mb-2">Filter by Category</div>
239
+ <div class="flex flex-wrap gap-1">
240
+ <button
241
+ class="btn btn-xs"
242
+ class:btn-primary={showCategoryFilter === null}
243
+ aria-pressed={showCategoryFilter === null}
244
+ onclick={() => (showCategoryFilter = null)}
245
+ >
246
+ All
247
+ </button>
248
+ {#each categories as category}
249
+ <button
250
+ class="btn btn-xs"
251
+ class:btn-primary={showCategoryFilter === category}
252
+ aria-pressed={showCategoryFilter === category}
253
+ onclick={() =>
254
+ (showCategoryFilter = showCategoryFilter === category ? null : category)}
255
+ >
256
+ {category}
257
+ </button>
258
+ {/each}
259
+ </div>
260
+ </div>
261
+ {/if}
262
+
263
+ <!-- Feature Toggles -->
264
+ <div class="border border-base-300 rounded-lg p-3 mb-3 bg-base-100">
265
+ <div class="text-xs font-semibold mb-2">
266
+ Standard Features
267
+ {#if filteredFeatures.length !== allStandardFeatures.length}
268
+ <span class="text-base-content/60 font-normal">
269
+ (showing {filteredFeatures.length} of {allStandardFeatures.length})
270
+ </span>
271
+ {/if}
272
+ </div>
273
+ <div class="max-h-80 overflow-y-auto space-y-0.5 pr-2">
274
+ {#each filteredFeatures as feature}
275
+ <label class="flex items-center gap-2 cursor-pointer hover:bg-base-200 p-2 rounded transition-colors">
276
+ <input
277
+ type="checkbox"
278
+ class="checkbox checkbox-sm"
279
+ checked={activeFeatures.has(feature.id)}
280
+ onchange={() => toggleFeature(feature.id)}
281
+ />
282
+ <span class="text-sm flex-1">{feature.name}</span>
283
+ {#if !compact}
284
+ <span class="badge badge-xs badge-ghost">{feature.category}</span>
285
+ {/if}
286
+ </label>
287
+ {/each}
288
+ {#if filteredFeatures.length === 0}
289
+ <div class="text-center text-sm text-base-content/60 py-4">
290
+ No features match your search
291
+ </div>
292
+ {/if}
293
+ </div>
294
+ </div>
295
+
296
+ <!-- Custom Features -->
297
+ {#if allowCustomFeatures}
298
+ <div class="border border-base-300 rounded-lg p-3 mb-3 bg-base-100">
299
+ <div class="text-xs font-semibold mb-2">Custom Features</div>
300
+ <div class="flex gap-2 mb-2">
301
+ <label for="custom-feature-input" class="sr-only">Custom feature ID</label>
302
+ <input
303
+ id="custom-feature-input"
304
+ type="text"
305
+ placeholder="e.g., ext:custom-feature"
306
+ class="input input-bordered input-sm flex-1"
307
+ bind:value={customFeature}
308
+ aria-label="Enter custom feature ID"
309
+ onkeydown={(e) => e.key === 'Enter' && addCustomFeature()}
310
+ />
311
+ <button class="btn btn-sm btn-primary" onclick={addCustomFeature}> Add </button>
312
+ </div>
313
+
314
+ {#if Array.from(activeFeatures).some((f) => !allStandardFeatures.find((sf) => sf.id === f))}
315
+ <div class="space-y-1 mt-2">
316
+ {#each Array.from(activeFeatures).filter((f) => !allStandardFeatures.find((sf) => sf.id === f)) as feature}
317
+ <div
318
+ class="flex items-center gap-2 text-sm bg-warning/10 p-2 rounded border border-warning/20"
319
+ >
320
+ <span class="flex-1 font-mono text-xs">{feature}</span>
321
+ <button
322
+ class="btn btn-xs btn-circle btn-ghost"
323
+ onclick={() => toggleFeature(feature)}
324
+ aria-label="Remove custom feature {feature}"
325
+ >
326
+
327
+ </button>
328
+ </div>
329
+ {/each}
330
+ </div>
331
+ {:else}
332
+ <div class="text-xs text-base-content/60 mt-2">
333
+ Add non-standard QTI features for testing
334
+ </div>
335
+ {/if}
336
+ </div>
337
+ {/if}
338
+
339
+ <!-- Active Features Summary -->
340
+ {#if !compact && activeFeatures.size > 0}
341
+ <details class="mt-3">
342
+ <summary class="cursor-pointer text-xs font-semibold text-base-content/70">
343
+ Active Features ({activeFeatures.size})
344
+ </summary>
345
+ <div class="mt-2 p-2 bg-base-200 rounded text-xs">
346
+ <code class="whitespace-pre-wrap">
347
+ {JSON.stringify({ supports: Array.from(activeFeatures) }, null, 2)}
348
+ </code>
349
+ </div>
350
+ </details>
351
+ {/if}
352
+
353
+ <!-- Live region for status announcements -->
354
+ <div role="status" aria-live="polite" aria-atomic="true" class="sr-only">
355
+ {statusMessage}
356
+ </div>
357
+ </div>
358
+
359
+ <style>
360
+ .pnp-profile-tester {
361
+ font-family: system-ui, -apple-system, sans-serif;
362
+ }
363
+
364
+ .pnp-profile-tester.compact {
365
+ font-size: 0.875rem;
366
+ }
367
+
368
+ /* Custom scrollbar for feature list */
369
+ .pnp-profile-tester .max-h-80::-webkit-scrollbar {
370
+ width: 8px;
371
+ }
372
+
373
+ .pnp-profile-tester .max-h-80::-webkit-scrollbar-track {
374
+ background: transparent;
375
+ }
376
+
377
+ .pnp-profile-tester .max-h-80::-webkit-scrollbar-thumb {
378
+ background: hsl(var(--bc) / 0.2);
379
+ border-radius: 4px;
380
+ }
381
+
382
+ .pnp-profile-tester .max-h-80::-webkit-scrollbar-thumb:hover {
383
+ background: hsl(var(--bc) / 0.3);
384
+ }
385
+
386
+ /* Firefox scrollbar */
387
+ .pnp-profile-tester .max-h-80 {
388
+ scrollbar-width: thin;
389
+ scrollbar-color: hsl(var(--bc) / 0.2) transparent;
390
+ }
391
+
392
+ /* Improve form control spacing */
393
+ .pnp-profile-tester .form-control .label {
394
+ padding-top: 0.25rem;
395
+ padding-bottom: 0.25rem;
396
+ }
397
+ </style>