lutaml-jsonschema 0.1.0

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 (59) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/README.md +39 -0
  5. data/Rakefile +26 -0
  6. data/exe/lutaml-jsonschema +6 -0
  7. data/frontend/index.html +60 -0
  8. data/frontend/package-lock.json +2715 -0
  9. data/frontend/package.json +27 -0
  10. data/frontend/public/lutaml-logo-dark.svg +1 -0
  11. data/frontend/public/lutaml-logo-full-dark.svg +1 -0
  12. data/frontend/public/lutaml-logo-full-light.svg +1 -0
  13. data/frontend/public/lutaml-logo-light.svg +1 -0
  14. data/frontend/src/App.vue +80 -0
  15. data/frontend/src/__tests__/useBuilderField.test.ts +137 -0
  16. data/frontend/src/__tests__/useDefinitionResolver.test.ts +46 -0
  17. data/frontend/src/__tests__/useSchemaTypes.test.ts +219 -0
  18. data/frontend/src/app.ts +10 -0
  19. data/frontend/src/components/AppHeader.vue +152 -0
  20. data/frontend/src/components/AppSidebar.vue +427 -0
  21. data/frontend/src/components/DetailPanel.vue +403 -0
  22. data/frontend/src/components/SchemaBuilder.vue +543 -0
  23. data/frontend/src/components/SchemaStructure.vue +168 -0
  24. data/frontend/src/components/SearchModal.vue +275 -0
  25. data/frontend/src/composables/useBuilderField.ts +92 -0
  26. data/frontend/src/composables/useDefinitionResolver.ts +17 -0
  27. data/frontend/src/composables/useSchemaTypes.ts +152 -0
  28. data/frontend/src/composables/useSearch.ts +104 -0
  29. data/frontend/src/router.ts +14 -0
  30. data/frontend/src/stores/schemaStore.ts +118 -0
  31. data/frontend/src/stores/uiStore.ts +78 -0
  32. data/frontend/src/style.css +194 -0
  33. data/frontend/src/types.ts +70 -0
  34. data/frontend/src/views/HomeView.vue +396 -0
  35. data/frontend/tsconfig.json +20 -0
  36. data/frontend/vite.config.ts +28 -0
  37. data/lib/lutaml/jsonschema/base.rb +11 -0
  38. data/lib/lutaml/jsonschema/cli.rb +102 -0
  39. data/lib/lutaml/jsonschema/combiner.rb +54 -0
  40. data/lib/lutaml/jsonschema/configuration.rb +47 -0
  41. data/lib/lutaml/jsonschema/link.rb +25 -0
  42. data/lib/lutaml/jsonschema/property_entry.rb +15 -0
  43. data/lib/lutaml/jsonschema/reference_resolver.rb +74 -0
  44. data/lib/lutaml/jsonschema/schema.rb +205 -0
  45. data/lib/lutaml/jsonschema/schema_set.rb +217 -0
  46. data/lib/lutaml/jsonschema/spa/generator.rb +22 -0
  47. data/lib/lutaml/jsonschema/spa/metadata.rb +23 -0
  48. data/lib/lutaml/jsonschema/spa/output_strategy.rb +17 -0
  49. data/lib/lutaml/jsonschema/spa/spa_builder.rb +178 -0
  50. data/lib/lutaml/jsonschema/spa/spa_definition.rb +27 -0
  51. data/lib/lutaml/jsonschema/spa/spa_document.rb +23 -0
  52. data/lib/lutaml/jsonschema/spa/spa_property.rb +47 -0
  53. data/lib/lutaml/jsonschema/spa/spa_schema.rb +29 -0
  54. data/lib/lutaml/jsonschema/spa/spa_search_entry.rb +21 -0
  55. data/lib/lutaml/jsonschema/spa/vue_inlined_strategy.rb +53 -0
  56. data/lib/lutaml/jsonschema/version.rb +7 -0
  57. data/lib/lutaml/jsonschema.rb +29 -0
  58. data/sig/lutaml/jsonschema.rbs +6 -0
  59. metadata +163 -0
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { createField, buildDefaultJson, isCircular, parseFieldValue } from '../composables/useBuilderField'
3
+ import type { SpaProperty, SpaSchema, SpaDefinition } from '../types'
4
+
5
+ function prop(overrides: Partial<SpaProperty> = {}): SpaProperty {
6
+ return { name: 'test', ...overrides }
7
+ }
8
+
9
+ function definition(overrides: Partial<SpaDefinition> = {}): SpaDefinition {
10
+ return { name: 'Address', properties: [], required: [], ...overrides }
11
+ }
12
+
13
+ function spaSchema(overrides: Partial<SpaSchema> = {}): SpaSchema {
14
+ return { name: 'Test', properties: [], definitions: [], required: [], ...overrides }
15
+ }
16
+
17
+ describe('createField', () => {
18
+ it('marks field as required when in required list', () => {
19
+ const field = createField(prop({ name: 'email' }), ['email'], spaSchema())
20
+ expect(field.isRequired).toBe(true)
21
+ expect(field.included).toBe(true)
22
+ })
23
+
24
+ it('marks field as optional when not in required list', () => {
25
+ const field = createField(prop({ name: 'nickname' }), ['email'], spaSchema())
26
+ expect(field.isRequired).toBe(false)
27
+ expect(field.included).toBe(false)
28
+ })
29
+
30
+ it('resolves $ref to definition', () => {
31
+ const addressDef = definition({
32
+ name: 'address',
33
+ properties: [
34
+ prop({ name: 'street', type: 'string' }),
35
+ ],
36
+ })
37
+ const s = spaSchema({ definitions: [addressDef] })
38
+ const field = createField(
39
+ prop({ name: 'address', type: 'object', ref: '#/definitions/address' }),
40
+ [],
41
+ s,
42
+ )
43
+ expect(field.resolvedDef).toBe(addressDef)
44
+ })
45
+
46
+ it('initializes array items for array type', () => {
47
+ const field = createField(
48
+ prop({ name: 'tags', type: 'array', itemsType: 'string' }),
49
+ [],
50
+ spaSchema(),
51
+ )
52
+ expect(field.arrayItems).toEqual([''])
53
+ })
54
+
55
+ it('leaves array items empty for non-array type', () => {
56
+ const field = createField(prop({ name: 'name', type: 'string' }), [], spaSchema())
57
+ expect(field.arrayItems).toEqual([])
58
+ })
59
+ })
60
+
61
+ describe('buildDefaultJson', () => {
62
+ it('builds object from properties', () => {
63
+ const props = [
64
+ prop({ name: 'name', type: 'string' }),
65
+ prop({ name: 'age', type: 'integer' }),
66
+ ]
67
+ const result = buildDefaultJson(props)
68
+ expect(result).toEqual({ name: 'string', age: 0 })
69
+ })
70
+
71
+ it('builds array property', () => {
72
+ const props = [
73
+ prop({ name: 'tags', type: 'array', itemsType: 'string' }),
74
+ ]
75
+ const result = buildDefaultJson(props)
76
+ expect(result).toEqual({ tags: [''] })
77
+ })
78
+
79
+ it('builds empty object for object without ref', () => {
80
+ const props = [
81
+ prop({ name: 'meta', type: 'object' }),
82
+ ]
83
+ const result = buildDefaultJson(props)
84
+ expect(result).toEqual({ meta: {} })
85
+ })
86
+ })
87
+
88
+ describe('isCircular', () => {
89
+ it('returns true when definition name is in visited set', () => {
90
+ const field = createField(
91
+ prop({ name: 'self', type: 'object', ref: '#/definitions/Address' }),
92
+ [],
93
+ spaSchema({ definitions: [definition({ name: 'Address' })] }),
94
+ )
95
+ expect(isCircular(field, new Set(['Address']))).toBe(true)
96
+ })
97
+
98
+ it('returns false when definition name is not in visited set', () => {
99
+ const field = createField(
100
+ prop({ name: 'self', type: 'object', ref: '#/definitions/Address' }),
101
+ [],
102
+ spaSchema({ definitions: [definition({ name: 'Address' })] }),
103
+ )
104
+ expect(isCircular(field, new Set())).toBe(false)
105
+ })
106
+
107
+ it('returns false when resolvedDef is null', () => {
108
+ const field = createField(prop({ name: 'x', type: 'string' }), [], spaSchema())
109
+ expect(isCircular(field, new Set())).toBe(false)
110
+ })
111
+ })
112
+
113
+ describe('parseFieldValue', () => {
114
+ it('parses string value', () => {
115
+ const field = createField(prop({ name: 'x', type: 'string' }), [], spaSchema())
116
+ field.rawValue = 'hello'
117
+ expect(parseFieldValue(field)).toBe('hello')
118
+ })
119
+
120
+ it('parses integer value', () => {
121
+ const field = createField(prop({ name: 'x', type: 'integer' }), [], spaSchema())
122
+ field.rawValue = '42'
123
+ expect(parseFieldValue(field)).toBe(42)
124
+ })
125
+
126
+ it('parses array from arrayItems', () => {
127
+ const field = createField(prop({ name: 'x', type: 'array', itemsType: 'string' }), [], spaSchema())
128
+ field.arrayItems = ['a', 'b']
129
+ expect(parseFieldValue(field)).toEqual(['a', 'b'])
130
+ })
131
+
132
+ it('parses array of integers', () => {
133
+ const field = createField(prop({ name: 'x', type: 'array', itemsType: 'integer' }), [], spaSchema())
134
+ field.arrayItems = ['1', '2', '3']
135
+ expect(parseFieldValue(field)).toEqual([1, 2, 3])
136
+ })
137
+ })
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { resolveSchemaRef } from '../composables/useDefinitionResolver'
3
+ import type { SpaSchema, SpaDefinition } from '../types'
4
+
5
+ function definition(overrides: Partial<SpaDefinition> = {}): SpaDefinition {
6
+ return { name: 'Test', properties: [], required: [], ...overrides }
7
+ }
8
+
9
+ function schema(overrides: Partial<SpaSchema> = {}): SpaSchema {
10
+ return { name: 'TestSchema', properties: [], definitions: [], required: [], ...overrides }
11
+ }
12
+
13
+ describe('resolveSchemaRef', () => {
14
+ it('returns null for undefined ref', () => {
15
+ expect(resolveSchemaRef(undefined, schema())).toBeNull()
16
+ })
17
+
18
+ it('returns null for empty string ref', () => {
19
+ expect(resolveSchemaRef('', schema())).toBeNull()
20
+ })
21
+
22
+ it('resolves #/definitions/NAME', () => {
23
+ const address = definition({ name: 'address', title: 'Address', type: 'object' })
24
+ const s = schema({ definitions: [address] })
25
+ expect(resolveSchemaRef('#/definitions/address', s)).toBe(address)
26
+ })
27
+
28
+ it('resolves #/$defs/NAME', () => {
29
+ const email = definition({ name: 'email', type: 'object' })
30
+ const s = schema({ definitions: [email] })
31
+ expect(resolveSchemaRef('#/$defs/email', s)).toBe(email)
32
+ })
33
+
34
+ it('returns null for non-existent definition', () => {
35
+ const s = schema({ definitions: [definition({ name: 'other' })] })
36
+ expect(resolveSchemaRef('#/definitions/missing', s)).toBeNull()
37
+ })
38
+
39
+ it('returns null for malformed ref', () => {
40
+ expect(resolveSchemaRef('not-a-ref', schema())).toBeNull()
41
+ })
42
+
43
+ it('returns null for partial ref without definition name', () => {
44
+ expect(resolveSchemaRef('#/definitions/', schema())).toBeNull()
45
+ })
46
+ })
@@ -0,0 +1,219 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ primaryType,
4
+ displayType,
5
+ formatInputType,
6
+ formatDefault,
7
+ initialValue,
8
+ arrayItemInputType,
9
+ arrayDefaultValue,
10
+ parseArrayItem,
11
+ isObjectProperty,
12
+ hasConstraints,
13
+ parsePropertyValue,
14
+ } from '../composables/useSchemaTypes'
15
+ import type { SpaProperty } from '../types'
16
+
17
+ function prop(overrides: Partial<SpaProperty> = {}): SpaProperty {
18
+ return { name: 'test', ...overrides }
19
+ }
20
+
21
+ describe('primaryType', () => {
22
+ it('returns the first type from a comma-separated string', () => {
23
+ expect(primaryType('string,integer')).toBe('string')
24
+ })
25
+
26
+ it('returns the single type', () => {
27
+ expect(primaryType('object')).toBe('object')
28
+ })
29
+
30
+ it('returns "any" for undefined', () => {
31
+ expect(primaryType(undefined)).toBe('any')
32
+ })
33
+
34
+ it('returns "any" for empty string', () => {
35
+ expect(primaryType('')).toBe('any')
36
+ })
37
+ })
38
+
39
+ describe('displayType', () => {
40
+ it('shows array<itemsType> for arrays', () => {
41
+ expect(displayType(prop({ type: 'array', itemsType: 'string' }))).toBe('array<string>')
42
+ })
43
+
44
+ it('shows type (format) when format is set', () => {
45
+ expect(displayType(prop({ type: 'string', format: 'email' }))).toBe('string (email)')
46
+ })
47
+
48
+ it('shows plain type when no format', () => {
49
+ expect(displayType(prop({ type: 'integer' }))).toBe('integer')
50
+ })
51
+ })
52
+
53
+ describe('formatInputType', () => {
54
+ it.each([
55
+ ['email', 'email'],
56
+ ['uri', 'url'],
57
+ ['uri-reference', 'url'],
58
+ ['date', 'date'],
59
+ ['time', 'time'],
60
+ ['date-time', 'datetime-local'],
61
+ [undefined, 'text'],
62
+ ['unknown', 'text'],
63
+ ])('maps format %s to input type %s', (format, expected) => {
64
+ expect(formatInputType(format)).toBe(expected)
65
+ })
66
+ })
67
+
68
+ describe('formatDefault', () => {
69
+ it('returns URI for uri format', () => {
70
+ expect(formatDefault('uri')).toBe('https://example.com')
71
+ })
72
+
73
+ it('returns "string" for unknown format', () => {
74
+ expect(formatDefault('unknown')).toBe('string')
75
+ })
76
+
77
+ it('returns "string" for no format', () => {
78
+ expect(formatDefault(undefined)).toBe('string')
79
+ })
80
+ })
81
+
82
+ describe('initialValue', () => {
83
+ it('uses examples[0] first', () => {
84
+ expect(initialValue(prop({ examples: ['hello', 'world'], type: 'string' }))).toBe('hello')
85
+ })
86
+
87
+ it('uses default second', () => {
88
+ expect(initialValue(prop({ default: 'fallback', type: 'string' }))).toBe('fallback')
89
+ })
90
+
91
+ it('uses first enum value third', () => {
92
+ expect(initialValue(prop({ enum: ['a', 'b'], type: 'string' }))).toBe('a')
93
+ })
94
+
95
+ it('uses type-based fallback for integer', () => {
96
+ expect(initialValue(prop({ type: 'integer' }))).toBe('0')
97
+ })
98
+
99
+ it('uses type-based fallback for boolean', () => {
100
+ expect(initialValue(prop({ type: 'boolean' }))).toBe('false')
101
+ })
102
+
103
+ it('uses format-aware default for string', () => {
104
+ expect(initialValue(prop({ type: 'string', format: 'email' }))).toBe('user@example.com')
105
+ })
106
+ })
107
+
108
+ describe('arrayItemInputType', () => {
109
+ it.each([
110
+ ['integer', 'number'],
111
+ ['number', 'number'],
112
+ ['boolean', 'checkbox'],
113
+ ['string', 'text'],
114
+ [undefined, 'text'],
115
+ ])('maps itemsType %s to input type %s', (itemsType, expected) => {
116
+ expect(arrayItemInputType(itemsType)).toBe(expected)
117
+ })
118
+ })
119
+
120
+ describe('arrayDefaultValue', () => {
121
+ it.each([
122
+ ['integer', '0'],
123
+ ['number', '0.0'],
124
+ ['boolean', 'false'],
125
+ ['string', ''],
126
+ [undefined, ''],
127
+ ])('returns %s default for itemsType %s', (itemsType, expected) => {
128
+ expect(arrayDefaultValue(itemsType)).toBe(expected)
129
+ })
130
+ })
131
+
132
+ describe('parseArrayItem', () => {
133
+ it('parses integer', () => {
134
+ expect(parseArrayItem('42', 'integer')).toBe(42)
135
+ })
136
+
137
+ it('parses number', () => {
138
+ expect(parseArrayItem('3.14', 'number')).toBeCloseTo(3.14)
139
+ })
140
+
141
+ it('parses boolean true', () => {
142
+ expect(parseArrayItem('true', 'boolean')).toBe(true)
143
+ })
144
+
145
+ it('parses boolean false', () => {
146
+ expect(parseArrayItem('false', 'boolean')).toBe(false)
147
+ })
148
+
149
+ it('returns string for unknown itemsType', () => {
150
+ expect(parseArrayItem('hello', 'string')).toBe('hello')
151
+ })
152
+
153
+ it('returns string for undefined itemsType', () => {
154
+ expect(parseArrayItem('hello', undefined)).toBe('hello')
155
+ })
156
+ })
157
+
158
+ describe('isObjectProperty', () => {
159
+ it('returns true for type=object', () => {
160
+ expect(isObjectProperty(prop({ type: 'object' }))).toBe(true)
161
+ })
162
+
163
+ it('returns true for no type but has ref', () => {
164
+ expect(isObjectProperty(prop({ type: undefined, ref: '#/definitions/X' }))).toBe(true)
165
+ })
166
+
167
+ it('returns false for string', () => {
168
+ expect(isObjectProperty(prop({ type: 'string' }))).toBe(false)
169
+ })
170
+ })
171
+
172
+ describe('hasConstraints', () => {
173
+ it('returns true for pattern', () => {
174
+ expect(hasConstraints(prop({ pattern: '[0-9]+' }))).toBe(true)
175
+ })
176
+
177
+ it('returns true for minimum', () => {
178
+ expect(hasConstraints(prop({ minimum: 0 }))).toBe(true)
179
+ })
180
+
181
+ it('returns true for examples', () => {
182
+ expect(hasConstraints(prop({ examples: ['a'] }))).toBe(true)
183
+ })
184
+
185
+ it('returns false for no constraints', () => {
186
+ expect(hasConstraints(prop({ type: 'string' }))).toBe(false)
187
+ })
188
+ })
189
+
190
+ describe('parsePropertyValue', () => {
191
+ it('returns enum raw value', () => {
192
+ expect(parsePropertyValue('a', prop({ enum: ['a', 'b'], type: 'string' }))).toBe('a')
193
+ })
194
+
195
+ it('parses boolean', () => {
196
+ expect(parsePropertyValue('true', prop({ type: 'boolean' }))).toBe(true)
197
+ expect(parsePropertyValue('false', prop({ type: 'boolean' }))).toBe(false)
198
+ })
199
+
200
+ it('parses integer', () => {
201
+ expect(parsePropertyValue('42', prop({ type: 'integer' }))).toBe(42)
202
+ })
203
+
204
+ it('parses integer as 0 for NaN', () => {
205
+ expect(parsePropertyValue('abc', prop({ type: 'integer' }))).toBe(0)
206
+ })
207
+
208
+ it('parses number', () => {
209
+ expect(parsePropertyValue('3.14', prop({ type: 'number' }))).toBeCloseTo(3.14)
210
+ })
211
+
212
+ it('returns empty object for object without ref', () => {
213
+ expect(parsePropertyValue('', prop({ type: 'object' }))).toEqual({})
214
+ })
215
+
216
+ it('returns raw string for string type', () => {
217
+ expect(parsePropertyValue('hello', prop({ type: 'string' }))).toBe('hello')
218
+ })
219
+ })
@@ -0,0 +1,10 @@
1
+ import { createApp } from 'vue'
2
+ import { createPinia } from 'pinia'
3
+ import App from './App.vue'
4
+ import router from './router'
5
+ import './style.css'
6
+
7
+ const app = createApp(App)
8
+ app.use(createPinia())
9
+ app.use(router)
10
+ app.mount('#app')
@@ -0,0 +1,152 @@
1
+ <template>
2
+ <header class="header">
3
+ <div class="header-left">
4
+ <button class="btn btn-ghost sidebar-toggle" @click="uiStore.toggleSidebar" title="Toggle sidebar">
5
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
6
+ <path d="M3 5h14M3 10h14M3 15h14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
7
+ </svg>
8
+ </button>
9
+ <button class="btn btn-ghost home-btn" @click="goHome" title="Overview">
10
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
11
+ <path d="M3 8.5L9 3.5L15 8.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
12
+ <path d="M4 8.5V14.5C4 14.5 4 15.5 5 15.5H13C13 15.5 14 15.5 14 14.5V8.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
13
+ </svg>
14
+ </button>
15
+ <span v-if="schemaStore.metadata" class="header-breadcrumb">
16
+ <span class="breadcrumb-item">{{ schemaStore.metadata.title || 'JSON Schema Docs' }}</span>
17
+ </span>
18
+ </div>
19
+
20
+ <div class="header-center">
21
+ <button class="search-trigger" @click="uiStore.openSearch">
22
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
23
+ <circle cx="7" cy="7" r="5" stroke="currentColor" stroke-width="1.5"/>
24
+ <path d="M11 11l3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
25
+ </svg>
26
+ <span class="search-placeholder">Search schemas, properties, definitions...</span>
27
+ <kbd class="search-kbd">/</kbd>
28
+ </button>
29
+ </div>
30
+
31
+ <div class="header-right">
32
+ <button class="btn btn-ghost theme-btn" @click="uiStore.toggleTheme" :title="uiStore.isDark ? 'Light mode' : 'Dark mode'">
33
+ <svg v-if="uiStore.isDark" width="18" height="18" viewBox="0 0 18 18" fill="none">
34
+ <path d="M9 3V2M9 16v-1M3 9H2M16 9h-1M4.22 4.22l-.7-.7M14.48 14.48l-.7-.7M4.22 13.78l-.7.7M14.48 3.52l-.7.7" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
35
+ <circle cx="9" cy="9" r="3.5" stroke="currentColor" stroke-width="1.3"/>
36
+ </svg>
37
+ <svg v-else width="18" height="18" viewBox="0 0 18 18" fill="none">
38
+ <circle cx="9" cy="9" r="4" stroke="currentColor" stroke-width="1.4"/>
39
+ <path d="M9 2v1.5M9 14.5V16M2 9h1.5M14.5 9H16M4.22 4.22l1.06 1.06M12.72 12.72l1.06 1.06M4.22 13.78l1.06-1.06M12.72 5.28l1.06-1.06" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
40
+ </svg>
41
+ </button>
42
+ </div>
43
+ </header>
44
+ </template>
45
+
46
+ <script setup lang="ts">
47
+ import { useSchemaStore } from '../stores/schemaStore'
48
+ import { useUiStore } from '../stores/uiStore'
49
+
50
+ const schemaStore = useSchemaStore()
51
+ const uiStore = useUiStore()
52
+
53
+ function goHome() {
54
+ schemaStore.selectSchema(null)
55
+ uiStore.closeDetailPanel()
56
+ }
57
+ </script>
58
+
59
+ <style scoped>
60
+ .header {
61
+ height: 56px;
62
+ flex-shrink: 0;
63
+ display: flex;
64
+ align-items: center;
65
+ padding: 0 var(--space-4);
66
+ background: var(--bg-elevated);
67
+ border-bottom: 1px solid var(--border-light);
68
+ gap: var(--space-4);
69
+ }
70
+
71
+ .header-left {
72
+ display: flex;
73
+ align-items: center;
74
+ gap: var(--space-3);
75
+ }
76
+
77
+ .sidebar-toggle {
78
+ padding: var(--space-2);
79
+ }
80
+
81
+ .header-breadcrumb {
82
+ display: flex;
83
+ align-items: center;
84
+ font-size: var(--text-sm);
85
+ color: var(--text-muted);
86
+ }
87
+
88
+ .breadcrumb-item {
89
+ color: var(--text-secondary);
90
+ }
91
+
92
+ .home-btn {
93
+ width: 32px;
94
+ height: 32px;
95
+ padding: 0;
96
+ color: var(--text-secondary);
97
+ }
98
+
99
+ .header-center {
100
+ flex: 1;
101
+ display: flex;
102
+ justify-content: center;
103
+ max-width: 480px;
104
+ margin: 0 auto;
105
+ }
106
+
107
+ .search-trigger {
108
+ display: flex;
109
+ align-items: center;
110
+ gap: var(--space-2);
111
+ width: 100%;
112
+ padding: var(--space-2) var(--space-3);
113
+ background: var(--bg-secondary);
114
+ border: 1px solid var(--border-light);
115
+ border-radius: var(--radius-md);
116
+ color: var(--text-muted);
117
+ font-size: var(--text-sm);
118
+ cursor: pointer;
119
+ transition: all var(--transition-fast);
120
+ }
121
+
122
+ .search-trigger:hover {
123
+ background: var(--bg-hover);
124
+ border-color: var(--border-medium);
125
+ }
126
+
127
+ .search-placeholder {
128
+ flex: 1;
129
+ text-align: left;
130
+ }
131
+
132
+ .search-kbd {
133
+ padding: 2px 6px;
134
+ font-size: var(--text-xs);
135
+ font-family: var(--font-mono);
136
+ background: var(--bg-elevated);
137
+ border: 1px solid var(--border-medium);
138
+ border-radius: var(--radius-sm);
139
+ }
140
+
141
+ .header-right {
142
+ display: flex;
143
+ align-items: center;
144
+ gap: var(--space-1);
145
+ }
146
+
147
+ .theme-btn {
148
+ width: 32px;
149
+ height: 32px;
150
+ padding: 0;
151
+ }
152
+ </style>