lutaml-jsonschema 0.1.9 → 0.1.11

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: d756394e76dba6d56e58308f5b5bf05dee2426f3ed1f06741db6d42e2b9b0722
4
- data.tar.gz: 1dda2ab2e8d89fc28c62df3a278d4bba0ef3e0bcb21a38176fc3816c4922927d
3
+ metadata.gz: c119b203d422df51dd4ef79c0e28ae59d1a3e8a01536c74baef3931f35481428
4
+ data.tar.gz: fd6e00bacd9f5d5ea9bab4afdff42adb786e1b0d7293f571c71e1e1dacb1d8f6
5
5
  SHA512:
6
- metadata.gz: 24cc7e89987cd5d49cf5bda35a6a43af05f6de286755d2f324128eca05d643972095eb56d9375406f714ebf11f2b5731563d02f2c8a20f6972fc056ba0727b94
7
- data.tar.gz: 9d65c1d9e86e2321d561f5853ac2d1617231b267f0ff0d32dbeab6e28f6b4067ecf1fc5fa8458df7db49fbe6dc4fc0e6cf8664abaf5f4a714b7a48cb3ca2442c
6
+ metadata.gz: 56edc028665efb3442c0ffe58eb7fe62576c02686cabf184fc2337749c6d460e9a7f817aef2e7737a8cc6b1a2dc1bd61ee0765ae5df558df1fde73e9ac0cc0a3
7
+ data.tar.gz: fed677f3dfb49ad12f056a494e73c2dceee12929f7f15c1753a6aac9d03387bba4ab1aa68c33bdb2ee152af46dd4a0a76898d3466575ad88cf0fdf944af766d1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.1.11] - 2026-05-09
4
+
5
+ ### Redoc-style UX improvements (round 5)
6
+
7
+ - Dark JSON preview panel with themed syntax colors in SchemaBuilder
8
+ - Select-on-click for source viewer and builder JSON blocks (double-click to select all)
9
+ - Print stylesheet: hides sidebar, header, controls; content prints full-width
10
+ - Format badges display with angle brackets (`<email>`) and accent color
11
+ - Responsive builder layout: stacks vertically on mobile (<768px)
12
+
13
+ ## [0.1.10] - 2026-05-09
14
+
15
+ ### Redoc-style UX improvements
16
+
17
+ - JSON viewer collapsed objects show key names (e.g. `{a, b, c}`), arrays show item count
18
+ - Sidebar auto-scrolls to active schema node on selection
19
+ - Search results sorted by relevance: exact name > starts with > contains > description
20
+ - Copy button shows positioned tooltip overlay instead of text swap
21
+ - Mobile sidebar backdrop overlay (click-to-close)
22
+ - DetailPanel overview shows definition-level metadata: required fields, composition badges, property range, additionalProperties
23
+ - Nullable badge for union types (e.g. `string,null`) in SchemaBuilder
24
+ - Focus trap in DetailPanel: focus moves to panel on open, Tab/Shift+Tab trapped, Escape closes
25
+
3
26
  ## [0.1.0] - 2026-05-05
4
27
 
5
28
  - 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>
@@ -98,6 +98,32 @@
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
 
@@ -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
 
@@ -379,6 +432,11 @@ const propertyItem = computed<SpaProperty | null>(() => {
379
432
  if (item.value?.kind !== 'property') return null
380
433
  return item.value.property
381
434
  })
435
+
436
+ const definitionItem = computed(() => {
437
+ if (item.value?.kind !== 'definition') return null
438
+ return item.value.definition
439
+ })
382
440
  </script>
383
441
 
384
442
  <style scoped>
@@ -16,7 +16,7 @@
16
16
  <span class="font-mono">{{ field.prop.name }}</span>
17
17
  </button>
18
18
  <span class="field-type-badge" :class="typeBadgeClass(field.prop)">{{ displayType(field.prop, field.resolvedDef?.title || field.resolvedDef?.name) }}</span>
19
- <span v-if="field.prop.format" class="field-format-badge">{{ field.prop.format }}</span>
19
+ <span v-if="field.prop.format" class="field-format-badge">&lt;{{ field.prop.format }}&gt;</span>
20
20
  <span v-if="field.prop.contentMediaType" class="field-format-badge">content-type: {{ field.prop.contentMediaType }}</span>
21
21
  <span v-if="field.prop.contentEncoding" class="field-format-badge">encoding: {{ field.prop.contentEncoding }}</span>
22
22
  <span v-if="field.prop.const != null" class="const-badge font-mono">const: {{ field.prop.const }}</span>
@@ -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">
@@ -205,16 +206,17 @@
205
206
  <div class="builder-preview">
206
207
  <div class="preview-inner">
207
208
  <div class="preview-toolbar">
