diff --git a/apps/web/e2e/accessibility.spec.ts b/apps/web/e2e/accessibility.spec.ts
index 639dcdf..324dac0 100644
--- a/apps/web/e2e/accessibility.spec.ts
+++ b/apps/web/e2e/accessibility.spec.ts
@@ -423,6 +423,10 @@ test('reorders tasks and subtasks by drag only and persists local order', async
const secondSubtask = reorderedTask.locator('.subtask-row').filter({
hasText: 'Second subtask',
})
+ await expect(reorderedTask.locator('.subtask-title')).toHaveText([
+ 'Second subtask',
+ 'First subtask',
+ ])
await dragHandleOnto(
page,
secondSubtask.locator('.subtask-drag-handle'),
@@ -430,8 +434,8 @@ test('reorders tasks and subtasks by drag only and persists local order', async
)
await expect(reorderedTask.locator('.subtask-title')).toHaveText([
- 'Second subtask',
'First subtask',
+ 'Second subtask',
])
await expect(reorderedTask.locator('.subtask-row .task-num')).toHaveText([
'1.1.1',
@@ -444,8 +448,8 @@ test('reorders tasks and subtasks by drag only and persists local order', async
await persistedTask.getByRole('button', { name: 'Expand task' }).click()
}
await expect(persistedTask.locator('.subtask-title')).toHaveText([
- 'Second subtask',
'First subtask',
+ 'Second subtask',
])
await expect(persistedTask.locator('.subtask-row .task-num')).toHaveText([
'1.1.1',
diff --git a/apps/web/src/components/roadmap/MarkdownDescription.tsx b/apps/web/src/components/roadmap/MarkdownDescription.tsx
index 4c1d766..f71e7e7 100644
--- a/apps/web/src/components/roadmap/MarkdownDescription.tsx
+++ b/apps/web/src/components/roadmap/MarkdownDescription.tsx
@@ -19,29 +19,32 @@ function safeMarkdownUrl(url: string): string | undefined {
export function MarkdownDescription({ value }: MarkdownDescriptionProps) {
return (
-
-
{
- if (!href) return <>{children}>
+
+ Description:
+
+
{
+ if (!href) return <>{children}>
- const external = /^https?:/i.test(href)
- return (
-
- {children}
-
- )
- },
- }}
- >
- {value}
-
-
+ const external = /^https?:/i.test(href)
+ return (
+
+ {children}
+
+ )
+ },
+ }}
+ >
+ {value}
+
+
+
)
}
diff --git a/apps/web/src/components/roadmap/TaskDetailMeta.tsx b/apps/web/src/components/roadmap/TaskDetailMeta.tsx
index a48f280..e19c712 100644
--- a/apps/web/src/components/roadmap/TaskDetailMeta.tsx
+++ b/apps/web/src/components/roadmap/TaskDetailMeta.tsx
@@ -1,5 +1,8 @@
'use client'
+import { Icon } from '@/components/ui/Icon'
+import { useTaskTagDisplayPreferences } from '@/hooks/useTaskTagDisplayPreferences'
+import { resolveTagDisplay } from '@/lib/tag-registry'
import type { Task, TagDefinition } from '@/types/roadmap'
import { TagChip } from './TagChip'
@@ -18,6 +21,14 @@ export function TaskDetailMeta({
visibleTags,
registry = [],
}: TaskDetailMetaProps) {
+ const {
+ userTags,
+ favoriteTagId,
+ showAutomaticStatus,
+ selectFavoriteTag,
+ toggleAutomaticStatus,
+ } = useTaskTagDisplayPreferences(task.id, visibleTags)
+
return (
{!isNested && (
@@ -44,12 +55,44 @@ export function TaskDetailMeta({
- Tags
- -
- {visibleTags.length > 0
- ? visibleTags.map((tagId) => (
-
- ))
- : No tags}
+
-
+
+ {userTags.map((tagId) => {
+ const selected = tagId === favoriteTagId
+ const label = resolveTagDisplay(tagId, registry).label
+ return (
+
+ )
+ })}
+ {userTags.length === 0 && (
+ No custom tags
+ )}
diff --git a/apps/web/src/components/roadmap/__tests__/MarkdownDescription.test.tsx b/apps/web/src/components/roadmap/__tests__/MarkdownDescription.test.tsx
index 2d4b241..e4f65b8 100644
--- a/apps/web/src/components/roadmap/__tests__/MarkdownDescription.test.tsx
+++ b/apps/web/src/components/roadmap/__tests__/MarkdownDescription.test.tsx
@@ -25,6 +25,13 @@ afterEach(() => {
})
describe('MarkdownDescription', () => {
+ it('labels the expanded task description', () => {
+ render('Task details')
+
+ expect(container.querySelector('.task-description-label')?.textContent).toBe('Description:')
+ expect(container.querySelector('.markdown-description')?.textContent).toBe('Task details')
+ })
+
it('renders the roadmap fixture headings, lists, and bold labels', () => {
render(`### Objective
diff --git a/apps/web/src/components/roadmap/task-row/TaskRowHeader.tsx b/apps/web/src/components/roadmap/task-row/TaskRowHeader.tsx
index abb7581..f1afeeb 100644
--- a/apps/web/src/components/roadmap/task-row/TaskRowHeader.tsx
+++ b/apps/web/src/components/roadmap/task-row/TaskRowHeader.tsx
@@ -2,6 +2,7 @@
import { Icon } from '@/components/ui/Icon'
import { TagChip } from '@/components/roadmap/TagChip'
+import { useTaskTagDisplayPreferences } from '@/hooks/useTaskTagDisplayPreferences'
import {
TASK_STATUS_LABELS,
type DerivedTaskStatus,
@@ -47,6 +48,16 @@ export function TaskRowHeader({
onCheck,
onToggle,
}: TaskRowHeaderProps) {
+ const {
+ userTags,
+ favoriteTagId,
+ showAutomaticStatus,
+ } = useTaskTagDisplayPreferences(task.id, visibleTags)
+ const remainingTagCount = Math.max(
+ 0,
+ userTags.length - (favoriteTagId ? 1 : 0),
+ )
+
return (
{task.title}
-
- {TASK_STATUS_LABELS[status]}
-
- {visibleTags.slice(0, 2).map((tagId) => (
+ {showAutomaticStatus && (
+
+ {TASK_STATUS_LABELS[status]}
+
+ )}
+ {favoriteTagId && (
- ))}
- {visibleTags.length > 2 && (
-
+{visibleTags.length - 2}
+ )}
+ {remainingTagCount > 0 && (
+
+{remainingTagCount}
)}
{lockedByOther && (
diff --git a/apps/web/src/hooks/__tests__/useTaskTagDisplayPreferences.test.ts b/apps/web/src/hooks/__tests__/useTaskTagDisplayPreferences.test.ts
new file mode 100644
index 0000000..4c1db91
--- /dev/null
+++ b/apps/web/src/hooks/__tests__/useTaskTagDisplayPreferences.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest'
+import {
+ getUserDisplayTags,
+ isAutomaticTaskTag,
+} from '../useTaskTagDisplayPreferences'
+
+describe('task tag display preferences', () => {
+ it('recognizes status namespace tags as automatic', () => {
+ expect(isAutomaticTaskTag('status:done')).toBe(true)
+ expect(isAutomaticTaskTag(' STATUS:in-progress ')).toBe(true)
+ expect(isAutomaticTaskTag('priority:P0')).toBe(false)
+ })
+
+ it('keeps custom tags in their authored order', () => {
+ expect(getUserDisplayTags([
+ 'status:done',
+ 'priority:P0',
+ 'area:coordination',
+ 'status:planned',
+ ])).toEqual([
+ 'priority:P0',
+ 'area:coordination',
+ ])
+ })
+})
diff --git a/apps/web/src/hooks/useTaskTagDisplayPreferences.ts b/apps/web/src/hooks/useTaskTagDisplayPreferences.ts
new file mode 100644
index 0000000..7d6738c
--- /dev/null
+++ b/apps/web/src/hooks/useTaskTagDisplayPreferences.ts
@@ -0,0 +1,132 @@
+'use client'
+
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { storage } from '@/lib/storage'
+
+interface TaskTagDisplayPreferences {
+ favoriteTagId: string | null
+ showAutomaticStatus: boolean
+}
+
+interface TaskTagPreferenceEventDetail {
+ storageKey: string
+ preferences: TaskTagDisplayPreferences
+}
+
+const STORAGE_PREFIX = 'roadforge.task-tag-display.v1'
+const PREFERENCE_EVENT = 'roadforge:task-tag-display-change'
+const DEFAULT_PREFERENCES: TaskTagDisplayPreferences = {
+ favoriteTagId: null,
+ showAutomaticStatus: true,
+}
+
+export function isAutomaticTaskTag(tagId: string): boolean {
+ return /^status:/i.test(tagId.trim())
+}
+
+export function getUserDisplayTags(tagIds: string[]): string[] {
+ return tagIds.filter((tagId) => !isAutomaticTaskTag(tagId))
+}
+
+function readPreferences(storageKey: string): TaskTagDisplayPreferences {
+ if (typeof window === 'undefined') return DEFAULT_PREFERENCES
+
+ try {
+ const stored = window.localStorage.getItem(storageKey)
+ if (!stored) return DEFAULT_PREFERENCES
+
+ const parsed = JSON.parse(stored) as Partial
+ return {
+ favoriteTagId: typeof parsed.favoriteTagId === 'string'
+ ? parsed.favoriteTagId
+ : null,
+ showAutomaticStatus: parsed.showAutomaticStatus !== false,
+ }
+ } catch {
+ return DEFAULT_PREFERENCES
+ }
+}
+
+function writePreferences(
+ storageKey: string,
+ preferences: TaskTagDisplayPreferences,
+): void {
+ if (typeof window === 'undefined') return
+ try {
+ window.localStorage.setItem(storageKey, JSON.stringify(preferences))
+ } catch {
+ // The preference remains active for this session when browser storage is blocked.
+ }
+}
+
+export function useTaskTagDisplayPreferences(taskId: string, tagIds: string[]) {
+ const activeRoadmapId = storage.getActiveRoadmapId()
+ const userTags = useMemo(() => getUserDisplayTags(tagIds), [tagIds])
+ const storageKey = `${STORAGE_PREFIX}:${activeRoadmapId ?? 'local'}:${taskId}`
+ const [preferences, setPreferences] = useState(
+ DEFAULT_PREFERENCES,
+ )
+
+ useEffect(() => {
+ setPreferences(readPreferences(storageKey))
+
+ const handleStorage = (event: StorageEvent) => {
+ if (event.key === storageKey) {
+ setPreferences(readPreferences(storageKey))
+ }
+ }
+ const handlePreferenceChange = (event: Event) => {
+ const detail = (event as CustomEvent).detail
+ if (detail?.storageKey === storageKey) {
+ setPreferences(detail.preferences)
+ }
+ }
+
+ window.addEventListener('storage', handleStorage)
+ window.addEventListener(PREFERENCE_EVENT, handlePreferenceChange)
+ return () => {
+ window.removeEventListener('storage', handleStorage)
+ window.removeEventListener(PREFERENCE_EVENT, handlePreferenceChange)
+ }
+ }, [storageKey])
+
+ const updatePreferences = useCallback((
+ updater: (current: TaskTagDisplayPreferences) => TaskTagDisplayPreferences,
+ ) => {
+ setPreferences((current) => {
+ const next = updater(current)
+ writePreferences(storageKey, next)
+ if (typeof window !== 'undefined') {
+ window.dispatchEvent(new CustomEvent(PREFERENCE_EVENT, {
+ detail: { storageKey, preferences: next },
+ }))
+ }
+ return next
+ })
+ }, [storageKey])
+
+ const selectFavoriteTag = useCallback((tagId: string) => {
+ if (!userTags.includes(tagId)) return
+ updatePreferences((current) => ({ ...current, favoriteTagId: tagId }))
+ }, [updatePreferences, userTags])
+
+ const toggleAutomaticStatus = useCallback(() => {
+ updatePreferences((current) => ({
+ ...current,
+ showAutomaticStatus: !current.showAutomaticStatus,
+ }))
+ }, [updatePreferences])
+
+ const favoriteTagId = preferences.favoriteTagId
+ && userTags.includes(preferences.favoriteTagId)
+ ? preferences.favoriteTagId
+ : userTags[0] ?? null
+
+ return {
+ userTags,
+ favoriteTagId,
+ showAutomaticStatus: preferences.showAutomaticStatus,
+ selectFavoriteTag,
+ toggleAutomaticStatus,
+ }
+}
diff --git a/apps/web/src/styles/workspace/task-actions.css b/apps/web/src/styles/workspace/task-actions.css
index 8ff3384..f712b29 100644
--- a/apps/web/src/styles/workspace/task-actions.css
+++ b/apps/web/src/styles/workspace/task-actions.css
@@ -18,11 +18,12 @@
max-width: min(100%, 720px);
display: flex;
align-items: center;
- justify-content: flex-start;
+ justify-content: flex-end;
flex-wrap: wrap;
}
.task-action-area.has-form {
width: min(100%, 720px);
+ justify-self: start;
}
.task-detail .actions {
@@ -30,7 +31,7 @@
gap: 6px;
flex-wrap: wrap;
align-items: center;
- justify-content: flex-start;
+ justify-content: flex-end;
}
.task-detail .actions .btn {
height: 30px;
@@ -40,6 +41,10 @@
gap: 6px;
}
.task-detail .actions .task-details-action {
+ position: absolute;
+ top: 2px;
+ right: 22px;
+ z-index: 1;
color: var(--ink-2);
border-color: var(--border-faint);
background: var(--bg-2);
@@ -105,11 +110,16 @@
/* ─── Responsive ───────────────────────────────────────────────────────────── */
+/* ── 760px — keep the edit action aligned with compact detail padding ─── */
+@media (max-width: 760px) {
+ .task-detail .actions .task-details-action { right: 16px; }
+}
+
/* ── 600px — mobile: full-width task actions ───────────────────────────── */
@media (max-width: 600px) {
.task-action-area,
.task-detail .actions { width: 100%; }
- .task-detail .actions .btn {
+ .task-detail .actions .btn:not(.task-details-action) {
flex: 1 1 140px;
justify-content: center;
}
diff --git a/apps/web/src/styles/workspace/task-description.css b/apps/web/src/styles/workspace/task-description.css
index 1daa01f..40ba6c2 100644
--- a/apps/web/src/styles/workspace/task-description.css
+++ b/apps/web/src/styles/workspace/task-description.css
@@ -1,4 +1,18 @@
/* ─── Task description display ─────────────────────────────────────────────── */
+.task-description-block {
+ min-width: 0;
+}
+.task-description-label {
+ display: flex;
+ align-items: center;
+ min-height: 30px;
+ margin: 0 0 4px;
+ padding-right: 118px;
+ color: var(--ink-1);
+ font-size: 14px;
+ letter-spacing: 0;
+ text-transform: none;
+}
.task-detail .desc {
font-size: 14.5px;
color: var(--ink-2);
diff --git a/apps/web/src/styles/workspace/task-detail.css b/apps/web/src/styles/workspace/task-detail.css
index 3ecf7c7..f6d5924 100644
--- a/apps/web/src/styles/workspace/task-detail.css
+++ b/apps/web/src/styles/workspace/task-detail.css
@@ -1,11 +1,29 @@
/* ─── Task detail panel ────────────────────────────────────────────────────── */
.task-detail {
padding: 2px 22px 18px 56px;
- display: flex; flex-direction: column;
- gap: 10px;
+ display: flex;
+ flex-flow: row wrap;
+ gap: 10px 16px;
animation: detailIn 240ms cubic-bezier(.2,.8,.2,1);
position: relative;
}
+.task-detail > * {
+ flex: 0 0 100%;
+ min-width: 0;
+}
+.task-detail > .task-links {
+ flex: 1 1 320px;
+ align-self: flex-start;
+}
+.task-detail > .task-action-area:not(.has-form) {
+ flex: 0 0 auto;
+ align-self: flex-start;
+ margin-top: 4px;
+ margin-left: auto;
+}
+.task-detail > .task-action-area.has-form {
+ flex-basis: 100%;
+}
/* ─── Task detail sections ─────────────────────────────────────────────────── */
.task-detail-section { margin-top: 4px; }
@@ -120,6 +138,11 @@
/* ── 600px — mobile: compact task detail density ────────────────────────── */
@media (max-width: 600px) {
.task-detail { gap: 8px; }
+ .task-detail > .task-links,
+ .task-detail > .task-action-area:not(.has-form) {
+ flex-basis: 100%;
+ margin-left: 0;
+ }
.task-link-form { grid-template-columns: 1fr; }
.task-link-form-actions { justify-content: flex-end; }
}
diff --git a/apps/web/src/styles/workspace/task-metadata.css b/apps/web/src/styles/workspace/task-metadata.css
index 9bea71f..09c4419 100644
--- a/apps/web/src/styles/workspace/task-metadata.css
+++ b/apps/web/src/styles/workspace/task-metadata.css
@@ -71,6 +71,58 @@
text-overflow: ellipsis;
white-space: nowrap;
}
+.task-row-tag.is-favorite {
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--tag-color, var(--ink-4)), transparent 72%);
+}
+.task-tag-preferences {
+ gap: 6px;
+}
+.automatic-tag-toggle,
+.favorite-tag-option {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ min-height: 22px;
+ border-radius: 5px;
+ cursor: pointer;
+}
+.automatic-tag-toggle {
+ padding: 2px 7px;
+ border: 1px dashed var(--border);
+ background: var(--bg-inset);
+ color: var(--ink-4);
+ font-size: 11px;
+ font-weight: 600;
+}
+.automatic-tag-toggle.selected {
+ border-style: solid;
+ border-color: color-mix(in srgb, var(--sage), transparent 55%);
+ background: color-mix(in srgb, var(--sage), transparent 88%);
+ color: var(--sage);
+}
+.favorite-tag-option {
+ padding: 1px;
+ border: 1px solid transparent;
+ background: transparent;
+ color: var(--ink-4);
+}
+.favorite-tag-option.selected {
+ border-color: color-mix(in srgb, var(--ember), transparent 55%);
+ background: var(--lava-glow);
+ color: var(--ember);
+}
+.favorite-tag-option .tag-chip {
+ pointer-events: none;
+}
+.automatic-tag-toggle:focus-visible,
+.favorite-tag-option:focus-visible {
+ outline: 2px solid var(--ember);
+ outline-offset: 2px;
+}
+.automatic-tag-toggle:hover,
+.favorite-tag-option:hover {
+ border-color: var(--border);
+}
.estimate-chip {
display: inline-flex;
align-items: center;