@cat-factory/app 0.274.0 → 0.276.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.
- package/README.md +158 -4
- package/app/components/board/LaneViewControl.vue +87 -0
- package/app/components/board/nodes/BlockNode.vue +31 -11
- package/app/components/board/nodes/FrameSwimlanes.vue +139 -0
- package/app/components/board/nodes/InitiativeCard.vue +9 -28
- package/app/components/board/nodes/LaneGroup.vue +93 -0
- package/app/components/board/nodes/LaneTask.vue +66 -0
- package/app/components/board/nodes/TaskCard.vue +16 -2
- package/app/components/board/nodes/TaskLane.vue +82 -0
- package/app/components/layout/BoardToolbar.vue +4 -0
- package/app/components/layout/CommandBar.vue +8 -1
- package/app/components/layout/RolePrompt.vue +75 -0
- package/app/components/layout/SideBar.vue +22 -13
- package/app/components/layout/UiRoleSwitcher.vue +73 -0
- package/app/components/panels/InspectorPanel.vue +15 -2
- package/app/components/panels/inspector/TaskStructure.vue +70 -3
- package/app/components/settings/WorkspaceSettingsPanel.vue +59 -0
- package/app/composables/useBlockDrag.ts +47 -17
- package/app/composables/useBlockQueries.ts +27 -24
- package/app/composables/useFrameLanes.ts +177 -0
- package/app/composables/useNavContributions.ts +3 -0
- package/app/composables/useTaskExpansion.ts +1 -1
- package/app/docs/consumer-extensions.md +20 -6
- package/app/modular/external-tools.spec.ts +0 -45
- package/app/modular/external-tools.ts +12 -23
- package/app/modular/nav-contributions.spec.ts +176 -17
- package/app/modular/nav-contributions.ts +106 -23
- package/app/modular/nav-gates.ts +11 -2
- package/app/modular/registry.spec.ts +1 -0
- package/app/modular/tutorial-tours.spec.ts +5 -3
- package/app/modular/tutorial-tours.ts +53 -8
- package/app/pages/index.vue +36 -7
- package/app/stores/board/placement.ts +7 -0
- package/app/stores/board.spec.ts +119 -14
- package/app/stores/laneView.spec.ts +61 -0
- package/app/stores/laneView.ts +85 -0
- package/app/stores/launchPrompt.ts +63 -0
- package/app/stores/taskExpansion.spec.ts +1 -1
- package/app/stores/taskExpansion.ts +1 -1
- package/app/stores/tutorial.ts +4 -4
- package/app/stores/uiMode.spec.ts +11 -0
- package/app/stores/uiMode.ts +14 -2
- package/app/stores/uiRole.spec.ts +185 -0
- package/app/stores/uiRole.ts +86 -0
- package/app/stores/workspaceSettings.ts +4 -0
- package/app/utils/framePlacement.ts +9 -4
- package/app/utils/laneGeometry.spec.ts +69 -0
- package/app/utils/laneGeometry.ts +104 -0
- package/app/utils/laneSort.spec.ts +236 -0
- package/app/utils/laneSort.ts +306 -0
- package/app/utils/swimlanes.spec.ts +259 -0
- package/app/utils/swimlanes.ts +355 -0
- package/app/utils/uiMode.spec.ts +12 -0
- package/app/utils/uiMode.ts +24 -6
- package/app/utils/uiRole.ts +123 -0
- package/i18n/locales/de.json +104 -3
- package/i18n/locales/en.json +110 -3
- package/i18n/locales/es.json +104 -3
- package/i18n/locales/fr.json +104 -3
- package/i18n/locales/he.json +104 -3
- package/i18n/locales/it.json +104 -3
- package/i18n/locales/ja.json +104 -3
- package/i18n/locales/pl.json +104 -3
- package/i18n/locales/tr.json +104 -3
- package/i18n/locales/uk.json +104 -3
- package/package.json +2 -2
- package/app/components/board/nodes/DraggableTask.vue +0 -58
- package/app/components/board/nodes/ModuleFrame.vue +0 -73
- package/app/stores/tutorial.prompt.ts +0 -59
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import LaneTask from './LaneTask.vue'
|
|
3
|
+
import { MODULE_META, taskTypeMeta } from '~/utils/catalog'
|
|
4
|
+
import { LANE_GEOMETRY } from '~/utils/laneGeometry'
|
|
5
|
+
import type { LaneGroup, LaneGroupKey } from '~/utils/laneSort'
|
|
6
|
+
import { LANE_REASON_LABEL_KEYS, type LaneReason } from '~/utils/swimlanes'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One labelled run of cards inside a lane.
|
|
10
|
+
*
|
|
11
|
+
* The WRAPPER carries `data-drop-zone`, not the header, so dropping anywhere in a module's group
|
|
12
|
+
* — on its title or on a card already in it — means "into that module". With the zone on the
|
|
13
|
+
* header alone, a drop onto one of the group's own cards would fall through to the lane's frame
|
|
14
|
+
* zone and reparent the card OUT of the module it was dropped into, which is the opposite of
|
|
15
|
+
* what the gesture said.
|
|
16
|
+
*
|
|
17
|
+
* That drop target is also why module grouping matters beyond presentation: module sub-frames no
|
|
18
|
+
* longer render as boxes, so this is the board's drag route into a module. The inspector's module
|
|
19
|
+
* picker is the route that does not depend on the current grouping.
|
|
20
|
+
*/
|
|
21
|
+
const props = withDefaults(
|
|
22
|
+
defineProps<{
|
|
23
|
+
group: LaneGroup
|
|
24
|
+
groupKey: LaneGroupKey
|
|
25
|
+
/** The enclosing service frame, so the catch-all group can be the way back OUT of a module. */
|
|
26
|
+
frameId: string
|
|
27
|
+
/**
|
|
28
|
+
* How the group's own cards run: down a lane column (the default) or wrapped across the full
|
|
29
|
+
* width of the Done strip, which is what makes the opened archive a grid rather than a column.
|
|
30
|
+
*/
|
|
31
|
+
layout?: 'column' | 'grid'
|
|
32
|
+
}>(),
|
|
33
|
+
{ layout: 'column' },
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const { t } = useI18n()
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Which block a drop onto this group reparents into.
|
|
40
|
+
*
|
|
41
|
+
* Only meaningful while grouping BY MODULE: a named group targets its module block, and the
|
|
42
|
+
* catch-all ("no module") targets the frame, which is what makes dragging a card out of a module
|
|
43
|
+
* possible. Under any other grouping the group is not a container at all, so it declares no zone
|
|
44
|
+
* and drops fall through to the lane's own frame zone.
|
|
45
|
+
*/
|
|
46
|
+
const dropZone = computed(() => {
|
|
47
|
+
if (props.groupKey !== 'module') return null
|
|
48
|
+
return props.group.label == null ? props.frameId : props.group.id
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
/** Group labels are DATA (a module name, a type, a reason), so each kind is rendered as itself. */
|
|
52
|
+
const label = computed(() => {
|
|
53
|
+
const raw = props.group.label
|
|
54
|
+
// `none` grouping renders no header at all, so it has no catch-all label to name.
|
|
55
|
+
if (props.groupKey === 'none') return ''
|
|
56
|
+
if (raw == null) return t(`board.lanes.group.catchAll.${props.groupKey}`)
|
|
57
|
+
if (props.groupKey === 'task_type') return taskTypeMeta(raw).label
|
|
58
|
+
if (props.groupKey === 'blocking_reason') {
|
|
59
|
+
return t(LANE_REASON_LABEL_KEYS[raw as LaneReason] ?? 'board.lanes.reason.unclassified')
|
|
60
|
+
}
|
|
61
|
+
return raw
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const icon = computed(() => {
|
|
65
|
+
if (props.groupKey === 'module') return MODULE_META.icon
|
|
66
|
+
if (props.groupKey === 'initiative') return 'i-lucide-flag'
|
|
67
|
+
if (props.groupKey === 'epic') return 'i-lucide-layers'
|
|
68
|
+
return null
|
|
69
|
+
})
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<template>
|
|
73
|
+
<div :data-drop-zone="dropZone ?? undefined" class="space-y-1.5">
|
|
74
|
+
<!-- `none` grouping renders no header: one unlabelled group IS the flat lane, and a header
|
|
75
|
+
saying "all of them" would be a row of chrome carrying no information. -->
|
|
76
|
+
<div
|
|
77
|
+
v-if="groupKey !== 'none'"
|
|
78
|
+
class="flex items-center gap-1 px-0.5 text-[10px] uppercase tracking-wide text-slate-500"
|
|
79
|
+
>
|
|
80
|
+
<UIcon v-if="icon" :name="icon" class="h-3 w-3 shrink-0" />
|
|
81
|
+
<span class="truncate" :title="label">{{ label }}</span>
|
|
82
|
+
<span class="ms-auto shrink-0 tabular-nums">{{ group.entries.length }}</span>
|
|
83
|
+
</div>
|
|
84
|
+
<div :class="layout === 'grid' ? 'flex flex-wrap items-start gap-2' : 'space-y-1.5'">
|
|
85
|
+
<LaneTask
|
|
86
|
+
v-for="entry in group.entries"
|
|
87
|
+
:key="entry.task.id"
|
|
88
|
+
:task-id="entry.task.id"
|
|
89
|
+
:style="layout === 'grid' ? { width: LANE_GEOMETRY.cardWidth + 'px' } : undefined"
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</template>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import TaskCard from './TaskCard.vue'
|
|
3
|
+
import { useBlockDrag } from '~/composables/useBlockDrag'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One task card in a swimlane.
|
|
7
|
+
*
|
|
8
|
+
* Replaces the old `DraggableTask`, and the difference is the whole point of the lanes: a card
|
|
9
|
+
* no longer carries coordinates. It sits where its lane's order puts it, so this wrapper is an
|
|
10
|
+
* ordinary flow item and the drag it starts is REPARENT-ONLY (`positioned: false`) — moving work
|
|
11
|
+
* between services, and into or out of a module, which is what a task drag was always actually
|
|
12
|
+
* for. Nothing is previewed while dragging, because there is nowhere to preview to.
|
|
13
|
+
*
|
|
14
|
+
* It also renders merged tasks, where `DraggableTask` returned nothing for them. That is what
|
|
15
|
+
* the Done lane needed: a finished task used to vanish from the board entirely, so a service's
|
|
16
|
+
* own history was invisible on it.
|
|
17
|
+
*/
|
|
18
|
+
const props = defineProps<{ taskId: string }>()
|
|
19
|
+
|
|
20
|
+
const board = useBoardStore()
|
|
21
|
+
const access = useWorkspaceAccess()
|
|
22
|
+
const expansion = useTaskExpansionStore()
|
|
23
|
+
const { t } = useI18n()
|
|
24
|
+
const { draggingId, startDrag } = useBlockDrag()
|
|
25
|
+
|
|
26
|
+
const task = computed(() => board.getBlock(props.taskId))
|
|
27
|
+
const dragging = computed(() => draggingId.value === props.taskId)
|
|
28
|
+
|
|
29
|
+
// An expanded pipeline overlays its neighbours, so it must stack above the compact cards
|
|
30
|
+
// around it. Reads the same predicate the pipeline itself renders on.
|
|
31
|
+
const expanded = computed(() => expansion.isExpanded(props.taskId))
|
|
32
|
+
|
|
33
|
+
function onHandle(e: PointerEvent) {
|
|
34
|
+
if (task.value) startDrag(task.value, e, { reparent: true, positioned: false })
|
|
35
|
+
}
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<template>
|
|
39
|
+
<div
|
|
40
|
+
v-if="task"
|
|
41
|
+
class="relative"
|
|
42
|
+
:style="{
|
|
43
|
+
zIndex: dragging ? 60 : expanded ? 20 : 10,
|
|
44
|
+
// While this card is being dragged it must not capture hit-tests, so the drop zone
|
|
45
|
+
// beneath the cursor (a lane, or a module's group) resolves on release. The handle sits
|
|
46
|
+
// in this wrapper above the card and would otherwise mask the zone under it.
|
|
47
|
+
pointerEvents: dragging ? 'none' : undefined,
|
|
48
|
+
}"
|
|
49
|
+
:class="{ 'opacity-40': dragging }"
|
|
50
|
+
>
|
|
51
|
+
<!-- Drag handle. `nopan` so a start-drag from here moves the card, not the pane. Hidden
|
|
52
|
+
for read-only viewers, for whom the drag is a no-op anyway (see useBlockDrag). -->
|
|
53
|
+
<div
|
|
54
|
+
v-if="access.canWriteBoard.value"
|
|
55
|
+
class="nodrag nopan flex cursor-grab touch-none items-center justify-center rounded-t-lg border border-b-0 border-slate-700 bg-slate-800/80 py-px active:cursor-grabbing pointer-coarse:py-2"
|
|
56
|
+
:title="t('board.frame.dragTask')"
|
|
57
|
+
@pointerdown="onHandle"
|
|
58
|
+
>
|
|
59
|
+
<UIcon
|
|
60
|
+
name="i-lucide-grip-horizontal"
|
|
61
|
+
class="h-3 w-3 text-slate-500 pointer-coarse:h-5 pointer-coarse:w-5"
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
<TaskCard :task-id="taskId" :class="access.canWriteBoard.value ? '!rounded-t-none' : ''" />
|
|
65
|
+
</div>
|
|
66
|
+
</template>
|
|
@@ -119,6 +119,7 @@ const prLabel = computed(() =>
|
|
|
119
119
|
* every section says "nothing here" would teach people the surface is empty. A task marked done
|
|
120
120
|
* by hand, with no pull request and no run, is that task.
|
|
121
121
|
*/
|
|
122
|
+
const laneView = useLaneViewStore()
|
|
122
123
|
const outcomeReadable = computed(() => {
|
|
123
124
|
const block = task.value
|
|
124
125
|
if (!block) return false
|
|
@@ -136,6 +137,17 @@ const outcomeReadable = computed(() => {
|
|
|
136
137
|
const showPrChip = computed(
|
|
137
138
|
() => Boolean(pr.value) && (uiMode.isAdvanced || !outcomeReadable.value),
|
|
138
139
|
)
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The module chip, dropped while the swimlanes are GROUPED by module.
|
|
143
|
+
*
|
|
144
|
+
* Written as the invariant ("the card names its module wherever nothing else does") for the same
|
|
145
|
+
* reason `showPrChip` is: the surface that carries the other half is itself conditional, and two
|
|
146
|
+
* predicates that have to agree by coincidence eventually do not.
|
|
147
|
+
*/
|
|
148
|
+
const showModuleChip = computed(
|
|
149
|
+
() => Boolean(task.value?.moduleName) && laneView.groupKey !== 'module',
|
|
150
|
+
)
|
|
139
151
|
function openOutcome() {
|
|
140
152
|
ui.openOutcome(props.taskId, task.value?.executionId ?? null)
|
|
141
153
|
}
|
|
@@ -539,9 +551,11 @@ function selectTask() {
|
|
|
539
551
|
</template>
|
|
540
552
|
</div>
|
|
541
553
|
|
|
542
|
-
<!--
|
|
554
|
+
<!-- Structural metadata: assigned module. Dropped while the lanes are GROUPED by module,
|
|
555
|
+
where the group header above the card already names it — two chips saying the same thing
|
|
556
|
+
cost a row of card height each and add nothing. -->
|
|
543
557
|
<div
|
|
544
|
-
v-if="
|
|
558
|
+
v-if="showModuleChip"
|
|
545
559
|
class="mt-2 flex flex-wrap items-center gap-1 border-t border-slate-800 pt-2"
|
|
546
560
|
>
|
|
547
561
|
<span
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import LaneGroup from './LaneGroup.vue'
|
|
3
|
+
import type { RenderedLane } from '~/composables/useFrameLanes'
|
|
4
|
+
import { LANE_GEOMETRY } from '~/utils/laneGeometry'
|
|
5
|
+
import type { LaneGroupKey } from '~/utils/laneSort'
|
|
6
|
+
import { LANE_META } from '~/utils/swimlanes'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One status lane inside a service frame.
|
|
10
|
+
*
|
|
11
|
+
* The lane BODY is the frame's drop zone, so a card dropped into any of another frame's lanes
|
|
12
|
+
* moves to that service. Which lane it lands in is not a choice a drop can make: the lane is
|
|
13
|
+
* derived from the task's state, so dropping a not-started card on "In progress" would have to
|
|
14
|
+
* either lie or silently ignore the gesture. Reparenting is the one thing a drag decides.
|
|
15
|
+
*
|
|
16
|
+
* The body SCROLLS rather than growing. A lane is a viewport onto an unbounded list, which is
|
|
17
|
+
* what keeps a busy service's frame the same size as a quiet one's; its height comes from the
|
|
18
|
+
* frame, so dragging the frame's border gives the reader more of the lane.
|
|
19
|
+
*
|
|
20
|
+
* It takes the whole {@link RenderedLane} rather than spreading it across three props, so the
|
|
21
|
+
* lane object stays one thing. Restating its fields here also collided the `LaneGroup` NAME with
|
|
22
|
+
* the component of that name imported above, leaving one identifier meaning the interface in
|
|
23
|
+
* type position and the component in value position.
|
|
24
|
+
*/
|
|
25
|
+
const props = defineProps<{
|
|
26
|
+
rendered: RenderedLane
|
|
27
|
+
groupKey: LaneGroupKey
|
|
28
|
+
frameId: string
|
|
29
|
+
/** The scroll viewport's height, resolved by the frame from its own size. */
|
|
30
|
+
bodyHeight: number
|
|
31
|
+
}>()
|
|
32
|
+
|
|
33
|
+
const { t } = useI18n()
|
|
34
|
+
const meta = computed(() => LANE_META[props.rendered.lane])
|
|
35
|
+
const isEmpty = computed(() => props.rendered.groups.every((g) => g.entries.length === 0))
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<template>
|
|
39
|
+
<div
|
|
40
|
+
class="flex min-w-0 flex-col rounded-lg bg-slate-900/40"
|
|
41
|
+
:style="{ width: LANE_GEOMETRY.laneWidth + 'px' }"
|
|
42
|
+
:data-lane="rendered.lane"
|
|
43
|
+
>
|
|
44
|
+
<!-- Lane header -->
|
|
45
|
+
<div
|
|
46
|
+
class="flex items-center gap-1.5 rounded-t-lg border-b px-2 py-1.5"
|
|
47
|
+
:style="{ borderColor: meta.color + '33' }"
|
|
48
|
+
>
|
|
49
|
+
<UIcon :name="meta.icon" class="h-3.5 w-3.5 shrink-0" :style="{ color: meta.color }" />
|
|
50
|
+
<span class="truncate text-[11px] font-semibold text-slate-200">{{ t(meta.labelKey) }}</span>
|
|
51
|
+
<span
|
|
52
|
+
class="ms-auto shrink-0 rounded px-1 text-[10px] font-semibold tabular-nums"
|
|
53
|
+
:style="{ backgroundColor: meta.color + '22', color: meta.color }"
|
|
54
|
+
:data-testid="`lane-count-${rendered.lane}`"
|
|
55
|
+
>{{ rendered.total }}</span
|
|
56
|
+
>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<!-- Lane body: the frame's drop zone, and the scroll viewport. -->
|
|
60
|
+
<div
|
|
61
|
+
:data-drop-zone="frameId"
|
|
62
|
+
:data-testid="`lane-${rendered.lane}`"
|
|
63
|
+
class="nodrag flex-1 space-y-2 overflow-y-auto overflow-x-hidden p-2"
|
|
64
|
+
:style="{ height: bodyHeight + 'px' }"
|
|
65
|
+
>
|
|
66
|
+
<!-- An empty lane SAYS it is empty. Left blank, "nothing needs you" and "the lane failed
|
|
67
|
+
to render" look identical, and the first is worth stating: it is the answer a reader
|
|
68
|
+
scanning the needs-you column is hoping for. -->
|
|
69
|
+
<p v-if="isEmpty" class="px-1 pt-2 text-[10px] leading-snug text-slate-600">
|
|
70
|
+
{{ t(meta.emptyKey) }}
|
|
71
|
+
</p>
|
|
72
|
+
<LaneGroup
|
|
73
|
+
v-for="(group, i) in rendered.groups"
|
|
74
|
+
v-else
|
|
75
|
+
:key="group.label ?? `catch-all-${i}`"
|
|
76
|
+
:group="group"
|
|
77
|
+
:group-key="groupKey"
|
|
78
|
+
:frame-id="frameId"
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</template>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { useBoardFlow, BOARD_MIN_ZOOM, BOARD_MAX_ZOOM } from '~/composables/useBoardFlow'
|
|
3
3
|
import NotificationsInbox from '~/components/layout/NotificationsInbox.vue'
|
|
4
4
|
import IconButton from '~/components/common/IconButton.vue'
|
|
5
|
+
import LaneViewControl from '~/components/board/LaneViewControl.vue'
|
|
5
6
|
|
|
6
7
|
const ui = useUiStore()
|
|
7
8
|
const board = useBoardStore()
|
|
@@ -209,6 +210,9 @@ const decisionItems = computed(() =>
|
|
|
209
210
|
</UButton>
|
|
210
211
|
</UDropdownMenu>
|
|
211
212
|
|
|
213
|
+
<!-- how every frame's swimlanes are ordered + grouped (an override; advanced tier) -->
|
|
214
|
+
<LaneViewControl />
|
|
215
|
+
|
|
212
216
|
<!-- in-org sharing: add an existing org service to this board (mount = board.write) -->
|
|
213
217
|
<UDropdownMenu
|
|
214
218
|
v-if="mountableItems.length && access.canWriteBoard.value"
|
|
@@ -24,6 +24,7 @@ const documents = useDocumentsStore()
|
|
|
24
24
|
const tasks = useTasksStore()
|
|
25
25
|
const library = useFragmentLibraryStore()
|
|
26
26
|
const access = useWorkspaceAccess()
|
|
27
|
+
const uiRole = useUiRoleStore()
|
|
27
28
|
|
|
28
29
|
// The static destination catalog + its RBAC/availability gating now comes from
|
|
29
30
|
// the shared nav manifest (backend/docs/adr/0049-modular-vue-adoption.md, slice 1),
|
|
@@ -44,8 +45,14 @@ const activeIndex = ref(0)
|
|
|
44
45
|
// carry: their label (connect vs manage) and set (one per document/task source)
|
|
45
46
|
// depend on live connection state. Gated by `integrations.manage`, they render
|
|
46
47
|
// under the palette's Integrations group.
|
|
48
|
+
//
|
|
49
|
+
// Also gated on the ROLE's surface, which the manifest entries get for free from `navSlotFilter`
|
|
50
|
+
// (see `NavGates.fullSurface`): these are the platform-configuration half: connecting a source,
|
|
51
|
+
// managing a connection, importing across the whole board. A narrowed role that happens to
|
|
52
|
+
// hold `integrations.manage` would otherwise reach through the palette exactly the surfaces its
|
|
53
|
+
// sidebar dropped. What it keeps is on the board: a frame's own from-ticket / from-design buttons.
|
|
47
54
|
const dynamicIntegrationCommands = computed<Command[]>(() => {
|
|
48
|
-
if (!access.canManageIntegrations.value) return []
|
|
55
|
+
if (!access.canManageIntegrations.value || !uiRole.fullSurface) return []
|
|
49
56
|
const groupIntegrations = t('layout.commandBar.groups.integrations')
|
|
50
57
|
const list: Command[] = []
|
|
51
58
|
if (github.available) {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The first-run role question: asks once what the person is here to do, so the SPA can open on
|
|
3
|
+
// the surfaces that job needs. Offered at launch (at most once per session, and only until it is
|
|
4
|
+
// answered: see `stores/uiRole.ts`) and re-openable at any time from the command palette.
|
|
5
|
+
//
|
|
6
|
+
// It states what each role GIVES you rather than only naming it, because the narrowed role
|
|
7
|
+
// genuinely removes destinations: a person picking blind would either avoid the choice or make it
|
|
8
|
+
// and not know what happened to their sidebar. Same reason the footer names the way back.
|
|
9
|
+
//
|
|
10
|
+
// Closing without picking writes nothing: the role stays the default (the FULL surface), and the
|
|
11
|
+
// next launch asks again. There is deliberately no "don't ask me again": an unanswered question
|
|
12
|
+
// costs nothing here, where a wrongly-recorded answer costs a person destinations they need.
|
|
13
|
+
import { ROLE_PRESENTATION, UI_ROLES, type UiRole } from '~/utils/uiRole'
|
|
14
|
+
|
|
15
|
+
const { t } = useI18n()
|
|
16
|
+
const uiRole = useUiRoleStore()
|
|
17
|
+
|
|
18
|
+
const open = computed({
|
|
19
|
+
get: () => uiRole.promptOpen,
|
|
20
|
+
set: (v: boolean) => (v ? uiRole.openPrompt() : uiRole.closePrompt()),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
function pick(role: UiRole) {
|
|
24
|
+
uiRole.setRole(role)
|
|
25
|
+
}
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<UModal v-model:open="open" :title="t('uiRole.prompt.title')" :ui="{ content: 'max-w-lg' }">
|
|
30
|
+
<template #body>
|
|
31
|
+
<div class="space-y-4" data-testid="role-prompt">
|
|
32
|
+
<p class="text-sm text-slate-300">{{ t('uiRole.prompt.intro') }}</p>
|
|
33
|
+
<div class="space-y-2">
|
|
34
|
+
<button
|
|
35
|
+
v-for="role in UI_ROLES"
|
|
36
|
+
:key="role"
|
|
37
|
+
type="button"
|
|
38
|
+
:data-testid="`role-option-${role}`"
|
|
39
|
+
:aria-pressed="role === uiRole.role && uiRole.chosen"
|
|
40
|
+
class="flex w-full items-center gap-3 rounded-lg border p-3 text-start transition"
|
|
41
|
+
:class="
|
|
42
|
+
role === uiRole.role && uiRole.chosen
|
|
43
|
+
? 'border-indigo-500/60 bg-indigo-500/10'
|
|
44
|
+
: 'border-slate-800 bg-slate-900/60 hover:border-slate-600 hover:bg-slate-800/60'
|
|
45
|
+
"
|
|
46
|
+
@click="pick(role)"
|
|
47
|
+
>
|
|
48
|
+
<UIcon :name="ROLE_PRESENTATION[role].icon" class="h-5 w-5 shrink-0 text-primary-400" />
|
|
49
|
+
<div class="min-w-0 flex-1">
|
|
50
|
+
<div class="text-sm font-medium text-slate-100">
|
|
51
|
+
{{ t(ROLE_PRESENTATION[role].labelKey) }}
|
|
52
|
+
</div>
|
|
53
|
+
<p class="text-xs text-slate-400">{{ t(ROLE_PRESENTATION[role].hintKey) }}</p>
|
|
54
|
+
</div>
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
<!-- The choice is not a commitment, and saying so is what makes the narrowed role
|
|
58
|
+
pickable: it is one dropdown at the top of the sidebar to leave again. -->
|
|
59
|
+
<p class="text-[11px] leading-snug text-slate-500">{{ t('uiRole.prompt.change') }}</p>
|
|
60
|
+
</div>
|
|
61
|
+
</template>
|
|
62
|
+
<template #footer>
|
|
63
|
+
<div class="flex w-full justify-end">
|
|
64
|
+
<UButton
|
|
65
|
+
color="neutral"
|
|
66
|
+
variant="soft"
|
|
67
|
+
data-testid="role-prompt-close"
|
|
68
|
+
@click="uiRole.closePrompt()"
|
|
69
|
+
>
|
|
70
|
+
{{ uiRole.chosen ? t('common.close') : t('uiRole.prompt.later') }}
|
|
71
|
+
</UButton>
|
|
72
|
+
</div>
|
|
73
|
+
</template>
|
|
74
|
+
</UModal>
|
|
75
|
+
</template>
|
|
@@ -6,15 +6,16 @@
|
|
|
6
6
|
// context-fragment library, and workspace configuration (merge thresholds +
|
|
7
7
|
// default models).
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// how much room they take is the
|
|
12
|
-
//
|
|
13
|
-
// the rail toggle.
|
|
9
|
+
// Three orthogonal ways this panel shrinks. WHICH destinations exist is the person's ROLE (a
|
|
10
|
+
// narrowed one keeps only the `intake` contributions) and then the interface TIER (basic hides the
|
|
11
|
+
// `advanced` ones), both filtered upstream in `navSlotFilter`; how much room they take is the
|
|
12
|
+
// COLLAPSE state (the icon-only rail). Basic mode starts railed, but each can be changed
|
|
13
|
+
// independently from the role / tier switchers at the top and the rail toggle.
|
|
14
14
|
import { useEventListener, useScrollLock } from '@vueuse/core'
|
|
15
15
|
import BoardSwitcher from '~/components/layout/BoardSwitcher.vue'
|
|
16
16
|
import LanguageSwitcher from '~/components/layout/LanguageSwitcher.vue'
|
|
17
17
|
import UiModeSwitcher from '~/components/layout/UiModeSwitcher.vue'
|
|
18
|
+
import UiRoleSwitcher from '~/components/layout/UiRoleSwitcher.vue'
|
|
18
19
|
import UserMenu from '~/components/auth/UserMenu.vue'
|
|
19
20
|
import { useViewport } from '~/composables/useViewport'
|
|
20
21
|
import type { NavContribution } from '~/modular/nav-contributions'
|
|
@@ -67,6 +68,7 @@ const { isCompact } = useViewport()
|
|
|
67
68
|
// it only to find a rail would be two taps for one destination. The tier decides the default
|
|
68
69
|
// (basic starts collapsed), the user's toggle wins from there — see `stores/uiMode.ts`.
|
|
69
70
|
const uiMode = useUiModeStore()
|
|
71
|
+
const uiRole = useUiRoleStore()
|
|
70
72
|
const railed = computed(() => !isCompact.value && uiMode.navCollapsed)
|
|
71
73
|
|
|
72
74
|
// The off-canvas drawer is a modal surface on compact viewports, so give it the
|
|
@@ -201,15 +203,22 @@ watch(
|
|
|
201
203
|
|
|
202
204
|
<BoardSwitcher :collapsed="railed" />
|
|
203
205
|
|
|
204
|
-
<!-- The
|
|
205
|
-
shipped default, so this row is most users' only sight of the
|
|
206
|
-
a scrolled navbar it is a thin thread to hang the advanced
|
|
207
|
-
wrapper is what keeps
|
|
208
|
-
otherwise push them apart. Kept OUT of the `onNavAction` group
|
|
209
|
-
|
|
210
|
-
switch just revealed.
|
|
206
|
+
<!-- The two "how much of the app do I see" controls sit ABOVE the destinations they gate, not
|
|
207
|
+
in the footer: basic is the shipped default, so this row is most users' only sight of the
|
|
208
|
+
tier, and below the fold in a scrolled navbar it is a thin thread to hang the advanced
|
|
209
|
+
half of the product on. The wrapper is what keeps each control with its hint, since the aside's
|
|
210
|
+
own `gap-4` would otherwise push them apart. Kept OUT of the `onNavAction` group
|
|
211
|
+
deliberately: switching tier or role opens nothing, and closing the compact drawer would
|
|
212
|
+
hide the destinations the switch just revealed.
|
|
213
|
+
|
|
214
|
+
The ROLE comes first because it is the outer of the two: it caps the tier (see
|
|
215
|
+
`resolveUiMode`), which is also why the tier switcher is dropped on a narrowed role:
|
|
216
|
+
with the tier fixed at basic, a control that flipped it would be advertising a choice the
|
|
217
|
+
resolver ignores, exactly as it refuses to be under an env pin. The role switcher itself
|
|
218
|
+
is rendered in EVERY role: it is the way back. -->
|
|
211
219
|
<div class="space-y-1">
|
|
212
|
-
<
|
|
220
|
+
<UiRoleSwitcher :collapsed="railed" />
|
|
221
|
+
<UiModeSwitcher v-if="uiRole.fullSurface" :collapsed="railed" />
|
|
213
222
|
</div>
|
|
214
223
|
|
|
215
224
|
<div class="contents" @click="onNavAction">
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { ROLE_PRESENTATION, UI_ROLES } from '~/utils/uiRole'
|
|
4
|
+
|
|
5
|
+
// Role picker, at the TOP of the sidebar beside the interface-tier switcher: one place answers
|
|
6
|
+
// "how much of the app do I see", and the role is the outer of the two (it can cap the tier, see
|
|
7
|
+
// `resolveUiMode`), so it sits above it.
|
|
8
|
+
//
|
|
9
|
+
// A DROPDOWN rather than the tier switcher's segmented control, and the difference is not
|
|
10
|
+
// cosmetic: three role names do not fit legibly across a 15rem sidebar, and the choice has already
|
|
11
|
+
// been made explicitly once (the first-run prompt states what each role gives you), so this
|
|
12
|
+
// control's job is to NAME the current role and offer the way out, not to advertise that a choice
|
|
13
|
+
// exists. It is rendered in EVERY role, including the narrowed one, because it is the way back.
|
|
14
|
+
//
|
|
15
|
+
// In the collapsed rail it keeps the menu and drops to the glyph plus the role's name, so the rail
|
|
16
|
+
// still says which role is on (the same reason the tier button keeps its label there).
|
|
17
|
+
withDefaults(defineProps<{ collapsed?: boolean }>(), { collapsed: false })
|
|
18
|
+
|
|
19
|
+
const uiRole = useUiRoleStore()
|
|
20
|
+
const { t } = useI18n()
|
|
21
|
+
|
|
22
|
+
// Name / glyph / one-line description all come from the shared presentation table, so this
|
|
23
|
+
// control and the first-run prompt can never describe the same role differently.
|
|
24
|
+
const current = computed(() => ROLE_PRESENTATION[uiRole.role])
|
|
25
|
+
const currentLabel = computed(() => t(current.value.labelKey))
|
|
26
|
+
|
|
27
|
+
const items = computed(() =>
|
|
28
|
+
UI_ROLES.map((role) => ({
|
|
29
|
+
label: t(ROLE_PRESENTATION[role].labelKey),
|
|
30
|
+
icon: ROLE_PRESENTATION[role].icon,
|
|
31
|
+
// The tick, so an open menu says which role is current as well as which are available.
|
|
32
|
+
trailingIcon: role === uiRole.role ? 'i-lucide-check' : undefined,
|
|
33
|
+
onSelect: () => uiRole.setRole(role),
|
|
34
|
+
})),
|
|
35
|
+
)
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<template>
|
|
39
|
+
<UDropdownMenu :items="items" :ui="{ content: 'min-w-48' }">
|
|
40
|
+
<!-- Rail: glyph over the role name, matching the tier button beside it. -->
|
|
41
|
+
<button
|
|
42
|
+
v-if="collapsed"
|
|
43
|
+
type="button"
|
|
44
|
+
data-testid="ui-role-toggle"
|
|
45
|
+
:aria-label="`${t('uiRole.switcher')}: ${currentLabel}`"
|
|
46
|
+
:title="`${t('uiRole.switcher')}: ${currentLabel}`"
|
|
47
|
+
class="flex w-full flex-col items-center gap-0.5 rounded-lg border border-slate-700 bg-slate-900/60 px-1 py-1.5 transition hover:border-indigo-500/60 hover:bg-slate-800/60"
|
|
48
|
+
>
|
|
49
|
+
<UIcon :name="current.icon" class="h-4 w-4 shrink-0 text-indigo-400" />
|
|
50
|
+
<span class="w-full truncate text-center text-[9px] font-medium uppercase text-slate-300">
|
|
51
|
+
{{ currentLabel }}
|
|
52
|
+
</span>
|
|
53
|
+
</button>
|
|
54
|
+
|
|
55
|
+
<button
|
|
56
|
+
v-else
|
|
57
|
+
type="button"
|
|
58
|
+
data-testid="ui-role-switcher"
|
|
59
|
+
:aria-label="t('uiRole.switcher')"
|
|
60
|
+
:title="t(current.hintKey)"
|
|
61
|
+
class="flex w-full items-center gap-2 rounded-lg border border-slate-700 bg-slate-900/60 p-2 text-start transition hover:border-indigo-500/60 hover:bg-slate-800/60"
|
|
62
|
+
>
|
|
63
|
+
<UIcon :name="current.icon" class="h-4 w-4 shrink-0 text-indigo-400" />
|
|
64
|
+
<div class="min-w-0 flex-1">
|
|
65
|
+
<div class="truncate text-[10px] uppercase tracking-wide text-slate-500">
|
|
66
|
+
{{ t('uiRole.switcher') }}
|
|
67
|
+
</div>
|
|
68
|
+
<div class="truncate text-xs font-medium text-slate-200">{{ currentLabel }}</div>
|
|
69
|
+
</div>
|
|
70
|
+
<UIcon name="i-lucide-chevron-down" class="h-3.5 w-3.5 shrink-0 text-slate-500" />
|
|
71
|
+
</button>
|
|
72
|
+
</UDropdownMenu>
|
|
73
|
+
</template>
|
|
@@ -319,10 +319,23 @@ const showOriginalDescription = ref(false)
|
|
|
319
319
|
</script>
|
|
320
320
|
|
|
321
321
|
<template>
|
|
322
|
+
<!-- On lg+ the panel is a rail in the board pane's end corner, and it CLEARS the board's top
|
|
323
|
+
overlay region rather than sitting beside it (`top-16`, below the region's toolbar pill).
|
|
324
|
+
That region has one owner, `BoardTopOverlays`, and this rail is deliberately not a member:
|
|
325
|
+
it is an end-anchored side panel, not centred chrome. Clearing the region is how a
|
|
326
|
+
non-member stays out of the owner's way.
|
|
327
|
+
Overlapping is not a cosmetic problem. The toolbar is centred and grows with its contents,
|
|
328
|
+
so at some width its end reaches this corner, and whichever of the two is on top covers the
|
|
329
|
+
other's controls and EATS THEIR CLICKS: the click lands on the box above and no handler
|
|
330
|
+
runs, which reads as a dead button rather than as two overlapping boxes. Adding the
|
|
331
|
+
swimlane view control was the width that finally did it, to the notifications bell (then
|
|
332
|
+
with the panel on top; the region now paints above at `z-40`, which only swaps which side
|
|
333
|
+
loses). Sitting the panel below fixes it whichever way the stacking goes, and needs no
|
|
334
|
+
left/right arithmetic to stay correct under RTL. -->
|
|
322
335
|
<div
|
|
323
336
|
v-if="block && statusMeta && typeMeta"
|
|
324
337
|
data-testid="inspector-panel"
|
|
325
|
-
class="fixed inset-x-0 bottom-0 z-20 overflow-hidden rounded-t-2xl border border-slate-700 bg-slate-900/95 shadow-2xl backdrop-blur lg:absolute lg:inset-x-auto lg:bottom-auto lg:end-4 lg:top-
|
|
338
|
+
class="fixed inset-x-0 bottom-0 z-20 overflow-hidden rounded-t-2xl border border-slate-700 bg-slate-900/95 shadow-2xl backdrop-blur lg:absolute lg:inset-x-auto lg:bottom-auto lg:end-4 lg:top-16 lg:w-80 lg:rounded-2xl"
|
|
326
339
|
>
|
|
327
340
|
<div class="h-1.5 w-full" :style="{ backgroundColor: statusMeta.color }" />
|
|
328
341
|
<!-- A tall task (execution steps + scenarios + docs) can overflow the
|
|
@@ -331,7 +344,7 @@ const showOriginalDescription = ref(false)
|
|
|
331
344
|
On compact viewports the panel is a bottom sheet capped to the visible
|
|
332
345
|
height (dvh excludes mobile browser chrome). -->
|
|
333
346
|
<div
|
|
334
|
-
class="max-h-[80dvh] space-y-4 overflow-y-auto overscroll-contain px-4 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] lg:max-h-[calc(100vh-
|
|
347
|
+
class="max-h-[80dvh] space-y-4 overflow-y-auto overscroll-contain px-4 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] lg:max-h-[calc(100vh-7rem)]"
|
|
335
348
|
>
|
|
336
349
|
<!-- header -->
|
|
337
350
|
<div class="flex items-start justify-between gap-2">
|