picocrank 1.30.0 → 1.31.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "picocrank",
3
- "version": "1.30.0",
3
+ "version": "1.31.1",
4
4
  "type": "module",
5
5
  "description": "A set of zero dependency Vue components built on the Femtocrank global CSS theme.",
6
6
  "main": "vite.config.js",
@@ -91,7 +91,13 @@
91
91
  --danger-zone-warning-fg: var(--muted-text-color);
92
92
  --danger-zone-focus-outline: var(--karma-bad-border);
93
93
 
94
- --picocrank-fixed-header-offset: 4em;
94
+ /* Matches Femtocrank header min-height (logo 2em + .5em padding). */
95
+ --picocrank-fixed-header-offset: 3em;
96
+ }
97
+
98
+ /* No fixed header → no sidebar/main offset. */
99
+ html:not(:has(header.fixed)) {
100
+ --picocrank-fixed-header-offset: 0px;
95
101
  }
96
102
 
97
103
  table thead th {
@@ -172,9 +178,23 @@ select.field-invalid:focus-visible {
172
178
  }
173
179
 
174
180
  /*
175
- * Fixed header layout: pin the app header and offset sidebar + main content.
176
- * Apply .layout-fixed-header on a wrapper whose direct children are header and #layout.
181
+ * Fixed header layout: pin the app header and offset main content.
182
+ * Prefer Header's `fixed` prop (adds Femtocrank `.fixed` on <header>).
183
+ * Sidebar top/height use --picocrank-fixed-header-offset in Sidebar.vue
184
+ * (unlayered) so they are not overridden by stuck-mode defaults.
185
+ * Alternatively wrap with .layout-fixed-header when header and #layout are direct children.
177
186
  */
187
+ header.fixed {
188
+ top: 0;
189
+ left: 0;
190
+ z-index: 5;
191
+ }
192
+
193
+ header.fixed ~ aside,
194
+ header.fixed ~ #layout aside {
195
+ z-index: 3;
196
+ }
197
+
178
198
  .layout-fixed-header > header {
179
199
  position: fixed;
180
200
  top: 0;
@@ -183,15 +203,20 @@ select.field-invalid:focus-visible {
183
203
  z-index: 5;
184
204
  }
185
205
 
206
+ .layout-fixed-header > aside,
186
207
  .layout-fixed-header > #layout aside {
187
208
  z-index: 3;
188
- padding-top: var(--picocrank-fixed-header-offset);
209
+ }
189
210
  }
190
211
 
191
- .layout-fixed-header > #layout main {
212
+ /*
213
+ * Unlayered so this wins over @layer components (same issue as Sidebar offsets).
214
+ * Pads #content below a fixed header — clears the chrome and leaves a readable gap.
215
+ */
216
+ header.fixed ~ #layout > #content,
217
+ .layout-fixed-header > #layout > #content {
192
218
  padding-top: var(--picocrank-fixed-header-offset);
193
219
  }
194
- }
195
220
 
