feat(web): categorized template picker with icons (TASK-617) (#149)

Turns the web workspace-creation pickers into category-grouped lists
that mirror the CLI picker shipped in TASK-616. Both the full-page
new-workspace flow (/console/new) and the create-workspace modal
now group templates under Software / People / Research / Content /
Operations / Personal headings and render each template's icon.

- WorkspaceTemplate TS type gains optional `category` and `icon`
  (already emitted by /workspaces/templates since TASK-610).
- New shared helper at web/src/lib/utils/templates.ts exposes
  CATEGORY_ORDER (mirrors Go CategoryOrder), categoryLabel, and
  groupTemplatesByCategory. Keeps CLI and web pickers aligned on
  ordering + labels without a third source of truth.
- /console/new: replaced the flat template grid with a grouped
  layout; each group has a small category subhead; each button
  renders tmpl.icon alongside name + description. Offline
  fallback templates (used when the API call fails) updated to
  include category='software'.
- CreateWorkspaceModal: same grouped layout for the create tab,
  with the existing "blank" option retained as a trailing
  category-less button. Icon prefixed on every template card.

Tests
-----
Go side (existing library tests for grouping behavior via
TestGroupTemplatesByCategory cover the shared ordering contract).
Web build verified via `npm run build` — clean.

Parent: PLAN-609.
This commit is contained in:
xarmian
2026-04-18 06:39:40 -04:00
committed by GitHub
parent e0f3583333
commit dee968e309
4 changed files with 169 additions and 28 deletions
@@ -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}
<span class="field-label">Template</span>
<div class="template-list">
{#each templates as tpl (tpl.name)}
<button
class="template-card"
class:selected={selectedTemplate === tpl.name}
onclick={() => (selectedTemplate = tpl.name)}
>
<span class="tpl-name">{tpl.name}</span>
<span class="tpl-desc">{tpl.collections.join(', ')}</span>
</button>
{#each grouped as group (group.category)}
<span class="cat-label">{group.label}</span>
{#each group.templates as tpl (tpl.name)}
<button
class="template-card"
class:selected={selectedTemplate === tpl.name}
onclick={() => (selectedTemplate = tpl.name)}
>
{#if tpl.icon}
<span class="tpl-icon">{tpl.icon}</span>
{/if}
<span class="tpl-text">
<span class="tpl-name">{tpl.name}</span>
<span class="tpl-desc">{tpl.collections.join(', ')}</span>
</span>
</button>
{/each}
{/each}
<button
class="template-card"
class:selected={selectedTemplate === ''}
onclick={() => (selectedTemplate = '')}
>
<span class="tpl-name">blank</span>
<span class="tpl-desc">Empty workspace</span>
<span class="tpl-text">
<span class="tpl-name">blank</span>
<span class="tpl-desc">Empty workspace</span>
</span>
</button>
</div>
{/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); }
+2
View File
@@ -182,7 +182,9 @@ export interface WorkspaceUpdate {
export interface WorkspaceTemplate {
name: string;
category?: string;
description: string;
icon?: string;
collections: string[];
}
+64
View File
@@ -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<string, string> = {
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<string, WorkspaceTemplate[]>();
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;
}
+61 -16
View File
@@ -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 @@
<div class="field">
<span class="field-label">Template</span>
<div class="template-grid">
{#each templates as tmpl (tmpl.name)}
<button
class="template-option"
class:selected={selectedTemplate === tmpl.name}
onclick={() => (selectedTemplate = tmpl.name)}
disabled={creating}
type="button"
>
<span class="template-name">{tmpl.name}</span>
<span class="template-desc">{tmpl.description}</span>
</button>
{#each grouped as group (group.category)}
<div class="category-group">
<span class="category-label">{group.label}</span>
{#each group.templates as tmpl (tmpl.name)}
<button
class="template-option"
class:selected={selectedTemplate === tmpl.name}
onclick={() => (selectedTemplate = tmpl.name)}
disabled={creating}
type="button"
>
{#if tmpl.icon}
<span class="template-icon">{tmpl.icon}</span>
{/if}
<span class="template-text">
<span class="template-name">{tmpl.name}</span>
<span class="template-desc">{tmpl.description}</span>
</span>
</button>
{/each}
</div>
{/each}
</div>
</div>
@@ -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);
}