@poveste/app 0.12.3 → 0.13.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.
Files changed (71) hide show
  1. package/dist/app/style/font.css +11 -7
  2. package/dist/app/style/fonts/LICENSE +93 -0
  3. package/dist/app/style/fonts/noto-sans-display-cyrillic-400-normal.woff2 +0 -0
  4. package/dist/app/style/fonts/noto-sans-display-cyrillic-ext-400-normal.woff2 +0 -0
  5. package/dist/app/style/fonts/noto-sans-display-greek-400-normal.woff2 +0 -0
  6. package/dist/app/style/fonts/noto-sans-display-greek-ext-400-normal.woff2 +0 -0
  7. package/dist/app/style/fonts/noto-sans-display-latin-400-normal.woff2 +0 -0
  8. package/dist/app/style/fonts/noto-sans-display-latin-ext-400-normal.woff2 +0 -0
  9. package/dist/app/style/fonts/noto-sans-display-vietnamese-400-normal.woff2 +0 -0
  10. package/dist/bundled/App.vue_vue_type_script_setup_true_lang.js +30 -38
  11. package/dist/bundled/app.css +11 -17
  12. package/dist/bundled/components/base/BaseListItemLink.js +6 -3
  13. package/dist/bundled/components/base/BaseListItemLink.vue_vue_type_script_lang.js +10 -4
  14. package/dist/bundled/components/base/BaseOverflowTab.js +6 -3
  15. package/dist/bundled/components/base/BaseOverflowTab.vue_vue_type_script_lang.js +4 -0
  16. package/dist/bundled/components/base/BaseSplitPane.js +1 -1
  17. package/dist/bundled/components/base/BaseSplitPane.vue_vue_type_script_setup_true_lang.js +67 -29
  18. package/dist/bundled/components/base/BaseTab.js +6 -3
  19. package/dist/bundled/components/base/BaseTab.vue_vue_type_script_lang.js +4 -0
  20. package/dist/bundled/components/panel/PaneTabs.vue_vue_type_script_setup_true_lang.js +4 -4
  21. package/dist/bundled/components/panel/StorySourceCode.js +1 -1
  22. package/dist/bundled/components/panel/StorySourceCode.vue_vue_type_script_setup_true_lang.js +3 -3
  23. package/dist/bundled/components/story/StoryVariantGridItem.vue_vue_type_script_setup_true_lang.js +1 -1
  24. package/dist/bundled/components/story/StoryVariantSingle.js +1 -1
  25. package/dist/bundled/components/story/StoryVariantSingle.vue_vue_type_script_setup_true_lang.js +28 -43
  26. package/dist/bundled/components/story/StoryVariantSinglePreviewNative.vue_vue_type_script_setup_true_lang.js +5 -2
  27. package/dist/bundled/components/story/StoryVariantSinglePreviewRemote.vue_vue_type_script_setup_true_lang.js +4 -0
  28. package/dist/bundled/components/story/StoryVariantSingleView.vue_vue_type_script_setup_true_lang.js +23 -13
  29. package/dist/bundled/components/story/StoryView.vue_vue_type_script_setup_true_lang.js +8 -11
  30. package/dist/bundled/components/toolbar/ToolbarBackground.js +1 -3
  31. package/dist/bundled/components/toolbar/ToolbarBackground.vue_vue_type_script_setup_true_lang.js +1 -5
  32. package/dist/bundled/sandbox.js +6 -1
  33. package/dist/bundled/util/events.js +4 -1
  34. package/dist/bundled/util/highlighter.js +11 -4
  35. package/dist/bundled/util/mapping.js +5 -1
  36. package/dist/bundled/util/occupant.js +16 -0
  37. package/dist/bundled/util/variant.js +12 -1
  38. package/dist/style.css +18 -6
  39. package/package.json +8 -6
  40. package/src/__tests__/font.spec.ts +48 -2
  41. package/src/app/App.vue +77 -63
  42. package/src/app/components/base/BaseListItemLink.vue +12 -1
  43. package/src/app/components/base/BaseOverflowTab.vue +12 -1
  44. package/src/app/components/base/BaseSplitPane.vue +95 -29
  45. package/src/app/components/base/BaseTab.vue +12 -1
  46. package/src/app/components/command/CommandPrompts.vue +9 -1
  47. package/src/app/components/panel/PaneTabs.vue +4 -4
  48. package/src/app/components/panel/StorySourceCode.vue +5 -5
  49. package/src/app/components/story/StoryVariantSingle.vue +65 -67
  50. package/src/app/components/story/StoryVariantSinglePreviewNative.vue +13 -2
  51. package/src/app/components/story/StoryVariantSinglePreviewRemote.vue +8 -0
  52. package/src/app/components/story/StoryVariantSingleView.vue +59 -13
  53. package/src/app/components/story/StoryView.vue +17 -17
  54. package/src/app/components/toolbar/ToolbarBackground.vue +0 -7
  55. package/src/app/components/tree/StoryGroup.vue +1 -1
  56. package/src/app/sandbox.ts +9 -1
  57. package/src/app/style/font.css +11 -7
  58. package/src/app/style/fonts/LICENSE +93 -0
  59. package/src/app/style/fonts/noto-sans-display-cyrillic-400-normal.woff2 +0 -0
  60. package/src/app/style/fonts/noto-sans-display-cyrillic-ext-400-normal.woff2 +0 -0
  61. package/src/app/style/fonts/noto-sans-display-greek-400-normal.woff2 +0 -0
  62. package/src/app/style/fonts/noto-sans-display-greek-ext-400-normal.woff2 +0 -0
  63. package/src/app/style/fonts/noto-sans-display-latin-400-normal.woff2 +0 -0
  64. package/src/app/style/fonts/noto-sans-display-latin-ext-400-normal.woff2 +0 -0
  65. package/src/app/style/fonts/noto-sans-display-vietnamese-400-normal.woff2 +0 -0
  66. package/src/app/util/events.ts +3 -0
  67. package/src/app/util/highlighter.ts +24 -9
  68. package/src/app/util/mapping.ts +5 -1
  69. package/src/app/util/occupant.ts +13 -0
  70. package/src/app/util/variant.ts +13 -1
  71. package/LICENSE +0 -22
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@poveste/app",
3
3
  "type": "module",