196
221
  /*
197
222
  * Responsive column priorities: classes col-priority-1 … col-priority-5 on th/td only
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <header>
2
+ <header :class="{ fixed }">
3
3
  <div
4
4
  :id = "showSidebarChrome ? 'sidebar-button' : null"
5
5
  :class="['image-and-title', 'flex-row', { 'disabled-branding': !showBranding, 'logo-home-link': !showSidebarChrome && showBranding }]"
@@ -146,6 +146,10 @@
146
146
  type: Boolean,
147
147
  default: false,
148
148
  },
149
+ fixed: {
150
+ type: Boolean,
151
+ default: true,
152
+ },
149
153
  navigation: {
150
154
  type: Object,
151
155
  default: null,
@@ -6,11 +6,21 @@
6
6
  <div
7
7
  v-if="showActions"
8
8
  class="readonly-textarea-actions"
9
- :class="{ 'readonly-textarea-actions--leading': hasActions }"
10
9
  >
11
10
  <div v-if="hasActions" class="readonly-textarea-actions-slot">
12
11
  <slot name="actions" />
13
12
  </div>
13
+ <button
14
+ v-if="showClearButton"
15
+ type="button"
16
+ class="readonly-textarea-clear neutral"
17
+ title="Clear contents"
18
+ :disabled="!modelValue"
19
+ @click="clear"
20
+ >
21
+ <HugeiconsIcon :icon="Delete02Icon" width="1em" height="1em" />
22
+ <span>{{ clearLabel }}</span>
23
+ </button>
14
24
  <button
15
25
  v-if="showCopyButton"
16
26
  type="button"
@@ -39,7 +49,7 @@
39
49
  <script setup>
40
50
  import { ref, computed, useId, useSlots, watch, Comment } from 'vue'
41
51
  import { HugeiconsIcon } from '@hugeicons/vue'
42
- import { Copy01Icon, CopyCheckIcon } from '@hugeicons/core-free-icons'
52
+ import { Copy01Icon, CopyCheckIcon, Delete02Icon } from '@hugeicons/core-free-icons'
43
53
 
44
54
  const props = defineProps({
45
55
  modelValue: {
@@ -70,6 +80,10 @@ const props = defineProps({
70
80
  type: Boolean,
71
81
  default: true,
72
82
  },
83
+ showClearButton: {
84
+ type: Boolean,
85
+ default: false,
86
+ },
73
87
  copyLabel: {
74
88
  type: String,
75
89
  default: 'Copy',
@@ -78,6 +92,10 @@ const props = defineProps({
78
92
  type: String,
79
93
  default: 'Copied',
80
94
  },
95
+ clearLabel: {
96
+ type: String,
97
+ default: 'Clear',
98
+ },
81
99
  copiedFeedbackMs: {
82
100
  type: Number,
83
101
  default: 2000,
@@ -92,27 +110,40 @@ const props = defineProps({
92
110
  },
93
111
  })
94
112
 
95
- const emit = defineEmits(['copy', 'copy-error', 'update:modelValue'])
113
+ const emit = defineEmits(['copy', 'copy-error', 'clear', 'update:modelValue'])
96
114
 
97
115
  const slots = useSlots()
98
116
 
99
- const hasActions = computed(() => {
117
+ const slotActionCount = computed(() => {
100
118
  const render = slots.actions
101
119
  if (!render) {
102
- return false
120
+ return 0
103
121
  }
104
- return render().some((vnode) => vnode.type !== Comment)
122
+ return render().filter((vnode) => vnode.type !== Comment).length
105
123
  })
106
124
 
107
- const showActions = computed(() => hasActions.value || props.showCopyButton)
125
+ const hasActions = computed(() => slotActionCount.value > 0)
126
+
127
+ const builtInActionCount = computed(() => {
128
+ let count = 0
129
+ if (props.showClearButton) count++
130
+ if (props.showCopyButton) count++
131
+ return count
132
+ })
133
+
134
+ const actionButtonCount = computed(
135
+ () => slotActionCount.value + builtInActionCount.value,
136
+ )
137
+
138
+ const showActions = computed(
139
+ () => actionButtonCount.value > 0,
140
+ )
108
141
 
109
142
  const showHeader = computed(
110
143
  () => Boolean(props.label) || showActions.value,
111
144
  )
112
145
 
113
- const pushActionsEnd = computed(
114
- () => showActions.value && !hasActions.value,
115
- )
146
+ const pushActionsEnd = computed(() => showActions.value)
116
147
 
117
148
  const generatedId = useId()
118
149
  const textareaId = props.id || `readonly-textarea-${generatedId}`
@@ -156,6 +187,7 @@ function appendSection(sectionName) {
156
187
  function clear() {
157
188
  content.value = ''
158
189
  emit('update:modelValue', content.value)
190
+ emit('clear')
159
191
  }
160
192
 
161
193
  function getContentAsString() {
@@ -236,10 +268,6 @@ defineExpose({
236
268
  flex-shrink: 0;
237
269
  }
238
270
 
239
- .readonly-textarea-actions--leading {
240
- margin-left: 0;
241
- }
242
-
243
271
  .readonly-textarea-actions-slot {
244
272
  display: inline-flex;
245
273
  align-items: center;
@@ -247,7 +275,8 @@ defineExpose({
247
275
  flex-wrap: wrap;
248
276
  }
249
277
 
250
- .readonly-textarea-copy {
278
+ .readonly-textarea-copy,
279
+ .readonly-textarea-clear {
251
280
  display: inline-flex;
252
281
  align-items: center;
253
282
  gap: 0.35em;
@@ -7,9 +7,6 @@
7
7
  <HugeiconsIcon
8
8
  :icon="icon"
9
9
  class="section-title-icon"
10
- width="1.1em"
11
- height="1.1em"
12
- :strokeWidth="2"
13
10
  aria-hidden="true"
14
11
  />
15
12
  <span class="section-title-text">
@@ -3,7 +3,7 @@
3
3
  :id="SIDEBAR_PANEL_ID"
4
4
  ref="sidebarRef"
5
5
  class="sidebar"
6
- :class="{ 'shown': isOpen, 'stuck': isStuck }"
6
+ :class="{ 'shown': isVisible, 'stuck': isStuck }"
7
7
  aria-label="Sidebar"
8
8
  :aria-hidden="isVisible ? undefined : 'true'"
9
9
  :inert="isVisible ? undefined : true"
@@ -25,14 +25,14 @@
25
25
  :icon="Pin02Icon"
26
26
  width="1em"
27
27
  height="1em"
28
- :strokeWidth="3"
28
+ :strokeWidth="1.5"
29
29
  />
30
30
  <HugeiconsIcon
31
31
  v-else
32
32
  :icon="PinIcon"
33
33
  width="1em"
34
34
  height="1em"
35
- :strokeWidth="3"
35
+ :strokeWidth="1.5"
36
36
  />
37
37
  </span>
38
38
  </button>
@@ -274,8 +274,13 @@ function unstick() {
274
274
  }
275
275
 
276
276
  function toggle() {
277
+ if (isStuck.value) {
278
+ isStuck.value = false
279
+ isOpen.value = false
280
+ return
281
+ }
282
+
277
283
  isOpen.value = !isOpen.value
278
- isStuck.value = false
279
284
  }
280
285
 
281
286
  function open() {
@@ -284,7 +289,6 @@ function open() {
284
289
 
285
290
  function close() {
286
291
  isOpen.value = false
287
- isStuck.value = false
288
292
  }
289
293
 
290
294
  function isLinkActive(link) {
@@ -397,6 +401,34 @@ defineExpose({
397
401
  </script>
398
402
 
399
403
  <style scoped>
404
+ .sidebar {
405
+ display: flex;
406
+ flex-direction: column;
407
+ box-sizing: border-box;
408
+ overflow-x: hidden;
409
+ overflow-y: auto;
410
+ overscroll-behavior: contain;
411
+ /*
412
+ * Sit fully below a fixed Header (Navigation, pin, and scrollbar).
413
+ * Offset is 0 when no header.fixed is present (see picocrank-extensions).
414
+ */
415
+ top: var(--picocrank-fixed-header-offset);
416
+ height: calc(100vh - var(--picocrank-fixed-header-offset));
417
+ max-height: calc(100vh - var(--picocrank-fixed-header-offset));
418
+ }
419
+
420
+ /*
421
+ * Stuck mode: same viewport band as overlay mode; scroll the whole contents
422
+ * instead of growing with content (Femtocrank defaults to height: auto).
423
+ */
424
+ .sidebar.stuck {
425
+ align-self: flex-start;
426
+ position: sticky;
427
+ top: var(--picocrank-fixed-header-offset);
428
+ height: calc(100vh - var(--picocrank-fixed-header-offset));
429
+ max-height: calc(100vh - var(--picocrank-fixed-header-offset));
430
+ }
431
+
400
432
  h2 {
401
433
  padding: .75em;
402
434
  }
