lutaml-jsonschema 0.1.8 → 0.1.10

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eea2d4379420899294e19ce129230527927e0af75f6d7f3aad847edc14a1072b
4
- data.tar.gz: 71831392b43dc29bed8c8da35da7b021eaf2ed2c377e90b0b949e23e8747df43
3
+ metadata.gz: c2817082f5751466a6e7bae6f77310304d3b58127a392c1e60341d561b65706a
4
+ data.tar.gz: 2c6f4b882f26600890a7a21a241e3a3709fa155df21097ca3c484513cef5e4ba
5
5
  SHA512:
6
- metadata.gz: 52c2b48f8a08223efc1926a18d5260e983d40359c2b5935a2a6962fc0260ff87ddfb7d88d2b4598072d66950ce999b89017afbf2ef9dbbac523b48722cffa336
7
- data.tar.gz: b9f3b9ae250242f212d5fd59d99497f75143cbca89b079c9a64f476e582cd66ab4cdbd71a9cd8a905f2642b5625071464e86c8de019703119bd2e639f06f7c39
6
+ metadata.gz: 4f1ca6699188d725ac053c230043316a96dbdb1fda2cf1c880a48e848ab31c6d4270e19f37f392c10d3205eafb27f529f5443f00fa39d20ec5f7f06dc32daa64
7
+ data.tar.gz: 905835a47ca2348e8bd2cd5b5f2bc526a115782fbd3264c61b212e7c61192e87d6dbd1e9193f0d9598c9f4bf28916f410cf8fcee0919f1b747cf8f6119ac0516
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.1.10] - 2026-05-09
4
+
5
+ ### Redoc-style UX improvements
6
+
7
+ - JSON viewer collapsed objects show key names (e.g. `{a, b, c}`), arrays show item count
8
+ - Sidebar auto-scrolls to active schema node on selection
9
+ - Search results sorted by relevance: exact name > starts with > contains > description
10
+ - Copy button shows positioned tooltip overlay instead of text swap
11
+ - Mobile sidebar backdrop overlay (click-to-close)
12
+ - DetailPanel overview shows definition-level metadata: required fields, composition badges, property range, additionalProperties
13
+ - Nullable badge for union types (e.g. `string,null`) in SchemaBuilder
14
+ - Focus trap in DetailPanel: focus moves to panel on open, Tab/Shift+Tab trapped, Escape closes
15
+
3
16
  ## [0.1.0] - 2026-05-05
4
17
 
5
18
  - Initial release
data/frontend/src/App.vue CHANGED
@@ -1,6 +1,7 @@
1
1
  <template>
2
2
  <div class="app-layout">
3
3
  <AppSidebar />
4
+ <div v-if="!uiStore.sidebarCollapsed" class="sidebar-overlay" @click="uiStore.toggleSidebar"></div>
4
5
  <div class="main-content">
5
6
  <AppHeader />
6
7
  <div class="content-area">
@@ -100,4 +101,19 @@ function isInputFocused(): boolean {
100
101
  padding: var(--space-6);
101
102
  background: var(--bg-primary);
102
103
  }
104
+
105
+ /* Mobile sidebar overlay — hidden on desktop, shown on mobile when sidebar open */
106
+ .sidebar-overlay {
107
+ display: none;
108
+ }
109
+
110
+ @media (max-width: 768px) {
111
+ .sidebar-overlay {
112
+ display: block;
113
+ position: fixed;
114
+ inset: 0;
115
+ background: rgba(0, 0, 0, 0.4);
116
+ z-index: 39;
117
+ }
118
+ }
103
119
  </style>
@@ -69,7 +69,7 @@
69
69
  <div v-else-if="debouncedQuery && !searchResults.length && searchQuery" class="search-no-results text-muted">
70
70
  No results found
71
71
  </div>
72
- <div v-else class="schema-tree">
72
+ <div v-else class="schema-tree" ref="sidebarTreeRef">
73
73
  <div
74
74
  v-for="schema in schemaStore.schemas"
75
75
  :key="schema.name"
@@ -150,13 +150,14 @@
150
150
  </template>
151
151
 
152
152
  <script setup lang="ts">