4
- "version": "0.12.3",
4
+ "version": "0.13.1",
5
5
  "description": "Pre-bundled UI for Poveste",
6
6
  "author": {
7
7
  "name": "Sorin Gitlan",
@@ -38,14 +38,14 @@
38
38
  "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
39
39
  },
40
40
  "dependencies": {
41
- "@fontsource/noto-sans-display": "^5.3.0",
42
- "@poveste/controls": "^0.12.3",
43
- "@poveste/shared": "^0.12.3",
44
- "@poveste/vendors": "^0.12.3",
41
+ "@poveste/controls": "^0.13.1",
42
+ "@poveste/shared": "^0.13.1",
43
+ "@poveste/vendors": "^0.13.1",
45
44
  "fuse.js": "^7.0.0",
46
45
  "shiki": "^3.0.0"
47
46
  },
48
47
  "devDependencies": {
48
+ "@fontsource/noto-sans-display": "^5.3.0",
49
49
  "@iconify-json/carbon": "^1.2.25",
50
50
  "@iconify-json/fluent": "1.2.56",
51
51
  "@iconify-json/mdi": "^1.2.3",
@@ -67,10 +67,12 @@
67
67
  "typescript": "5.6.3",
68
68
  "vite": "^8.2.0",
69
69
  "vitest": "^4.1.10",
70
- "vue": "^3.5.26"
70
+ "vue": "^3.5.26",
71
+ "vue-tsc": "^2.1.10"
71
72
  },