208
- <span class="toolbar-label text-muted">JSON Preview</span>
209
+ <span class="toolbar-label">JSON Preview</span>
209
210
  <div class="toolbar-actions">
210
- <button class="btn btn-ghost btn-sm" @click="expandAllJson">Expand all</button>
211
- <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' }}
211
+ <button class="btn btn-sm btn-dark-panel" @click="expandAllJson">Expand all</button>
212
+ <button class="btn btn-sm btn-dark-panel" @click="collapseAllJson">Collapse all</button>
213
+ <button class="btn btn-sm btn-dark-panel 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>
217
- <pre ref="jsonBlockRef" class="json-block" @click="handleJsonClick" v-html="highlightedJson"></pre>
219
+ <pre ref="jsonBlockRef" class="json-block" @click="handleJsonClick" @dblclick="selectJsonBlock" v-html="highlightedJson"></pre>
218
220
  </div>
219
221
  </div>
220
222
  </div>
@@ -389,6 +391,18 @@ function handleJsonClick(event: MouseEvent) {
389
391
  }
390
392
  }
391
393
 
394
+ function selectJsonBlock() {
395
+ const el = jsonBlockRef.value
396
+ if (!el) return
397
+ const range = document.createRange()
398
+ range.selectNodeContents(el)
399
+ const selection = window.getSelection()
400
+ if (selection) {
401
+ selection.removeAllRanges()
402
+ selection.addRange(range)
403
+ }
404
+ }
405
+
392
406
  const MAX_PATTERN_LEN = 45
393
407
 
394
408
  function typeBadgeClass(prop: SpaProperty): string {
@@ -405,6 +419,10 @@ function typeBadgeClass(prop: SpaProperty): string {
405
419
  }
406
420
  }
407
421
 