@@ -410,6 +442,28 @@ button {
410
442
  border: 0;
411
443
  }
412
444
 
445
+ .stick-toggle {
446
+ display: inline-flex;
447
+ align-items: center;
448
+ justify-content: center;
449
+ align-self: stretch;
450
+ flex-shrink: 0;
451
+ padding-top: 0;
452
+ padding-bottom: 0;
453
+ padding-left: 1em;
454
+ padding-right: 1em;
455
+ border-radius: 0;
456
+ color: var(--text-color);
457
+ background-color: transparent;
458
+ cursor: pointer;
459
+ }
460
+
461
+ .stick-toggle:hover,
462
+ .stick-toggle:focus-visible {
463
+ background-color: var(--sidebar-hover-bg-color);
464
+ color: var(--hover-text-color);
465
+ }
466
+
413
467
  .navigation-links,
414
468
  .nav-section-links {
415
469
  list-style: none;
@@ -0,0 +1,211 @@
1
+ <template>
2
+ <component
3
+ :is="rootComponent"
4
+ v-bind="rootBindings"
5
+ :class="rootClasses"
6
+ >
7
+ <div
8
+ v-if="karma"
9
+ class="status-card-bar"
10
+ :class="{ 'status-card-bar--labeled': Boolean(barLabel) }"
11
+ >
12
+ <span
13
+ v-if="barLabel"
14
+ class="status-card-bar-label"
15
+ >{{ barLabel }}</span>
16
+ </div>
17
+ <div class="status-card-body">
18
+ <slot />
19
+ </div>
20
+ </component>
21
+ </template>
22
+
23
+ <script setup>
24
+ import { computed } from 'vue'
25
+ import { RouterLink } from 'vue-router'
26
+
27
+ const props = defineProps({
28
+ karma: {
29
+ type: String,
30
+ default: '',
31
+ validator: (value) => (
32
+ value === ''
33
+ || ['info', 'note', 'success', 'good', 'warning', 'severe', 'critical', 'error', 'bad', 'old'].includes(value)
34
+ ),
35
+ },
36
+ barLabel: {
37
+ type: String,
38
+ default: '',
39
+ },
40
+ align: {
41
+ type: String,
42
+ default: 'start',
43
+ validator: (value) => ['start', 'center'].includes(value),
44
+ },
45
+ compact: {
46
+ type: Boolean,
47
+ default: false,
48
+ },
49
+ to: {
50
+ type: [String, Object],
51
+ default: null,
52
+ },
53
+ href: {
54
+ type: String,
55
+ default: '',
56
+ },
57
+ })
58
+
59
+ const isLink = computed(() => Boolean(props.to || props.href))
60
+
61
+ const rootComponent = computed(() => {
62
+ if (props.to) {
63
+ return RouterLink
64
+ }
65
+ if (props.href) {
66
+ return 'a'
67
+ }
68
+ return 'div'
69
+ })
70
+
71
+ const rootBindings = computed(() => {
72
+ if (props.to) {
73
+ return { to: props.to }
74
+ }
75
+ if (props.href) {
76
+ return { href: props.href }
77
+ }
78
+ return {}
79
+ })
80
+
81
+ const rootClasses = computed(() => ({
82
+ 'status-card': true,
83
+ 'status-card--link': isLink.value,
84
+ [`status-card--${props.karma}`]: Boolean(props.karma),
85
+ [`status-card--align-${props.align}`]: true,
86
+ 'status-card--compact': props.compact,
87
+ }))
88
+ </script>
89
+
90
+ <style scoped>
91
+ .status-card {
92
+ display: flex;
93
+ align-items: stretch;
94
+ position: relative;
95
+ padding: 0;
96
+ overflow: hidden;
97
+ background: transparent;
98
+ border: 1px solid var(--nav-grid-border);
99
+ border-radius: 8px;
100
+ color: inherit;
101
+ text-align: left;
102
+ transition: all 0.2s ease;
103
+ }
104
+
105
+ a.status-card {
106
+ text-decoration: none;
107
+ cursor: pointer;
108
+ }
109
+
110
+ .status-card:hover {
111
+ border-color: var(--nav-grid-accent-fg);
112
+ background: var(--nav-grid-hover-bg);
113
+ color: var(--text-color);
114
+ transform: translateY(-2px);
115
+ box-shadow: 0 4px 8px var(--nav-grid-hover-shadow);
116
+ }
117
+
118
+ .status-card-bar {
119
+ position: relative;
120
+ flex: 0 0 0.35em;
121
+ width: 0.35em;
122
+ }
123
+
124
+ .status-card-bar--labeled {
125
+ flex-basis: 1.35em;
126
+ width: 1.35em;
127
+ }
128
+
129
+ .status-card-bar-label {
130
+ position: absolute;
131
+ top: 50%;
132
+ left: 50%;
133
+ transform: translate(-50%, -50%) rotate(270deg);
134
+ font-size: 0.65em;
135
+ font-weight: 600;
136
+ letter-spacing: 0.06em;
137
+ line-height: 1;
138
+ text-transform: uppercase;
139
+ white-space: nowrap;
140
+ color: var(--text-color);
141
+ user-select: none;
142
+ }
143
+
144
+ .status-card-body {
145
+ display: flex;
146
+ flex: 1;
147
+ flex-direction: column;
148
+ justify-content: flex-start;
149
+ gap: 0.25rem;
150
+ min-width: 0;
151
+ padding: 1.25rem 0.75rem;
152
+ }
153
+
154
+ .status-card--compact .status-card-body {
155
+ padding: 0.75rem 0.5rem;
156
+ gap: 0.15rem;
157
+ }
158
+
159
+ .status-card--align-start .status-card-body {
160
+ align-items: flex-start;
161
+ text-align: left;
162
+ }
163
+
164
+ .status-card--align-center .status-card-body {
165
+ align-items: center;
166
+ text-align: center;
167
+ }
168
+
169
+ .status-card-body :deep(h4) {
170
+ margin: 0;
171
+ }
172
+
173
+ .status-card-body :deep(.subtle) {
174
+ margin-top: 0.35rem;
175
+ }
176
+
177
+ .status-card--compact .status-card-body :deep(.subtle) {
178
+ margin-top: 0.2rem;
179
+ }
180
+
181
+ .status-card--good .status-card-bar,
182
+ .status-card--success .status-card-bar {
183
+ background-color: var(--karma-good-tint);
184
+ }
185
+
186
+ .status-card--bad .status-card-bar,
187
+ .status-card--critical .status-card-bar,
188
+ .status-card--error .status-card-bar {
189
+ background-color: var(--karma-bad-tint);
190
+ }
191
+
192
+ .status-card--warning .status-card-bar {
193
+ background-color: var(--karma-warning-tint);
194
+ }
195
+
196
+ .status-card--severe .status-card-bar {
197
+ background-color: var(--karma-severe-tint);
198
+ }
199
+
200
+ .status-card--note .status-card-bar {
201
+ background-color: var(--karma-note-tint);
202
+ }
203
+
204
+ .status-card--info .status-card-bar {
205
+ background-color: var(--karma-info-tint);
206
+ }
207
+
208
+ .status-card--old .status-card-bar {
209
+ background-color: var(--karma-old-tint);
210
+ }
211
+ </style>
@@ -8,67 +8,99 @@
8
8
  <div class="admin-section">
9
9
  <h3>System Overview</h3>
10
10
  <p>Welcome to the administrative panel. This is a placeholder for administrative functionality.</p>
11
+
12
+ <FormLayout @submit.prevent>
13
+ <FormField
14
+ label="Grid"
15
+ component-has-label
16
+ description="How many StatusCard columns to show."
17
+ >
18
+ <RadioGroup
19
+ v-model="gridLayout"
20
+ name="status-card-grid"
21
+ variant="boolean"
22
+ :options="gridLayoutOptions"
23
+ />
24
+ </FormField>
25
+
26
+ <FormField label="Content align" component-has-label>
27
+ <RadioGroup
28
+ v-model="cardAlign"
29
+ name="status-card-align"
30
+ variant="boolean"
31
+ :options="alignOptions"
32
+ />
33
+ </FormField>
34
+
35
+ <FormField label="Bar labels" component-has-label>
36
+ <RadioGroup
37
+ v-model="showBarLabels"
38
+ name="status-card-bar-labels"
39
+ variant="boolean"
40
+ :options="showHideOptions"
41
+ />
42
+ </FormField>
43
+
44
+ <FormField label="Compact" component-has-label>
45
+ <RadioGroup
46
+ v-model="cardCompact"
47
+ name="status-card-compact"
48
+ variant="boolean"
49
+ :options="onOffOptions"
50
+ />
51
+ </FormField>
52
+ </FormLayout>
11
53
 
12
- <div class="grid-boxed">
13
- <div class="stat-display good">
14
- <h4>Total Users</h4>
15
- <span class="stat">1,247</span>
16
- <span class="subtle">+12% from last month</span>
17
- </div>
18
-
19
- <div class="stat-display bad">
20
- <h4>Active Sessions</h4>
21
- <span class="stat">89</span>
22
- <span class="subtle">-3% from last hour</span>
23
- </div>
24
-
25
- <div class="stat-display warning">
26
- <h4>System Load</h4>
27
- <span class="stat">67%</span>
28
- <span class="subtle">Moderate</span>
29
- </div>
30
-
31
- <div class="stat-display good">
32
- <h4>Storage Used</h4>
33
- <span class="stat">2.4 GB</span>
34
- <span class="subtle">45% of total</span>
35
- </div>
54
+ <div :class="gridClass">
55
+ <StatusCard
56
+ v-for="card in overviewCards"
57
+ :key="card.title"
58
+ :karma="card.karma"
59
+ :bar-label="showBarLabels ? card.barLabel : ''"
60
+ :align="cardAlign"
61
+ :compact="cardCompact"
62
+ :to="card.to"
63
+ >
64
+ <h4>{{ card.title }}</h4>
65
+ <span class="stat">{{ card.stat }}</span>
66
+ <span class="subtle">{{ card.subtle }}</span>
67
+ </StatusCard>
36
68
  </div>
37
69
  </div>
38
70
 
39
71
  <div class="admin-section">
40
72
  <h3>Recent Activity</h3>
41
- <div class="activity-list">
42
- <div class="activity-item">
43
- <span class="activity-time">2 minutes ago</span>
44
- <span class="activity-text">User "john.doe" logged in</span>
45
- </div>
46
- <div class="activity-item">
47
- <span class="activity-time">5 minutes ago</span>
48
- <span class="activity-text">New user account created</span>
49
- </div>
50
- <div class="activity-item">
51
- <span class="activity-time">12 minutes ago</span>
52
- <span class="activity-text">System backup completed</span>
53
- </div>
54
- <div class="activity-item">
55
- <span class="activity-time">1 hour ago</span>
56
- <span class="activity-text">Database maintenance performed</span>
57
- </div>
58
- <div class="activity-item">
59
- <span class="activity-time">2 hours ago</span>
60
- <span class="activity-text">User "admin" updated settings</span>
61
- </div>
62
- </div>
73
+ <ul>
74
+ <li>
75
+ <span class="subtle">2 minutes ago</span>
76
+ — User "john.doe" logged in
77
+ </li>
78
+ <li>
79
+ <span class="subtle">5 minutes ago</span>
80
+ — New user account created
81
+ </li>
82
+ <li>
83
+ <span class="subtle">12 minutes ago</span>
84
+ — System backup completed
85
+ </li>
86
+ <li>
87
+ <span class="subtle">1 hour ago</span>
88
+ — Database maintenance performed
89
+ </li>
90
+ <li>
91
+ <span class="subtle">2 hours ago</span>
92
+ — User "admin" updated settings
93
+ </li>
94
+ </ul>
63
95
  </div>
64
96
 
65
97
  <div class="admin-section">
66
98
  <h3>Quick Actions</h3>
67
- <div role = "toolbar">
68
- <button class="action-btn">Create User</button>
69
- <button class="action-btn">System Backup</button>
70
- <button class="action-btn">View Logs</button>
71
- <button class="action-btn">Settings</button>
99
+ <div role="toolbar">
100
+ <button type="button">Create User</button>
101
+ <button type="button">System Backup</button>
102
+ <button type="button">View Logs</button>
103
+ <button type="button">Settings</button>
72
104
  </div>
73
105
  </div>
74
106
  </div>
@@ -76,6 +108,69 @@
76
108
  </template>
77
109
 
78
110
  <script setup>
79
- // This is a placeholder admin view with dummy content
80
- // No functionality implemented yet
111
+ import { computed, ref } from 'vue'
112
+
113
+ const showBarLabels = ref(true)
114
+ const cardCompact = ref(false)
115
+ const cardAlign = ref('start')
116
+ const gridLayout = ref('3')
117
+
118
+ const showHideOptions = [
119
+ { label: 'Shown', value: true },
120
+ { label: 'Hidden', value: false },
121
+ ]
122
+
123
+ const onOffOptions = [
124
+ { label: 'On', value: true },
125
+ { label: 'Off', value: false },
126
+ ]
127
+
128
+ const alignOptions = [
129
+ { value: 'start', label: 'Start' },
130
+ { value: 'center', label: 'Center' },
131
+ ]
132
+
133
+ const gridLayoutOptions = [
134
+ { value: '3', label: '3 columns' },
135
+ { value: '2', label: '2 columns' },
136
+ ]
137
+
138
+ const gridClass = computed(() => (
139
+ gridLayout.value === '2' ? 'grid-boxed-2' : 'grid-boxed'
140
+ ))
141
+
142
+ const overviewCards = [
143
+ {
144
+ karma: 'good',
145
+ barLabel: 'Users',
146
+ title: 'Total Users',
147
+ stat: '1,247',
148
+ subtle: '+12% from last month',
149
+ to: { name: 'UserDetails' },
150
+ },
151
+ {
152
+ karma: 'bad',
153
+ barLabel: 'Live',
154
+ title: 'Active Sessions',
155
+ stat: '89',
156
+ subtle: '-3% from last hour',
157
+ to: { name: 'TableExample' },
158
+ },
159
+ {
160
+ karma: 'warning',
161
+ barLabel: 'Load',
162
+ title: 'System Load',
163
+ stat: '67%',
164
+ subtle: 'Moderate',
165
+ to: { name: 'StatusExample' },
166
+ },
167
+ {
168
+ karma: 'good',
169
+ barLabel: 'Disk',
170
+ title: 'Storage Used',
171
+ stat: '2.4 GB',
172
+ subtle: '45% of total',
173
+ to: { name: 'ViewItem', params: { id: '1' } },
174
+ },
175
+ ]
81
176
  </script>
@@ -222,11 +222,6 @@
222
222
  registerSidebarNavigation(navigation.value, { onHelloWorld: helloWorld })
223
223
  registerTopBarNavigation(topBarNavigation.value)
224
224
 
225
- if (sidebar.value) {
226
- sidebar.value.open();
227
- sidebar.value.stick();
228
- }
229
-
230
225
  registerQuickSearchItems()
231
226
  });
