diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index ce22b11..c30f3fb 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -220,5 +220,15 @@
"manage_dissolve_confirm_button": "Dissolve permanently",
"sidebar_create_collective": "+ New collective",
"create_collective_modal_title": "New collective",
- "create_collective_modal_button": "Create"
+ "create_collective_modal_button": "Create",
+ "tag_picker_placeholder": "Search or create tag",
+ "tag_picker_create": "Create \"{name}\"",
+ "tag_picker_empty": "No tags yet.",
+ "tag_picker_label": "Tags",
+ "list_filter_by_tag": "Filter by tag",
+ "list_filter_clear": "Clear",
+ "settings_tags_section": "Tags",
+ "settings_tags_empty": "No tags yet — create one from any list.",
+ "settings_tags_delete": "Delete",
+ "settings_tags_color": "Color"
}
diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json
index a601bd0..96da857 100644
--- a/apps/web/messages/es.json
+++ b/apps/web/messages/es.json
@@ -220,5 +220,15 @@
"manage_dissolve_confirm_button": "Disolver permanentemente",
"sidebar_create_collective": "+ Nuevo colectivo",
"create_collective_modal_title": "Nuevo colectivo",
- "create_collective_modal_button": "Crear"
+ "create_collective_modal_button": "Crear",
+ "tag_picker_placeholder": "Buscar o crear etiqueta",
+ "tag_picker_create": "Crear «{name}»",
+ "tag_picker_empty": "Aún no hay etiquetas.",
+ "tag_picker_label": "Etiquetas",
+ "list_filter_by_tag": "Filtrar por etiqueta",
+ "list_filter_clear": "Limpiar",
+ "settings_tags_section": "Etiquetas",
+ "settings_tags_empty": "Aún no hay etiquetas — créalas desde cualquier lista.",
+ "settings_tags_delete": "Eliminar",
+ "settings_tags_color": "Color"
}
diff --git a/apps/web/src/lib/components/TagChip.svelte b/apps/web/src/lib/components/TagChip.svelte
new file mode 100644
index 0000000..ac7587d
--- /dev/null
+++ b/apps/web/src/lib/components/TagChip.svelte
@@ -0,0 +1,67 @@
+
+
+{#if onSelect}
+
+{:else}
+
+ {name}
+ {#if onRemove}
+
+ {/if}
+
+{/if}
diff --git a/apps/web/src/lib/components/TagPicker.svelte b/apps/web/src/lib/components/TagPicker.svelte
new file mode 100644
index 0000000..49728dd
--- /dev/null
+++ b/apps/web/src/lib/components/TagPicker.svelte
@@ -0,0 +1,130 @@
+
+
+
+ {#if showSelected && selectedTags.length > 0}
+
+ {#each selectedTags as tag (tag.id)}
+ handleToggle(tag)}
+ testid="tag-picker-selected"
+ />
+ {/each}
+
+ {/if}
+
+
+
+
+ {#each result.matches as tag (tag.id)}
+ {@const isSelected = selectedIds.includes(tag.id)}
+
+ {/each}
+
+ {#if result.canCreate && onCreate}
+
+ {/if}
+
+ {#if result.matches.length === 0 && !result.canCreate}
+
{m.tag_picker_empty()}
+ {/if}
+
+
diff --git a/apps/web/src/lib/components/tag-picker-filter.test.ts b/apps/web/src/lib/components/tag-picker-filter.test.ts
new file mode 100644
index 0000000..a7a5678
--- /dev/null
+++ b/apps/web/src/lib/components/tag-picker-filter.test.ts
@@ -0,0 +1,58 @@
+import { describe, it, expect } from 'vitest';
+import { filterTagOptions } from './tag-picker-filter';
+import type { ItemTag } from '@colectivo/types';
+
+function tag(name: string, id = name): ItemTag {
+ return {
+ id,
+ collective_id: 'c',
+ name,
+ color: 'slate',
+ created_at: '2026-05-18T00:00:00Z'
+ };
+}
+
+describe('filterTagOptions', () => {
+ const all = [tag('Vegano'), tag('Oferta'), tag('Vegetariano'), tag('Bio')];
+
+ it('TP-01: substring match is case-insensitive', () => {
+ const result = filterTagOptions(all, 'veg', []);
+ expect(result.matches.map((t) => t.name)).toEqual(['Vegano', 'Vegetariano']);
+ });
+
+ it('TP-02: returns the full list when query is empty', () => {
+ const result = filterTagOptions(all, '', []);
+ expect(result.matches).toHaveLength(4);
+ expect(result.canCreate).toBe(false);
+ });
+
+ it('TP-03: canCreate is true when the query is non-empty and no exact match exists', () => {
+ const result = filterTagOptions(all, 'Lácteo', []);
+ expect(result.canCreate).toBe(true);
+ expect(result.createName).toBe('Lácteo');
+ });
+
+ it('TP-04: canCreate is false when an exact (case-insensitive) match exists', () => {
+ const result = filterTagOptions(all, 'vegano', []);
+ expect(result.canCreate).toBe(false);
+ // The exact match must still appear in the matches list.
+ expect(result.matches.map((t) => t.name)).toContain('Vegano');
+ });
+
+ it('TP-05: excludes already-selected ids from the suggestions', () => {
+ const result = filterTagOptions(all, 'veg', ['Vegano']);
+ expect(result.matches.map((t) => t.name)).toEqual(['Vegetariano']);
+ });
+
+ it('TP-06: trims whitespace before evaluating the query', () => {
+ const result = filterTagOptions(all, ' ', []);
+ expect(result.matches).toHaveLength(4);
+ expect(result.canCreate).toBe(false);
+ });
+
+ it('TP-07: createName preserves the user input verbatim (no lowercasing)', () => {
+ const result = filterTagOptions(all, ' Sin-Gluten ', []);
+ expect(result.createName).toBe('Sin-Gluten');
+ expect(result.canCreate).toBe(true);
+ });
+});
diff --git a/apps/web/src/lib/components/tag-picker-filter.ts b/apps/web/src/lib/components/tag-picker-filter.ts
new file mode 100644
index 0000000..b3a3964
--- /dev/null
+++ b/apps/web/src/lib/components/tag-picker-filter.ts
@@ -0,0 +1,45 @@
+/**
+ * Pure logic backing TagPicker.svelte — extracted so it can be unit-tested
+ * without rendering a component.
+ *
+ * Given the collective's full tag list, a search query, and the set of
+ * already-selected tag ids, returns:
+ * - `matches`: tags matching the query (substring, case-insensitive)
+ * with already-selected entries filtered out
+ * - `canCreate`: whether the "Create `query`" affordance should appear
+ * (true iff the trimmed query is non-empty AND no existing tag matches
+ * the query exactly, case-insensitive)
+ * - `createName`: the verbatim trimmed query — used as the new tag name
+ * when the user picks the create affordance (no casing applied)
+ */
+import type { ItemTag } from '@colectivo/types';
+
+export interface TagFilterResult {
+ matches: ItemTag[];
+ canCreate: boolean;
+ createName: string;
+}
+
+export function filterTagOptions(
+ all: ItemTag[],
+ query: string,
+ selectedIds: string[]
+): TagFilterResult {
+ const trimmed = query.trim();
+ const lowerQuery = trimmed.toLowerCase();
+ const selectedSet = new Set(selectedIds);
+
+ const filtered = all.filter((t) => !selectedSet.has(t.id));
+
+ const matches = trimmed
+ ? filtered.filter((t) => t.name.toLowerCase().includes(lowerQuery))
+ : filtered;
+
+ const exactMatchExists = all.some((t) => t.name.toLowerCase() === lowerQuery);
+
+ return {
+ matches,
+ canCreate: trimmed.length > 0 && !exactMatchExists,
+ createName: trimmed
+ };
+}
diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts
index edcf1e1..f0222b4 100644
--- a/apps/web/tailwind.config.ts
+++ b/apps/web/tailwind.config.ts
@@ -1,7 +1,23 @@
import type { Config } from 'tailwindcss';
+// Fase 11: tag colours are picked at runtime from an 8-preset palette
+// (see public.item_tags.color CHECK constraint). Tailwind cannot statically
+// see the class names (they're built from the colour string), so safelist
+// every (variant × intensity) combination the chip + dot use.
+const TAG_COLORS = ['slate', 'red', 'amber', 'green', 'sky', 'indigo', 'pink', 'stone'];
+const tagSafelist = TAG_COLORS.flatMap((c) => [
+ `bg-${c}-100`,
+ `text-${c}-700`,
+ `ring-${c}-200`,
+ `dark:bg-${c}-900/40`,
+ `dark:text-${c}-200`,
+ `dark:ring-${c}-700/50`,
+ `bg-${c}-500`
+]);
+
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
+ safelist: tagSafelist,
// Fase 9.1: drive dark mode from so the inline
// anti-FOUC script in app.html can set the attribute synchronously before
// the first paint. Existing `dark:` utilities keep working as-is.