72
73
  "scripts": {
73
74
  "build": "pnpm run icons && rimraf dist && vite build && pnpm run build:css && node ./scripts/copy.mjs",
75
+ "typecheck": "vue-tsc --noEmit",
74
76
  "build:css": "postcss src/app/style/main.pcss -o ./dist/style.css",
75
77
  "icons": "node ./scripts/icons.mjs",
76
78
  "test": "vitest run",
@@ -1,8 +1,8 @@
1
- import { readFileSync } from 'node:fs'
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { describe, expect, it } from 'vitest'
4
4
  // @ts-expect-error plain script, no types
5
- import { FONT_PACKAGE, generateFontCss } from '../../scripts/font.mjs'
5
+ import { FONT_PACKAGE, generateFontCss, referencedFiles } from '../../scripts/font.mjs'
6
6
 
7
7
  // Vitest runs from the package root, and stubs `?raw` CSS imports to empty.
8
8
  // Newlines are normalised: the shipped sheet is checked in, so a Windows
@@ -13,6 +13,52 @@ const read = (path: string) => readFileSync(join(process.cwd(), path), 'utf8').r
13
13
  const shipped = read('src/app/style/font.css')
14
14
  const upstream = read(join('node_modules', FONT_PACKAGE, 'index.css'))
15
15
 
16
+ describe('the vendored files', () => {
17
+ // The sheet and the files are one artifact now that the package is a
18
+ // devDependency: a book asking for a face that is not on disk renders the
19
+ // fallback, and nothing else would say so (#306).
20
+ it('ships every file the sheet asks for', () => {
21
+ const missing = referencedFiles(shipped).filter(file => !existsSync(join(process.cwd(), 'src/app/style/fonts', file)))
22
+
23
+ expect(missing).toEqual([])
24
+ })
25
+
26
+ // The other direction: a subset upstream drops or renames leaves a file the
27
+ // sheet no longer names, and `files: ['dist', 'src']` would publish it.
28
+ it('ships nothing the sheet does not ask for', () => {
29
+ const named = new Set([...referencedFiles(shipped), 'LICENSE'])
30
+ const orphans = readdirSync(join(process.cwd(), 'src/app/style/fonts')).filter(file => !named.has(file))
31
+
32
+ expect(orphans).toEqual([])
33
+ })
34
+
35
+ it('asks for at least one, so a sheet that references nothing is not a pass', () => {
36
+ expect(referencedFiles(shipped).length).toBeGreaterThan(0)
37
+ })
38
+
39
+ // SIL OFL requires the notice to travel with the font.
40
+ it('carries the licence the font is under', () => {
41
+ expect(read('src/app/style/fonts/LICENSE')).toContain('SIL Open Font License')
42
+ })
43
+
44
+ // Vendoring froze these bytes. A Fontsource patch that fixes hinting or adds
45
+ // a glyph keeps the filenames and unicode-ranges, so the sheet comparison
46
+ // below stays green while the files it names are stale — the property that
47
+ // installing the package used to provide for free.
48
+ it('matches upstream byte for byte, since a bump no longer ships itself', () => {
49
+ const stale = referencedFiles(shipped).filter(file => !readFileSync(join(process.cwd(), 'src/app/style/fonts', file))
50
+ .equals(readFileSync(join(process.cwd(), 'node_modules', FONT_PACKAGE, 'files', file))))
51
+
52
+ expect(stale).toEqual([])
53
+ })
54
+
55
+ // `read`, not `readFileSync`: the licence is checked in, so a Windows
56
+ // checkout hands it back with CRLF against the LF of the installed copy.
57
+ it('carries upstream\'s own licence text, not a copy that has drifted', () => {
58
+ expect(read('src/app/style/fonts/LICENSE')).toBe(read(join('node_modules', FONT_PACKAGE, 'LICENSE')))
59
+ })
60
+ })
61
+
16
62
  describe('the bundled app face', () => {
17
63
  it('matches the installed package, so a bump cannot go unnoticed', () => {
18
64
  expect(shipped).toBe(generateFontCss(upstream))
package/src/app/App.vue CHANGED
@@ -107,6 +107,15 @@ onMounted(() => {
107
107
 
108
108
  const commandStore = useCommandStore()
109
109
  const layoutStore = useLayoutStore()
110
+
111
+ /*
112
+ * Coerced once, because the pane prop and the heading below have to be exact
113
+ * complements. A stored settings object written before a key existed leaves it
114
+ * `undefined`, which a Boolean prop reads as its `true` default while `!` reads
115
+ * as `false` — so both the real header and its stand-in rendered, giving the
116
+ * page two `h1`s and two banners (#596).
117
+ */
118
+ const storyListVisible = computed(() => !!layoutStore.settings.storyListVisible)
110
119
  </script>
111
120
 
112
121
  <template>
@@ -132,79 +141,84 @@ const layoutStore = useLayoutStore()
132
141
  }"
133
142
  >
134
143
  <div
135
- v-if="isMobile"
136
- class="h-full flex flex-col divide-y divide-gray-100 dark:divide-gray-800"
137
- >
138
- <div class="flex items-center gap-2 pr-4">
139
- <AppHeader class="flex-1" />
140
- <AppActions
141
- class="flex-none"
142
- @layout="isLayoutOpen = true"
143
- @search="isSearchOpen = true"
144
- />
145
- </div>
146
- <Breadcrumb
147
- :tree="tree"
148
- :stories="stories"
149
- />
150
- <main class="grow flex flex-col min-h-0">
151
- <RouterView class="grow" />
152
- </main>
153
- </div>
154
-
155
- <BaseSplitPane
156
- v-else-if="layoutStore.settings.storyListVisible"
157
- save-id="main-horiz"
158
- :min="5"
159
- :max="50"
160
- :default-split="15"
161
- class="h-full"
144
+ class="h-full flex flex-col"
145
+ :class="{ 'divide-y divide-gray-100 dark:divide-gray-800': isMobile }"
162
146
  >
163
- <template #first>
164
- <div class="flex flex-col h-full bg-gray-100 dark:bg-gray-750 __poveste-pane-shadow-from-right">
165
- <AppHeader class="flex-none" />
166
- <nav
167
- aria-label="Stories"
168
- class="flex-1 flex flex-col min-h-0"
169
- >
170
- <StoryList
171
- :tree="tree"
172
- :stories="stories"
173
- class="flex-1"
174
- />
175
- </nav>
176
- </div>
177
- </template>
178
-
179
- <template #last>
180
- <main class="flex flex-col h-full">
181
- <TopBar
147
+ <!--
148
+ The mobile chrome is a sibling of `main`, not a child: `AppHeader`
149
+ carries the page's banner and its only `h1`, and a `header` inside
150
+ `main` is neither (#310).
151
+ -->
152
+ <template v-if="isMobile">
153
+ <div class="flex items-center gap-2 pr-4">
154
+ <AppHeader class="flex-1" />
155
+ <AppActions
156
+ class="flex-none"
182
157
  @layout="isLayoutOpen = true"
183
158
  @search="isSearchOpen = true"
184
159
  />
185
- <RouterView class="flex-1 min-h-0" />
186
- </main>
160
+ </div>
161
+ <Breadcrumb
162
+ :tree="tree"
163
+ :stories="stories"
164
+ />
187
165
  </template>
188
- </BaseSplitPane>
189
- <template v-else>
166
+
190
167
  <!--
191
- The story list is hidden in this layout, and `AppHeader` went with it —
192
- taking the page's only `h1` and its banner. A heading has to exist in
193
- every layout, and it has to sit inside a landmark, so this branch
194
- carries its own (#310).
168
+ With the story list hidden `AppHeader` goes with it, taking the page's
169
+ only `h1` and its banner. A heading has to exist in every layout, and
170
+ it has to sit inside a landmark, so this stands in for it (#310).
195
171
  -->
196
- <header class="sr-only">
172
+ <header
173
+ v-else-if="!storyListVisible"
174
+ class="sr-only"
175
+ >
197
176
  <h1>{{ povesteConfig.theme?.title ?? 'Poveste' }}</h1>
198
177
  </header>
199
178
 
200
- <main class="h-full flex flex-col">
201
- <TopBar
202
- @layout="isLayoutOpen = true"
203
- @search="isSearchOpen = true"
204
- />
205
- <RouterView class="flex-1 min-h-0" />
206
- </main>
207
- </template>
179
+ <!--
180
+ One split pane for every layout, mobile included. Each chrome used to
181
+ be a branch carrying its own `RouterView`, so switching between them
182
+ moved the routed view in the tree and cold-booted the sandbox under
183
+ it. The settings toggles were #596; `isMobile` is a live media query,
184
+ so this one fired on a window resize (#600).
185
+ -->
186
+ <BaseSplitPane
187
+ save-id="main-horiz"
188
+ :min="5"
189
+ :max="50"
190
+ :default-split="15"
191
+ :show-first="!isMobile && storyListVisible"
192
+ class="flex-1 min-h-0"
193
+ >
194
+ <template #first>
195
+ <div class="flex flex-col h-full bg-gray-100 dark:bg-gray-750 __poveste-pane-shadow-from-right">
196
+ <AppHeader class="flex-none" />
197
+ <nav
198
+ aria-label="Stories"
199
+ class="flex-1 flex flex-col min-h-0"
200
+ >
201
+ <StoryList
202
+ :tree="tree"
203
+ :stories="stories"
204
+ class="flex-1"
205
+ />
206
+ </nav>
207
+ </div>
208
+ </template>
209
+
210
+ <template #last>
211
+ <main class="flex flex-col h-full">
212
+ <TopBar
213
+ v-if="!isMobile"
214
+ @layout="isLayoutOpen = true"
215
+ @search="isSearchOpen = true"
216
+ />
217
+ <RouterView class="flex-1 min-h-0" />
218
+ </main>
219
+ </template>
220
+ </BaseSplitPane>
221
+ </div>
208
222
 
209
223
  <LayoutModal
210
224
  v-if="!isMobile"
@@ -1,10 +1,20 @@
1
1
  <script lang="ts">
2
+ import type { PropType } from 'vue'
3
+ import type { RouteLocationRaw } from 'vue-router'
2
4
  import { defineComponent } from 'vue'
3
5
 
4
6
  export default defineComponent({
5
7
  inheritAttrs: false,
6
8
 
7
9
  props: {
10
+ // Declared rather than passed through `$attrs`: with
11
+ // `inheritAttrs: false` the checker cannot see it there, and
12
+ // router-link requires it.
13
+ to: {
14
+ type: [String, Object] as PropType<RouteLocationRaw>,
15
+ required: true,
16
+ },
17
+
8
18
  isActive: {
9
19
  type: Boolean,
10
20
  default: undefined,
@@ -31,6 +41,7 @@ export default defineComponent({
31
41
  <template>
32
42
  <RouterLink
33
43
  v-slot="{ isActive: linkIsActive, href, navigate }"
44
+ :to="to"
34
45
  class="poveste-base-list-item-link"
35
46
  v-bind="$attrs"
36
47
  custom
@@ -42,7 +53,7 @@ export default defineComponent({
42
53
  :class="[
43
54
  $attrs.class,
44
55
  (isActive != null ? isActive : linkIsActive)
45
- ? 'active bg-primary-500 hover:bg-primary-600 text-white dark:text-black'
56
+ ? 'active bg-primary-700 hover:bg-primary-800 dark:bg-primary-500 dark:hover:bg-primary-600 text-white dark:text-black'
46
57
  : 'hover:bg-primary-100 dark:hover:bg-primary-900',
47
58
  ]"
48
59
  @click="handleNavigate($event, navigate)"
@@ -1,10 +1,20 @@
1
1
  <script lang="ts">
2
+ import type { PropType } from 'vue'
3
+ import type { RouteLocationRaw } from 'vue-router'
2
4
  import { defineComponent } from 'vue'
3
5
 
4
6
  export default defineComponent({
5
7
  inheritAttrs: false,
6
8
 
7
9
  props: {
10
+ // Declared rather than passed through `$attrs`: with
11
+ // `inheritAttrs: false` the checker cannot see it there, and
12
+ // router-link requires it.
13
+ to: {
14
+ type: [String, Object] as PropType<RouteLocationRaw>,
15
+ required: true,
16
+ },
17
+
8
18
  exact: {
9
19
  type: Boolean,
10
20
  default: false,
@@ -21,6 +31,7 @@ export default defineComponent({
21
31
  <template>
22
32
  <router-link
23
33
  v-slot="{ isActive, isExactActive, href, navigate }"
34
+ :to="to"
24
35
  class="poveste-base-overflow-tab"
25
36
  v-bind="$attrs"
26
37
  custom
@@ -30,7 +41,7 @@ export default defineComponent({
30
41
  :href="href"
31
42
  class="px-4 h-10 min-w-[150px] inline-flex items-center hover:bg-primary-50 dark:hover:bg-primary-900 relative text-gray-900 dark:text-gray-100"
32
43
  :class="{
33
- 'text-primary-500 dark:text-primary-400': matched != null ? matched : (exact && isExactActive) || (!exact && isActive),
44
+ 'text-primary-700 dark:text-primary-400': matched != null ? matched : (exact && isExactActive) || (!exact && isActive),
34
45
  }"
35
46
  @click="navigate"
36
47
  >
@@ -48,6 +48,34 @@ const props = defineProps({
48
48
  type: Boolean,
49
49
  default: true,
50
50
  },
51
+
52
+ /**
53
+ * Whether the first pane exists at all.
54
+ *
55
+ * Dropping it here rather than at the call site keeps the other slot at one
56
+ * position in the tree, so a layout that sometimes has nothing to put beside
57
+ * its content does not remount that content when it gains or loses the pane
58
+ * (#328). A `v-if` inside this component leaves a comment placeholder, so the
59
+ * surviving pane keeps its index and Vue patches it rather than rebuilding it;
60
+ * a `v-if` at the call site, choosing between a pane and a bare element, is
61
+ * what does not.
62
+ */
63
+ showFirst: {
64
+ type: Boolean,
65
+ default: true,
66
+ },
67
+
68
+ /**
69
+ * The same for the last pane — see `showFirst` (#596).
70
+ *
71
+ * At most one of the two is meant to be false. Both false renders an empty
72
+ * box that still persists a split, which is a caller mistake rather than a
73
+ * mode this component supports.
74
+ */
75
+ showLast: {
76
+ type: Boolean,
77
+ default: true,
78
+ },
51
79
  })
52
80
 
53
81
  const emit = defineEmits({
@@ -66,36 +94,48 @@ watch(() => props.split, (value) => {
66
94
  immediate: true,
67
95
  })
68
96
 
69
- if (props.saveId) {
70
- const storageKey = `${SAVE_PREFIX}-split-pane-${props.saveId}`
97
+ /*
98
+ * The key follows `saveId`, which callers build from reactive state —
99
+ * `story-main-${placement}` changes when the reader moves the options pane.
100
+ * Reading it once at setup only looked right while a layout change happened to
101
+ * remount this component; hiding a pane instead of replacing it removed that,
102
+ * and a stale key writes one placement's split over the other's (#596).
103
+ */
104
+ const storageKey = computed(() => props.saveId ? `${SAVE_PREFIX}-split-pane-${props.saveId}` : null)
105
+
106
+ watch(storageKey, (key) => {
107
+ if (!key) {
108
+ return
109
+ }
71
110
 
72
- const savedValue = localStorage.getItem(storageKey)
111
+ const savedValue = localStorage.getItem(key)
112
+ let parsedValue
73
113
  if (savedValue != null) {
74
- let parsedValue
75
114
  try {
76
115
  parsedValue = JSON.parse(savedValue)
77
116
  }
78
117
  catch (e) {
79
118
  console.error(e)
80
119
  }
81
-
82
- if (typeof parsedValue === 'number') {
83
- currentSplit.value = parsedValue
84
- }
85
120
  }
86
121
 
87
- watch(currentSplit, (value) => {
88
- localStorage.setItem(storageKey, JSON.stringify(value))
89
- })
122
+ // A key with nothing behind it means this layout has never been sized, so it
123
+ // starts from its own default rather than keeping the previous layout's.
124
+ currentSplit.value = typeof parsedValue === 'number' ? parsedValue : props.defaultSplit
125
+ }, {
126
+ immediate: true,
127
+ })
90
128
 
91
- watch(currentSplit, (value) => {
92
- if (value !== props.split) {
93
- emit('update:split', value)
94
- }
95
- }, {
96
- immediate: true,
97
- })
98
- }
129
+ watch(currentSplit, (value) => {
130
+ if (storageKey.value) {
131
+ localStorage.setItem(storageKey.value, JSON.stringify(value))
132
+ }
133
+ if (value !== props.split) {
134
+ emit('update:split', value)
135
+ }
136
+ }, {
137
+ immediate: true,
138
+ })
99
139
 
100
140
  const boundSplit = computed(() => {
101
141
  if (currentSplit.value < props.min) {
@@ -109,13 +149,27 @@ const boundSplit = computed(() => {
109
149
  }
110
150
  })
111
151
 
112
- const leftStyle = computed(() => ({
113
- [props.orientation === 'landscape' ? 'width' : 'height']: props.fixed ? `${boundSplit.value}px` : `${boundSplit.value}%`,
114
- }))
152
+ const axis = computed(() => props.orientation === 'landscape' ? 'width' : 'height')
115
153
 
116
- const rightStyle = computed(() => ({
117
- [props.orientation === 'landscape' ? 'width' : 'height']: props.fixed ? null : `${100 - boundSplit.value}%`,
118
- }))
154
+ /** Whether there are two panes to divide, drag between, or share the axis. */
155
+ const hasBothPanes = computed(() => props.showFirst && props.showLast)
156
+
157
+ // A sole pane takes the axis whichever half it is, and loses the `flex-none` /
158
+ // `flex-1` modifiers below that only mean something against a sibling — the
159
+ // `fixed` case included, where a pixel width would pin it beside dead space.
160
+ const leftStyle = computed(() => {
161
+ if (!hasBothPanes.value) {
162
+ return { [axis.value]: '100%' }
163
+ }
164
+ return { [axis.value]: props.fixed ? `${boundSplit.value}px` : `${boundSplit.value}%` }
165
+ })
166
+
167
+ const rightStyle = computed(() => {
168
+ if (!hasBothPanes.value) {
169
+ return { [axis.value]: '100%' }
170
+ }
171
+ return { [axis.value]: props.fixed ? null : `${100 - boundSplit.value}%` }
172
+ })
119
173
 
120
174
  const dragging = ref(false)
121
175
  let startPosition = 0
@@ -157,6 +211,15 @@ function dragEnd() {
157
211
  removeDragListeners()
158
212
  }
159
213
 
214
+ // The dragger goes with the boundary, but the listeners are on `window` and
215
+ // this component now outlives the toggle that removed it — so a drag in flight
216
+ // would otherwise keep writing a split for a divider that is no longer there.
217
+ watch(hasBothPanes, (both) => {
218
+ if (!both && dragging.value) {
219
+ dragEnd()
220
+ }
221
+ })
222
+
160
223
  function removeDragListeners() {
161
224
  window.removeEventListener('mousemove', dragMove)
162
225
  window.removeEventListener('mouseup', dragEnd)
@@ -179,17 +242,19 @@ onUnmounted(() => {
179
242
  }"
180
243
  >
181
244
  <div
245
+ v-if="showFirst"
182
246
  class="relative top-0 left-0 z-20"
183
247
  :class="{
184
248
  'pointer-events-none': dragging,
185
- 'border-r border-gray-300/30 dark:border-gray-800': orientation === 'landscape' && showDivider,
186
- 'flex-none': fixed,
249
+ 'border-r border-gray-300/30 dark:border-gray-800': orientation === 'landscape' && showDivider && hasBothPanes,
250
+ 'flex-none': fixed && hasBothPanes,
187
251
  }"
188
252
  :style="leftStyle"
189
253
  >
190
254
  <slot name="first" />
191
255
 
192
256
  <div
257
+ v-if="hasBothPanes"
193
258
  class="dragger absolute z-100 hover:bg-primary-500/50 transition-colors duration-150 delay-150"
194
259
  :class="{
195
260
  'top-0 bottom-0 cursor-ew-resize': orientation === 'landscape',
@@ -201,11 +266,12 @@ onUnmounted(() => {
201
266
  />
202
267
  </div>
203
268
  <div
269
+ v-if="showLast"
204
270
  class="relative bottom-0 right-0"
205
271
  :class="{
206
272
  'pointer-events-none': dragging,
207
- 'border-t border-gray-300/30 dark:border-gray-800': orientation === 'portrait' && showDivider,
208
- 'flex-1': fixed,
273
+ 'border-t border-gray-300/30 dark:border-gray-800': orientation === 'portrait' && showDivider && hasBothPanes,
274
+ 'flex-1': fixed && hasBothPanes,
209
275
  }"
210
276
  :style="rightStyle"
211
277
  >
@@ -1,10 +1,20 @@
1
1
  <script lang="ts">
2
+ import type { PropType } from 'vue'
3
+ import type { RouteLocationRaw } from 'vue-router'
2
4
  import { defineComponent } from 'vue'
3
5
 
4
6
  export default defineComponent({
5
7
  inheritAttrs: false,
6
8
 
7
9
  props: {
10
+ // Declared rather than passed through `$attrs`: with
11
+ // `inheritAttrs: false` the checker cannot see it there, and
12
+ // router-link requires it.
13
+ to: {
14
+ type: [String, Object] as PropType<RouteLocationRaw>,
15
+ required: true,
16
+ },
17
+
8
18
  exact: {
9
19
  type: Boolean,
10
20
  default: false,
@@ -21,6 +31,7 @@ export default defineComponent({
21
31
  <template>
22
32
  <router-link
23
33
  v-slot="{ isActive, isExactActive, href, navigate }"
34
+ :to="to"
24
35
  class="poveste-base-tab"
25
36
  v-bind="$attrs"
26
37
  custom
@@ -30,7 +41,7 @@ export default defineComponent({
30
41
  :href="href"
31
42
  class="px-4 h-full inline-flex items-center hover:bg-primary-50 dark:hover:bg-primary-900 relative text-gray-900 dark:text-gray-100"
32
43
  :class="{
33
- 'text-primary-500 dark:text-primary-400': matched != null ? matched : (exact && isExactActive) || (!exact && isActive),
44
+ 'text-primary-700 dark:text-primary-400': matched != null ? matched : (exact && isExactActive) || (!exact && isActive),
34
45
  }"
35
46
  @click="navigate"
36
47
  >
@@ -1,5 +1,6 @@
1
1
  <script lang="ts" setup>
2
2
  import type { ClientCommand } from '@poveste/shared'
3
+ import type { Component } from 'vue'
3
4
  import { nextTick, onMounted, reactive, ref } from 'vue'
4
5
  import { executeCommand, getCommandContext } from '../../util/commands.js'
5
6
  import BaseButton from '../base/BaseButton.vue'
@@ -67,8 +68,15 @@ onMounted(() => {
67
68
  {{ command.label }}
68
69
  </div>
69
70
 
71
+ <!--
72
+ `promptTypes[prompt.type]` resolves to the component whose `prompt` prop
73
+ is that member of the union, but nothing states the correlation and the
74
+ checker cannot infer it. Splitting this into `v-if` branches per type is
75
+ what makes it checkable; that is a refactor of untested UI, so it waits
76
+ for coverage rather than riding along with the type gate (#441).
77
+ -->
70
78
  <component
71
- :is="promptTypes[prompt.type]"
79
+ :is="promptTypes[prompt.type] as Component"
72
80
  v-for="(prompt, index) of command.prompts"
73
81
  :key="prompt.field"
74
82
  ref="promptComps"
@@ -33,7 +33,7 @@ const hasEvents = computed(() => eventsStore.events.length)
33
33
  :to="{ ...$route, query: { ...$route.query, tab: 'docs' } }"
34
34
  :matched="$route.query.tab === 'docs'"
35
35
  :class="{
36
- 'opacity-50': !renderedDoc,
36
+ 'opacity-70': !renderedDoc,
37
37
  }"
38
38
  >
39
39
  Docs
@@ -42,7 +42,7 @@ const hasEvents = computed(() => eventsStore.events.length)
42
42
  :to="{ ...$route, query: { ...$route.query, tab: 'events' } }"
43
43
  :matched="$route.query.tab === 'events'"
44
44
  :class="{
45
- 'opacity-50': !hasEvents,
45
+ 'opacity-70': !hasEvents,
46
46
  }"
47
47
  >
48
48
  Events
@@ -62,7 +62,7 @@ const hasEvents = computed(() => eventsStore.events.length)
62
62
  :to="{ ...$route, query: { ...$route.query, tab: 'docs' } }"
63
63
  :matched="$route.query.tab === 'docs'"
64
64
  :class="{
65
- 'opacity-50': !renderedDoc,
65
+ 'opacity-70': !renderedDoc,
66
66
  }"
67
67
  >
68
68
  Docs
@@ -71,7 +71,7 @@ const hasEvents = computed(() => eventsStore.events.length)
71
71
  :to="{ ...$route, query: { ...$route.query, tab: 'events' } }"
72
72
  :matched="$route.query.tab === 'events'"
73
73
  :class="{
74
- 'opacity-50': !hasEvents,
74
+ 'opacity-70': !hasEvents,
75
75
  }"
76
76
  >
77
77
  Events