422
+ function isNullableType(type?: string): boolean {
423
+ return (type || '').split(',').map(s => s.trim()).includes('null')
424
+ }
425
+
408
426
  function truncatedPattern(pattern: string): { text: string; truncated: boolean } {
409
427
  if (pattern.length <= MAX_PATTERN_LEN) return { text: pattern, truncated: false }
410
428
  if (expandedPatterns.value.has(pattern)) return { text: pattern, truncated: true }
@@ -545,11 +563,12 @@ async function copyJson() {
545
563
 
546
564
  .field-format-badge {
547
565
  font-size: 10px;
548
- color: var(--text-muted);
549
- background: var(--bg-secondary);
566
+ color: var(--color-accent);
567
+ background: var(--color-accent-alpha);
550
568
  padding: 1px 5px;
551
569
  border-radius: var(--radius-sm);
552
570
  font-family: var(--font-mono);
571
+ font-weight: 500;
553
572
  }
554
573
 
555
574
  .deprecated-badge {
@@ -583,6 +602,18 @@ async function copyJson() {
583
602
  background: var(--color-orange-alpha);
584
603
  }
585
604
 
605
+ .nullable-badge {
606
+ font-size: 10px;
607
+ font-weight: 600;
608
+ text-transform: uppercase;
609
+ color: var(--text-muted);
610
+ background: var(--bg-secondary);
611
+ border: 1px dashed var(--border-medium);
612
+ padding: 1px 5px;
613
+ border-radius: 2px;
614
+ flex-shrink: 0;
615
+ }
616
+
586
617
  .composition-badge {
587
618
  font-size: 10px;
588
619
  font-weight: 600;
@@ -982,6 +1013,7 @@ async function copyJson() {
982
1013
  display: flex;
983
1014
  align-items: center;
984
1015
  justify-content: space-between;
1016
+ color: var(--panel-dark-muted);
985
1017
  }
986
1018
 
987
1019
  .toolbar-label {
@@ -993,9 +1025,18 @@ async function copyJson() {
993
1025
  padding: var(--space-1) var(--space-2);
994
1026
  }
995
1027
 
1028
+ .btn-dark-panel {
1029
+ color: var(--panel-dark-muted);
1030
+ }
1031
+
1032
+ .btn-dark-panel:hover {
1033
+ color: var(--panel-dark-text);
1034
+ background: rgba(255, 255, 255, 0.08);
1035
+ }
1036
+
996
1037
  .json-block {
997
- background: var(--bg-secondary);
998
- border: 1px solid var(--border-light);
1038
+ background: var(--panel-dark-bg);
1039
+ border: 1px solid var(--panel-dark-bg);
999
1040
  border-radius: var(--radius-md);
1000
1041
  padding: var(--space-4);
1001
1042
  overflow-x: auto;
@@ -1005,7 +1046,7 @@ async function copyJson() {
1005
1046
  max-height: 70vh;
1006
1047
  overflow-y: auto;
1007
1048
  font-family: var(--font-mono);
1008
- color: var(--text-primary);
1049
+ color: var(--panel-dark-text);
1009
1050
  }
1010
1051
 
1011
1052
  .json-block :deep(ul) {
@@ -1020,7 +1061,7 @@ async function copyJson() {
1020
1061
 
1021
1062
  .json-block :deep(.jv-toggle) {
1022
1063
  background: none;
1023
- border: 1px solid var(--border-light);
1064
+ border: 1px solid var(--panel-dark-muted);
1024
1065
  border-radius: 2px;
1025
1066
  cursor: pointer;
1026
1067
  width: 14px;
@@ -1046,15 +1087,12 @@ async function copyJson() {
1046
1087
 
1047
1088
  .json-block :deep(.jv-ellipsis) {
1048
1089
  display: none;
1049
- color: var(--text-muted);
1090
+ color: var(--panel-dark-muted);
1050
1091
  font-size: var(--text-xs);
1092
+ font-style: italic;
1051
1093
  margin-left: 4px;
1052
1094
  }
1053
1095
 
1054
- .json-block :deep(.jv-ellipsis::after) {
1055
- content: ' … ';
1056
- }
1057
-
1058
1096
  .json-block :deep(.jv-children.jv-collapsed) {
1059
1097
  display: none;
1060
1098
  }
@@ -1064,48 +1102,72 @@ async function copyJson() {
1064
1102
  }
1065
1103
 
1066
1104
  .json-block :deep(.jv-key) {
1067
- color: var(--color-primary-dark);
1105
+ color: var(--panel-dark-key);
1068
1106
  }
1069
1107
 
1070
1108
  .json-block :deep(.jv-punct) {
1071
- color: var(--text-muted);
1109
+ color: var(--panel-dark-muted);
1072
1110
  }
1073
1111
 
1074
1112
  .json-block :deep(.jv-string) {
1075
- color: var(--color-green);
1113
+ color: var(--panel-dark-string);
1076
1114
  }
1077
1115
 
1078
1116
  .json-block :deep(.jv-number) {
1079
- color: var(--color-orange);
1117
+ color: var(--panel-dark-number);
1080
1118
  }
1081
1119
 
1082
1120
  .json-block :deep(.jv-boolean) {
1083
- color: var(--color-accent);
1121
+ color: var(--panel-dark-boolean);
1084
1122
  }
1085
1123
 
1086
1124
  .json-block :deep(.jv-null) {
1087
- color: var(--text-muted);
1125
+ color: var(--panel-dark-null);
1088
1126
  font-style: italic;
1089
1127
  }
1090
1128
 
1091
1129
  .json-block :deep(.jv-link) {
1092
- color: var(--color-primary);
1130
+ color: var(--panel-dark-string);
1093
1131
  text-decoration: underline;
1094
1132
  }
1095
1133
 
1096
1134
  .json-block :deep(.jv-row:hover) {
1097
- background: var(--bg-hover);
1135
+ background: rgba(255, 255, 255, 0.06);
1098
1136
  border-radius: 2px;
1099
1137
  }
1100
1138
 
1101
- :root[data-theme="dark"] .json-block :deep(.jv-key) { color: var(--color-primary-light); }
1102
- :root[data-theme="dark"] .json-block :deep(.jv-string) { color: var(--color-teal); }
1103
-
1104
1139
  .toolbar-actions {
1105
1140
  display: flex;
1106
1141
  gap: var(--space-1);
1107
1142
  }
1108
1143
 
1144
+ /* Copy button tooltip */
1145
+ .copy-btn-wrap {
1146
+ position: relative;
1147
+ }
1148
+
1149
+ .copy-tooltip {
1150
+ position: absolute;
1151
+ bottom: calc(100% + 4px);
1152
+ left: 50%;
1153
+ transform: translateX(-50%);
1154
+ background: var(--bg-elevated);
1155
+ color: var(--text-primary);
1156
+ font-size: var(--text-xs);
1157
+ padding: 3px 8px;
1158
+ border-radius: var(--radius-sm);
1159
+ border: 1px solid var(--border-light);
1160
+ box-shadow: var(--shadow-md);
1161
+ white-space: nowrap;
1162
+ pointer-events: none;
1163
+ animation: tooltipFade var(--transition-slow);
1164
+ }
1165
+
1166
+ @keyframes tooltipFade {
1167
+ from { opacity: 0; transform: translateX(-50%) translateY(2px); }
1168
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
1169
+ }
1170
+
1109
1171
  .empty-hint {
1110
1172
  padding: var(--space-8);
1111
1173
  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
  }
@@ -96,6 +96,15 @@
96
96
  --type-array-bg: rgba(139, 92, 246, 0.1);
97
97
  --type-null: #A8A29E;
98
98
  --type-null-bg: rgba(168, 162, 158, 0.1);
99
+
100
+ --panel-dark-bg: #263238;
101
+ --panel-dark-text: #e8edf4;
102
+ --panel-dark-muted: #8b9db5;
103
+ --panel-dark-key: #80cbc4;
104
+ --panel-dark-string: #a5d6a7;
105
+ --panel-dark-number: #ffcc80;
106
+ --panel-dark-boolean: #90caf9;
107
+ --panel-dark-null: #8b9db5;
99
108
  }
100
109
 
101
110
  :root[data-theme="dark"] {
@@ -143,6 +152,15 @@
143
152
  --type-array-bg: rgba(167, 139, 250, 0.15);
144
153
  --type-null: #6b7a8f;
145
154
  --type-null-bg: rgba(107, 122, 143, 0.15);
155
+
156
+ --panel-dark-bg: #1e2a35;
157
+ --panel-dark-text: #dce4f0;
158
+ --panel-dark-muted: #7b8fa8;
159
+ --panel-dark-key: #4db6ac;
160
+ --panel-dark-string: #81c784;
161
+ --panel-dark-number: #ffb74d;
162
+ --panel-dark-boolean: #64b5f6;
163
+ --panel-dark-null: #7b8fa8;
146
164
  }
147
165
 
148
166
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -233,6 +251,19 @@ button { font-family: inherit; font-size: inherit; cursor: pointer; border: none
233
251
  }
234
252
  }
235
253
 
254
+ @media print {
255
+ .sidebar, .sidebar-overlay, .app-header,
256
+ .view-toggle, .schema-actions,
257
+ .ctrl-expand, .field-check,
258
+ .source-toolbar, .copy-btn-wrap,
259
+ .section-actions { display: none !important; }
260
+ .app-layout { grid-template-columns: 1fr !important; }
261
+ .app-main { padding: 0 !important; max-width: 100% !important; }
262
+ .card, .def-card { break-inside: avoid; box-shadow: none; border: 1px solid #ccc; }
263
+ .field-row { break-inside: avoid; }
264
+ body { color: #000; background: #fff; }
265
+ }
266
+
236
267
  .text-muted { color: var(--text-muted); }
237
268
  .text-secondary { color: var(--text-secondary); }
238
269
  .font-mono { font-family: var(--font-mono); }
@@ -136,13 +136,14 @@
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">
144
145
  <div class="source-lines" aria-hidden="true"><span v-for="n in sourceLineCount" :key="n">{{ n }}</span></div>
145
- <pre class="source-pre"><code v-html="highlightedSource"></code></pre>
146
+ <pre class="source-pre" @dblclick="selectSourceBlock"><code v-html="highlightedSource"></code></pre>
146
147
  </div>
147
148
  </div>
148
149
  <div v-else class="source-empty">
@@ -284,6 +285,18 @@ function syntaxHighlight(json: string): string {
284
285
  })
285
286
  }
286
287
 
288
+ function selectSourceBlock() {
289
+ const el = document.querySelector('.source-pre') as HTMLElement | null
290
+ if (!el) return
291
+ const range = document.createRange()
292
+ range.selectNodeContents(el)
293
+ const selection = window.getSelection()
294
+ if (selection) {
295
+ selection.removeAllRanges()
296
+ selection.addRange(range)
297
+ }
298
+ }
299
+
287
300
  function selectSchema(name: string) {
288
301
  schemaStore.selectSchema(name)
289
302
  }
@@ -592,6 +605,33 @@ watch(() => schemaStore.selectedItemKey, (key) => {
592
605
  font-size: var(--text-xs);
593
606
  }
594
607
 
608
+ /* Copy button tooltip */
609
+ .copy-btn-wrap {
610
+ position: relative;
611
+ }
612
+
613
+ .copy-tooltip {
614
+ position: absolute;
615
+ bottom: calc(100% + 4px);
616
+ left: 50%;
617
+ transform: translateX(-50%);
618
+ background: var(--bg-elevated);
619
+ color: var(--text-primary);
620
+ font-size: var(--text-xs);
621
+ padding: 3px 8px;
622
+ border-radius: var(--radius-sm);
623
+ border: 1px solid var(--border-light);
624
+ box-shadow: var(--shadow-md);
625
+ white-space: nowrap;
626
+ pointer-events: none;
627
+ animation: tooltipFade var(--transition-slow);
628
+ }
629
+
630
+ @keyframes tooltipFade {
631
+ from { opacity: 0; transform: translateX(-50%) translateY(2px); }
632
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
633
+ }
634
+
595
635
  .source-code-wrapper {
596
636
  display: flex;
597
637
  max-height: 70vh;
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Jsonschema
5
- VERSION = "0.1.9"
5
+ VERSION = "0.1.11"
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.9
4
+ version: 0.1.11
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