diff --git a/web/src/lib/components/layout/CreateWorkspaceModal.svelte b/web/src/lib/components/layout/CreateWorkspaceModal.svelte
index 9afa8e4b..264393b3 100644
--- a/web/src/lib/components/layout/CreateWorkspaceModal.svelte
+++ b/web/src/lib/components/layout/CreateWorkspaceModal.svelte
@@ -5,6 +5,7 @@
import { api } from '$lib/api/client';
import { toastStore } from '$lib/stores/toast.svelte';
import type { WorkspaceTemplate } from '$lib/types';
+ import { groupTemplatesByCategory } from '$lib/utils/templates';
let mode = $state<'create' | 'import'>('create');
let newName = $state('');
@@ -18,6 +19,8 @@
let dragging = $state(false);
let dragCounter = 0;
+ let grouped = $derived(groupTemplatesByCategory(templates));
+
$effect(() => {
if (uiStore.createWorkspaceOpen) {
// Reset state on open
@@ -146,23 +149,33 @@
{#if templates.length > 0}
Template
- {#each templates as tpl (tpl.name)}
-
+ {#each grouped as group (group.category)}
+ {group.label}
+ {#each group.templates as tpl (tpl.name)}
+
+ {/each}
{/each}
{/if}
@@ -309,8 +322,19 @@
.modal-body input:focus { outline: none; border-color: var(--accent-blue); }
.template-list { display: flex; flex-direction: column; gap: 4px; }
+ .cat-label {
+ font-size: 0.72em;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-muted);
+ opacity: 0.8;
+ margin-top: var(--space-2);
+ }
+ .cat-label:first-child { margin-top: 0; }
.template-card {
- display: flex; flex-direction: column; gap: 1px;
+ display: flex; flex-direction: row; align-items: center;
+ gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
background: var(--bg-tertiary);
@@ -323,6 +347,12 @@
border-color: var(--accent-blue);
background: color-mix(in srgb, var(--accent-blue) 8%, var(--bg-tertiary));
}
+ .tpl-icon {
+ font-size: 1.1em;
+ margin-right: var(--space-2);
+ flex-shrink: 0;
+ }
+ .tpl-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.tpl-name { font-size: 0.88em; font-weight: 600; color: var(--text-primary); text-transform: capitalize; }
.tpl-desc { font-size: 0.78em; color: var(--text-muted); }
diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts
index 15a219cb..74a55895 100644
--- a/web/src/lib/types/index.ts
+++ b/web/src/lib/types/index.ts
@@ -182,7 +182,9 @@ export interface WorkspaceUpdate {
export interface WorkspaceTemplate {
name: string;
+ category?: string;
description: string;
+ icon?: string;
collections: string[];
}
diff --git a/web/src/lib/utils/templates.ts b/web/src/lib/utils/templates.ts
new file mode 100644
index 00000000..3efe6ae0
--- /dev/null
+++ b/web/src/lib/utils/templates.ts
@@ -0,0 +1,64 @@
+// Shared helpers for grouping workspace templates by category in pickers.
+// Mirrors internal/collections/templates.go (CategoryOrder + CategoryLabel)
+// so the CLI and web UI use the same canonical ordering and labels.
+
+import type { WorkspaceTemplate } from '$lib/types';
+
+export const CATEGORY_ORDER = [
+ 'software',
+ 'people',
+ 'research',
+ 'content',
+ 'operations',
+ 'personal',
+] as const;
+
+const CATEGORY_LABELS: Record = {
+ software: 'Software',
+ people: 'People',
+ research: 'Research',
+ content: 'Content',
+ operations: 'Operations',
+ personal: 'Personal',
+};
+
+export function categoryLabel(slug: string | undefined): string {
+ if (!slug) return 'Other';
+ return CATEGORY_LABELS[slug] ?? slug;
+}
+
+export interface TemplateGroup {
+ category: string;
+ label: string;
+ templates: WorkspaceTemplate[];
+}
+
+// groupTemplatesByCategory returns templates bucketed by category in
+// CATEGORY_ORDER. Any template with a category not in CATEGORY_ORDER
+// (including undefined) is collected into a trailing "other" group so
+// nothing is hidden from the picker.
+export function groupTemplatesByCategory(templates: WorkspaceTemplate[]): TemplateGroup[] {
+ const byCat = new Map();
+ for (const t of templates) {
+ const cat = t.category ?? '';
+ const bucket = byCat.get(cat);
+ if (bucket) bucket.push(t);
+ else byCat.set(cat, [t]);
+ }
+
+ const groups: TemplateGroup[] = [];
+ for (const cat of CATEGORY_ORDER) {
+ const items = byCat.get(cat);
+ if (items && items.length > 0) {
+ groups.push({ category: cat, label: categoryLabel(cat), templates: items });
+ byCat.delete(cat);
+ }
+ }
+ // Append any leftover categories (custom or empty) in insertion order.
+ for (const [cat, items] of byCat) {
+ if (items.length > 0) {
+ groups.push({ category: cat, label: categoryLabel(cat), templates: items });
+ }
+ }
+ return groups;
+}
diff --git a/web/src/routes/console/new/+page.svelte b/web/src/routes/console/new/+page.svelte
index 5f7360d4..44e47879 100644
--- a/web/src/routes/console/new/+page.svelte
+++ b/web/src/routes/console/new/+page.svelte
@@ -4,6 +4,7 @@
import { api } from '$lib/api/client';
import { authStore } from '$lib/stores/auth.svelte';
import type { WorkspaceTemplate } from '$lib/types';
+ import { groupTemplatesByCategory } from '$lib/utils/templates';
let name = $state('');
let selectedTemplate = $state('startup');
@@ -11,6 +12,8 @@
let creating = $state(false);
let error = $state('');
+ let grouped = $derived(groupTemplatesByCategory(templates));
+
let slug = $derived(
name
.toLowerCase()
@@ -28,9 +31,9 @@
} catch {
// Templates are optional; fall back to defaults
templates = [
- { name: 'startup', description: 'Default template for general projects', collections: [] },
- { name: 'scrum', description: 'Scrum-style sprints and backlogs', collections: [] },
- { name: 'product', description: 'Product development workflow', collections: [] }
+ { name: 'startup', description: 'Default template for general projects', collections: [], category: 'software' },
+ { name: 'scrum', description: 'Scrum-style sprints and backlogs', collections: [], category: 'software' },
+ { name: 'product', description: 'Product development workflow', collections: [], category: 'software' }
];
}
});
@@ -92,17 +95,27 @@
Template
- {#each templates as tmpl (tmpl.name)}
-
+ {#each grouped as group (group.category)}
+
+ {group.label}
+ {#each group.templates as tmpl (tmpl.name)}
+
+ {/each}
+
{/each}
@@ -198,10 +211,30 @@
gap: var(--space-2);
}
- .template-option {
+ .category-group {
display: flex;
flex-direction: column;
- gap: 2px;
+ gap: var(--space-2);
+ }
+
+ .category-group + .category-group {
+ margin-top: var(--space-3);
+ }
+
+ .category-label {
+ font-size: 0.72rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-muted);
+ opacity: 0.85;
+ }
+
+ .template-option {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--bg-tertiary);
border: 1px solid var(--border);
@@ -211,6 +244,18 @@
transition: border-color 0.15s;
}
+ .template-icon {
+ font-size: 1.2em;
+ flex-shrink: 0;
+ }
+
+ .template-text {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+ }
+
.template-option:hover {
border-color: var(--text-muted);
}