232
227
 
@@ -17,7 +17,9 @@
17
17
  v-model="diagnosticLog"
18
18
  label="Application log"
19
19
  :rows="10"
20
+ show-clear-button
20
21
  @copy="onCopy"
22
+ @clear="onClear"
21
23
  >
22
24
  <template #actions>
23
25
  <button type="button" @click="refreshDiagnostics">Refresh log</button>
@@ -28,6 +30,7 @@
28
30
  v-model="environmentInfo"
29
31
  label="Environment (JSON)"
30
32
  :rows="12"
33
+ show-clear-button
31
34
  markdown-ticks
32
35
  markdown-lang="json"
33
36
  />
@@ -37,14 +40,99 @@
37
40
  v-model="yamlConfig"
38
41
  label="Configuration (YAML)"
39
42
  :rows="12"
43
+ show-clear-button
40
44
  markdown-ticks
41
45
  markdown-lang="yaml"
42
46
  />
43
47
 
44
- <ReadOnlyTextArea
45
- :rows="4"
46
- placeholder="No diagnostics available."
47
- />
48
+ <div class="readonly-playground">
49
+ <p class="readonly-playground-intro">
50
+ Toggle options on the playground instance below to explore every
51
+ <code>ReadOnlyTextArea</code> prop and the actions slot.
52
+ </p>
53
+
54
+ <div
55
+ class="readonly-playground-controls"
56
+ role="group"
57
+ aria-label="ReadOnlyTextArea playground options"
58
+ >
59
+ <label class="readonly-example-toggle">
60
+ <input v-model="playground.labelEnabled" type="checkbox" />
61
+ Label
62
+ </label>
63
+ <label class="readonly-example-toggle">
64
+ <input v-model="playground.showCopyButton" type="checkbox" />
65
+ Copy button
66
+ </label>
67
+ <label class="readonly-example-toggle">
68
+ <input v-model="playground.showClearButton" type="checkbox" />
69
+ Clear button
70
+ </label>
71
+ <label class="readonly-example-toggle">
72
+ <input v-model="playground.showActions" type="checkbox" />
73
+ Actions slot
74
+ </label>
75
+ <label class="readonly-example-toggle">
76
+ <input v-model="playground.monospace" type="checkbox" />
77
+ Monospace
78
+ </label>
79
+ <label class="readonly-example-toggle">
80
+ <input v-model="playground.markdownTicks" type="checkbox" />
81
+ Markdown ticks
82
+ </label>
83
+ <label class="readonly-example-toggle">
84
+ <input
85
+ v-model="playground.markdownLangJson"
86
+ type="checkbox"
87
+ :disabled="!playground.markdownTicks"
88
+ />
89
+ Markdown lang: json
90
+ </label>
91
+ <label class="readonly-example-toggle">
92
+ <input v-model="playground.usePlaceholder" type="checkbox" />
93
+ Placeholder
94
+ </label>
95
+ <label class="readonly-example-toggle">
96
+ <input v-model="playground.customButtonLabels" type="checkbox" />
97
+ Custom button labels
98
+ </label>
99
+ <label class="readonly-example-toggle readonly-playground-rows">
100
+ <span>Rows</span>
101
+ <input
102
+ v-model.number="playground.rows"
103
+ type="number"
104
+ min="2"
105
+ max="24"
106
+ />
107
+ </label>
108
+ </div>
109
+
110
+ <ReadOnlyTextArea
111
+ v-model="playgroundContent"
112
+ :label="playground.labelEnabled ? 'Playground output' : ''"
113
+ :rows="playground.rows"
114
+ :placeholder="playground.usePlaceholder ? 'No diagnostics available.' : ''"
115
+ :monospace="playground.monospace"
116
+ :show-copy-button="playground.showCopyButton"
117
+ :show-clear-button="playground.showClearButton"
118
+ :copy-label="playground.customButtonLabels ? 'Copy all' : undefined"
119
+ :copied-label="playground.customButtonLabels ? 'Copied!' : undefined"
120
+ :clear-label="playground.customButtonLabels ? 'Reset' : undefined"
121
+ :markdown-ticks="playground.markdownTicks"
122
+ :markdown-lang="playground.markdownTicks && playground.markdownLangJson ? 'json' : ''"
123
+ @copy="onPlaygroundCopy"
124
+ @clear="onPlaygroundClear"
125
+ @copy-error="onPlaygroundCopyError"
126
+ >
127
+ <template v-if="playground.showActions" #actions>
128
+ <button type="button" @click="fillPlaygroundContent">Fill sample</button>
129
+ </template>
130
+ </ReadOnlyTextArea>
131
+
132
+ <p v-if="playgroundFeedback" class="copy-feedback subtle">
133
+ {{ playgroundFeedback }}
134
+ </p>
135
+ </div>
48
136
 