153
- import { ref, computed, watch } from 'vue'
153
+ import { ref, computed, watch, nextTick } from 'vue'
154
154
  import { useSchemaStore } from '../stores/schemaStore'
155
155
  import { useUiStore } from '../stores/uiStore'
156
156
  import type { SpaSearchEntry } from '../types'
157
157
 
158
158
  const schemaStore = useSchemaStore()
159
159
  const uiStore = useUiStore()
160
+ const sidebarTreeRef = ref<HTMLElement | null>(null)
160
161
 
161
162
  const searchQuery = ref('')
162
163
  const debouncedQuery = ref('')
@@ -177,16 +178,41 @@ watch(searchQuery, (q) => {
177
178
  const searchResults = computed(() => {
178
179
  const q = debouncedQuery.value.trim().toLowerCase()
179
180
  if (!q) return []
180
- return schemaStore.searchIndex.filter(entry =>
181
- entry.name.toLowerCase().includes(q) ||
182
- (entry.title && entry.title.toLowerCase().includes(q)) ||
183
- entry.schemaName.toLowerCase().includes(q) ||
184
- (entry.description && entry.description.toLowerCase().includes(q)),
185
- ).slice(0, 15)
181
+ const scored = schemaStore.searchIndex
182
+ .map(entry => {
183
+ let score = 0
184
+ const nameLower = entry.name.toLowerCase()
185
+ const titleLower = (entry.title || '').toLowerCase()
186
+ const descLower = (entry.description || '').toLowerCase()
187
+ const schemaLower = entry.schemaName.toLowerCase()
188
+ if (nameLower === q) score += 100
189
+ else if (nameLower.startsWith(q)) score += 50
190
+ else if (nameLower.includes(q)) score += 30
191
+ if (titleLower === q) score += 80
192
+ else if (titleLower.startsWith(q)) score += 40
193
+ else if (titleLower.includes(q)) score += 20
194
+ if (descLower.includes(q)) score += 10
195
+ if (schemaLower.includes(q)) score += 5
196
+ return { entry, score }
197
+ })
198
+ .filter(r => r.score > 0)
199
+ .sort((a, b) => b.score - a.score)
200
+ .slice(0, 15)
201
+ .map(r => r.entry)
202
+ return scored
186
203
  })
187
204
 
188
205
  watch(searchResults, () => { activeResultIdx.value = -1 })
189
206
 
207
+ watch(() => schemaStore.selectedSchemaName, () => {
208
+ nextTick(() => {
209
+ const container = sidebarTreeRef.value
210
+ if (!container) return
211
+ const active = container.querySelector('.schema-node-header.active') as HTMLElement | null
212
+ if (active) active.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
213
+ })
214
+ })
215
+
190
216
  function handleSearchKey(event: KeyboardEvent) {
191
217
  if (event.key === 'ArrowDown') {
192
218
  activeResultIdx.value = Math.min(activeResultIdx.value + 1, searchResults.value.length - 1)
@@ -1,11 +1,11 @@
1
1
  <template>
2
2
  <div class="detail-panel-overlay" @click.self="uiStore.closeDetailPanel">
3
- <aside class="detail-panel">
3
+ <aside class="detail-panel" ref="panelRef">
4
4
  <div class="panel-header">
5
5
  <div class="panel-title">
6
6
  <h2 v-if="item">{{ itemTitle }}</h2>
7
7
  </div>
8
- <button class="btn btn-ghost" @click="uiStore.closeDetailPanel">
8
+ <button ref="closeBtnRef" class="btn btn-ghost" @click="uiStore.closeDetailPanel" aria-label="Close panel">
9
9
  <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
10
10
  <path d="M5 5l8 8M13 5l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
11
11
  </svg>
@@ -34,35 +34,35 @@
34
34
  <span class="meta-label">Type</span>
35
35
  <span class="badge badge-type">{{ itemType }}</span>
36
36
  </div>
37
- <div v-if="item.kind === 'property' && (item as any).property.format" class="meta-row">
37
+ <div v-if="propertyItem?.format" class="meta-row">
38
38
  <span class="meta-label">Format</span>
39
- <span class="badge badge-format">{{ (item as any).property.format }}</span>
39
+ <span class="badge badge-format">{{ propertyItem.format }}</span>
40
40
  </div>
41
41
  <div v-if="itemDescription" class="meta-row">
42
42
  <span class="meta-label">Description</span>
43
43
  <span class="text-secondary">{{ itemDescription }}</span>
44
44
  </div>
45
- <div v-if="item.kind === 'property'" class="meta-row">
45
+ <div v-if="propertyItem" class="meta-row">
46
46
  <span class="meta-label">Required</span>
47
- <span :class="['badge', (item as any).property.required ? 'badge-required' : 'badge-optional']">
48
- {{ (item as any).property.required ? 'Yes' : 'No' }}
47
+ <span :class="['badge', propertyItem.required ? 'badge-required' : 'badge-optional']">
48
+ {{ propertyItem.required ? 'Yes' : 'No' }}
49
49
  </span>
50
50
  </div>
51
- <div v-if="item.kind === 'property' && (item as any).property.deprecated" class="meta-row">
51
+ <div v-if="propertyItem?.deprecated" class="meta-row">
52
52
  <span class="meta-label">Status</span>
53
53
  <span class="badge badge-deprecated">Deprecated</span>
54
54
  </div>
55
- <div v-if="item.kind === 'property' && (item as any).property.ref" class="meta-row">
55
+ <div v-if="propertyItem?.ref" class="meta-row">
56
56
  <span class="meta-label">Reference</span>
57
- <span class="font-mono text-secondary">{{ (item as any).property.ref }}</span>
57
+ <span class="font-mono text-secondary">{{ propertyItem.ref }}</span>
58
58
  </div>
59
- <div v-if="item.kind === 'property' && (item as any).property.compositionSource" class="meta-row">
59
+ <div v-if="propertyItem?.compositionSource" class="meta-row">
60
60
  <span class="meta-label">Source</span>
61
- <span class="badge badge-composition-detail">{{ (item as any).property.compositionSource }}</span>
61
+ <span class="badge badge-composition-detail">{{ propertyItem.compositionSource }}</span>
62
62
  </div>
63
- <div v-if="item.kind === 'property' && (item as any).property.default" class="meta-row">
63
+ <div v-if="propertyItem?.default" class="meta-row">
64
64
  <span class="meta-label">Default</span>
65
- <span class="font-mono">{{ (item as any).property.default }}</span>
65
+ <span class="font-mono">{{ propertyItem.default }}</span>
66
66
  </div>
67
67
  <div v-if="item.kind === 'schema' && schema.required.length" class="meta-row">
68
68
  <span class="meta-label">Required</span>
@@ -98,79 +98,105 @@
98
98
  <span class="meta-label">Additional</span>
99
99
  <span class="badge badge-locked-detail">Denied</span>
100
100
  </div>
101
+ <div v-if="definitionItem && definitionItem.required?.length" class="meta-row">
102
+ <span class="meta-label">Required</span>
103
+ <div class="meta-tags">
104
+ <span v-for="r in definitionItem.required" :key="r" class="badge badge-required-sm">{{ r }}</span>
105
+ </div>
106
+ </div>
107
+ <div v-if="definitionItem && (definitionItem.hasAllOf || definitionItem.hasAnyOf || definitionItem.hasOneOf)" class="meta-row">
108
+ <span class="meta-label">Composition</span>
109
+ <div class="meta-tags">
110
+ <span v-if="definitionItem.hasAllOf" class="badge badge-composition-detail">allOf</span>
111
+ <span v-if="definitionItem.hasAnyOf" class="badge badge-composition-detail">anyOf</span>
112
+ <span v-if="definitionItem.hasOneOf" class="badge badge-composition-detail">oneOf</span>
113
+ </div>
114
+ </div>
115
+ <div v-if="definitionItem && (definitionItem.minProperties != null || definitionItem.maxProperties != null)" class="meta-row">
116
+ <span class="meta-label">Properties</span>
117
+ <span class="text-secondary">
118
+ <template v-if="definitionItem.minProperties != null && definitionItem.maxProperties != null">{{ definitionItem.minProperties }}..{{ definitionItem.maxProperties }}</template>
119
+ <template v-else-if="definitionItem.minProperties != null">&ge; {{ definitionItem.minProperties }}</template>
120
+ <template v-else>&le; {{ definitionItem.maxProperties }}</template>
121
+ </span>
122
+ </div>
123
+ <div v-if="definitionItem && definitionItem.additionalProperties === false" class="meta-row">
124
+ <span class="meta-label">Additional</span>
125
+ <span class="badge badge-locked-detail">Denied</span>
126
+ </div>
101
127
  </div>
102
128
  </div>
103
129
 
104
130
  <!-- Constraints for property -->
105
- <div v-if="item.kind === 'property' && hasConstraints" class="detail-section">
131
+ <div v-if="propertyItem && hasConstraints" class="detail-section">
106
132
  <h3 class="detail-heading">Constraints</h3>
107
133
  <table class="table constraint-table">
108
134
  <tbody>
109
- <tr v-if="(item as any).property.minimum != null">
135
+ <tr v-if="propertyItem.minimum != null">
110
136
  <td class="constraint-key">Minimum</td>
111
- <td>{{ (item as any).property.minimum }}</td>
137
+ <td>{{ propertyItem.minimum }}</td>
112
138
  </tr>
113
- <tr v-if="(item as any).property.maximum != null">
139
+ <tr v-if="propertyItem.maximum != null">
114
140
  <td class="constraint-key">Maximum</td>
115
- <td>{{ (item as any).property.maximum }}</td>
141
+ <td>{{ propertyItem.maximum }}</td>
116
142
  </tr>
117
- <tr v-if="(item as any).property.minLength != null">
143
+ <tr v-if="propertyItem.minLength != null">
118
144
  <td class="constraint-key">Min Length</td>
119
- <td>{{ (item as any).property.minLength }}</td>
145
+ <td>{{ propertyItem.minLength }}</td>
120
146
  </tr>
121
- <tr v-if="(item as any).property.maxLength != null">
147
+ <tr v-if="propertyItem.maxLength != null">
122
148
  <td class="constraint-key">Max Length</td>
123
- <td>{{ (item as any).property.maxLength }}</td>
149
+ <td>{{ propertyItem.maxLength }}</td>
124
150
  </tr>
125
- <tr v-if="(item as any).property.pattern">
151
+ <tr v-if="propertyItem.pattern">
126
152
  <td class="constraint-key">Pattern</td>
127
- <td class="font-mono">{{ (item as any).property.pattern }}</td>
153
+ <td class="font-mono">{{ propertyItem.pattern }}</td>
128
154
  </tr>
129
- <tr v-if="(item as any).property.enum?.length">
155
+ <tr v-if="propertyItem.enum?.length">
130
156
  <td class="constraint-key">Enum</td>
131
- <td>{{ (item as any).property.enum.join(', ') }}</td>
157
+ <td>{{ propertyItem.enum.join(', ') }}</td>
132
158
  </tr>
133
- <tr v-if="(item as any).property.itemsType">
159
+ <tr v-if="propertyItem.itemsType">
134
160
  <td class="constraint-key">Items</td>
135
- <td>{{ (item as any).property.itemsType }}</td>
161
+ <td>{{ propertyItem.itemsType }}</td>
136
162
  </tr>
137
- <tr v-if="(item as any).property.exclusiveMinimum != null">
163
+ <tr v-if="propertyItem.exclusiveMinimum != null">
138
164
  <td class="constraint-key">Exclusive Min</td>
139
- <td>{{ (item as any).property.exclusiveMinimum }}</td>
165
+ <td>{{ propertyItem.exclusiveMinimum }}</td>
140
166
  </tr>
141
- <tr v-if="(item as any).property.exclusiveMaximum != null">
167
+ <tr v-if="propertyItem.exclusiveMaximum != null">
142
168
  <td class="constraint-key">Exclusive Max</td>
143
- <td>{{ (item as any).property.exclusiveMaximum }}</td>
169
+ <td>{{ propertyItem.exclusiveMaximum }}</td>
144
170
  </tr>
145
- <tr v-if="(item as any).property.minItems != null">
171
+ <tr v-if="propertyItem.minItems != null">
146
172
  <td class="constraint-key">Min Items</td>
147
- <td>{{ (item as any).property.minItems }}</td>
173
+ <td>{{ propertyItem.minItems }}</td>
148
174
  </tr>
149
- <tr v-if="(item as any).property.maxItems != null">
175
+ <tr v-if="propertyItem.maxItems != null">
150
176
  <td class="constraint-key">Max Items</td>
151
- <td>{{ (item as any).property.maxItems }}</td>
177
+ <td>{{ propertyItem.maxItems }}</td>
152
178
  </tr>
153
- <tr v-if="(item as any).property.uniqueItems">
179
+ <tr v-if="propertyItem.uniqueItems">
154
180
  <td class="constraint-key">Unique Items</td>
155
181
  <td>Yes</td>
156
182
  </tr>
157
- <tr v-if="(item as any).property.multipleOf != null">
183
+ <tr v-if="propertyItem.multipleOf != null">
158
184
  <td class="constraint-key">Multiple Of</td>
159
- <td>{{ (item as any).property.multipleOf }}</td>
185
+ <td>{{ propertyItem.multipleOf }}</td>
160
186
  </tr>
161
- <tr v-if="(item as any).property.const != null">
187
+ <tr v-if="propertyItem.const != null">
162
188
  <td class="constraint-key">Const</td>
163
- <td class="font-mono">{{ (item as any).property.const }}</td>
189
+ <td class="font-mono">{{ propertyItem.const }}</td>
164
190
  </tr>
165
- <tr v-if="(item as any).property.contentMediaType">
191
+ <tr v-if="propertyItem.contentMediaType">
166
192
  <td class="constraint-key">Content Type</td>
167
- <td>{{ (item as any).property.contentMediaType }}</td>
193
+ <td>{{ propertyItem.contentMediaType }}</td>
168
194
  </tr>
169
- <tr v-if="(item as any).property.contentEncoding">
195
+ <tr v-if="propertyItem.contentEncoding">
170
196
  <td class="constraint-key">Content Encoding</td>
171
- <td>{{ (item as any).property.contentEncoding }}</td>
197
+ <td>{{ propertyItem.contentEncoding }}</td>
172
198
  </tr>
173
- <tr v-if="(item as any).property.additionalProperties === false">
199
+ <tr v-if="propertyItem.additionalProperties === false">
174
200
  <td class="constraint-key">Additional Props</td>
175
201
  <td>Denied</td>
176
202
  </tr>
@@ -241,7 +267,7 @@
241
267
  </template>
242
268
 
243
269
  <script setup lang="ts">
244
- import { computed } from 'vue'
270
+ import { computed, ref, onMounted, onUnmounted } from 'vue'
245
271
  import { useSchemaStore, type SelectedItem } from '../stores/schemaStore'
246
272
  import { useUiStore } from '../stores/uiStore'
247
273
  import type { SpaProperty } from '../types'
@@ -249,6 +275,33 @@ import type { SpaProperty } from '../types'
249
275
  const schemaStore = useSchemaStore()
250
276
  const uiStore = useUiStore()
251
277
 
278
+ const panelRef = ref<HTMLElement | null>(null)
279
+ const closeBtnRef = ref<HTMLButtonElement | null>(null)
280
+
281
+ onMounted(() => {
282
+ closeBtnRef.value?.focus()
283
+ document.addEventListener('keydown', trapFocus)
284
+ })
285
+
286
+ onUnmounted(() => {
287
+ document.removeEventListener('keydown', trapFocus)
288
+ })
289
+
290
+ function trapFocus(e: KeyboardEvent) {
291
+ if (e.key !== 'Tab' || !panelRef.value) return
292
+ const focusable = panelRef.value.querySelectorAll<HTMLElement>(
293
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
294
+ )
295
+ if (!focusable.length) return
296
+ const first = focusable[0]
297
+ const last = focusable[focusable.length - 1]
298
+ if (e.shiftKey) {
299
+ if (document.activeElement === first) { e.preventDefault(); last.focus() }
300
+ } else {
301
+ if (document.activeElement === last) { e.preventDefault(); first.focus() }
302
+ }
303
+ }
304
+
252
305
  const item = computed<SelectedItem | null>(() => schemaStore.selectedItem)
253
306
  const schema = computed(() => schemaStore.selectedSchema)
254
307
 
@@ -296,8 +349,8 @@ function formatExample(value: unknown): string {
296
349
  }
297
350
 
298
351
  const hasConstraints = computed(() => {
299
- if (item.value?.kind !== 'property') return false
300
- const p = item.value.property
352
+ const p = propertyItem.value
353
+ if (!p) return false
301
354
  return p.minimum != null || p.maximum != null ||
302
355
  p.minLength != null || p.maxLength != null ||
303
356
  p.pattern || p.enum?.length || p.itemsType ||
@@ -374,6 +427,16 @@ const currentTabs = computed<{ id: TabId; label: string }[]>(() => {
374
427
  }
375
428
  return tabs
376
429
  })
430
+
431
+ const propertyItem = computed<SpaProperty | null>(() => {
432
+ if (item.value?.kind !== 'property') return null
433
+ return item.value.property
434
+ })
435
+
436
+ const definitionItem = computed(() => {
437
+ if (item.value?.kind !== 'definition') return null
438
+ return item.value.definition
439
+ })
377
440
  </script>
378
441
 
379
442
  <style scoped>
@@ -25,6 +25,7 @@
25
25
  <span v-if="field.prop.deprecated" class="deprecated-badge">deprecated</span>
26
26
  <span v-if="field.prop.readOnly" class="readonly-badge">read-only</span>
27
27
  <span v-if="field.prop.writeOnly" class="writeonly-badge">write-only</span>
28
+ <span v-if="isNullableType(field.prop.type)" class="nullable-badge">nullable</span>
28
29
  <span v-if="field.prop.compositionSource" class="composition-badge">{{ field.prop.compositionSource }}</span>
29
30
 
30
31
  <div class="field-control">
@@ -209,8 +210,9 @@
209
210
  <div class="toolbar-actions">
210
211
  <button class="btn btn-ghost btn-sm" @click="expandAllJson">Expand all</button>
211
212
  <button class="btn btn-ghost btn-sm" @click="collapseAllJson">Collapse all</button>
212
- <button class="btn btn-ghost btn-sm" @click="copyJson">
213
- {{ copied ? 'Copied!' : 'Copy' }}
213
+ <button class="btn btn-ghost btn-sm copy-btn-wrap" @click="copyJson">
214
+ Copy
215
+ <span v-if="copied" class="copy-tooltip">Copied!</span>
214
216
  </button>
215
217
  </div>
216
218
  </div>
@@ -405,6 +407,10 @@ function typeBadgeClass(prop: SpaProperty): string {
405
407
  }
406
408
  }
407
409
 
410
+ function isNullableType(type?: string): boolean {
411
+ return (type || '').split(',').map(s => s.trim()).includes('null')
412
+ }
413
+
408
414
  function truncatedPattern(pattern: string): { text: string; truncated: boolean } {
409
415
  if (pattern.length <= MAX_PATTERN_LEN) return { text: pattern, truncated: false }
410
416
  if (expandedPatterns.value.has(pattern)) return { text: pattern, truncated: true }
@@ -583,6 +589,18 @@ async function copyJson() {
583
589
  background: var(--color-orange-alpha);
584
590
  }
585
591
 
592
+ .nullable-badge {
593
+ font-size: 10px;
594
+ font-weight: 600;
595
+ text-transform: uppercase;
596
+ color: var(--text-muted);
597
+ background: var(--bg-secondary);
598
+ border: 1px dashed var(--border-medium);
599
+ padding: 1px 5px;
600
+ border-radius: 2px;
601
+ flex-shrink: 0;
602
+ }
603
+
586
604
  .composition-badge {
587
605
  font-size: 10px;
588
606
  font-weight: 600;
@@ -1048,13 +1066,10 @@ async function copyJson() {
1048
1066
  display: none;
1049
1067
  color: var(--text-muted);
1050
1068
  font-size: var(--text-xs);
1069
+ font-style: italic;
1051
1070
  margin-left: 4px;
1052
1071
  }
1053
1072
 
1054
- .json-block :deep(.jv-ellipsis::after) {
1055
- content: ' … ';
1056
- }
1057
-
1058
1073
  .json-block :deep(.jv-children.jv-collapsed) {
1059
1074
  display: none;
1060
1075
  }
@@ -1106,6 +1121,33 @@ async function copyJson() {
1106
1121
  gap: var(--space-1);
1107
1122
  }
1108
1123
 
1124
+ /* Copy button tooltip */
1125
+ .copy-btn-wrap {
1126
+ position: relative;
1127
+ }
1128
+
1129
+ .copy-tooltip {
1130
+ position: absolute;
1131
+ bottom: calc(100% + 4px);
1132
+ left: 50%;
1133
+ transform: translateX(-50%);
1134
+ background: var(--bg-elevated);
1135
+ color: var(--text-primary);
1136
+ font-size: var(--text-xs);
1137
+ padding: 3px 8px;
1138
+ border-radius: var(--radius-sm);
1139
+ border: 1px solid var(--border-light);
1140
+ box-shadow: var(--shadow-md);
1141
+ white-space: nowrap;
1142
+ pointer-events: none;
1143
+ animation: tooltipFade var(--transition-slow);
1144
+ }
1145
+
1146
+ @keyframes tooltipFade {
1147
+ from { opacity: 0; transform: translateX(-50%) translateY(2px); }
1148
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
1149
+ }
1150
+
1109
1151
  .empty-hint {
1110
1152
  padding: var(--space-8);
1111
1153
  text-align: center;
@@ -56,7 +56,7 @@ function renderArray(arr: unknown[], maxExpand: number, level: number): string {
56
56
  html += '</div></li>'
57
57
  })
58
58
  html += '</ul>'
59
- html += `<span class="jv-ellipsis"></span>`
59
+ html += `<span class="jv-ellipsis">${arr.length} items</span>`
60
60
  html += punct(']')
61
61
  return html
62
62
  }
@@ -76,7 +76,8 @@ function renderObject(obj: Record<string, unknown>, maxExpand: number, level: nu
76
76
  html += '</div></li>'
77
77
  })
78
78
  html += '</ul>'
79
- html += `<span class="jv-ellipsis"></span>`
79
+ const previewKeys = keys.length <= 5 ? keys : [...keys.slice(0, 5), `+${keys.length - 5}`]
80
+ html += `<span class="jv-ellipsis">${previewKeys.map(k => escHtml(k)).join(', ')}</span>`
80
81
  html += punct('}')
81
82
  return html
82
83
  }
@@ -136,8 +136,9 @@
136
136
  <div v-if="schemaStore.selectedSchema.sourceJson" class="source-viewer">
137
137
  <div class="source-toolbar">
138
138
  <span class="text-muted">{{ sourceLineCount }} lines</span>
139
- <button class="btn btn-ghost btn-sm" @click="copySource">
140
- {{ sourceCopied ? 'Copied!' : 'Copy' }}
139
+ <button class="btn btn-ghost btn-sm copy-btn-wrap" @click="copySource">
140
+ Copy
141
+ <span v-if="sourceCopied" class="copy-tooltip">Copied!</span>
141
142
  </button>
142
143
  </div>
143
144
  <div class="source-code-wrapper">
@@ -592,6 +593,33 @@ watch(() => schemaStore.selectedItemKey, (key) => {
592
593
  font-size: var(--text-xs);
593
594
  }
594
595
 
596
+ /* Copy button tooltip */
597
+ .copy-btn-wrap {
598
+ position: relative;
599
+ }
600
+
601
+ .copy-tooltip {
602
+ position: absolute;
603
+ bottom: calc(100% + 4px);
604
+ left: 50%;
605
+ transform: translateX(-50%);
606
+ background: var(--bg-elevated);
607
+ color: var(--text-primary);
608
+ font-size: var(--text-xs);
609
+ padding: 3px 8px;
610
+ border-radius: var(--radius-sm);
611
+ border: 1px solid var(--border-light);
612
+ box-shadow: var(--shadow-md);
613
+ white-space: nowrap;
614
+ pointer-events: none;
615
+ animation: tooltipFade var(--transition-slow);
616
+ }
617
+
618
+ @keyframes tooltipFade {
619
+ from { opacity: 0; transform: translateX(-50%) translateY(2px); }
620
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
621
+ }
622
+
595
623
  .source-code-wrapper {
596
624
  display: flex;
597
625
  max-height: 70vh;
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Jsonschema
5
- VERSION = "0.1.8"
5
+ VERSION = "0.1.10"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-jsonschema
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.8
4
+ version: 0.1.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-08 00:00:00.000000000 Z
11
+ date: 2026-05-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json