49
137
  <p v-if="lastCopied" class="copy-feedback subtle">
50
138
  Copied {{ lastCopied.length > 60 ? `${lastCopied.slice(0, 60)}…` : lastCopied }}
@@ -62,8 +150,40 @@ const diagnosticLog = ref('')
62
150
  const environmentInfo = ref('')
63
151
  const yamlConfig = ref('')
64
152
  const lastCopied = ref('')
153
+ const playgroundFeedback = ref('')
65
154
  const yamlConfigRef = ref(null)
66
155
 
156
+ const playground = ref({
157
+ labelEnabled: false,
158
+ showCopyButton: true,
159
+ showClearButton: false,
160
+ showActions: false,
161
+ monospace: true,
162
+ markdownTicks: false,
163
+ markdownLangJson: false,
164
+ usePlaceholder: false,
165
+ customButtonLabels: false,
166
+ rows: 4,
167
+ })
168
+
169
+ function buildPlaygroundSample() {
170
+ return [
171
+ 'Playground sample output',
172
+ 'Toggle the checkboxes above to try label, buttons, markdown fences, and monospace.',
173
+ '',
174
+ 'status: ok',
175
+ 'items: 3',
176
+ ].join('\n')
177
+ }
178
+
179
+ const playgroundContent = ref(buildPlaygroundSample())
180
+
181
+ function fillPlaygroundContent() {
182
+ playground.value.usePlaceholder = false
183
+ playgroundContent.value = buildPlaygroundSample()
184
+ playgroundFeedback.value = 'Filled playground with sample content.'
185
+ }
186
+
67
187
  function buildDiagnosticLog() {
68
188
  const now = new Date().toISOString()
69
189
  return [
@@ -122,6 +242,23 @@ function onCopy(value) {
122
242
  lastCopied.value = value.split('\n')[0]
123
243
  }
124
244
 
245
+ function onClear() {
246
+ lastCopied.value = ''
247
+ }
248
+
249
+ function onPlaygroundCopy(value) {
250
+ playgroundFeedback.value = `Copied ${value.split('\n').length} line(s).`
251
+ }
252
+
253
+ function onPlaygroundClear() {
254
+ playgroundContent.value = ''
255
+ playgroundFeedback.value = 'Playground cleared.'
256
+ }
257
+
258
+ function onPlaygroundCopyError(error) {
259
+ playgroundFeedback.value = `Copy failed: ${error?.message ?? error}`
260
+ }
261
+
125
262
  refreshDiagnostics()
126
263
  </script>
127
264
 
@@ -140,4 +277,34 @@ refreshDiagnostics()
140
277
  margin: 0;
141
278
  font-size: 0.875rem;
142
279
  }
280
+
281
+ .readonly-playground {
282
+ display: flex;
283
+ flex-direction: column;
284
+ gap: 0.75rem;
285
+ }
286
+
287
+ .readonly-playground-intro {
288
+ margin: 0;
289
+ }
290
+
291
+ .readonly-example-toggle {
292
+ display: inline-flex;
293
+ align-items: center;
294
+ gap: 0.35rem;
295
+ font-size: 0.875rem;
296
+ cursor: pointer;
297
+ user-select: none;
298
+ }
299
+
300
+ .readonly-playground-controls {
301
+ display: flex;
302
+ flex-wrap: wrap;
303
+ gap: 0.75rem 1rem;
304
+ }
305
+
306
+ .readonly-playground-rows input[type='number'] {
307
+ width: 4rem;
308
+ padding: 0.15rem 0.35rem;
309
+ }
143
310
  </style>