hlassiege/panopliepublic Fork
⑂ eb24262
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/panoplie.git
git clone ssh://git@rickub.com/hlassiege/panoplie.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(ui): redesign foundations (theme tokens, local fonts, top bar shell, Sk primitives, kit page)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hlassiege committed 2026-10-23T13:55:45+02:00 Browse files
eb24262 parent: 166a834
modified packages/ui/app/app.config.ts +158 -2
@@ -1,6 +1,162 @@
1+// Nuxt UI theme: makes stock components look like the mockup (design/BRIEF.md section 1).
2+// Colors/tokens live in assets/css/main.css; this file only holds palette names and slot overrides.
3+// Rule of thumb for components: radius 7px (buttons, inputs), 9-10px (menus), 20px (cards, modals), pills = rounded-full.
4+const menuContent = 'bg-sk-panel rounded-[10px] shadow-sk-modal'
5+
16 export default defineAppConfig({
27 ui: {
3- // `secondary` is used for Codex so that agents never share a color with status badges (success/warning/info).
4- colors: { secondary: 'violet' }
8+ colors: {
9+ primary: 'clay', // accent: switches, focus rings, links
10+ secondary: 'neutral', // legacy (was violet for Codex); agents are neutral in the new design
11+ success: 'ok',
12+ info: 'azure',
13+ warning: 'rust', // accent-coloured text, see "Status colour semantics" in the brief
14+ error: 'rust',
15+ neutral: 'stone'
16+ },
17+
18+ button: {
19+ slots: { base: 'rounded-[7px]' },
20+ variants: {
21+ size: {
22+ xs: { base: 'h-6 px-2 py-0 text-xs gap-1' },
23+ sm: { base: 'h-7 px-2.5 py-0 text-xs gap-1' },
24+ md: { base: 'h-[30px] px-3 py-0 text-sm gap-1.5' },
25+ lg: { base: 'h-8 px-4 py-0 text-sm gap-2' }
26+ }
27+ },
28+ compoundVariants: [
29+ // secondary: panel background + 1px line, hover tint
30+ { color: 'neutral', variant: 'outline', class: 'bg-sk-panel ring-sk-line hover:bg-sk-hover active:bg-sk-hover disabled:bg-sk-panel aria-disabled:bg-sk-panel focus-visible:ring-sk-faint' },
31+ // ghost: muted text, darker on hover
32+ { color: 'neutral', variant: 'ghost', class: 'text-sk-muted hover:text-sk-text hover:bg-sk-line2 active:bg-sk-line2' },
33+ { color: 'neutral', variant: 'solid', class: 'font-semibold' }
34+ ]
35+ },
36+
37+ card: {
38+ slots: { root: 'rounded-[20px]', header: 'px-5 py-4', body: 'p-5', footer: 'px-5 py-4' },
39+ variants: {
40+ variant: {
41+ outline: { root: 'bg-sk-panel ring-default divide-sk-line2' },
42+ subtle: { root: 'bg-sk-hover ring-default divide-sk-line2' },
43+ soft: { root: 'bg-sk-hover divide-sk-line2' }
44+ }
45+ }
46+ },
47+
48+ badge: {
49+ slots: { base: 'rounded-full' },
50+ variants: {
51+ size: {
52+ sm: { base: 'text-[11px]/3 px-2 py-1' },
53+ md: { base: 'text-xs px-2 py-0.5' },
54+ lg: { base: 'text-xs px-2.5 py-1' }
55+ }
56+ }
57+ },
58+
59+ switch: {
60+ // 28x16 track with a 12px knob = size xs
61+ slots: { thumb: 'bg-sk-knob shadow-none' },
62+ defaultVariants: { size: 'xs' }
63+ },
64+
65+ checkbox: {
66+ slots: { base: 'rounded-[4px] ring-sk-line', indicator: 'text-sk-onink' },
67+ defaultVariants: { color: 'neutral' }
68+ },
69+
70+ input: {
71+ slots: { base: 'rounded-[7px]' },
72+ variants: {
73+ size: {
74+ sm: { base: 'h-7 px-2.5 py-0 text-xs' },
75+ md: { base: 'h-8 px-2.5 py-0 text-sm' },
76+ lg: { base: 'h-9 px-3 py-0 text-sm' }
77+ },
78+ variant: { outline: 'ring-sk-line focus-visible:ring-sk-faint' }
79+ }
80+ },
81+ inputMenu: { slots: { base: 'rounded-[7px]', content: menuContent } },
82+ select: {
83+ slots: { base: 'rounded-[7px]', content: menuContent },
84+ variants: { size: { md: { base: 'h-8 px-2.5 py-0 text-sm' }, sm: { base: 'h-7 px-2.5 py-0 text-xs' } } }
85+ },
86+ selectMenu: { slots: { base: 'rounded-[7px]', content: menuContent } },
87+ textarea: {
88+ slots: { base: 'rounded-[7px]' },
89+ variants: { size: { md: { base: 'px-2.5 py-1.5 text-sm' } } }
90+ },
91+
92+ // Pill segmented control (Automatic / On demand style)
93+ tabs: {
94+ slots: { list: 'bg-sk-line2 rounded-full p-[3px] gap-[2px]', trigger: 'rounded-full', indicator: 'rounded-full shadow-none' },
95+ variants: {
96+ variant: { pill: { list: 'bg-sk-line2 rounded-full', indicator: 'rounded-full shadow-none' } },
97+ size: {
98+ xs: { trigger: 'h-[22px] px-2.5 text-xs' },
99+ sm: { trigger: 'h-[26px] px-3 text-sm' },
100+ md: { trigger: 'h-7 px-3 text-sm' }
101+ }
102+ },
103+ compoundVariants: [
104+ { color: 'neutral', variant: 'pill', class: { indicator: 'bg-sk-ink', trigger: 'data-[state=active]:text-sk-onink' } }
105+ ],
106+ defaultVariants: { color: 'neutral', size: 'sm' }
107+ },
108+
109+ modal: {
110+ slots: {
111+ overlay: 'bg-sk-overlay',
112+ content: 'bg-sk-panel divide-sk-line2',
113+ header: 'px-5 py-4 min-h-0',
114+ body: 'p-5',
115+ footer: 'px-5 py-4 justify-end gap-2',
116+ title: 'text-base font-semibold'
117+ },
118+ variants: {
119+ fullscreen: { false: { content: 'rounded-[20px] shadow-sk-modal max-w-[520px]' } },
120+ overlay: { true: { overlay: 'bg-sk-overlay' } }
121+ }
122+ },
123+ slideover: {
124+ slots: {
125+ overlay: 'bg-sk-overlay',
126+ content: 'bg-sk-panel divide-sk-line2 shadow-sk-modal',
127+ header: 'px-5 py-4 min-h-0',
128+ body: 'p-5',
129+ footer: 'px-5 py-4 justify-end gap-2',
130+ title: 'text-base font-semibold'
131+ },
132+ variants: { side: { right: { content: 'sm:max-w-[520px]' } }, inset: { true: { content: 'rounded-[20px]' } } }
133+ },
134+ toast: {
135+ slots: {
136+ root: 'bg-sk-ink rounded-[9px] shadow-sk-toast ring-0 px-4 py-3',
137+ title: 'text-sk-onink font-medium',
138+ description: 'text-sk-onink/70',
139+ close: 'text-sk-onink/70 hover:text-sk-onink'
140+ }
141+ },
142+ alert: { slots: { root: 'rounded-[9px]' } },
143+ popover: { slots: { content: menuContent } },
144+ dropdownMenu: { slots: { content: menuContent } },
145+ contextMenu: { slots: { content: menuContent } },
146+ tooltip: { slots: { content: 'bg-sk-ink text-sk-onink ring-0 rounded-[7px] h-auto py-1' } },
147+ kbd: { base: 'font-mono' },
148+
149+ // Legacy shell pieces: the dashboard group is no longer the app frame, it only provides the context
150+ // that UDashboardPanel/UDashboardNavbar need. It must flow with the page (no fixed viewport).
151+ dashboardGroup: { base: 'relative flex flex-col min-w-0 overflow-visible' },
152+ dashboardPanel: {
153+ slots: { root: 'min-h-0 lg:not-last:border-e-0', body: 'overflow-visible sm:p-6' }
154+ },
155+ dashboardNavbar: {
156+ slots: {
157+ root: 'h-auto min-h-12 py-3 border-b-0 px-4 sm:px-6',
158+ title: 'font-mono text-[22px] font-normal tracking-[-0.01em] text-sk-text'
159+ }
160+ }
5161 }
6162 })
@@ -1,6 +1,162 @@
1+// Nuxt UI theme: makes stock components look like the mockup (design/BRIEF.md section 1).
2+// Colors/tokens live in assets/css/main.css; this file only holds palette names and slot overrides.
3+// Rule of thumb for components: radius 7px (buttons, inputs), 9-10px (menus), 20px (cards, modals), pills = rounded-full.
4+const menuContent = 'bg-sk-panel rounded-[10px] shadow-sk-modal'
5+
1 export default defineAppConfig({6 export default defineAppConfig({
2 ui: {7 ui: {
3- // `secondary` is used for Codex so that agents never share a color with status badges (success/warning/info).8+ colors: {
4- colors: { secondary: 'violet' }9+ primary: 'clay', // accent: switches, focus rings, links
10+ secondary: 'neutral', // legacy (was violet for Codex); agents are neutral in the new design
11+ success: 'ok',
12+ info: 'azure',
13+ warning: 'rust', // accent-coloured text, see "Status colour semantics" in the brief
14+ error: 'rust',
15+ neutral: 'stone'
16+ },
17+
18+ button: {
19+ slots: { base: 'rounded-[7px]' },
20+ variants: {
21+ size: {
22+ xs: { base: 'h-6 px-2 py-0 text-xs gap-1' },
23+ sm: { base: 'h-7 px-2.5 py-0 text-xs gap-1' },
24+ md: { base: 'h-[30px] px-3 py-0 text-sm gap-1.5' },
25+ lg: { base: 'h-8 px-4 py-0 text-sm gap-2' }
26+ }
27+ },
28+ compoundVariants: [
29+ // secondary: panel background + 1px line, hover tint
30+ { color: 'neutral', variant: 'outline', class: 'bg-sk-panel ring-sk-line hover:bg-sk-hover active:bg-sk-hover disabled:bg-sk-panel aria-disabled:bg-sk-panel focus-visible:ring-sk-faint' },
31+ // ghost: muted text, darker on hover
32+ { color: 'neutral', variant: 'ghost', class: 'text-sk-muted hover:text-sk-text hover:bg-sk-line2 active:bg-sk-line2' },
33+ { color: 'neutral', variant: 'solid', class: 'font-semibold' }
34+ ]
35+ },
36+
37+ card: {
38+ slots: { root: 'rounded-[20px]', header: 'px-5 py-4', body: 'p-5', footer: 'px-5 py-4' },
39+ variants: {
40+ variant: {
41+ outline: { root: 'bg-sk-panel ring-default divide-sk-line2' },
42+ subtle: { root: 'bg-sk-hover ring-default divide-sk-line2' },
43+ soft: { root: 'bg-sk-hover divide-sk-line2' }
44+ }
45+ }
46+ },
47+
48+ badge: {
49+ slots: { base: 'rounded-full' },
50+ variants: {
51+ size: {
52+ sm: { base: 'text-[11px]/3 px-2 py-1' },
53+ md: { base: 'text-xs px-2 py-0.5' },
54+ lg: { base: 'text-xs px-2.5 py-1' }
55+ }
56+ }
57+ },
58+
59+ switch: {
60+ // 28x16 track with a 12px knob = size xs
61+ slots: { thumb: 'bg-sk-knob shadow-none' },
62+ defaultVariants: { size: 'xs' }
63+ },
64+
65+ checkbox: {
66+ slots: { base: 'rounded-[4px] ring-sk-line', indicator: 'text-sk-onink' },
67+ defaultVariants: { color: 'neutral' }
68+ },
69+
70+ input: {
71+ slots: { base: 'rounded-[7px]' },
72+ variants: {
73+ size: {
74+ sm: { base: 'h-7 px-2.5 py-0 text-xs' },
75+ md: { base: 'h-8 px-2.5 py-0 text-sm' },
76+ lg: { base: 'h-9 px-3 py-0 text-sm' }
77+ },
78+ variant: { outline: 'ring-sk-line focus-visible:ring-sk-faint' }
79+ }
80+ },
81+ inputMenu: { slots: { base: 'rounded-[7px]', content: menuContent } },
82+ select: {
83+ slots: { base: 'rounded-[7px]', content: menuContent },
84+ variants: { size: { md: { base: 'h-8 px-2.5 py-0 text-sm' }, sm: { base: 'h-7 px-2.5 py-0 text-xs' } } }
85+ },
86+ selectMenu: { slots: { base: 'rounded-[7px]', content: menuContent } },
87+ textarea: {
88+ slots: { base: 'rounded-[7px]' },
89+ variants: { size: { md: { base: 'px-2.5 py-1.5 text-sm' } } }
90+ },
91+
92+ // Pill segmented control (Automatic / On demand style)
93+ tabs: {
94+ slots: { list: 'bg-sk-line2 rounded-full p-[3px] gap-[2px]', trigger: 'rounded-full', indicator: 'rounded-full shadow-none' },
95+ variants: {
96+ variant: { pill: { list: 'bg-sk-line2 rounded-full', indicator: 'rounded-full shadow-none' } },
97+ size: {
98+ xs: { trigger: 'h-[22px] px-2.5 text-xs' },
99+ sm: { trigger: 'h-[26px] px-3 text-sm' },
100+ md: { trigger: 'h-7 px-3 text-sm' }
101+ }
102+ },
103+ compoundVariants: [
104+ { color: 'neutral', variant: 'pill', class: { indicator: 'bg-sk-ink', trigger: 'data-[state=active]:text-sk-onink' } }
105+ ],
106+ defaultVariants: { color: 'neutral', size: 'sm' }
107+ },
108+
109+ modal: {
110+ slots: {
111+ overlay: 'bg-sk-overlay',
112+ content: 'bg-sk-panel divide-sk-line2',
113+ header: 'px-5 py-4 min-h-0',
114+ body: 'p-5',
115+ footer: 'px-5 py-4 justify-end gap-2',
116+ title: 'text-base font-semibold'
117+ },
118+ variants: {
119+ fullscreen: { false: { content: 'rounded-[20px] shadow-sk-modal max-w-[520px]' } },
120+ overlay: { true: { overlay: 'bg-sk-overlay' } }
121+ }
122+ },
123+ slideover: {
124+ slots: {
125+ overlay: 'bg-sk-overlay',
126+ content: 'bg-sk-panel divide-sk-line2 shadow-sk-modal',
127+ header: 'px-5 py-4 min-h-0',
128+ body: 'p-5',
129+ footer: 'px-5 py-4 justify-end gap-2',
130+ title: 'text-base font-semibold'
131+ },
132+ variants: { side: { right: { content: 'sm:max-w-[520px]' } }, inset: { true: { content: 'rounded-[20px]' } } }
133+ },
134+ toast: {
135+ slots: {
136+ root: 'bg-sk-ink rounded-[9px] shadow-sk-toast ring-0 px-4 py-3',
137+ title: 'text-sk-onink font-medium',
138+ description: 'text-sk-onink/70',
139+ close: 'text-sk-onink/70 hover:text-sk-onink'
140+ }
141+ },
142+ alert: { slots: { root: 'rounded-[9px]' } },
143+ popover: { slots: { content: menuContent } },
144+ dropdownMenu: { slots: { content: menuContent } },
145+ contextMenu: { slots: { content: menuContent } },
146+ tooltip: { slots: { content: 'bg-sk-ink text-sk-onink ring-0 rounded-[7px] h-auto py-1' } },
147+ kbd: { base: 'font-mono' },
148+
149+ // Legacy shell pieces: the dashboard group is no longer the app frame, it only provides the context
150+ // that UDashboardPanel/UDashboardNavbar need. It must flow with the page (no fixed viewport).
151+ dashboardGroup: { base: 'relative flex flex-col min-w-0 overflow-visible' },
152+ dashboardPanel: {
153+ slots: { root: 'min-h-0 lg:not-last:border-e-0', body: 'overflow-visible sm:p-6' }
154+ },
155+ dashboardNavbar: {
156+ slots: {
157+ root: 'h-auto min-h-12 py-3 border-b-0 px-4 sm:px-6',
158+ title: 'font-mono text-[22px] font-normal tracking-[-0.01em] text-sk-text'
159+ }
160+ }
5 }161 }
6 })162 })
modified packages/ui/app/app.vue +1 -1
@@ -1,5 +1,5 @@
11 <template>
2- <UApp>
2+ <UApp :toaster="{ position: 'bottom-center', duration: 2600 }">
33 <NuxtLayout>
44 <NuxtPage />
55 </NuxtLayout>
@@ -1,5 +1,5 @@
1 <template>1 <template>
2- <UApp>2+ <UApp :toaster="{ position: 'bottom-center', duration: 2600 }">
3 <NuxtLayout>3 <NuxtLayout>
4 <NuxtPage />4 <NuxtPage />
5 </NuxtLayout>5 </NuxtLayout>
modified packages/ui/app/assets/css/main.css +221 -10
@@ -1,19 +1,230 @@
11 @import "tailwindcss";
22 @import "@nuxt/ui";
33
4-.md-body { font-size: 0.875rem; line-height: 1.6; overflow-wrap: anywhere; }
5-.md-body h1 { font-size: 1.25rem; font-weight: 700; margin: 1rem 0 0.5rem; }
6-.md-body h2 { font-size: 1.1rem; font-weight: 650; margin: 1rem 0 0.5rem; }
4+/* Fonts are bundled (the app runs offline). Latin subset only, weights actually used. */
5+@import "@fontsource/instrument-sans/latin-400.css";
6+@import "@fontsource/instrument-sans/latin-500.css";
7+@import "@fontsource/instrument-sans/latin-600.css";
8+@import "@fontsource/ibm-plex-mono/latin-400.css";
9+@import "@fontsource/ibm-plex-mono/latin-500.css";
10+
11+/* ------------------------------------------------------------------ palettes
12+ Custom palettes must be `static`, otherwise Tailwind tree-shakes them away
13+ (Nuxt UI only references them at runtime through --ui-color-*).
14+ Each palette is built so that 500 (light) and 400 (dark) hit the mockup tokens. */
15+@theme static {
16+ /* clay: the accent (switches, focus, primary). 500 = --accent light, 400 = --accent dark */
17+ --color-clay-50: #fdf3ee;
18+ --color-clay-100: #fbeee6;
19+ --color-clay-200: #f6d5c3;
20+ --color-clay-300: #f1b79b;
21+ --color-clay-400: #ec8a5f;
22+ --color-clay-500: #e2774c;
23+ --color-clay-600: #c9592f;
24+ --color-clay-700: #b4471d;
25+ --color-clay-800: #8f3a1b;
26+ --color-clay-900: #74321b;
27+ --color-clay-950: #3f180b;
28+
29+ /* rust: accent-coloured TEXT (warning / error / drift). 500 = --accent-text light, 400 = dark */
30+ --color-rust-50: #fbeee6;
31+ --color-rust-100: #f7dccc;
32+ --color-rust-200: #f3c3aa;
33+ --color-rust-300: #f4b190;
34+ --color-rust-400: #f1a07b;
35+ --color-rust-500: #b4471d;
36+ --color-rust-600: #9a3b16;
37+ --color-rust-700: #7f3014;
38+ --color-rust-800: #652711;
39+ --color-rust-900: #4d1e0e;
40+ --color-rust-950: #2b1c14;
41+
42+ /* ok: green (up to date, synced, in catalog) */
43+ --color-ok-50: oklch(0.97 0.02 155);
44+ --color-ok-100: oklch(0.94 0.04 155);
45+ --color-ok-200: oklch(0.88 0.08 155);
46+ --color-ok-300: oklch(0.8 0.11 155);
47+ --color-ok-400: oklch(0.72 0.13 155);
48+ --color-ok-500: oklch(0.6 0.13 155);
49+ --color-ok-600: oklch(0.52 0.12 155);
50+ --color-ok-700: oklch(0.44 0.1 155);
51+ --color-ok-800: oklch(0.36 0.08 155);
52+ --color-ok-900: oklch(0.3 0.06 155);
53+ --color-ok-950: oklch(0.2 0.04 155);
54+
55+ /* azure: blue (update available, syncing) */
56+ --color-azure-50: oklch(0.97 0.02 250);
57+ --color-azure-100: oklch(0.94 0.04 250);
58+ --color-azure-200: oklch(0.88 0.07 250);
59+ --color-azure-300: oklch(0.8 0.1 250);
60+ --color-azure-400: oklch(0.72 0.12 250);
61+ --color-azure-500: oklch(0.58 0.13 250);
62+ --color-azure-600: oklch(0.5 0.13 250);
63+ --color-azure-700: oklch(0.43 0.12 250);
64+ --color-azure-800: oklch(0.36 0.1 250);
65+ --color-azure-900: oklch(0.3 0.08 250);
66+ --color-azure-950: oklch(0.2 0.05 250);
67+}
68+
69+/* ------------------------------------------------------------------ fonts + density */
70+@theme {
71+ --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif;
72+ --font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
73+ /* 13px base density: Nuxt UI components use text-sm for their default size. */
74+ --text-sm: 0.8125rem;
75+ --text-sm--line-height: 1.45;
76+}
77+
78+/* ------------------------------------------------------------------ skilly tokens (source of truth: design/BRIEF.md 1.2) */
79+:root,
80+.light {
81+ --sk-bg: #f5f4f1;
82+ --sk-dot: #dcd8d0;
83+ --sk-panel: #ffffff;
84+ --sk-side: #ffffff;
85+ --sk-line: #e3e0da;
86+ --sk-line2: #eeebe6;
87+ --sk-hover: #f6f5f2;
88+ --sk-sel: #efede9;
89+ --sk-text: #1c1b19;
90+ --sk-muted: #6b6862;
91+ --sk-faint: #8a867e;
92+ --sk-ink: #1c1b19;
93+ --sk-onink: #ffffff;
94+ --sk-accent: #e2774c;
95+ --sk-accent-text: #b4471d;
96+ --sk-accent-bg: #fbeee6;
97+ --sk-ok: oklch(0.6 0.13 155);
98+ --sk-info: oklch(0.58 0.13 250);
99+ --sk-knob: #ffffff;
100+ --sk-ring: #ebe8e2;
101+ --sk-overlay: rgba(28, 27, 25, 0.3);
102+}
103+.dark {
104+ --sk-bg: #121110;
105+ --sk-dot: #272521;
106+ --sk-panel: #1a1917;
107+ --sk-side: #1a1917;
108+ --sk-line: #2e2c28;
109+ --sk-line2: #24221f;
110+ --sk-hover: #22201d;
111+ --sk-sel: #2a2825;
112+ --sk-text: #eeece8;
113+ --sk-muted: #a7a39b;
114+ --sk-faint: #7d7970;
115+ --sk-ink: #eeece8;
116+ --sk-onink: #121110;
117+ --sk-accent: #ec8a5f;
118+ --sk-accent-text: #f1a07b;
119+ --sk-accent-bg: #2b1c14;
120+ --sk-ok: oklch(0.72 0.13 155);
121+ --sk-info: oklch(0.72 0.12 250);
122+ --sk-knob: #eeece8;
123+ --sk-ring: #2e2c28;
124+ --sk-overlay: rgba(0, 0, 0, 0.55);
125+}
126+
127+/* Tailwind utilities for the tokens: bg-sk-panel, text-sk-faint, border-sk-line, shadow-sk-ring ... */
128+@theme inline {
129+ --color-sk-bg: var(--sk-bg);
130+ --color-sk-dot: var(--sk-dot);
131+ --color-sk-panel: var(--sk-panel);
132+ --color-sk-line: var(--sk-line);
133+ --color-sk-line2: var(--sk-line2);
134+ --color-sk-hover: var(--sk-hover);
135+ --color-sk-sel: var(--sk-sel);
136+ --color-sk-text: var(--sk-text);
137+ --color-sk-muted: var(--sk-muted);
138+ --color-sk-faint: var(--sk-faint);
139+ --color-sk-ink: var(--sk-ink);
140+ --color-sk-onink: var(--sk-onink);
141+ --color-sk-accent: var(--sk-accent);
142+ --color-sk-accent-text: var(--sk-accent-text);
143+ --color-sk-accent-bg: var(--sk-accent-bg);
144+ --color-sk-ok: var(--sk-ok);
145+ --color-sk-info: var(--sk-info);
146+ --color-sk-knob: var(--sk-knob);
147+ --color-sk-ring: var(--sk-ring);
148+ --color-sk-overlay: var(--sk-overlay);
149+ --shadow-sk-ring: 0 0 0 1px var(--sk-ring);
150+ --shadow-sk-ring-hover: 0 0 0 1px var(--sk-faint);
151+ --shadow-sk-modal: 0 20px 50px rgba(0, 0, 0, 0.18);
152+ --shadow-sk-toast: 0 10px 30px rgba(0, 0, 0, 0.2);
153+ --shadow-sk-bar: 0 0 0 1px var(--sk-ring), 0 12px 32px rgba(0, 0, 0, 0.16);
154+}
155+
156+/* ------------------------------------------------------------------ bridge to Nuxt UI semantic variables */
157+:root,
158+.light,
159+.dark {
160+ --ui-radius: 0.3rem; /* rounded-md = 1.5x = ~7px (buttons, inputs) */
161+ --ui-header-height: 3.5rem;
162+
163+ --ui-bg: var(--sk-bg);
164+ --ui-bg-muted: var(--sk-hover);
165+ --ui-bg-elevated: var(--sk-line2);
166+ --ui-bg-accented: var(--sk-line);
167+ --ui-bg-inverted: var(--sk-ink);
168+
169+ --ui-border: var(--sk-ring);
170+ --ui-border-muted: var(--sk-line2);
171+ --ui-border-accented: var(--sk-line);
172+ --ui-border-inverted: var(--sk-ink);
173+
174+ --ui-text-dimmed: var(--sk-faint);
175+ --ui-text-muted: var(--sk-muted);
176+ --ui-text-toned: var(--sk-muted);
177+ --ui-text: var(--sk-text);
178+ --ui-text-highlighted: var(--sk-text);
179+ --ui-text-inverted: var(--sk-onink);
180+}
181+
182+/* A .light wrapper inside the dark app (dev kit side-by-side): restore the light semantic colours. */
183+.light {
184+ --ui-primary: var(--ui-color-primary-500);
185+ --ui-secondary: var(--ui-color-secondary-500);
186+ --ui-success: var(--ui-color-success-500);
187+ --ui-info: var(--ui-color-info-500);
188+ --ui-warning: var(--ui-color-warning-500);
189+ --ui-error: var(--ui-color-error-500);
190+ color-scheme: light;
191+}
192+.dark { color-scheme: dark; }
193+
194+html {
195+ font-size: 16px; /* rem stays 16px; density comes from text-sm = 13px and body 13px */
196+}
197+body {
198+ font-family: var(--font-sans);
199+ font-size: 13px;
200+ line-height: 1.45;
201+ color: var(--sk-text);
202+ background-color: var(--sk-bg);
203+ background-image: radial-gradient(var(--sk-dot) 1px, transparent 1.2px);
204+ background-size: 12px 12px;
205+ -webkit-font-smoothing: antialiased;
206+}
207+::selection {
208+ background: color-mix(in oklch, var(--sk-accent) 28%, transparent);
209+}
210+a {
211+ text-underline-offset: 2px;
212+}
213+
214+/* ------------------------------------------------------------------ markdown (legacy; owned by WP2 once MarkdownView is rebuilt) */
215+.md-body { font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
216+.md-body h1 { font-size: 1.25rem; font-weight: 600; margin: 1rem 0 0.5rem; }
217+.md-body h2 { font-size: 1.05rem; font-weight: 600; margin: 1rem 0 0.5rem; }
7218 .md-body h3, .md-body h4 { font-size: 0.95rem; font-weight: 600; margin: 0.75rem 0 0.4rem; }
8219 .md-body p { margin: 0.5rem 0; }
9220 .md-body ul { list-style: disc; padding-left: 1.25rem; margin: 0.5rem 0; }
10221 .md-body ol { list-style: decimal; padding-left: 1.25rem; margin: 0.5rem 0; }
11-.md-body a { color: var(--ui-primary); text-decoration: underline; }
12-.md-body code { background: var(--ui-bg-elevated); padding: 0.1rem 0.3rem; border-radius: 0.25rem; font-size: 0.8rem; }
13-.md-body pre { background: var(--ui-bg-elevated); padding: 0.75rem; border-radius: 0.5rem; overflow-x: auto; margin: 0.5rem 0; }
222+.md-body a { color: var(--sk-accent-text); text-decoration: underline; }
223+.md-body code { font-family: var(--font-mono); background: var(--sk-line2); padding: 0.1rem 0.3rem; border-radius: 0.25rem; font-size: 0.78rem; }
224+.md-body pre { background: var(--sk-hover); box-shadow: 0 0 0 1px var(--sk-ring); padding: 0.75rem; border-radius: 10px; overflow-x: auto; margin: 0.5rem 0; }
14225 .md-body pre code { background: transparent; padding: 0; }
15-.md-body blockquote { border-left: 3px solid var(--ui-border-accented); padding-left: 0.75rem; color: var(--ui-text-muted); margin: 0.5rem 0; }
226+.md-body blockquote { border-left: 3px solid var(--sk-line); padding-left: 0.75rem; color: var(--sk-muted); margin: 0.5rem 0; }
16227 .md-body table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 0.5rem 0; }
17-.md-body th, .md-body td { border: 1px solid var(--ui-border); padding: 0.25rem 0.5rem; text-align: left; vertical-align: top; }
18-.md-body th { background: var(--ui-bg-elevated); font-weight: 600; }
19-.md-body hr { border-color: var(--ui-border); margin: 1rem 0; }
228+.md-body th, .md-body td { border: 1px solid var(--sk-line); padding: 0.25rem 0.5rem; text-align: left; vertical-align: top; }
229+.md-body th { background: var(--sk-hover); font-weight: 600; }
230+.md-body hr { border-color: var(--sk-line); margin: 1rem 0; }
@@ -1,19 +1,230 @@
1 @import "tailwindcss";1 @import "tailwindcss";
2 @import "@nuxt/ui";2 @import "@nuxt/ui";
3 3
4-.md-body { font-size: 0.875rem; line-height: 1.6; overflow-wrap: anywhere; }4+/* Fonts are bundled (the app runs offline). Latin subset only, weights actually used. */
5-.md-body h1 { font-size: 1.25rem; font-weight: 700; margin: 1rem 0 0.5rem; }5+@import "@fontsource/instrument-sans/latin-400.css";
6-.md-body h2 { font-size: 1.1rem; font-weight: 650; margin: 1rem 0 0.5rem; }6+@import "@fontsource/instrument-sans/latin-500.css";
7+@import "@fontsource/instrument-sans/latin-600.css";
8+@import "@fontsource/ibm-plex-mono/latin-400.css";
9+@import "@fontsource/ibm-plex-mono/latin-500.css";
10+
11+/* ------------------------------------------------------------------ palettes
12+ Custom palettes must be `static`, otherwise Tailwind tree-shakes them away
13+ (Nuxt UI only references them at runtime through --ui-color-*).
14+ Each palette is built so that 500 (light) and 400 (dark) hit the mockup tokens. */
15+@theme static {
16+ /* clay: the accent (switches, focus, primary). 500 = --accent light, 400 = --accent dark */
17+ --color-clay-50: #fdf3ee;
18+ --color-clay-100: #fbeee6;
19+ --color-clay-200: #f6d5c3;
20+ --color-clay-300: #f1b79b;
21+ --color-clay-400: #ec8a5f;
22+ --color-clay-500: #e2774c;
23+ --color-clay-600: #c9592f;
24+ --color-clay-700: #b4471d;
25+ --color-clay-800: #8f3a1b;
26+ --color-clay-900: #74321b;
27+ --color-clay-950: #3f180b;
28+
29+ /* rust: accent-coloured TEXT (warning / error / drift). 500 = --accent-text light, 400 = dark */
30+ --color-rust-50: #fbeee6;
31+ --color-rust-100: #f7dccc;
32+ --color-rust-200: #f3c3aa;
33+ --color-rust-300: #f4b190;
34+ --color-rust-400: #f1a07b;
35+ --color-rust-500: #b4471d;
36+ --color-rust-600: #9a3b16;
37+ --color-rust-700: #7f3014;
38+ --color-rust-800: #652711;
39+ --color-rust-900: #4d1e0e;
40+ --color-rust-950: #2b1c14;
41+
42+ /* ok: green (up to date, synced, in catalog) */
43+ --color-ok-50: oklch(0.97 0.02 155);
44+ --color-ok-100: oklch(0.94 0.04 155);
45+ --color-ok-200: oklch(0.88 0.08 155);
46+ --color-ok-300: oklch(0.8 0.11 155);
47+ --color-ok-400: oklch(0.72 0.13 155);
48+ --color-ok-500: oklch(0.6 0.13 155);
49+ --color-ok-600: oklch(0.52 0.12 155);
50+ --color-ok-700: oklch(0.44 0.1 155);
51+ --color-ok-800: oklch(0.36 0.08 155);
52+ --color-ok-900: oklch(0.3 0.06 155);
53+ --color-ok-950: oklch(0.2 0.04 155);
54+
55+ /* azure: blue (update available, syncing) */
56+ --color-azure-50: oklch(0.97 0.02 250);
57+ --color-azure-100: oklch(0.94 0.04 250);
58+ --color-azure-200: oklch(0.88 0.07 250);
59+ --color-azure-300: oklch(0.8 0.1 250);
60+ --color-azure-400: oklch(0.72 0.12 250);
61+ --color-azure-500: oklch(0.58 0.13 250);
62+ --color-azure-600: oklch(0.5 0.13 250);
63+ --color-azure-700: oklch(0.43 0.12 250);
64+ --color-azure-800: oklch(0.36 0.1 250);
65+ --color-azure-900: oklch(0.3 0.08 250);
66+ --color-azure-950: oklch(0.2 0.05 250);
67+}
68+
69+/* ------------------------------------------------------------------ fonts + density */
70+@theme {
71+ --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif;
72+ --font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
73+ /* 13px base density: Nuxt UI components use text-sm for their default size. */
74+ --text-sm: 0.8125rem;
75+ --text-sm--line-height: 1.45;
76+}
77+
78+/* ------------------------------------------------------------------ skilly tokens (source of truth: design/BRIEF.md 1.2) */
79+:root,
80+.light {
81+ --sk-bg: #f5f4f1;
82+ --sk-dot: #dcd8d0;
83+ --sk-panel: #ffffff;
84+ --sk-side: #ffffff;
85+ --sk-line: #e3e0da;
86+ --sk-line2: #eeebe6;
87+ --sk-hover: #f6f5f2;
88+ --sk-sel: #efede9;
89+ --sk-text: #1c1b19;
90+ --sk-muted: #6b6862;
91+ --sk-faint: #8a867e;
92+ --sk-ink: #1c1b19;
93+ --sk-onink: #ffffff;
94+ --sk-accent: #e2774c;
95+ --sk-accent-text: #b4471d;
96+ --sk-accent-bg: #fbeee6;
97+ --sk-ok: oklch(0.6 0.13 155);
98+ --sk-info: oklch(0.58 0.13 250);
99+ --sk-knob: #ffffff;
100+ --sk-ring: #ebe8e2;
101+ --sk-overlay: rgba(28, 27, 25, 0.3);
102+}
103+.dark {
104+ --sk-bg: #121110;
105+ --sk-dot: #272521;
106+ --sk-panel: #1a1917;
107+ --sk-side: #1a1917;
108+ --sk-line: #2e2c28;
109+ --sk-line2: #24221f;
110+ --sk-hover: #22201d;
111+ --sk-sel: #2a2825;
112+ --sk-text: #eeece8;
113+ --sk-muted: #a7a39b;
114+ --sk-faint: #7d7970;
115+ --sk-ink: #eeece8;
116+ --sk-onink: #121110;
117+ --sk-accent: #ec8a5f;
118+ --sk-accent-text: #f1a07b;
119+ --sk-accent-bg: #2b1c14;
120+ --sk-ok: oklch(0.72 0.13 155);
121+ --sk-info: oklch(0.72 0.12 250);
122+ --sk-knob: #eeece8;
123+ --sk-ring: #2e2c28;
124+ --sk-overlay: rgba(0, 0, 0, 0.55);
125+}
126+
127+/* Tailwind utilities for the tokens: bg-sk-panel, text-sk-faint, border-sk-line, shadow-sk-ring ... */
128+@theme inline {
129+ --color-sk-bg: var(--sk-bg);
130+ --color-sk-dot: var(--sk-dot);
131+ --color-sk-panel: var(--sk-panel);
132+ --color-sk-line: var(--sk-line);
133+ --color-sk-line2: var(--sk-line2);
134+ --color-sk-hover: var(--sk-hover);
135+ --color-sk-sel: var(--sk-sel);
136+ --color-sk-text: var(--sk-text);
137+ --color-sk-muted: var(--sk-muted);
138+ --color-sk-faint: var(--sk-faint);
139+ --color-sk-ink: var(--sk-ink);
140+ --color-sk-onink: var(--sk-onink);
141+ --color-sk-accent: var(--sk-accent);
142+ --color-sk-accent-text: var(--sk-accent-text);
143+ --color-sk-accent-bg: var(--sk-accent-bg);
144+ --color-sk-ok: var(--sk-ok);
145+ --color-sk-info: var(--sk-info);
146+ --color-sk-knob: var(--sk-knob);
147+ --color-sk-ring: var(--sk-ring);
148+ --color-sk-overlay: var(--sk-overlay);
149+ --shadow-sk-ring: 0 0 0 1px var(--sk-ring);
150+ --shadow-sk-ring-hover: 0 0 0 1px var(--sk-faint);
151+ --shadow-sk-modal: 0 20px 50px rgba(0, 0, 0, 0.18);
152+ --shadow-sk-toast: 0 10px 30px rgba(0, 0, 0, 0.2);
153+ --shadow-sk-bar: 0 0 0 1px var(--sk-ring), 0 12px 32px rgba(0, 0, 0, 0.16);
154+}
155+
156+/* ------------------------------------------------------------------ bridge to Nuxt UI semantic variables */
157+:root,
158+.light,
159+.dark {
160+ --ui-radius: 0.3rem; /* rounded-md = 1.5x = ~7px (buttons, inputs) */
161+ --ui-header-height: 3.5rem;
162+
163+ --ui-bg: var(--sk-bg);
164+ --ui-bg-muted: var(--sk-hover);
165+ --ui-bg-elevated: var(--sk-line2);
166+ --ui-bg-accented: var(--sk-line);
167+ --ui-bg-inverted: var(--sk-ink);
168+
169+ --ui-border: var(--sk-ring);
170+ --ui-border-muted: var(--sk-line2);
171+ --ui-border-accented: var(--sk-line);
172+ --ui-border-inverted: var(--sk-ink);
173+
174+ --ui-text-dimmed: var(--sk-faint);
175+ --ui-text-muted: var(--sk-muted);
176+ --ui-text-toned: var(--sk-muted);
177+ --ui-text: var(--sk-text);
178+ --ui-text-highlighted: var(--sk-text);
179+ --ui-text-inverted: var(--sk-onink);
180+}
181+
182+/* A .light wrapper inside the dark app (dev kit side-by-side): restore the light semantic colours. */
183+.light {
184+ --ui-primary: var(--ui-color-primary-500);
185+ --ui-secondary: var(--ui-color-secondary-500);
186+ --ui-success: var(--ui-color-success-500);
187+ --ui-info: var(--ui-color-info-500);
188+ --ui-warning: var(--ui-color-warning-500);
189+ --ui-error: var(--ui-color-error-500);
190+ color-scheme: light;
191+}
192+.dark { color-scheme: dark; }
193+
194+html {
195+ font-size: 16px; /* rem stays 16px; density comes from text-sm = 13px and body 13px */
196+}
197+body {
198+ font-family: var(--font-sans);
199+ font-size: 13px;
200+ line-height: 1.45;
201+ color: var(--sk-text);
202+ background-color: var(--sk-bg);
203+ background-image: radial-gradient(var(--sk-dot) 1px, transparent 1.2px);
204+ background-size: 12px 12px;
205+ -webkit-font-smoothing: antialiased;
206+}
207+::selection {
208+ background: color-mix(in oklch, var(--sk-accent) 28%, transparent);
209+}
210+a {
211+ text-underline-offset: 2px;
212+}
213+
214+/* ------------------------------------------------------------------ markdown (legacy; owned by WP2 once MarkdownView is rebuilt) */
215+.md-body { font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
216+.md-body h1 { font-size: 1.25rem; font-weight: 600; margin: 1rem 0 0.5rem; }
217+.md-body h2 { font-size: 1.05rem; font-weight: 600; margin: 1rem 0 0.5rem; }
7 .md-body h3, .md-body h4 { font-size: 0.95rem; font-weight: 600; margin: 0.75rem 0 0.4rem; }218 .md-body h3, .md-body h4 { font-size: 0.95rem; font-weight: 600; margin: 0.75rem 0 0.4rem; }
8 .md-body p { margin: 0.5rem 0; }219 .md-body p { margin: 0.5rem 0; }
9 .md-body ul { list-style: disc; padding-left: 1.25rem; margin: 0.5rem 0; }220 .md-body ul { list-style: disc; padding-left: 1.25rem; margin: 0.5rem 0; }
10 .md-body ol { list-style: decimal; padding-left: 1.25rem; margin: 0.5rem 0; }221 .md-body ol { list-style: decimal; padding-left: 1.25rem; margin: 0.5rem 0; }
11-.md-body a { color: var(--ui-primary); text-decoration: underline; }222+.md-body a { color: var(--sk-accent-text); text-decoration: underline; }
12-.md-body code { background: var(--ui-bg-elevated); padding: 0.1rem 0.3rem; border-radius: 0.25rem; font-size: 0.8rem; }223+.md-body code { font-family: var(--font-mono); background: var(--sk-line2); padding: 0.1rem 0.3rem; border-radius: 0.25rem; font-size: 0.78rem; }
13-.md-body pre { background: var(--ui-bg-elevated); padding: 0.75rem; border-radius: 0.5rem; overflow-x: auto; margin: 0.5rem 0; }224+.md-body pre { background: var(--sk-hover); box-shadow: 0 0 0 1px var(--sk-ring); padding: 0.75rem; border-radius: 10px; overflow-x: auto; margin: 0.5rem 0; }
14 .md-body pre code { background: transparent; padding: 0; }225 .md-body pre code { background: transparent; padding: 0; }
15-.md-body blockquote { border-left: 3px solid var(--ui-border-accented); padding-left: 0.75rem; color: var(--ui-text-muted); margin: 0.5rem 0; }226+.md-body blockquote { border-left: 3px solid var(--sk-line); padding-left: 0.75rem; color: var(--sk-muted); margin: 0.5rem 0; }
16 .md-body table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 0.5rem 0; }227 .md-body table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 0.5rem 0; }
17-.md-body th, .md-body td { border: 1px solid var(--ui-border); padding: 0.25rem 0.5rem; text-align: left; vertical-align: top; }228+.md-body th, .md-body td { border: 1px solid var(--sk-line); padding: 0.25rem 0.5rem; text-align: left; vertical-align: top; }
18-.md-body th { background: var(--ui-bg-elevated); font-weight: 600; }229+.md-body th { background: var(--sk-hover); font-weight: 600; }
19-.md-body hr { border-color: var(--ui-border); margin: 1rem 0; }230+.md-body hr { border-color: var(--sk-line); margin: 1rem 0; }
modified packages/ui/app/components/AgentBadge.vue +2 -8
@@ -1,16 +1,10 @@
11 <script setup lang="ts">
22 import type { AgentId } from '@skilly/core'
33
4+// Legacy name kept for existing screens; agents are neutral in the new design (see SkAgentBadge).
45 withDefaults(defineProps<{ agent: AgentId, compact?: boolean }>(), { compact: false })
56 </script>
67
78 <template>
8- <UBadge
9- :color="agent === 'claude' ? 'warning' : 'secondary'"
10- variant="subtle"
11- :size="compact ? 'sm' : 'md'"
12- :icon="agent === 'claude' ? 'i-lucide-asterisk' : 'i-lucide-terminal'"
13- :label="compact ? (agent === 'claude' ? 'CC' : 'CX') : AGENT_LABELS[agent]"
14- :title="AGENT_LABELS[agent]"
15- />
9+ <SkAgentBadge :agent="agent" :compact="compact" variant="tag" />
1610 </template>
@@ -1,16 +1,10 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2 import type { AgentId } from '@skilly/core'2 import type { AgentId } from '@skilly/core'
3 3
4+// Legacy name kept for existing screens; agents are neutral in the new design (see SkAgentBadge).
4 withDefaults(defineProps<{ agent: AgentId, compact?: boolean }>(), { compact: false })5 withDefaults(defineProps<{ agent: AgentId, compact?: boolean }>(), { compact: false })
5 </script>6 </script>
6 7
7 <template>8 <template>
8- <UBadge9+ <SkAgentBadge :agent="agent" :compact="compact" variant="tag" />
9- :color="agent === 'claude' ? 'warning' : 'secondary'"
10- variant="subtle"
11- :size="compact ? 'sm' : 'md'"
12- :icon="agent === 'claude' ? 'i-lucide-asterisk' : 'i-lucide-terminal'"
13- :label="compact ? (agent === 'claude' ? 'CC' : 'CX') : AGENT_LABELS[agent]"
14- :title="AGENT_LABELS[agent]"
15- />
16 </template>10 </template>
added packages/ui/app/components/AppHeader.vue +103 -0
new file mode 100644
@@ -0,0 +1,103 @@
1+<script setup lang="ts">
2+/**
3+ * App shell header (design/BRIEF.md 1.6): sticky, blurred. Logo, nav with counts, quick switcher, theme switch,
4+ * catalog sync pill (reads useCatalog().git), settings pill.
5+ * Shared file owned by WP0: screens never edit it.
6+ */
7+const route = useRoute()
8+const { projects } = useProjects()
9+const { home, refresh: refreshHome } = useHomeInventory()
10+const { items, exists, git } = useCatalog()
11+const { busy, run } = useCatalogGit()
12+const colorMode = useColorMode()
13+const switcher = useSwitcher()
14+
15+onMounted(() => {
16+ if (!home.value) refreshHome()
17+})
18+
19+interface NavItem { label: string, to: string, count?: number }
20+const nav = computed<NavItem[]>(() => [
21+ // Instruction files are a separate group on the Home page, not "items loaded everywhere".
22+ { label: 'Home', to: '/home', count: home.value ? home.value.items.filter(i => i.type !== 'instructions').length : undefined },
23+ { label: 'Projects', to: '/projects', count: projects.value.length },
24+ { label: 'Catalog', to: '/catalog', count: exists.value ? items.value.length : undefined },
25+ { label: 'Marketplaces', to: '/marketplaces' },
26+ { label: 'Sync', to: '/sync' }
27+])
28+const isActive = (to: string) => route.path === to || route.path.startsWith(to + '/')
29+
30+const theme = computed({
31+ get: () => (colorMode.value === 'dark' ? 'dark' : 'light'),
32+ set: (v: 'light' | 'dark') => { colorMode.preference = v }
33+})
34+
35+const pill = computed(() => gitPillState(git.value, { catalogExists: exists.value, busy: !!busy.value }))
36+async function onPill() {
37+ if (busy.value) return
38+ if (pill.value.click === 'sync') await run('sync', undefined, 'Catalog synced')
39+ else await navigateTo('/sync')
40+}
41+</script>
42+
43+<template>
44+ <header
45+ class="sticky top-0 z-30 flex min-w-0 items-center gap-2.5 px-[18px] py-3 backdrop-blur-[8px] bg-[color-mix(in_oklch,var(--sk-bg)_82%,transparent)]"
46+ >
47+ <NuxtLink to="/projects" class="flex shrink-0 items-center gap-2 text-sk-text hover:text-sk-text" aria-label="skilly">
48+ <SkLogo />
49+ <span class="text-base font-semibold">skilly</span>
50+ </NuxtLink>
51+
52+ <span class="min-w-0 flex-1" />
53+
54+ <nav class="flex min-w-0 items-center gap-0.5 overflow-x-auto" aria-label="Main">
55+ <NuxtLink
56+ v-for="n in nav"
57+ :key="n.to"
58+ :to="n.to"
59+ class="flex h-[34px] shrink-0 items-center gap-1.5 rounded-[9px] px-3 text-[13px] font-medium whitespace-nowrap transition-colors"
60+ :class="isActive(n.to) ? 'bg-sk-ink text-sk-onink' : 'text-sk-muted hover:text-sk-text'"
61+ :aria-current="isActive(n.to) ? 'page' : undefined"
62+ >
63+ {{ n.label }}
64+ <span v-if="n.count !== undefined" class="font-mono text-[11px] opacity-60">{{ n.count }}</span>
65+ </NuxtLink>
66+ </nav>
67+
68+ <span class="min-w-0 flex-1" />
69+
70+ <UTooltip text="Quick switcher" :kbds="['ctrl', 'K']">
71+ <button
72+ type="button"
73+ class="flex h-[34px] shrink-0 cursor-pointer items-center gap-2 rounded-full border border-sk-line bg-sk-panel px-3 text-sk-muted transition-colors hover:border-sk-faint hover:text-sk-text"
74+ aria-label="Open the quick switcher"
75+ @click="switcher = true"
76+ >
77+ <UIcon name="i-lucide-search" class="size-3.5" />
78+ <span class="hidden font-mono text-[11px] xl:inline">Ctrl K</span>
79+ </button>
80+ </UTooltip>
81+
82+ <SkSeg v-model="theme" size="sm" aria-label="Theme" :options="[{ value: 'light', label: 'Light' }, { value: 'dark', label: 'Dark' }]" />
83+
84+ <button
85+ type="button"
86+ class="flex h-[34px] shrink-0 cursor-pointer items-center gap-2 rounded-full border border-sk-line bg-sk-panel px-3.5 text-[13px] font-medium whitespace-nowrap text-sk-text transition-colors hover:border-sk-faint"
87+ :title="pill.title"
88+ @click="onPill"
89+ >
90+ <SkStatusDot :tone="pill.tone" />
91+ {{ pill.label }}
92+ </button>
93+
94+ <NuxtLink
95+ to="/settings"
96+ class="flex h-[34px] shrink-0 items-center rounded-full border border-sk-line bg-sk-panel px-3 text-[13px] transition-colors hover:text-sk-text"
97+ :class="isActive('/settings') ? 'text-sk-text' : 'text-sk-muted'"
98+ title="Settings"
99+ >
100+ Settings
101+ </NuxtLink>
102+ </header>
103+</template>
new file mode 100644
@@ -0,0 +1,103 @@
1+<script setup lang="ts">
2+/**
3+ * App shell header (design/BRIEF.md 1.6): sticky, blurred. Logo, nav with counts, quick switcher, theme switch,
4+ * catalog sync pill (reads useCatalog().git), settings pill.
5+ * Shared file owned by WP0: screens never edit it.
6+ */
7+const route = useRoute()
8+const { projects } = useProjects()
9+const { home, refresh: refreshHome } = useHomeInventory()
10+const { items, exists, git } = useCatalog()
11+const { busy, run } = useCatalogGit()
12+const colorMode = useColorMode()
13+const switcher = useSwitcher()
14+
15+onMounted(() => {
16+ if (!home.value) refreshHome()
17+})
18+
19+interface NavItem { label: string, to: string, count?: number }
20+const nav = computed<NavItem[]>(() => [
21+ // Instruction files are a separate group on the Home page, not "items loaded everywhere".
22+ { label: 'Home', to: '/home', count: home.value ? home.value.items.filter(i => i.type !== 'instructions').length : undefined },
23+ { label: 'Projects', to: '/projects', count: projects.value.length },
24+ { label: 'Catalog', to: '/catalog', count: exists.value ? items.value.length : undefined },
25+ { label: 'Marketplaces', to: '/marketplaces' },
26+ { label: 'Sync', to: '/sync' }
27+])
28+const isActive = (to: string) => route.path === to || route.path.startsWith(to + '/')
29+
30+const theme = computed({
31+ get: () => (colorMode.value === 'dark' ? 'dark' : 'light'),
32+ set: (v: 'light' | 'dark') => { colorMode.preference = v }
33+})
34+
35+const pill = computed(() => gitPillState(git.value, { catalogExists: exists.value, busy: !!busy.value }))
36+async function onPill() {
37+ if (busy.value) return
38+ if (pill.value.click === 'sync') await run('sync', undefined, 'Catalog synced')
39+ else await navigateTo('/sync')
40+}
41+</script>
42+
43+<template>
44+ <header
45+ class="sticky top-0 z-30 flex min-w-0 items-center gap-2.5 px-[18px] py-3 backdrop-blur-[8px] bg-[color-mix(in_oklch,var(--sk-bg)_82%,transparent)]"
46+ >
47+ <NuxtLink to="/projects" class="flex shrink-0 items-center gap-2 text-sk-text hover:text-sk-text" aria-label="skilly">
48+ <SkLogo />
49+ <span class="text-base font-semibold">skilly</span>
50+ </NuxtLink>
51+
52+ <span class="min-w-0 flex-1" />
53+
54+ <nav class="flex min-w-0 items-center gap-0.5 overflow-x-auto" aria-label="Main">
55+ <NuxtLink
56+ v-for="n in nav"
57+ :key="n.to"
58+ :to="n.to"
59+ class="flex h-[34px] shrink-0 items-center gap-1.5 rounded-[9px] px-3 text-[13px] font-medium whitespace-nowrap transition-colors"
60+ :class="isActive(n.to) ? 'bg-sk-ink text-sk-onink' : 'text-sk-muted hover:text-sk-text'"
61+ :aria-current="isActive(n.to) ? 'page' : undefined"
62+ >
63+ {{ n.label }}
64+ <span v-if="n.count !== undefined" class="font-mono text-[11px] opacity-60">{{ n.count }}</span>
65+ </NuxtLink>
66+ </nav>
67+
68+ <span class="min-w-0 flex-1" />
69+
70+ <UTooltip text="Quick switcher" :kbds="['ctrl', 'K']">
71+ <button
72+ type="button"
73+ class="flex h-[34px] shrink-0 cursor-pointer items-center gap-2 rounded-full border border-sk-line bg-sk-panel px-3 text-sk-muted transition-colors hover:border-sk-faint hover:text-sk-text"
74+ aria-label="Open the quick switcher"
75+ @click="switcher = true"
76+ >
77+ <UIcon name="i-lucide-search" class="size-3.5" />
78+ <span class="hidden font-mono text-[11px] xl:inline">Ctrl K</span>
79+ </button>
80+ </UTooltip>
81+
82+ <SkSeg v-model="theme" size="sm" aria-label="Theme" :options="[{ value: 'light', label: 'Light' }, { value: 'dark', label: 'Dark' }]" />
83+
84+ <button
85+ type="button"
86+ class="flex h-[34px] shrink-0 cursor-pointer items-center gap-2 rounded-full border border-sk-line bg-sk-panel px-3.5 text-[13px] font-medium whitespace-nowrap text-sk-text transition-colors hover:border-sk-faint"
87+ :title="pill.title"
88+ @click="onPill"
89+ >
90+ <SkStatusDot :tone="pill.tone" />
91+ {{ pill.label }}
92+ </button>
93+
94+ <NuxtLink
95+ to="/settings"
96+ class="flex h-[34px] shrink-0 items-center rounded-full border border-sk-line bg-sk-panel px-3 text-[13px] transition-colors hover:text-sk-text"
97+ :class="isActive('/settings') ? 'text-sk-text' : 'text-sk-muted'"
98+ title="Settings"
99+ >
100+ Settings
101+ </NuxtLink>
102+ </header>
103+</template>
deleted packages/ui/app/components/AppSidebar.vue +0 -80
deleted file mode 100644
@@ -1,80 +0,0 @@
1-<script setup lang="ts">
2-defineEmits<{ add: [] }>()
3-
4-const { projects, loaded, error } = useProjects()
5-const route = useRoute()
6-
7-const linkClass = (active: boolean) =>
8- `flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${active ? 'bg-elevated text-highlighted font-medium' : 'text-muted hover:bg-elevated/60 hover:text-default'}`
9-
10-const library = [
11- { label: 'Catalog', icon: 'i-lucide-library', to: '/catalog' },
12- { label: 'Presets', icon: 'i-lucide-layers', to: '/presets' },
13- { label: 'Marketplaces', icon: 'i-lucide-store', to: '/marketplaces' }
14-]
15-const isActive = (to: string) => route.path === to || route.path.startsWith(to + '/')
16-</script>
17-
18-<template>
19- <nav class="flex w-full flex-col gap-1">
20- <NuxtLink to="/" :class="linkClass(route.path === '/')">
21- <UIcon name="i-lucide-layout-dashboard" class="size-4" />
22- Dashboard
23- </NuxtLink>
24- <NuxtLink to="/home" :class="linkClass(route.path === '/home')">
25- <UIcon name="i-lucide-house" class="size-4" />
26- Home
27- </NuxtLink>
28-
29- <div class="mt-4 flex items-center justify-between px-2">
30- <span class="text-xs font-semibold uppercase tracking-wide text-muted">Projects</span>
31- <UTooltip text="Add project" :content="{ side: 'right' }">
32- <UButton icon="i-lucide-plus" size="xs" color="neutral" variant="ghost" aria-label="Add project" @click="$emit('add')" />
33- </UTooltip>
34- </div>
35-
36- <div v-if="!loaded" class="space-y-1 px-1">
37- <USkeleton v-for="i in 3" :key="i" class="h-8 w-full" />
38- </div>
39- <p v-else-if="error" class="px-2 text-xs text-error">{{ error }}</p>
40- <p v-else-if="!projects.length" class="px-2 text-xs text-muted">No project yet.</p>
41- <NuxtLink
42- v-for="p in projects"
43- :key="p.id"
44- :to="`/projects/${encodeURIComponent(p.id)}`"
45- :class="linkClass(route.path === `/projects/${p.id}` || route.params.id === p.id)"
46- >
47- <UIcon name="i-lucide-folder" class="size-4 shrink-0" />
48- <span class="min-w-0 flex-1 truncate" :title="p.path">{{ p.name }}</span>
49- <UTooltip v-if="!p.exists" text="Folder not found on disk">
50- <UIcon name="i-lucide-triangle-alert" class="size-4 shrink-0 text-warning" />
51- </UTooltip>
52- <AgentBadge v-for="a in p.agentsDetected" :key="a" :agent="a" compact />
53- </NuxtLink>
54-
55- <UButton
56- label="Add project"
57- icon="i-lucide-folder-plus"
58- color="neutral"
59- variant="outline"
60- size="sm"
61- class="mt-1"
62- block
63- @click="$emit('add')"
64- />
65-
66- <div class="mt-4 px-2">
67- <span class="text-xs font-semibold uppercase tracking-wide text-muted">Library</span>
68- </div>
69- <NuxtLink v-for="l in library" :key="l.to" :to="l.to" :class="linkClass(isActive(l.to))">
70- <UIcon :name="l.icon" class="size-4" />
71- {{ l.label }}
72- </NuxtLink>
73- <NuxtLink to="/settings" :class="linkClass(isActive('/settings'))">
74- <UIcon name="i-lucide-settings" class="size-4" />
75- Settings
76- </NuxtLink>
77-
78- <CatalogGitIndicator class="mt-3" />
79- </nav>
80-</template>
deleted file mode 100644
@@ -1,80 +0,0 @@
1-<script setup lang="ts">
2-defineEmits<{ add: [] }>()
3-
4-const { projects, loaded, error } = useProjects()
5-const route = useRoute()
6-
7-const linkClass = (active: boolean) =>
8- `flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${active ? 'bg-elevated text-highlighted font-medium' : 'text-muted hover:bg-elevated/60 hover:text-default'}`
9-
10-const library = [
11- { label: 'Catalog', icon: 'i-lucide-library', to: '/catalog' },
12- { label: 'Presets', icon: 'i-lucide-layers', to: '/presets' },
13- { label: 'Marketplaces', icon: 'i-lucide-store', to: '/marketplaces' }
14-]
15-const isActive = (to: string) => route.path === to || route.path.startsWith(to + '/')
16-</script>
17-
18-<template>
19- <nav class="flex w-full flex-col gap-1">
20- <NuxtLink to="/" :class="linkClass(route.path === '/')">
21- <UIcon name="i-lucide-layout-dashboard" class="size-4" />
22- Dashboard
23- </NuxtLink>
24- <NuxtLink to="/home" :class="linkClass(route.path === '/home')">
25- <UIcon name="i-lucide-house" class="size-4" />
26- Home
27- </NuxtLink>
28-
29- <div class="mt-4 flex items-center justify-between px-2">
30- <span class="text-xs font-semibold uppercase tracking-wide text-muted">Projects</span>
31- <UTooltip text="Add project" :content="{ side: 'right' }">
32- <UButton icon="i-lucide-plus" size="xs" color="neutral" variant="ghost" aria-label="Add project" @click="$emit('add')" />
33- </UTooltip>
34- </div>
35-
36- <div v-if="!loaded" class="space-y-1 px-1">
37- <USkeleton v-for="i in 3" :key="i" class="h-8 w-full" />
38- </div>
39- <p v-else-if="error" class="px-2 text-xs text-error">{{ error }}</p>
40- <p v-else-if="!projects.length" class="px-2 text-xs text-muted">No project yet.</p>
41- <NuxtLink
42- v-for="p in projects"
43- :key="p.id"
44- :to="`/projects/${encodeURIComponent(p.id)}`"
45- :class="linkClass(route.path === `/projects/${p.id}` || route.params.id === p.id)"
46- >
47- <UIcon name="i-lucide-folder" class="size-4 shrink-0" />
48- <span class="min-w-0 flex-1 truncate" :title="p.path">{{ p.name }}</span>
49- <UTooltip v-if="!p.exists" text="Folder not found on disk">
50- <UIcon name="i-lucide-triangle-alert" class="size-4 shrink-0 text-warning" />
51- </UTooltip>
52- <AgentBadge v-for="a in p.agentsDetected" :key="a" :agent="a" compact />
53- </NuxtLink>
54-
55- <UButton
56- label="Add project"
57- icon="i-lucide-folder-plus"
58- color="neutral"
59- variant="outline"
60- size="sm"
61- class="mt-1"
62- block
63- @click="$emit('add')"
64- />
65-
66- <div class="mt-4 px-2">
67- <span class="text-xs font-semibold uppercase tracking-wide text-muted">Library</span>
68- </div>
69- <NuxtLink v-for="l in library" :key="l.to" :to="l.to" :class="linkClass(isActive(l.to))">
70- <UIcon :name="l.icon" class="size-4" />
71- {{ l.label }}
72- </NuxtLink>
73- <NuxtLink to="/settings" :class="linkClass(isActive('/settings'))">
74- <UIcon name="i-lucide-settings" class="size-4" />
75- Settings
76- </NuxtLink>
77-
78- <CatalogGitIndicator class="mt-3" />
79- </nav>
80-</template>
added packages/ui/app/components/AppSwitcher.vue +74 -0
new file mode 100644
@@ -0,0 +1,74 @@
1+<script setup lang="ts">
2+/** Ctrl+K quick switcher: pages, projects, catalog items and presets. Mounted once in the default layout. */
3+const open = useSwitcher()
4+const { projects } = useProjects()
5+const { items, presets, exists } = useCatalog()
6+
7+defineShortcuts({
8+ meta_k: { usingInput: true, handler: () => { open.value = !open.value } },
9+ ctrl_k: { usingInput: true, handler: () => { open.value = !open.value } }
10+})
11+
12+function go(to: string) {
13+ open.value = false
14+ navigateTo(to)
15+}
16+
17+const PAGES = [
18+ { label: 'Home', to: '/home', description: 'User-level config' },
19+ { label: 'Projects', to: '/projects' },
20+ { label: 'Catalog', to: '/catalog' },
21+ { label: 'Presets', to: '/catalog/presets' },
22+ { label: 'Marketplaces', to: '/marketplaces' },
23+ { label: 'Sync', to: '/sync' },
24+ { label: 'Settings', to: '/settings' }
25+]
26+
27+const groups = computed(() => [
28+ {
29+ id: 'pages',
30+ label: 'Pages',
31+ items: PAGES.map(p => ({ label: p.label, suffix: p.description, onSelect: () => go(p.to) }))
32+ },
33+ {
34+ id: 'projects',
35+ label: 'Projects',
36+ items: projects.value.map(p => ({
37+ label: p.name,
38+ suffix: p.path,
39+ onSelect: () => go(`/projects/${encodeURIComponent(p.id)}`)
40+ }))
41+ },
42+ ...(exists.value
43+ ? [
44+ {
45+ id: 'catalog',
46+ label: 'Catalog',
47+ items: items.value.map(i => ({
48+ label: i.name,
49+ suffix: TYPE_TAG[i.type],
50+ onSelect: () => go(`/catalog/${i.type}/${encodeURIComponent(i.name)}`)
51+ }))
52+ },
53+ {
54+ id: 'presets',
55+ label: 'Presets',
56+ items: presets.value.map(p => ({ label: p.name, suffix: 'Preset', onSelect: () => go(`/catalog/presets/${encodeURIComponent(p.name)}`) }))
57+ }
58+ ]
59+ : [])
60+])
61+</script>
62+
63+<template>
64+ <UModal v-model:open="open" :close="false" :ui="{ content: 'max-w-[560px] top-[18%] -translate-y-0 bg-sk-panel rounded-[14px] shadow-sk-modal ring ring-default overflow-hidden' }">
65+ <template #content>
66+ <UCommandPalette
67+ :groups="groups"
68+ placeholder="Jump to a project, catalog item or page"
69+ :ui="{ input: '[&_input]:font-sans', itemLabelSuffix: 'font-mono text-[11px]' }"
70+ class="h-[min(420px,70vh)]"
71+ />
72+ </template>
73+ </UModal>
74+</template>
new file mode 100644
@@ -0,0 +1,74 @@
1+<script setup lang="ts">
2+/** Ctrl+K quick switcher: pages, projects, catalog items and presets. Mounted once in the default layout. */
3+const open = useSwitcher()
4+const { projects } = useProjects()
5+const { items, presets, exists } = useCatalog()
6+
7+defineShortcuts({
8+ meta_k: { usingInput: true, handler: () => { open.value = !open.value } },
9+ ctrl_k: { usingInput: true, handler: () => { open.value = !open.value } }
10+})
11+
12+function go(to: string) {
13+ open.value = false
14+ navigateTo(to)
15+}
16+
17+const PAGES = [
18+ { label: 'Home', to: '/home', description: 'User-level config' },
19+ { label: 'Projects', to: '/projects' },
20+ { label: 'Catalog', to: '/catalog' },
21+ { label: 'Presets', to: '/catalog/presets' },
22+ { label: 'Marketplaces', to: '/marketplaces' },
23+ { label: 'Sync', to: '/sync' },
24+ { label: 'Settings', to: '/settings' }
25+]
26+
27+const groups = computed(() => [
28+ {
29+ id: 'pages',
30+ label: 'Pages',
31+ items: PAGES.map(p => ({ label: p.label, suffix: p.description, onSelect: () => go(p.to) }))
32+ },
33+ {
34+ id: 'projects',
35+ label: 'Projects',
36+ items: projects.value.map(p => ({
37+ label: p.name,
38+ suffix: p.path,
39+ onSelect: () => go(`/projects/${encodeURIComponent(p.id)}`)
40+ }))
41+ },
42+ ...(exists.value
43+ ? [
44+ {
45+ id: 'catalog',
46+ label: 'Catalog',
47+ items: items.value.map(i => ({
48+ label: i.name,
49+ suffix: TYPE_TAG[i.type],
50+ onSelect: () => go(`/catalog/${i.type}/${encodeURIComponent(i.name)}`)
51+ }))
52+ },
53+ {
54+ id: 'presets',
55+ label: 'Presets',
56+ items: presets.value.map(p => ({ label: p.name, suffix: 'Preset', onSelect: () => go(`/catalog/presets/${encodeURIComponent(p.name)}`) }))
57+ }
58+ ]
59+ : [])
60+])
61+</script>
62+
63+<template>
64+ <UModal v-model:open="open" :close="false" :ui="{ content: 'max-w-[560px] top-[18%] -translate-y-0 bg-sk-panel rounded-[14px] shadow-sk-modal ring ring-default overflow-hidden' }">
65+ <template #content>
66+ <UCommandPalette
67+ :groups="groups"
68+ placeholder="Jump to a project, catalog item or page"
69+ :ui="{ input: '[&_input]:font-sans', itemLabelSuffix: 'font-mono text-[11px]' }"
70+ class="h-[min(420px,70vh)]"
71+ />
72+ </template>
73+ </UModal>
74+</template>
deleted packages/ui/app/components/CatalogGitIndicator.vue +0 -43
deleted file mode 100644
@@ -1,43 +0,0 @@
1-<script setup lang="ts">
2-const { git, exists } = useCatalog()
3-const { open, busy, run } = useCatalogGit()
4-
5-const parts = computed(() => {
6- const g = git.value
7- if (!g?.isRepo) return []
8- const p: { icon: string, label: string, color: string, title: string }[] = []
9- if (g.conflicts.length) p.push({ icon: 'i-lucide-triangle-alert', label: String(g.conflicts.length), color: 'text-error', title: `${g.conflicts.length} conflict(s)` })
10- if (g.dirty.length) p.push({ icon: 'i-lucide-circle-dot', label: String(g.dirty.length), color: 'text-warning', title: `${g.dirty.length} uncommitted change(s)` })
11- if (g.ahead) p.push({ icon: 'i-lucide-arrow-up', label: String(g.ahead), color: 'text-info', title: `${g.ahead} commit(s) to push` })
12- if (g.behind) p.push({ icon: 'i-lucide-arrow-down', label: String(g.behind), color: 'text-info', title: `${g.behind} commit(s) to pull` })
13- return p
14-})
15-</script>
16-
17-<template>
18- <div v-if="exists && git" class="flex items-center gap-1 rounded-md border border-default px-1.5 py-1">
19- <button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 rounded px-1 py-0.5 text-left text-xs hover:bg-elevated/60" title="Catalog git status" @click="open = true">
20- <UIcon name="i-lucide-git-branch" class="size-3.5 shrink-0 text-muted" />
21- <span v-if="!git.isRepo" class="truncate text-muted">Not a git repo</span>
22- <template v-else>
23- <span class="truncate text-muted">{{ git.branch ?? 'catalog' }}</span>
24- <span v-if="!parts.length" class="flex items-center gap-0.5 text-success" title="Clean and up to date"><UIcon name="i-lucide-check" class="size-3.5" /></span>
25- <span v-for="p in parts" :key="p.icon" class="flex items-center gap-0.5 tabular-nums" :class="p.color" :title="p.title">
26- <UIcon :name="p.icon" class="size-3" />{{ p.label }}
27- </span>
28- </template>
29- </button>
30- <UTooltip v-if="git.isRepo" :text="git.remote ? 'Commit, pull and push' : 'Set a remote in the git dialog to sync'">
31- <UButton
32- icon="i-lucide-refresh-cw"
33- size="xs"
34- color="neutral"
35- variant="ghost"
36- aria-label="Sync catalog"
37- :loading="busy === 'sync'"
38- :disabled="!git.remote || !!busy"
39- @click="run('sync', undefined, 'Catalog synced')"
40- />
41- </UTooltip>
42- </div>
43-</template>
deleted file mode 100644
@@ -1,43 +0,0 @@
1-<script setup lang="ts">
2-const { git, exists } = useCatalog()
3-const { open, busy, run } = useCatalogGit()
4-
5-const parts = computed(() => {
6- const g = git.value
7- if (!g?.isRepo) return []
8- const p: { icon: string, label: string, color: string, title: string }[] = []
9- if (g.conflicts.length) p.push({ icon: 'i-lucide-triangle-alert', label: String(g.conflicts.length), color: 'text-error', title: `${g.conflicts.length} conflict(s)` })
10- if (g.dirty.length) p.push({ icon: 'i-lucide-circle-dot', label: String(g.dirty.length), color: 'text-warning', title: `${g.dirty.length} uncommitted change(s)` })
11- if (g.ahead) p.push({ icon: 'i-lucide-arrow-up', label: String(g.ahead), color: 'text-info', title: `${g.ahead} commit(s) to push` })
12- if (g.behind) p.push({ icon: 'i-lucide-arrow-down', label: String(g.behind), color: 'text-info', title: `${g.behind} commit(s) to pull` })
13- return p
14-})
15-</script>
16-
17-<template>
18- <div v-if="exists && git" class="flex items-center gap-1 rounded-md border border-default px-1.5 py-1">
19- <button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 rounded px-1 py-0.5 text-left text-xs hover:bg-elevated/60" title="Catalog git status" @click="open = true">
20- <UIcon name="i-lucide-git-branch" class="size-3.5 shrink-0 text-muted" />
21- <span v-if="!git.isRepo" class="truncate text-muted">Not a git repo</span>
22- <template v-else>
23- <span class="truncate text-muted">{{ git.branch ?? 'catalog' }}</span>
24- <span v-if="!parts.length" class="flex items-center gap-0.5 text-success" title="Clean and up to date"><UIcon name="i-lucide-check" class="size-3.5" /></span>
25- <span v-for="p in parts" :key="p.icon" class="flex items-center gap-0.5 tabular-nums" :class="p.color" :title="p.title">
26- <UIcon :name="p.icon" class="size-3" />{{ p.label }}
27- </span>
28- </template>
29- </button>
30- <UTooltip v-if="git.isRepo" :text="git.remote ? 'Commit, pull and push' : 'Set a remote in the git dialog to sync'">
31- <UButton
32- icon="i-lucide-refresh-cw"
33- size="xs"
34- color="neutral"
35- variant="ghost"
36- aria-label="Sync catalog"
37- :loading="busy === 'sync'"
38- :disabled="!git.remote || !!busy"
39- @click="run('sync', undefined, 'Catalog synced')"
40- />
41- </UTooltip>
42- </div>
43-</template>
modified packages/ui/app/components/PresetEditor.vue +4 -4
@@ -82,7 +82,7 @@ async function save() {
8282 toast.add({ title: isNew.value ? 'Preset created' : 'Preset saved', description: form.name, icon: 'i-lucide-check', color: 'success' })
8383 await catalog.refresh()
8484 original.value = snapshot()
85- if (isNew.value || renaming.value) await navigateTo(`/presets/${encodeURIComponent(form.name)}`, { replace: true })
85+ if (isNew.value || renaming.value) await navigateTo(`/catalog/presets/${encodeURIComponent(form.name)}`, { replace: true })
8686 } catch (e) {
8787 saveError.value = (e as Error).message
8888 } finally {
@@ -105,7 +105,7 @@ async function doDelete(force = false) {
105105 toast.add({ title: 'Preset deleted', description: props.name, icon: 'i-lucide-check', color: 'success' })
106106 delOpen.value = false
107107 await catalog.refresh()
108- await navigateTo('/presets')
108+ await navigateTo('/catalog/presets')
109109 } catch (e) {
110110 const err = e as { status?: number, message: string }
111111 if (err.status === 409) delConflict.value = err.message
@@ -125,7 +125,7 @@ function askDelete() {
125125 <template #header>
126126 <UDashboardNavbar icon="i-lucide-layers">
127127 <template #title>
128- <NuxtLink to="/presets" class="text-muted hover:text-default">Presets</NuxtLink>
128+ <NuxtLink to="/catalog/presets" class="text-muted hover:text-default">Presets</NuxtLink>
129129 <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" />
130130 <span class="truncate">{{ isNew ? 'New' : name }}</span>
131131 </template>
@@ -139,7 +139,7 @@ function askDelete() {
139139
140140 <template #body>
141141 <USkeleton v-if="!ready" class="h-72 w-full" />
142- <UAlert v-else-if="notFound" color="warning" variant="subtle" icon="i-lucide-circle-help" title="Preset not found" description="It may have been renamed or deleted." :actions="[{ label: 'Back to presets', to: '/presets', color: 'neutral', variant: 'outline' }]" />
142+ <UAlert v-else-if="notFound" color="warning" variant="subtle" icon="i-lucide-circle-help" title="Preset not found" description="It may have been renamed or deleted." :actions="[{ label: 'Back to presets', to: '/catalog/presets', color: 'neutral', variant: 'outline' }]" />
143143
144144 <div v-else class="grid items-start gap-6 xl:grid-cols-2">
145145 <div class="min-w-0 space-y-4">
@@ -82,7 +82,7 @@ async function save() {
82 toast.add({ title: isNew.value ? 'Preset created' : 'Preset saved', description: form.name, icon: 'i-lucide-check', color: 'success' })82 toast.add({ title: isNew.value ? 'Preset created' : 'Preset saved', description: form.name, icon: 'i-lucide-check', color: 'success' })
83 await catalog.refresh()83 await catalog.refresh()
84 original.value = snapshot()84 original.value = snapshot()
85- if (isNew.value || renaming.value) await navigateTo(`/presets/${encodeURIComponent(form.name)}`, { replace: true })85+ if (isNew.value || renaming.value) await navigateTo(`/catalog/presets/${encodeURIComponent(form.name)}`, { replace: true })
86 } catch (e) {86 } catch (e) {
87 saveError.value = (e as Error).message87 saveError.value = (e as Error).message
88 } finally {88 } finally {
@@ -105,7 +105,7 @@ async function doDelete(force = false) {
105 toast.add({ title: 'Preset deleted', description: props.name, icon: 'i-lucide-check', color: 'success' })105 toast.add({ title: 'Preset deleted', description: props.name, icon: 'i-lucide-check', color: 'success' })
106 delOpen.value = false106 delOpen.value = false
107 await catalog.refresh()107 await catalog.refresh()
108- await navigateTo('/presets')108+ await navigateTo('/catalog/presets')
109 } catch (e) {109 } catch (e) {
110 const err = e as { status?: number, message: string }110 const err = e as { status?: number, message: string }
111 if (err.status === 409) delConflict.value = err.message111 if (err.status === 409) delConflict.value = err.message
@@ -125,7 +125,7 @@ function askDelete() {
125 <template #header>125 <template #header>
126 <UDashboardNavbar icon="i-lucide-layers">126 <UDashboardNavbar icon="i-lucide-layers">
127 <template #title>127 <template #title>
128- <NuxtLink to="/presets" class="text-muted hover:text-default">Presets</NuxtLink>128+ <NuxtLink to="/catalog/presets" class="text-muted hover:text-default">Presets</NuxtLink>
129 <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" />129 <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" />
130 <span class="truncate">{{ isNew ? 'New' : name }}</span>130 <span class="truncate">{{ isNew ? 'New' : name }}</span>
131 </template>131 </template>
@@ -139,7 +139,7 @@ function askDelete() {
139 139
140 <template #body>140 <template #body>
141 <USkeleton v-if="!ready" class="h-72 w-full" />141 <USkeleton v-if="!ready" class="h-72 w-full" />
142- <UAlert v-else-if="notFound" color="warning" variant="subtle" icon="i-lucide-circle-help" title="Preset not found" description="It may have been renamed or deleted." :actions="[{ label: 'Back to presets', to: '/presets', color: 'neutral', variant: 'outline' }]" />142+ <UAlert v-else-if="notFound" color="warning" variant="subtle" icon="i-lucide-circle-help" title="Preset not found" description="It may have been renamed or deleted." :actions="[{ label: 'Back to presets', to: '/catalog/presets', color: 'neutral', variant: 'outline' }]" />
143 143
144 <div v-else class="grid items-start gap-6 xl:grid-cols-2">144 <div v-else class="grid items-start gap-6 xl:grid-cols-2">
145 <div class="min-w-0 space-y-4">145 <div class="min-w-0 space-y-4">
modified packages/ui/app/components/ScopeConfigBar.vue +1 -1
@@ -62,7 +62,7 @@ const count = (p: Preset) => CAT_TYPES.reduce((n, t) => n + (p.items[t]?.length
6262 </UTooltip>
6363 <span v-if="!presets.length && !unknown.length" class="text-xs text-muted">
6464 {{ catalogExists ? 'No preset yet.' : 'No catalog yet.' }}
65- <NuxtLink :to="catalogExists ? '/presets/new' : '/catalog'" class="text-primary hover:underline">{{ catalogExists ? 'Create one' : 'Create the catalog' }}</NuxtLink>
65+ <NuxtLink :to="catalogExists ? '/catalog/presets/new' : '/catalog'" class="text-primary hover:underline">{{ catalogExists ? 'Create one' : 'Create the catalog' }}</NuxtLink>
6666 </span>
6767 </div>
6868 </div>
@@ -62,7 +62,7 @@ const count = (p: Preset) => CAT_TYPES.reduce((n, t) => n + (p.items[t]?.length
62 </UTooltip>62 </UTooltip>
63 <span v-if="!presets.length && !unknown.length" class="text-xs text-muted">63 <span v-if="!presets.length && !unknown.length" class="text-xs text-muted">
64 {{ catalogExists ? 'No preset yet.' : 'No catalog yet.' }}64 {{ catalogExists ? 'No preset yet.' : 'No catalog yet.' }}
65- <NuxtLink :to="catalogExists ? '/presets/new' : '/catalog'" class="text-primary hover:underline">{{ catalogExists ? 'Create one' : 'Create the catalog' }}</NuxtLink>65+ <NuxtLink :to="catalogExists ? '/catalog/presets/new' : '/catalog'" class="text-primary hover:underline">{{ catalogExists ? 'Create one' : 'Create the catalog' }}</NuxtLink>
66 </span>66 </span>
67 </div>67 </div>
68 </div>68 </div>
modified packages/ui/app/components/StatusBadge.vue +5 -8
@@ -1,17 +1,14 @@
11 <script setup lang="ts">
22 import type { ItemStatus } from '@skilly/core'
33
4+// Legacy wrapper kept for existing screens; new screens use SkStatus directly.
45 defineProps<{ status?: ItemStatus }>()
56 </script>
67
78 <template>
8- <div v-if="status" class="flex flex-wrap gap-1">
9- <UTooltip :text="STATUS_META[status.state].hint">
10- <UBadge :label="STATUS_META[status.state].label" :color="STATUS_META[status.state].color" :icon="STATUS_META[status.state].icon" variant="subtle" size="sm" />
11- </UTooltip>
12- <UTooltip v-if="status.managed" text="Written by skilly: it keeps this file up to date.">
13- <UBadge label="managed" icon="i-lucide-link" color="neutral" variant="outline" size="sm" />
14- </UTooltip>
9+ <div v-if="status" class="flex flex-wrap items-center gap-1.5">
10+ <SkStatus :state="status.state" />
11+ <SkTag v-if="status.managed" mono title="Written by skilly: it keeps this file up to date.">managed</SkTag>
1512 </div>
16- <span v-else class="text-xs text-dimmed">-</span>
13+ <span v-else class="text-xs text-sk-faint">-</span>
1714 </template>
@@ -1,17 +1,14 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2 import type { ItemStatus } from '@skilly/core'2 import type { ItemStatus } from '@skilly/core'
3 3
4+// Legacy wrapper kept for existing screens; new screens use SkStatus directly.
4 defineProps<{ status?: ItemStatus }>()5 defineProps<{ status?: ItemStatus }>()
5 </script>6 </script>
6 7
7 <template>8 <template>
8- <div v-if="status" class="flex flex-wrap gap-1">9+ <div v-if="status" class="flex flex-wrap items-center gap-1.5">
9- <UTooltip :text="STATUS_META[status.state].hint">10+ <SkStatus :state="status.state" />
10- <UBadge :label="STATUS_META[status.state].label" :color="STATUS_META[status.state].color" :icon="STATUS_META[status.state].icon" variant="subtle" size="sm" />11+ <SkTag v-if="status.managed" mono title="Written by skilly: it keeps this file up to date.">managed</SkTag>
11- </UTooltip>
12- <UTooltip v-if="status.managed" text="Written by skilly: it keeps this file up to date.">
13- <UBadge label="managed" icon="i-lucide-link" color="neutral" variant="outline" size="sm" />
14- </UTooltip>
15 </div>12 </div>
16- <span v-else class="text-xs text-dimmed">-</span>13+ <span v-else class="text-xs text-sk-faint">-</span>
17 </template>14 </template>
added packages/ui/app/components/dev/DevKitSheet.vue +321 -0
new file mode 100644
@@ -0,0 +1,321 @@
1+<script setup lang="ts">
2+import type { ItemStatus, PlanActionKind } from '@skilly/core'
3+
4+/** Every Sk primitive on one sheet. Rendered once per theme by /dev/kit. Keep in sync when adding a primitive. */
5+const toast = useToast()
6+
7+const tab = ref('config')
8+const agentMode = ref<'auto' | 'demand'>('auto')
9+const typeFilter = ref('all')
10+const seg = ref('CLAUDE.md')
11+const chipOn = ref(true)
12+const sw = ref(true)
13+const swOff = ref(false)
14+const chk = ref(true)
15+const text = ref('seo-audit')
16+const modalOpen = ref(false)
17+const drawerOpen = ref(false)
18+const diffMode = ref<'unified' | 'split'>('unified')
19+
20+const states: ItemStatus['state'][] = ['in-sync', 'catalog-updated', 'modified', 'differs-from-catalog', 'not-in-catalog']
21+const kinds: PlanActionKind[] = ['add', 'update', 'remove', 'conflict', 'unchanged']
22+
23+const OLD = `---
24+name: seo-audit
25+description: Audit a page for SEO issues
26+---
27+
28+# SEO audit
29+
30+1. Fetch the page
31+2. Check title and meta description
32+3. Check headings
33+4. Report issues
34+`
35+const NEW = `---
36+name: seo-audit
37+description: Audit a page for technical SEO issues
38+---
39+
40+# SEO audit
41+
42+1. Fetch the page
43+2. Check title and meta description
44+3. Check headings and canonical URL
45+4. Check structured data
46+5. Report issues
47+`
48+
49+const typeOptions = [
50+ { value: 'all', label: 'All', count: 12 },
51+ { value: 'skill', label: 'Skills', count: 5 },
52+ { value: 'rule', label: 'Rules', count: 2 },
53+ { value: 'mcp', label: 'MCP', count: 3 }
54+]
55+</script>
56+
57+<template>
58+ <SkPage>
59+ <SkPageHeader title="Project name" subtitle="~/code/acme/dashboard · Claude Code, Codex">
60+ <template #breadcrumb><SkBreadcrumb :items="[{ label: 'Projects', to: '/projects' }, { label: 'acme-dashboard' }]" /></template>
61+ <template #actions>
62+ <SkSeg v-model="tab" :options="[{ value: 'config', label: 'Configuration' }, { value: 'instr', label: 'Instructions' }]" />
63+ </template>
64+ </SkPageHeader>
65+
66+ <SkPageHeader title="Home · user-level config" subtitle="Everything here is visible in every project." aside>
67+ <template #lead><SkAvatar /></template>
68+ </SkPageHeader>
69+
70+ <!-- Buttons -->
71+ <SkSection title="Buttons" hint="SkButton wraps UButton: primary, secondary, ghost, danger">
72+ <SkPanel padded class="flex flex-col gap-3">
73+ <div class="flex flex-wrap items-center gap-2">
74+ <SkButton variant="primary" label="Add project" />
75+ <SkButton variant="secondary" label="Add preset" />
76+ <SkButton variant="ghost" label="Remove" />
77+ <SkButton variant="danger" label="Remove from global" />
78+ <SkButton variant="primary" label="Disabled" disabled />
79+ <SkButton variant="primary" label="Loading" loading />
80+ <SkButton variant="secondary" pill label="Review" />
81+ <SkButton variant="secondary" icon="i-lucide-plus" label="With icon" />
82+ <SkLinkAction>Resolve</SkLinkAction>
83+ </div>
84+ <div class="flex flex-wrap items-center gap-2">
85+ <SkButton variant="primary" size="xs" label="xs 24" />
86+ <SkButton variant="primary" size="sm" label="Move" />
87+ <SkButton variant="primary" size="md" label="md 30" />
88+ <SkButton variant="primary" size="lg" label="lg 32" />
89+ <UButton color="primary" label="Nuxt UI primary (clay)" />
90+ <UButton color="neutral" variant="soft" label="Soft" />
91+ <UBadge label="UBadge" color="neutral" variant="subtle" />
92+ <UBadge label="Info" color="info" variant="subtle" />
93+ <UBadge label="Warning" color="warning" variant="subtle" />
94+ <UBadge label="Success" color="success" variant="subtle" />
95+ </div>
96+ </SkPanel>
97+ </SkSection>
98+
99+ <!-- Home style: tray + row cards -->
100+ <SkPanel hero>
101+ <div class="grid grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)]">
102+ <div class="flex flex-col gap-4 p-[22px] lg:border-r lg:border-sk-line2">
103+ <SkStepHeader :step="1" title="Loaded everywhere" />
104+ <SkDitherTile variant="hero" :value="12" caption="items in every project" />
105+ <div class="flex flex-col gap-1.5 text-sk-muted">
106+ <span class="flex justify-between"><span class="font-mono text-xs">~/.claude</span><span class="font-mono">8</span></span>
107+ <span class="flex justify-between"><span class="font-mono text-xs">~/.codex</span><span class="font-mono">2</span></span>
108+ </div>
109+ </div>
110+ <div class="flex min-w-0 flex-col gap-3 p-[22px]">
111+ <div class="flex flex-wrap justify-between gap-2.5">
112+ <SkSeg v-model="typeFilter" size="md" :options="typeOptions" />
113+ <SkSeg :model-value="'all'" size="md" :options="[{ value: 'all', label: 'All agents' }, { value: 'claude', label: 'Claude Code' }, { value: 'codex', label: 'Codex' }]" />
114+ </div>
115+ <SkTray>
116+ <SkSectionLabel inset :count="2">Skills</SkSectionLabel>
117+ <SkRowCard cols="minmax(0,1fr) minmax(0,100px) minmax(0,96px) auto">
118+ <span class="flex min-w-0 flex-col gap-px">
119+ <span class="flex items-baseline gap-2"><span class="font-medium">pr-review</span></span>
120+ <span class="truncate font-mono text-[11px] text-sk-faint">~/.claude/skills/pr-review/SKILL.md</span>
121+ </span>
122+ <SkAgentBadge agent="claude" />
123+ <SkStatus tone="ok" label="In catalog" small muted />
124+ <SkButton variant="primary" size="sm" label="Move" />
125+ </SkRowCard>
126+ <SkRowCard cols="minmax(0,1fr) minmax(0,100px) minmax(0,96px) auto">
127+ <span class="flex min-w-0 flex-col gap-px">
128+ <span class="flex items-baseline gap-2"><span class="font-medium">seo-audit</span><span class="text-[11px] whitespace-nowrap text-sk-accent-text">Project-specific</span></span>
129+ <span class="truncate font-mono text-[11px] text-sk-faint">~/.agents/skills/seo-audit/SKILL.md</span>
130+ </span>
131+ <SkAgentBadge agent="codex" />
132+ <SkStatus tone="faint" label="Local" small muted />
133+ <SkButton variant="primary" size="sm" label="Move" />
134+ </SkRowCard>
135+ <SkSectionLabel inset :count="1">Hooks</SkSectionLabel>
136+ <SkRowCard faded>
137+ <span class="text-sk-muted">Excluded row (faded)</span>
138+ </SkRowCard>
139+ </SkTray>
140+ </div>
141+ </div>
142+ </SkPanel>
143+
144+ <SkPanel hero padded class="flex flex-col gap-3.5">
145+ <SkStepHeader :step="2" title="5 global items look project-specific" hint="Move them to the catalog and enable them only where they are needed.">
146+ <template #actions><SkButton variant="secondary" pill label="Review" /></template>
147+ </SkStepHeader>
148+ <div class="grid grid-cols-2 gap-3 md:grid-cols-4">
149+ <SkSuggestionTile v-for="(n, i) in ['seo-audit', 'seo-keywords', 'nuxt-conventions', 'kotlin-style']" :key="n" :name="n" hint="Claude Code · Skill · in catalog" :variant="suggestionVariant(i)" />
150+ </div>
151+ </SkPanel>
152+
153+ <!-- Table style -->
154+ <SkSection title="Table rows" hint="SkRow head + interactive rows">
155+ <SkPanel>
156+ <SkRow head cols="minmax(0,2fr) 1fr 1fr 70px minmax(0,1.4fr)">
157+ <span>Project</span><span>Agents</span><span>Presets</span><span>Items</span><span>Status</span>
158+ </SkRow>
159+ <SkRow interactive cols="minmax(0,2fr) 1fr 1fr 70px minmax(0,1.4fr)">
160+ <span class="flex flex-col"><span class="font-medium">acme-dashboard</span><span class="font-mono text-[11px] text-sk-faint">~/code/acme/dashboard</span></span>
161+ <span class="text-sk-muted">Claude Code, Codex</span><span class="text-sk-muted">Nuxt SaaS</span><span class="font-mono">11</span>
162+ <SkStatus tone="accent" label="1 drift · 1 update" />
163+ </SkRow>
164+ <SkRow interactive selected cols="minmax(0,2fr) 1fr 1fr 70px minmax(0,1.4fr)">
165+ <span class="flex flex-col"><span class="font-medium">marketing-site</span><span class="font-mono text-[11px] text-sk-faint">~/code/marketing</span></span>
166+ <span class="text-sk-muted">Claude Code</span><span class="text-sk-muted">Base</span><span class="font-mono">6</span>
167+ <SkStatus state="in-sync" />
168+ </SkRow>
169+ <SkRow faded cols="minmax(0,2fr) 1fr 1fr 70px minmax(0,1.4fr)">
170+ <span class="font-medium">legacy-api (faded)</span><span class="text-sk-muted">Codex</span><span class="text-sk-muted">-</span><span class="font-mono">2</span>
171+ <SkStatus tone="faint" label="Not managed" />
172+ </SkRow>
173+ </SkPanel>
174+ </SkSection>
175+
176+ <!-- Status -->
177+ <SkSection title="Status" hint="dot + label + inline action, mapped by utils/status.ts">
178+ <SkPanel padded class="flex flex-col gap-4">
179+ <div class="flex flex-wrap items-center gap-x-6 gap-y-2">
180+ <SkStatus v-for="s in states" :key="s" :state="s" actionable />
181+ </div>
182+ <div class="flex flex-wrap items-center gap-x-6 gap-y-2">
183+ <SkStatus v-for="k in kinds" :key="k" :tone="planActionMeta(k).tone" :label="planActionMeta(k).label" />
184+ </div>
185+ <div class="flex flex-wrap items-center gap-2">
186+ <SkStatus v-for="s in states" :key="s" :state="s" pill />
187+ <SkStatus state="in-sync" faded label="Excluded (faded)" />
188+ </div>
189+ <div class="flex flex-wrap items-center gap-3">
190+ <SkStatusDot tone="ok" /><SkStatusDot tone="info" /><SkStatusDot tone="accent" /><SkStatusDot tone="faint" /><SkStatusDot tone="ok" small />
191+ <span class="font-mono text-[11px] text-sk-muted">claude <SkStatusDot tone="ok" small /> Up to date &nbsp; codex <SkStatusDot tone="accent" small /> Drift</span>
192+ </div>
193+ </SkPanel>
194+ </SkSection>
195+
196+ <!-- Tags -->
197+ <SkSection title="Tags, chips, agents">
198+ <SkPanel padded class="flex flex-col gap-3">
199+ <div class="flex flex-wrap items-center gap-3">
200+ <SkTypeTag type="skill" /><SkTypeTag type="rule" /><SkTypeTag type="mcp" /><SkTypeTag type="hook" /><SkTypeTag type="agent" /><SkTypeTag type="instructions" />
201+ <SkAgentBadge agent="claude" /><SkAgentBadge agent="codex" />
202+ <SkAgentBadge agent="claude" variant="tag" /><SkAgentBadge agent="codex" variant="tag" compact />
203+ </div>
204+ <div class="flex flex-wrap items-center gap-2">
205+ <SkTag>seo</SkTag><SkTag>marketing</SkTag><SkTag mono>symlink</SkTag><SkTag mono>plugin</SkTag>
206+ <SkChip :active="chipOn" :count="5" @click="chipOn = !chipOn">Skills</SkChip>
207+ <SkChip :count="2">Rules</SkChip>
208+ <SkChip disabled>Disabled</SkChip>
209+ <SkItemChip name="nuxt-conventions" type="skill" /><SkItemChip name="github" type="mcp" /><SkStepBadge>3</SkStepBadge>
210+ </div>
211+ </SkPanel>
212+ </SkSection>
213+
214+ <!-- Controls -->
215+ <SkSection title="Controls">
216+ <SkPanel padded class="flex flex-col gap-4">
217+ <div class="flex flex-wrap items-center gap-4">
218+ <SkSeg v-model="agentMode" size="xs" :options="[{ value: 'auto', label: 'Automatic' }, { value: 'demand', label: 'On demand' }]" />
219+ <SkSeg v-model="agentMode" size="sm" :options="[{ value: 'auto', label: 'Automatic' }, { value: 'demand', label: 'On demand' }]" />
220+ <SkSeg v-model="seg" mono :options="[{ value: 'CLAUDE.md', label: 'CLAUDE.md' }, { value: 'AGENTS.md', label: 'AGENTS.md' }]" />
221+ <SkSeg :model-value="'a'" :options="[{ value: 'a', label: 'Disabled opt', disabled: true }, { value: 'b', label: 'Other' }]" />
222+ </div>
223+ <div class="flex flex-col gap-3">
224+ <SkToggleRow v-model="sw" label="nuxt-conventions" hint="Preset · Nuxt SaaS">
225+ <template #label>nuxt-conventions <SkTypeTag type="skill" /></template>
226+ <template #trailing><SkStatus state="catalog-updated" actionable /></template>
227+ </SkToggleRow>
228+ <SkToggleRow v-model="swOff" label="code-reviewer" hint="Excluded from Nuxt SaaS" />
229+ <SkToggleRow :model-value="false" disabled label="nuxt-conventions" hint="Codex can't hide skills" />
230+ <div class="flex items-center gap-4">
231+ <USwitch v-model="sw" label="USwitch" /><UCheckbox v-model="chk" label="UCheckbox" /><UCheckbox :model-value="false" label="Unchecked" />
232+ </div>
233+ </div>
234+ <div class="grid max-w-xl grid-cols-2 gap-3">
235+ <UInput v-model="text" placeholder="Search name, tag or description" icon="i-lucide-search" />
236+ <USelect :model-value="'skill'" :items="['skill', 'rule', 'mcp']" />
237+ <UInput model-value="invalid-name" color="error" highlight />
238+ <UTextarea model-value="Textarea" :rows="2" class="col-span-2" />
239+ </div>
240+ <UTabs :items="[{ label: 'Nuxt UI tabs', value: 'a' }, { label: 'Second', value: 'b' }]" :content="false" class="w-72" />
241+ </SkPanel>
242+ </SkSection>
243+
244+ <!-- Tiles / meta / notes -->
245+ <SkSection title="Tiles, meta, notes, empty">
246+ <div class="grid gap-3 sm:grid-cols-4">
247+ <SkCountTile :value="17" label="catalog items" />
248+ <SkCountTile :value="5" label="presets" />
249+ <SkCountTile :value="3" label="to push" to="/sync" />
250+ <SkDitherTile variant="blue" :height="86" value="0" caption="up to date" class="[&_span.text-\[64px\]]:text-[40px]" />
251+ </div>
252+ <SkPanel padded class="grid gap-6 md:grid-cols-2">
253+ <SkMetaGrid>
254+ <SkMeta label="Tags"><span class="flex flex-wrap gap-1.5"><SkTag>seo</SkTag><SkTag>audit</SkTag></span></SkMeta>
255+ <SkMeta label="Renders for">Claude Code, Codex</SkMeta>
256+ <SkMeta label="Source">Marketplace · skills.sh</SkMeta>
257+ <SkMeta label="Env vars" mono>GITHUB_TOKEN</SkMeta>
258+ <SkMeta label="Used in"><NuxtLink to="/projects" class="underline">acme-dashboard</NuxtLink>, Home</SkMeta>
259+ </SkMetaGrid>
260+ <div class="flex flex-col gap-2.5">
261+ <SkNote tone="accent">This writes into your Home agent folders.</SkNote>
262+ <SkNote tone="ok">Restored 17 items and 5 presets</SkNote>
263+ <SkNote tone="info">2 to push · 1 to pull</SkNote>
264+ <SkNote>A neutral note with an aside <template #aside><span class="font-mono text-[11px] text-sk-faint">main</span></template></SkNote>
265+ </div>
266+ </SkPanel>
267+ <SkPanel><SkEmpty text="Nothing matches these filters." /></SkPanel>
268+ <SkEmpty dashed title="No preset enabled" text="Add one, or enable items one by one below.">
269+ <template #actions><SkButton variant="secondary" label="Add preset" /></template>
270+ </SkEmpty>
271+ </SkSection>
272+
273+ <!-- Diff -->
274+ <SkSection title="Inline diff" hint="utils/diff.ts: LCS line diff">
275+ <template #actions><SkSeg v-model="diffMode" size="xs" :options="[{ value: 'unified', label: 'Unified' }, { value: 'split', label: 'Side by side' }]" /></template>
276+ <SkPanel padded>
277+ <SkDiff :old-text="OLD" :new-text="NEW" :mode="diffMode" old-label="Catalog" new-label="This project" summary :context="2" />
278+ </SkPanel>
279+ <SkCode :text="NEW" max-height="140px" />
280+ </SkSection>
281+
282+ <!-- Overlays -->
283+ <SkSection title="Overlays and bar">
284+ <SkPanel padded class="flex flex-wrap items-center gap-2">
285+ <SkButton label="Open modal" @click="modalOpen = true" />
286+ <SkButton label="Open drawer" @click="drawerOpen = true" />
287+ <SkButton label="Show toast" @click="toast.add({ title: 'Applied 4 changes · 2 files written' })" />
288+ <UDropdownMenu :items="[[{ label: 'Rescan' }, { label: 'Copy path' }], [{ label: 'Remove from skilly', color: 'error' }]]">
289+ <SkButton icon="i-lucide-ellipsis" label="Menu" />
290+ </UDropdownMenu>
291+ <UTooltip text="A tooltip"><SkButton variant="ghost" label="Hover me" /></UTooltip>
292+ </SkPanel>
293+ <SkActionBar class="static!" :changes="4" :conflicts="1" saved />
294+ <SkActionBar class="static!" :changes="2" />
295+ </SkSection>
296+
297+ <SkModal v-model:open="modalOpen" title="Remove seo-audit from global" width="md">
298+ <div class="flex flex-col gap-4">
299+ <SkStepHeader :step="1" title="Catalog">Already in the catalog, nothing to copy.</SkStepHeader>
300+ <SkStepHeader :step="2" title="Enable in projects">Pick the projects that need it.</SkStepHeader>
301+ <SkNote tone="accent">It will stop loading in every project, and only run where you enable it.</SkNote>
302+ </div>
303+ <template #footer>
304+ <SkButton label="Cancel" @click="modalOpen = false" />
305+ <SkButton variant="primary" label="Remove from global" @click="modalOpen = false" />
306+ </template>
307+ </SkModal>
308+
309+ <SkDrawer v-model:open="drawerOpen" title="seo-audit" description="Skill · Claude Code">
310+ <SkMetaGrid>
311+ <SkMeta label="Path" mono>~/.claude/skills/seo-audit/SKILL.md</SkMeta>
312+ <SkMeta label="Status"><SkStatus state="modified" actionable /></SkMeta>
313+ </SkMetaGrid>
314+ <SkCode :text="NEW" class="mt-4" />
315+ <template #footer>
316+ <SkButton label="Close" @click="drawerOpen = false" />
317+ <SkButton variant="primary" label="Open in catalog" />
318+ </template>
319+ </SkDrawer>
320+ </SkPage>
321+</template>
new file mode 100644
@@ -0,0 +1,321 @@
1+<script setup lang="ts">
2+import type { ItemStatus, PlanActionKind } from '@skilly/core'
3+
4+/** Every Sk primitive on one sheet. Rendered once per theme by /dev/kit. Keep in sync when adding a primitive. */
5+const toast = useToast()
6+
7+const tab = ref('config')
8+const agentMode = ref<'auto' | 'demand'>('auto')
9+const typeFilter = ref('all')
10+const seg = ref('CLAUDE.md')
11+const chipOn = ref(true)
12+const sw = ref(true)
13+const swOff = ref(false)
14+const chk = ref(true)
15+const text = ref('seo-audit')
16+const modalOpen = ref(false)
17+const drawerOpen = ref(false)
18+const diffMode = ref<'unified' | 'split'>('unified')
19+
20+const states: ItemStatus['state'][] = ['in-sync', 'catalog-updated', 'modified', 'differs-from-catalog', 'not-in-catalog']
21+const kinds: PlanActionKind[] = ['add', 'update', 'remove', 'conflict', 'unchanged']
22+
23+const OLD = `---
24+name: seo-audit
25+description: Audit a page for SEO issues
26+---
27+
28+# SEO audit
29+
30+1. Fetch the page
31+2. Check title and meta description
32+3. Check headings
33+4. Report issues
34+`
35+const NEW = `---
36+name: seo-audit
37+description: Audit a page for technical SEO issues
38+---
39+
40+# SEO audit
41+
42+1. Fetch the page
43+2. Check title and meta description
44+3. Check headings and canonical URL
45+4. Check structured data
46+5. Report issues
47+`
48+
49+const typeOptions = [
50+ { value: 'all', label: 'All', count: 12 },
51+ { value: 'skill', label: 'Skills', count: 5 },
52+ { value: 'rule', label: 'Rules', count: 2 },
53+ { value: 'mcp', label: 'MCP', count: 3 }
54+]
55+</script>
56+
57+<template>
58+ <SkPage>
59+ <SkPageHeader title="Project name" subtitle="~/code/acme/dashboard · Claude Code, Codex">
60+ <template #breadcrumb><SkBreadcrumb :items="[{ label: 'Projects', to: '/projects' }, { label: 'acme-dashboard' }]" /></template>
61+ <template #actions>
62+ <SkSeg v-model="tab" :options="[{ value: 'config', label: 'Configuration' }, { value: 'instr', label: 'Instructions' }]" />
63+ </template>
64+ </SkPageHeader>
65+
66+ <SkPageHeader title="Home · user-level config" subtitle="Everything here is visible in every project." aside>
67+ <template #lead><SkAvatar /></template>
68+ </SkPageHeader>
69+
70+ <!-- Buttons -->
71+ <SkSection title="Buttons" hint="SkButton wraps UButton: primary, secondary, ghost, danger">
72+ <SkPanel padded class="flex flex-col gap-3">
73+ <div class="flex flex-wrap items-center gap-2">
74+ <SkButton variant="primary" label="Add project" />
75+ <SkButton variant="secondary" label="Add preset" />
76+ <SkButton variant="ghost" label="Remove" />
77+ <SkButton variant="danger" label="Remove from global" />
78+ <SkButton variant="primary" label="Disabled" disabled />
79+ <SkButton variant="primary" label="Loading" loading />
80+ <SkButton variant="secondary" pill label="Review" />
81+ <SkButton variant="secondary" icon="i-lucide-plus" label="With icon" />
82+ <SkLinkAction>Resolve</SkLinkAction>
83+ </div>
84+ <div class="flex flex-wrap items-center gap-2">
85+ <SkButton variant="primary" size="xs" label="xs 24" />
86+ <SkButton variant="primary" size="sm" label="Move" />
87+ <SkButton variant="primary" size="md" label="md 30" />
88+ <SkButton variant="primary" size="lg" label="lg 32" />
89+ <UButton color="primary" label="Nuxt UI primary (clay)" />
90+ <UButton color="neutral" variant="soft" label="Soft" />
91+ <UBadge label="UBadge" color="neutral" variant="subtle" />
92+ <UBadge label="Info" color="info" variant="subtle" />
93+ <UBadge label="Warning" color="warning" variant="subtle" />
94+ <UBadge label="Success" color="success" variant="subtle" />
95+ </div>
96+ </SkPanel>
97+ </SkSection>
98+
99+ <!-- Home style: tray + row cards -->
100+ <SkPanel hero>
101+ <div class="grid grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)]">
102+ <div class="flex flex-col gap-4 p-[22px] lg:border-r lg:border-sk-line2">
103+ <SkStepHeader :step="1" title="Loaded everywhere" />
104+ <SkDitherTile variant="hero" :value="12" caption="items in every project" />
105+ <div class="flex flex-col gap-1.5 text-sk-muted">
106+ <span class="flex justify-between"><span class="font-mono text-xs">~/.claude</span><span class="font-mono">8</span></span>
107+ <span class="flex justify-between"><span class="font-mono text-xs">~/.codex</span><span class="font-mono">2</span></span>
108+ </div>
109+ </div>
110+ <div class="flex min-w-0 flex-col gap-3 p-[22px]">
111+ <div class="flex flex-wrap justify-between gap-2.5">
112+ <SkSeg v-model="typeFilter" size="md" :options="typeOptions" />
113+ <SkSeg :model-value="'all'" size="md" :options="[{ value: 'all', label: 'All agents' }, { value: 'claude', label: 'Claude Code' }, { value: 'codex', label: 'Codex' }]" />
114+ </div>
115+ <SkTray>
116+ <SkSectionLabel inset :count="2">Skills</SkSectionLabel>
117+ <SkRowCard cols="minmax(0,1fr) minmax(0,100px) minmax(0,96px) auto">
118+ <span class="flex min-w-0 flex-col gap-px">
119+ <span class="flex items-baseline gap-2"><span class="font-medium">pr-review</span></span>
120+ <span class="truncate font-mono text-[11px] text-sk-faint">~/.claude/skills/pr-review/SKILL.md</span>
121+ </span>
122+ <SkAgentBadge agent="claude" />
123+ <SkStatus tone="ok" label="In catalog" small muted />
124+ <SkButton variant="primary" size="sm" label="Move" />
125+ </SkRowCard>
126+ <SkRowCard cols="minmax(0,1fr) minmax(0,100px) minmax(0,96px) auto">
127+ <span class="flex min-w-0 flex-col gap-px">
128+ <span class="flex items-baseline gap-2"><span class="font-medium">seo-audit</span><span class="text-[11px] whitespace-nowrap text-sk-accent-text">Project-specific</span></span>
129+ <span class="truncate font-mono text-[11px] text-sk-faint">~/.agents/skills/seo-audit/SKILL.md</span>
130+ </span>
131+ <SkAgentBadge agent="codex" />
132+ <SkStatus tone="faint" label="Local" small muted />
133+ <SkButton variant="primary" size="sm" label="Move" />
134+ </SkRowCard>
135+ <SkSectionLabel inset :count="1">Hooks</SkSectionLabel>
136+ <SkRowCard faded>
137+ <span class="text-sk-muted">Excluded row (faded)</span>
138+ </SkRowCard>
139+ </SkTray>
140+ </div>
141+ </div>
142+ </SkPanel>
143+
144+ <SkPanel hero padded class="flex flex-col gap-3.5">
145+ <SkStepHeader :step="2" title="5 global items look project-specific" hint="Move them to the catalog and enable them only where they are needed.">
146+ <template #actions><SkButton variant="secondary" pill label="Review" /></template>
147+ </SkStepHeader>
148+ <div class="grid grid-cols-2 gap-3 md:grid-cols-4">
149+ <SkSuggestionTile v-for="(n, i) in ['seo-audit', 'seo-keywords', 'nuxt-conventions', 'kotlin-style']" :key="n" :name="n" hint="Claude Code · Skill · in catalog" :variant="suggestionVariant(i)" />
150+ </div>
151+ </SkPanel>
152+
153+ <!-- Table style -->
154+ <SkSection title="Table rows" hint="SkRow head + interactive rows">
155+ <SkPanel>
156+ <SkRow head cols="minmax(0,2fr) 1fr 1fr 70px minmax(0,1.4fr)">
157+ <span>Project</span><span>Agents</span><span>Presets</span><span>Items</span><span>Status</span>
158+ </SkRow>
159+ <SkRow interactive cols="minmax(0,2fr) 1fr 1fr 70px minmax(0,1.4fr)">
160+ <span class="flex flex-col"><span class="font-medium">acme-dashboard</span><span class="font-mono text-[11px] text-sk-faint">~/code/acme/dashboard</span></span>
161+ <span class="text-sk-muted">Claude Code, Codex</span><span class="text-sk-muted">Nuxt SaaS</span><span class="font-mono">11</span>
162+ <SkStatus tone="accent" label="1 drift · 1 update" />
163+ </SkRow>
164+ <SkRow interactive selected cols="minmax(0,2fr) 1fr 1fr 70px minmax(0,1.4fr)">
165+ <span class="flex flex-col"><span class="font-medium">marketing-site</span><span class="font-mono text-[11px] text-sk-faint">~/code/marketing</span></span>
166+ <span class="text-sk-muted">Claude Code</span><span class="text-sk-muted">Base</span><span class="font-mono">6</span>
167+ <SkStatus state="in-sync" />
168+ </SkRow>
169+ <SkRow faded cols="minmax(0,2fr) 1fr 1fr 70px minmax(0,1.4fr)">
170+ <span class="font-medium">legacy-api (faded)</span><span class="text-sk-muted">Codex</span><span class="text-sk-muted">-</span><span class="font-mono">2</span>
171+ <SkStatus tone="faint" label="Not managed" />
172+ </SkRow>
173+ </SkPanel>
174+ </SkSection>
175+
176+ <!-- Status -->
177+ <SkSection title="Status" hint="dot + label + inline action, mapped by utils/status.ts">
178+ <SkPanel padded class="flex flex-col gap-4">
179+ <div class="flex flex-wrap items-center gap-x-6 gap-y-2">
180+ <SkStatus v-for="s in states" :key="s" :state="s" actionable />
181+ </div>
182+ <div class="flex flex-wrap items-center gap-x-6 gap-y-2">
183+ <SkStatus v-for="k in kinds" :key="k" :tone="planActionMeta(k).tone" :label="planActionMeta(k).label" />
184+ </div>
185+ <div class="flex flex-wrap items-center gap-2">
186+ <SkStatus v-for="s in states" :key="s" :state="s" pill />
187+ <SkStatus state="in-sync" faded label="Excluded (faded)" />
188+ </div>
189+ <div class="flex flex-wrap items-center gap-3">
190+ <SkStatusDot tone="ok" /><SkStatusDot tone="info" /><SkStatusDot tone="accent" /><SkStatusDot tone="faint" /><SkStatusDot tone="ok" small />
191+ <span class="font-mono text-[11px] text-sk-muted">claude <SkStatusDot tone="ok" small /> Up to date &nbsp; codex <SkStatusDot tone="accent" small /> Drift</span>
192+ </div>
193+ </SkPanel>
194+ </SkSection>
195+
196+ <!-- Tags -->
197+ <SkSection title="Tags, chips, agents">
198+ <SkPanel padded class="flex flex-col gap-3">
199+ <div class="flex flex-wrap items-center gap-3">
200+ <SkTypeTag type="skill" /><SkTypeTag type="rule" /><SkTypeTag type="mcp" /><SkTypeTag type="hook" /><SkTypeTag type="agent" /><SkTypeTag type="instructions" />
201+ <SkAgentBadge agent="claude" /><SkAgentBadge agent="codex" />
202+ <SkAgentBadge agent="claude" variant="tag" /><SkAgentBadge agent="codex" variant="tag" compact />
203+ </div>
204+ <div class="flex flex-wrap items-center gap-2">
205+ <SkTag>seo</SkTag><SkTag>marketing</SkTag><SkTag mono>symlink</SkTag><SkTag mono>plugin</SkTag>
206+ <SkChip :active="chipOn" :count="5" @click="chipOn = !chipOn">Skills</SkChip>
207+ <SkChip :count="2">Rules</SkChip>
208+ <SkChip disabled>Disabled</SkChip>
209+ <SkItemChip name="nuxt-conventions" type="skill" /><SkItemChip name="github" type="mcp" /><SkStepBadge>3</SkStepBadge>
210+ </div>
211+ </SkPanel>
212+ </SkSection>
213+
214+ <!-- Controls -->
215+ <SkSection title="Controls">
216+ <SkPanel padded class="flex flex-col gap-4">
217+ <div class="flex flex-wrap items-center gap-4">
218+ <SkSeg v-model="agentMode" size="xs" :options="[{ value: 'auto', label: 'Automatic' }, { value: 'demand', label: 'On demand' }]" />
219+ <SkSeg v-model="agentMode" size="sm" :options="[{ value: 'auto', label: 'Automatic' }, { value: 'demand', label: 'On demand' }]" />
220+ <SkSeg v-model="seg" mono :options="[{ value: 'CLAUDE.md', label: 'CLAUDE.md' }, { value: 'AGENTS.md', label: 'AGENTS.md' }]" />
221+ <SkSeg :model-value="'a'" :options="[{ value: 'a', label: 'Disabled opt', disabled: true }, { value: 'b', label: 'Other' }]" />
222+ </div>
223+ <div class="flex flex-col gap-3">
224+ <SkToggleRow v-model="sw" label="nuxt-conventions" hint="Preset · Nuxt SaaS">
225+ <template #label>nuxt-conventions <SkTypeTag type="skill" /></template>
226+ <template #trailing><SkStatus state="catalog-updated" actionable /></template>
227+ </SkToggleRow>
228+ <SkToggleRow v-model="swOff" label="code-reviewer" hint="Excluded from Nuxt SaaS" />
229+ <SkToggleRow :model-value="false" disabled label="nuxt-conventions" hint="Codex can't hide skills" />
230+ <div class="flex items-center gap-4">
231+ <USwitch v-model="sw" label="USwitch" /><UCheckbox v-model="chk" label="UCheckbox" /><UCheckbox :model-value="false" label="Unchecked" />
232+ </div>
233+ </div>
234+ <div class="grid max-w-xl grid-cols-2 gap-3">
235+ <UInput v-model="text" placeholder="Search name, tag or description" icon="i-lucide-search" />
236+ <USelect :model-value="'skill'" :items="['skill', 'rule', 'mcp']" />
237+ <UInput model-value="invalid-name" color="error" highlight />
238+ <UTextarea model-value="Textarea" :rows="2" class="col-span-2" />
239+ </div>
240+ <UTabs :items="[{ label: 'Nuxt UI tabs', value: 'a' }, { label: 'Second', value: 'b' }]" :content="false" class="w-72" />
241+ </SkPanel>
242+ </SkSection>
243+
244+ <!-- Tiles / meta / notes -->
245+ <SkSection title="Tiles, meta, notes, empty">
246+ <div class="grid gap-3 sm:grid-cols-4">
247+ <SkCountTile :value="17" label="catalog items" />
248+ <SkCountTile :value="5" label="presets" />
249+ <SkCountTile :value="3" label="to push" to="/sync" />
250+ <SkDitherTile variant="blue" :height="86" value="0" caption="up to date" class="[&_span.text-\[64px\]]:text-[40px]" />
251+ </div>
252+ <SkPanel padded class="grid gap-6 md:grid-cols-2">
253+ <SkMetaGrid>
254+ <SkMeta label="Tags"><span class="flex flex-wrap gap-1.5"><SkTag>seo</SkTag><SkTag>audit</SkTag></span></SkMeta>
255+ <SkMeta label="Renders for">Claude Code, Codex</SkMeta>
256+ <SkMeta label="Source">Marketplace · skills.sh</SkMeta>
257+ <SkMeta label="Env vars" mono>GITHUB_TOKEN</SkMeta>
258+ <SkMeta label="Used in"><NuxtLink to="/projects" class="underline">acme-dashboard</NuxtLink>, Home</SkMeta>
259+ </SkMetaGrid>
260+ <div class="flex flex-col gap-2.5">
261+ <SkNote tone="accent">This writes into your Home agent folders.</SkNote>
262+ <SkNote tone="ok">Restored 17 items and 5 presets</SkNote>
263+ <SkNote tone="info">2 to push · 1 to pull</SkNote>
264+ <SkNote>A neutral note with an aside <template #aside><span class="font-mono text-[11px] text-sk-faint">main</span></template></SkNote>
265+ </div>
266+ </SkPanel>
267+ <SkPanel><SkEmpty text="Nothing matches these filters." /></SkPanel>
268+ <SkEmpty dashed title="No preset enabled" text="Add one, or enable items one by one below.">
269+ <template #actions><SkButton variant="secondary" label="Add preset" /></template>
270+ </SkEmpty>
271+ </SkSection>
272+
273+ <!-- Diff -->
274+ <SkSection title="Inline diff" hint="utils/diff.ts: LCS line diff">
275+ <template #actions><SkSeg v-model="diffMode" size="xs" :options="[{ value: 'unified', label: 'Unified' }, { value: 'split', label: 'Side by side' }]" /></template>
276+ <SkPanel padded>
277+ <SkDiff :old-text="OLD" :new-text="NEW" :mode="diffMode" old-label="Catalog" new-label="This project" summary :context="2" />
278+ </SkPanel>
279+ <SkCode :text="NEW" max-height="140px" />
280+ </SkSection>
281+
282+ <!-- Overlays -->
283+ <SkSection title="Overlays and bar">
284+ <SkPanel padded class="flex flex-wrap items-center gap-2">
285+ <SkButton label="Open modal" @click="modalOpen = true" />
286+ <SkButton label="Open drawer" @click="drawerOpen = true" />
287+ <SkButton label="Show toast" @click="toast.add({ title: 'Applied 4 changes · 2 files written' })" />
288+ <UDropdownMenu :items="[[{ label: 'Rescan' }, { label: 'Copy path' }], [{ label: 'Remove from skilly', color: 'error' }]]">
289+ <SkButton icon="i-lucide-ellipsis" label="Menu" />
290+ </UDropdownMenu>
291+ <UTooltip text="A tooltip"><SkButton variant="ghost" label="Hover me" /></UTooltip>
292+ </SkPanel>
293+ <SkActionBar class="static!" :changes="4" :conflicts="1" saved />
294+ <SkActionBar class="static!" :changes="2" />
295+ </SkSection>
296+
297+ <SkModal v-model:open="modalOpen" title="Remove seo-audit from global" width="md">
298+ <div class="flex flex-col gap-4">
299+ <SkStepHeader :step="1" title="Catalog">Already in the catalog, nothing to copy.</SkStepHeader>
300+ <SkStepHeader :step="2" title="Enable in projects">Pick the projects that need it.</SkStepHeader>
301+ <SkNote tone="accent">It will stop loading in every project, and only run where you enable it.</SkNote>
302+ </div>
303+ <template #footer>
304+ <SkButton label="Cancel" @click="modalOpen = false" />
305+ <SkButton variant="primary" label="Remove from global" @click="modalOpen = false" />
306+ </template>
307+ </SkModal>
308+
309+ <SkDrawer v-model:open="drawerOpen" title="seo-audit" description="Skill · Claude Code">
310+ <SkMetaGrid>
311+ <SkMeta label="Path" mono>~/.claude/skills/seo-audit/SKILL.md</SkMeta>
312+ <SkMeta label="Status"><SkStatus state="modified" actionable /></SkMeta>
313+ </SkMetaGrid>
314+ <SkCode :text="NEW" class="mt-4" />
315+ <template #footer>
316+ <SkButton label="Close" @click="drawerOpen = false" />
317+ <SkButton variant="primary" label="Open in catalog" />
318+ </template>
319+ </SkDrawer>
320+ </SkPage>
321+</template>
added packages/ui/app/components/sk/SkActionBar.vue +37 -0
new file mode 100644
@@ -0,0 +1,37 @@
1+<script setup lang="ts">
2+/**
3+ * Sticky "pending changes" bar: "4 changes to apply · 1 conflict" + ink "Review & apply".
4+ * Purely presentational: the page decides when to render it (v-if) and what `review` does (open the review drawer).
5+ * The dot is clay when there are conflicts, blue otherwise. `saved` shows the mono "Saved" note (autosave feedback).
6+ * Place it as the last child of a SkPage: it sticks to the bottom of the viewport.
7+ */
8+const props = withDefaults(defineProps<{
9+ changes: number
10+ conflicts?: number
11+ saved?: boolean
12+ busy?: boolean
13+ label?: string
14+ /** Replaces "changes to apply" (e.g. "selected"). */
15+ noun?: string
16+}>(), { label: 'Review & apply', noun: 'to apply' })
17+defineEmits<{ review: [] }>()
18+
19+const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`
20+const summary = computed(() => {
21+ const base = props.noun === 'to apply' ? `${plural(props.changes, 'change')} to apply` : `${props.changes} ${props.noun}`
22+ return props.conflicts ? `${base} · ${plural(props.conflicts, 'conflict')}` : base
23+})
24+</script>
25+
26+<template>
27+ <div
28+ class="sticky bottom-[18px] z-20 flex w-fit max-w-full items-center gap-4 self-center rounded-[22px] bg-sk-panel py-2.5 pr-2.5 pl-5 shadow-sk-bar"
29+ role="status"
30+ >
31+ <SkStatusDot :tone="conflicts ? 'accent' : 'info'" />
32+ <span class="font-medium">{{ summary }}</span>
33+ <span v-if="saved" class="font-mono text-[11px] text-sk-faint">Saved</span>
34+ <slot />
35+ <SkButton variant="primary" pill :loading="busy" :label="label" @click="$emit('review')" />
36+ </div>
37+</template>
new file mode 100644
@@ -0,0 +1,37 @@
1+<script setup lang="ts">
2+/**
3+ * Sticky "pending changes" bar: "4 changes to apply · 1 conflict" + ink "Review & apply".
4+ * Purely presentational: the page decides when to render it (v-if) and what `review` does (open the review drawer).
5+ * The dot is clay when there are conflicts, blue otherwise. `saved` shows the mono "Saved" note (autosave feedback).
6+ * Place it as the last child of a SkPage: it sticks to the bottom of the viewport.
7+ */
8+const props = withDefaults(defineProps<{
9+ changes: number
10+ conflicts?: number
11+ saved?: boolean
12+ busy?: boolean
13+ label?: string
14+ /** Replaces "changes to apply" (e.g. "selected"). */
15+ noun?: string
16+}>(), { label: 'Review & apply', noun: 'to apply' })
17+defineEmits<{ review: [] }>()
18+
19+const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`
20+const summary = computed(() => {
21+ const base = props.noun === 'to apply' ? `${plural(props.changes, 'change')} to apply` : `${props.changes} ${props.noun}`
22+ return props.conflicts ? `${base} · ${plural(props.conflicts, 'conflict')}` : base
23+})
24+</script>
25+
26+<template>
27+ <div
28+ class="sticky bottom-[18px] z-20 flex w-fit max-w-full items-center gap-4 self-center rounded-[22px] bg-sk-panel py-2.5 pr-2.5 pl-5 shadow-sk-bar"
29+ role="status"
30+ >
31+ <SkStatusDot :tone="conflicts ? 'accent' : 'info'" />
32+ <span class="font-medium">{{ summary }}</span>
33+ <span v-if="saved" class="font-mono text-[11px] text-sk-faint">Saved</span>
34+ <slot />
35+ <SkButton variant="primary" pill :loading="busy" :label="label" @click="$emit('review')" />
36+ </div>
37+</template>
added packages/ui/app/components/sk/SkAgentBadge.vue +19 -0
new file mode 100644
@@ -0,0 +1,19 @@
1+<script setup lang="ts">
2+import type { AgentId } from '@skilly/core'
3+
4+/**
5+ * Agents have no colour in the design: plain muted text ("text", default) or a neutral mono tag ("tag").
6+ * `compact` = "CC" / "CX" with the full name as tooltip.
7+ */
8+withDefaults(defineProps<{ agent: AgentId, compact?: boolean, variant?: 'text' | 'tag' }>(), { variant: 'text' })
9+</script>
10+
11+<template>
12+ <span
13+ class="whitespace-nowrap"
14+ :class="variant === 'tag'
15+ ? 'inline-flex items-center rounded-full bg-sk-line2 px-2 py-px font-mono text-[11px] text-sk-muted'
16+ : 'text-sk-muted'"
17+ :title="AGENT_LABELS[agent]"
18+ >{{ compact ? (agent === 'claude' ? 'CC' : 'CX') : AGENT_LABELS[agent] }}</span>
19+</template>
new file mode 100644
@@ -0,0 +1,19 @@
1+<script setup lang="ts">
2+import type { AgentId } from '@skilly/core'
3+
4+/**
5+ * Agents have no colour in the design: plain muted text ("text", default) or a neutral mono tag ("tag").
6+ * `compact` = "CC" / "CX" with the full name as tooltip.
7+ */
8+withDefaults(defineProps<{ agent: AgentId, compact?: boolean, variant?: 'text' | 'tag' }>(), { variant: 'text' })
9+</script>
10+
11+<template>
12+ <span
13+ class="whitespace-nowrap"
14+ :class="variant === 'tag'
15+ ? 'inline-flex items-center rounded-full bg-sk-line2 px-2 py-px font-mono text-[11px] text-sk-muted'
16+ : 'text-sk-muted'"
17+ :title="AGENT_LABELS[agent]"
18+ >{{ compact ? (agent === 'claude' ? 'CC' : 'CX') : AGENT_LABELS[agent] }}</span>
19+</template>
added packages/ui/app/components/sk/SkAvatar.vue +12 -0
new file mode 100644
@@ -0,0 +1,12 @@
1+<script setup lang="ts">
2+/** Round outlined glyph (the `~` before "Home" in the mockup). */
3+withDefaults(defineProps<{ text?: string, size?: number }>(), { text: '~', size: 36 })
4+</script>
5+
6+<template>
7+ <span
8+ class="flex shrink-0 items-center justify-center rounded-full border border-sk-line bg-sk-panel font-mono text-xs text-sk-faint"
9+ :style="{ width: `${size}px`, height: `${size}px` }"
10+ aria-hidden="true"
11+ >{{ text }}</span>
12+</template>
new file mode 100644
@@ -0,0 +1,12 @@
1+<script setup lang="ts">
2+/** Round outlined glyph (the `~` before "Home" in the mockup). */
3+withDefaults(defineProps<{ text?: string, size?: number }>(), { text: '~', size: 36 })
4+</script>
5+
6+<template>
7+ <span
8+ class="flex shrink-0 items-center justify-center rounded-full border border-sk-line bg-sk-panel font-mono text-xs text-sk-faint"
9+ :style="{ width: `${size}px`, height: `${size}px` }"
10+ aria-hidden="true"
11+ >{{ text }}</span>
12+</template>
added packages/ui/app/components/sk/SkBreadcrumb.vue +14 -0
new file mode 100644
@@ -0,0 +1,14 @@
1+<script setup lang="ts">
2+/** "Projects /" style breadcrumb. The last item is rendered as plain text. */
3+defineProps<{ items: { label: string, to?: string }[] }>()
4+</script>
5+
6+<template>
7+ <nav aria-label="Breadcrumb" class="flex flex-wrap items-center gap-1.5">
8+ <template v-for="(it, i) in items" :key="i">
9+ <NuxtLink v-if="it.to" :to="it.to" class="text-sk-muted hover:text-sk-accent-text">{{ it.label }}</NuxtLink>
10+ <span v-else class="text-sk-muted">{{ it.label }}</span>
11+ <span v-if="i < items.length - 1" class="text-sk-faint" aria-hidden="true">/</span>
12+ </template>
13+ </nav>
14+</template>
new file mode 100644
@@ -0,0 +1,14 @@
1+<script setup lang="ts">
2+/** "Projects /" style breadcrumb. The last item is rendered as plain text. */
3+defineProps<{ items: { label: string, to?: string }[] }>()
4+</script>
5+
6+<template>
7+ <nav aria-label="Breadcrumb" class="flex flex-wrap items-center gap-1.5">
8+ <template v-for="(it, i) in items" :key="i">
9+ <NuxtLink v-if="it.to" :to="it.to" class="text-sk-muted hover:text-sk-accent-text">{{ it.label }}</NuxtLink>
10+ <span v-else class="text-sk-muted">{{ it.label }}</span>
11+ <span v-if="i < items.length - 1" class="text-sk-faint" aria-hidden="true">/</span>
12+ </template>
13+ </nav>
14+</template>
added packages/ui/app/components/sk/SkButton.vue +48 -0
new file mode 100644
@@ -0,0 +1,48 @@
1+<script setup lang="ts">
2+/**
3+ * Mockup buttons on top of UButton. Variants:
4+ * - primary: ink pill/rect (the main action of a page or dialog)
5+ * - secondary: panel background + 1px line
6+ * - ghost: muted text, no background
7+ * - danger: secondary look with accent text (destructive confirmations)
8+ * Sizes: xs 24px, sm 28px (row buttons such as "Move"), md 30px (default), lg 32px.
9+ * Everything else (label, icon, to, loading, disabled, type, block...) is forwarded to UButton, slots too.
10+ */
11+type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'
12+const props = withDefaults(defineProps<{
13+ variant?: Variant
14+ size?: 'xs' | 'sm' | 'md' | 'lg'
15+ /** Fully rounded (header pills, "Review"). */
16+ pill?: boolean
17+ label?: string
18+ icon?: string
19+ trailingIcon?: string
20+ to?: string
21+ loading?: boolean
22+ disabled?: boolean
23+ block?: boolean
24+}>(), { variant: 'secondary', size: 'md' })
25+
26+const color = computed(() => (props.variant === 'danger' ? 'error' : 'neutral'))
27+const variant = computed(() => ({ primary: 'solid', secondary: 'outline', ghost: 'ghost', danger: 'outline' }[props.variant] as 'solid' | 'outline' | 'ghost'))
28+</script>
29+
30+<template>
31+ <UButton
32+ :color="color"
33+ :variant="variant"
34+ :size="size"
35+ :label="label"
36+ :icon="icon"
37+ :trailing-icon="trailingIcon"
38+ :to="to"
39+ :loading="loading"
40+ :disabled="disabled"
41+ :block="block"
42+ :class="pill && 'rounded-full'"
43+ >
44+ <template v-for="(_, name) in $slots" #[name]="slotData">
45+ <slot :name="name" v-bind="slotData || {}" />
46+ </template>
47+ </UButton>
48+</template>
new file mode 100644
@@ -0,0 +1,48 @@
1+<script setup lang="ts">
2+/**
3+ * Mockup buttons on top of UButton. Variants:
4+ * - primary: ink pill/rect (the main action of a page or dialog)
5+ * - secondary: panel background + 1px line
6+ * - ghost: muted text, no background
7+ * - danger: secondary look with accent text (destructive confirmations)
8+ * Sizes: xs 24px, sm 28px (row buttons such as "Move"), md 30px (default), lg 32px.
9+ * Everything else (label, icon, to, loading, disabled, type, block...) is forwarded to UButton, slots too.
10+ */
11+type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'
12+const props = withDefaults(defineProps<{
13+ variant?: Variant
14+ size?: 'xs' | 'sm' | 'md' | 'lg'
15+ /** Fully rounded (header pills, "Review"). */
16+ pill?: boolean
17+ label?: string
18+ icon?: string
19+ trailingIcon?: string
20+ to?: string
21+ loading?: boolean
22+ disabled?: boolean
23+ block?: boolean
24+}>(), { variant: 'secondary', size: 'md' })
25+
26+const color = computed(() => (props.variant === 'danger' ? 'error' : 'neutral'))
27+const variant = computed(() => ({ primary: 'solid', secondary: 'outline', ghost: 'ghost', danger: 'outline' }[props.variant] as 'solid' | 'outline' | 'ghost'))
28+</script>
29+
30+<template>
31+ <UButton
32+ :color="color"
33+ :variant="variant"
34+ :size="size"
35+ :label="label"
36+ :icon="icon"
37+ :trailing-icon="trailingIcon"
38+ :to="to"
39+ :loading="loading"
40+ :disabled="disabled"
41+ :block="block"
42+ :class="pill && 'rounded-full'"
43+ >
44+ <template v-for="(_, name) in $slots" #[name]="slotData">
45+ <slot :name="name" v-bind="slotData || {}" />
46+ </template>
47+ </UButton>
48+</template>
added packages/ui/app/components/sk/SkChip.vue +21 -0
new file mode 100644
@@ -0,0 +1,21 @@
1+<script setup lang="ts">
2+/** Toggle chip (type filters, "Include presets"). Ink when active. `count` is shown in mono. */
3+defineProps<{ active?: boolean, count?: number | string, disabled?: boolean }>()
4+defineEmits<{ click: [e: MouseEvent] }>()
5+</script>
6+
7+<template>
8+ <button
9+ type="button"
10+ class="inline-flex h-6 cursor-pointer items-center gap-1.5 rounded-full border px-[9px] text-xs whitespace-nowrap transition-colors focus-visible:outline-2 focus-visible:outline-sk-accent disabled:cursor-not-allowed disabled:opacity-50"
11+ :class="active
12+ ? 'border-sk-ink bg-sk-ink text-sk-onink'
13+ : 'border-sk-line bg-sk-panel text-sk-muted hover:border-sk-faint hover:text-sk-text'"
14+ :aria-pressed="!!active"
15+ :disabled="disabled"
16+ @click="$emit('click', $event)"
17+ >
18+ <slot />
19+ <span v-if="count !== undefined" class="font-mono text-[11px] opacity-60">{{ count }}</span>
20+ </button>
21+</template>
new file mode 100644
@@ -0,0 +1,21 @@
1+<script setup lang="ts">
2+/** Toggle chip (type filters, "Include presets"). Ink when active. `count` is shown in mono. */
3+defineProps<{ active?: boolean, count?: number | string, disabled?: boolean }>()
4+defineEmits<{ click: [e: MouseEvent] }>()
5+</script>
6+
7+<template>
8+ <button
9+ type="button"
10+ class="inline-flex h-6 cursor-pointer items-center gap-1.5 rounded-full border px-[9px] text-xs whitespace-nowrap transition-colors focus-visible:outline-2 focus-visible:outline-sk-accent disabled:cursor-not-allowed disabled:opacity-50"
11+ :class="active
12+ ? 'border-sk-ink bg-sk-ink text-sk-onink'
13+ : 'border-sk-line bg-sk-panel text-sk-muted hover:border-sk-faint hover:text-sk-text'"
14+ :aria-pressed="!!active"
15+ :disabled="disabled"
16+ @click="$emit('click', $event)"
17+ >
18+ <slot />
19+ <span v-if="count !== undefined" class="font-mono text-[11px] opacity-60">{{ count }}</span>
20+ </button>
21+</template>
added packages/ui/app/components/sk/SkCode.vue +11 -0
new file mode 100644
@@ -0,0 +1,11 @@
1+<script setup lang="ts">
2+/** Mono `pre` block (instructions files, item bodies, YAML). 10px radius, hairline border. */
3+withDefaults(defineProps<{ text: string, maxHeight?: string }>(), { maxHeight: '420px' })
4+</script>
5+
6+<template>
7+ <pre
8+ class="m-0 overflow-auto rounded-[10px] border border-sk-line2 bg-sk-bg p-4 font-mono text-xs leading-[1.65] break-words whitespace-pre-wrap text-sk-text"
9+ :style="{ maxHeight }"
10+ >{{ text }}</pre>
11+</template>
new file mode 100644
@@ -0,0 +1,11 @@
1+<script setup lang="ts">
2+/** Mono `pre` block (instructions files, item bodies, YAML). 10px radius, hairline border. */
3+withDefaults(defineProps<{ text: string, maxHeight?: string }>(), { maxHeight: '420px' })
4+</script>
5+
6+<template>
7+ <pre
8+ class="m-0 overflow-auto rounded-[10px] border border-sk-line2 bg-sk-bg p-4 font-mono text-xs leading-[1.65] break-words whitespace-pre-wrap text-sk-text"
9+ :style="{ maxHeight }"
10+ >{{ text }}</pre>
11+</template>
added packages/ui/app/components/sk/SkCountTile.vue +16 -0
new file mode 100644
@@ -0,0 +1,16 @@
1+<script setup lang="ts">
2+/** Compact count tile: big mono number + label. `to` makes it a link. */
3+defineProps<{ value: number | string, label: string, to?: string }>()
4+</script>
5+
6+<template>
7+ <component
8+ :is="to ? 'NuxtLink' : 'div'"
9+ :to="to"
10+ class="flex flex-col gap-0.5 rounded-[14px] bg-sk-panel px-4 py-3 text-sk-text shadow-sk-ring"
11+ :class="to && 'transition-shadow hover:shadow-sk-ring-hover'"
12+ >
13+ <span class="font-mono text-[26px] leading-none">{{ value }}</span>
14+ <span class="text-xs text-sk-muted">{{ label }}</span>
15+ </component>
16+</template>
new file mode 100644
@@ -0,0 +1,16 @@
1+<script setup lang="ts">
2+/** Compact count tile: big mono number + label. `to` makes it a link. */
3+defineProps<{ value: number | string, label: string, to?: string }>()
4+</script>
5+
6+<template>
7+ <component
8+ :is="to ? 'NuxtLink' : 'div'"
9+ :to="to"
10+ class="flex flex-col gap-0.5 rounded-[14px] bg-sk-panel px-4 py-3 text-sk-text shadow-sk-ring"
11+ :class="to && 'transition-shadow hover:shadow-sk-ring-hover'"
12+ >
13+ <span class="font-mono text-[26px] leading-none">{{ value }}</span>
14+ <span class="text-xs text-sk-muted">{{ label }}</span>
15+ </component>
16+</template>
added packages/ui/app/components/sk/SkDiff.vue +114 -0
new file mode 100644
@@ -0,0 +1,114 @@
1+<script setup lang="ts">
2+/**
3+ * Inline line diff, unified or side by side. Text in, no parsing.
4+ * <SkDiff :old-text="catalog" :new-text="project" old-label="Catalog" new-label="This project" mode="unified" />
5+ * - `context`: unchanged lines kept around each change (the rest folds into "N unchanged lines"); 0 or null = show all
6+ * - `summary`: show "3 lines added, 1 removed" above the diff
7+ * Removed lines are clay, added lines green (tokens --accent / --ok at 12-14%).
8+ */
9+import type { DiffChunk, DiffOp } from '../../utils/diff'
10+
11+const props = withDefaults(defineProps<{
12+ oldText: string
13+ newText: string
14+ mode?: 'unified' | 'split'
15+ oldLabel?: string
16+ newLabel?: string
17+ context?: number | null
18+ summary?: boolean
19+ maxHeight?: string
20+}>(), { mode: 'unified', context: 3, summary: false, maxHeight: '360px' })
21+
22+const lines = computed(() => diffLines(props.oldText, props.newText))
23+const stats = computed(() => diffStats(lines.value))
24+const chunks = computed<DiffChunk[]>(() =>
25+ props.context ? collapseContext(lines.value, props.context) : [{ kind: 'lines', lines: lines.value }]
26+)
27+const rows = computed(() => toSideBySide(lines.value))
28+const identical = computed(() => !stats.value.added && !stats.value.removed)
29+
30+const DEL = 'bg-[color-mix(in_oklch,var(--sk-accent)_12%,transparent)]'
31+const ADD = 'bg-[color-mix(in_oklch,var(--sk-ok)_14%,transparent)]'
32+const rowClass = (op: DiffOp) => (op === 'del' ? DEL : op === 'add' ? ADD : '')
33+const sign = (op: DiffOp) => (op === 'del' ? '-' : op === 'add' ? '+' : ' ')
34+
35+// Split mode folds unchanged runs too: keep rows within `context` of a change.
36+const splitChunks = computed(() => {
37+ const keep = rows.value.map(() => !props.context)
38+ if (props.context) {
39+ rows.value.forEach((r, i) => {
40+ if (r.left?.op === 'eq') return
41+ for (let k = Math.max(0, i - props.context!); k <= Math.min(rows.value.length - 1, i + props.context!); k++) keep[k] = true
42+ })
43+ }
44+ const out: ({ kind: 'rows', rows: typeof rows.value } | { kind: 'skip', count: number })[] = []
45+ let i = 0
46+ while (i < rows.value.length) {
47+ const s = i
48+ const k = keep[i]
49+ while (i < rows.value.length && keep[i] === k) i++
50+ out.push(k ? { kind: 'rows', rows: rows.value.slice(s, i) } : { kind: 'skip', count: i - s })
51+ }
52+ return out
53+})
54+</script>
55+
56+<template>
57+ <div class="flex flex-col gap-2">
58+ <p v-if="summary" class="m-0 text-sk-muted">
59+ <slot name="summary">{{ identical ? 'Identical' : `${oldLabel ?? 'Old'} and ${newLabel ?? 'new'} differ: ${describeDiff(stats)}` }}</slot>
60+ </p>
61+
62+ <div
63+ class="overflow-auto rounded-[10px] border border-sk-line2 bg-sk-bg font-mono text-xs leading-[1.65]"
64+ :style="{ maxHeight }"
65+ >
66+ <!-- Unified -->
67+ <template v-if="mode === 'unified'">
68+ <div v-if="oldLabel || newLabel" class="flex gap-3 border-b border-sk-line2 px-3 py-1.5 text-[11px] text-sk-faint">
69+ <span v-if="oldLabel">- {{ oldLabel }}</span><span v-if="newLabel">+ {{ newLabel }}</span>
70+ </div>
71+ <template v-for="(c, ci) in chunks" :key="ci">
72+ <div v-if="c.kind === 'skip'" class="px-3 py-0.5 text-center text-[11px] text-sk-faint">
73+ {{ c.count }} unchanged {{ c.count === 1 ? 'line' : 'lines' }}
74+ </div>
75+ <div
76+ v-for="(l, li) in (c.kind === 'lines' ? c.lines : [])"
77+ :key="`${ci}-${li}`"
78+ class="grid grid-cols-[2.5rem_2.5rem_1.25rem_minmax(0,1fr)]"
79+ :class="rowClass(l.op)"
80+ >
81+ <span class="px-1.5 text-right text-sk-faint select-none">{{ l.oldNo }}</span>
82+ <span class="px-1.5 text-right text-sk-faint select-none">{{ l.newNo }}</span>
83+ <span class="text-center select-none" :class="l.op === 'del' ? 'text-sk-accent-text' : l.op === 'add' ? 'text-sk-ok' : 'text-sk-faint'">{{ sign(l.op) }}</span>
84+ <span class="pr-3 break-words whitespace-pre-wrap">{{ l.text }}</span>
85+ </div>
86+ </template>
87+ </template>
88+
89+ <!-- Side by side -->
90+ <template v-else>
91+ <div v-if="oldLabel || newLabel" class="grid grid-cols-2 border-b border-sk-line2 text-[11px] text-sk-faint">
92+ <span class="px-3 py-1.5">{{ oldLabel }}</span><span class="border-l border-sk-line2 px-3 py-1.5">{{ newLabel }}</span>
93+ </div>
94+ <template v-for="(c, ci) in splitChunks" :key="ci">
95+ <div v-if="c.kind === 'skip'" class="px-3 py-0.5 text-center text-[11px] text-sk-faint">
96+ {{ c.count }} unchanged {{ c.count === 1 ? 'line' : 'lines' }}
97+ </div>
98+ <div v-for="(r, ri) in (c.kind === 'rows' ? c.rows : [])" :key="`${ci}-${ri}`" class="grid grid-cols-2">
99+ <div class="grid grid-cols-[2.5rem_minmax(0,1fr)]" :class="r.left ? rowClass(r.left.op) : 'bg-sk-line2/40'">
100+ <span class="px-1.5 text-right text-sk-faint select-none">{{ r.left?.oldNo }}</span>
101+ <span class="pr-3 pl-1 break-words whitespace-pre-wrap">{{ r.left?.text }}</span>
102+ </div>
103+ <div class="grid grid-cols-[2.5rem_minmax(0,1fr)] border-l border-sk-line2" :class="r.right ? rowClass(r.right.op) : 'bg-sk-line2/40'">
104+ <span class="px-1.5 text-right text-sk-faint select-none">{{ r.right?.newNo }}</span>
105+ <span class="pr-3 pl-1 break-words whitespace-pre-wrap">{{ r.right?.text }}</span>
106+ </div>
107+ </div>
108+ </template>
109+ </template>
110+
111+ <div v-if="identical" class="px-3 py-3 text-center text-sk-faint">No differences.</div>
112+ </div>
113+ </div>
114+</template>
new file mode 100644
@@ -0,0 +1,114 @@
1+<script setup lang="ts">
2+/**
3+ * Inline line diff, unified or side by side. Text in, no parsing.
4+ * <SkDiff :old-text="catalog" :new-text="project" old-label="Catalog" new-label="This project" mode="unified" />
5+ * - `context`: unchanged lines kept around each change (the rest folds into "N unchanged lines"); 0 or null = show all
6+ * - `summary`: show "3 lines added, 1 removed" above the diff
7+ * Removed lines are clay, added lines green (tokens --accent / --ok at 12-14%).
8+ */
9+import type { DiffChunk, DiffOp } from '../../utils/diff'
10+
11+const props = withDefaults(defineProps<{
12+ oldText: string
13+ newText: string
14+ mode?: 'unified' | 'split'
15+ oldLabel?: string
16+ newLabel?: string
17+ context?: number | null
18+ summary?: boolean
19+ maxHeight?: string
20+}>(), { mode: 'unified', context: 3, summary: false, maxHeight: '360px' })
21+
22+const lines = computed(() => diffLines(props.oldText, props.newText))
23+const stats = computed(() => diffStats(lines.value))
24+const chunks = computed<DiffChunk[]>(() =>
25+ props.context ? collapseContext(lines.value, props.context) : [{ kind: 'lines', lines: lines.value }]
26+)
27+const rows = computed(() => toSideBySide(lines.value))
28+const identical = computed(() => !stats.value.added && !stats.value.removed)
29+
30+const DEL = 'bg-[color-mix(in_oklch,var(--sk-accent)_12%,transparent)]'
31+const ADD = 'bg-[color-mix(in_oklch,var(--sk-ok)_14%,transparent)]'
32+const rowClass = (op: DiffOp) => (op === 'del' ? DEL : op === 'add' ? ADD : '')
33+const sign = (op: DiffOp) => (op === 'del' ? '-' : op === 'add' ? '+' : ' ')
34+
35+// Split mode folds unchanged runs too: keep rows within `context` of a change.
36+const splitChunks = computed(() => {
37+ const keep = rows.value.map(() => !props.context)
38+ if (props.context) {
39+ rows.value.forEach((r, i) => {
40+ if (r.left?.op === 'eq') return
41+ for (let k = Math.max(0, i - props.context!); k <= Math.min(rows.value.length - 1, i + props.context!); k++) keep[k] = true
42+ })
43+ }
44+ const out: ({ kind: 'rows', rows: typeof rows.value } | { kind: 'skip', count: number })[] = []
45+ let i = 0
46+ while (i < rows.value.length) {
47+ const s = i
48+ const k = keep[i]
49+ while (i < rows.value.length && keep[i] === k) i++
50+ out.push(k ? { kind: 'rows', rows: rows.value.slice(s, i) } : { kind: 'skip', count: i - s })
51+ }
52+ return out
53+})
54+</script>
55+
56+<template>
57+ <div class="flex flex-col gap-2">
58+ <p v-if="summary" class="m-0 text-sk-muted">
59+ <slot name="summary">{{ identical ? 'Identical' : `${oldLabel ?? 'Old'} and ${newLabel ?? 'new'} differ: ${describeDiff(stats)}` }}</slot>
60+ </p>
61+
62+ <div
63+ class="overflow-auto rounded-[10px] border border-sk-line2 bg-sk-bg font-mono text-xs leading-[1.65]"
64+ :style="{ maxHeight }"
65+ >
66+ <!-- Unified -->
67+ <template v-if="mode === 'unified'">
68+ <div v-if="oldLabel || newLabel" class="flex gap-3 border-b border-sk-line2 px-3 py-1.5 text-[11px] text-sk-faint">
69+ <span v-if="oldLabel">- {{ oldLabel }}</span><span v-if="newLabel">+ {{ newLabel }}</span>
70+ </div>
71+ <template v-for="(c, ci) in chunks" :key="ci">
72+ <div v-if="c.kind === 'skip'" class="px-3 py-0.5 text-center text-[11px] text-sk-faint">
73+ {{ c.count }} unchanged {{ c.count === 1 ? 'line' : 'lines' }}
74+ </div>
75+ <div
76+ v-for="(l, li) in (c.kind === 'lines' ? c.lines : [])"
77+ :key="`${ci}-${li}`"
78+ class="grid grid-cols-[2.5rem_2.5rem_1.25rem_minmax(0,1fr)]"
79+ :class="rowClass(l.op)"
80+ >
81+ <span class="px-1.5 text-right text-sk-faint select-none">{{ l.oldNo }}</span>
82+ <span class="px-1.5 text-right text-sk-faint select-none">{{ l.newNo }}</span>
83+ <span class="text-center select-none" :class="l.op === 'del' ? 'text-sk-accent-text' : l.op === 'add' ? 'text-sk-ok' : 'text-sk-faint'">{{ sign(l.op) }}</span>
84+ <span class="pr-3 break-words whitespace-pre-wrap">{{ l.text }}</span>
85+ </div>
86+ </template>
87+ </template>
88+
89+ <!-- Side by side -->
90+ <template v-else>
91+ <div v-if="oldLabel || newLabel" class="grid grid-cols-2 border-b border-sk-line2 text-[11px] text-sk-faint">
92+ <span class="px-3 py-1.5">{{ oldLabel }}</span><span class="border-l border-sk-line2 px-3 py-1.5">{{ newLabel }}</span>
93+ </div>
94+ <template v-for="(c, ci) in splitChunks" :key="ci">
95+ <div v-if="c.kind === 'skip'" class="px-3 py-0.5 text-center text-[11px] text-sk-faint">
96+ {{ c.count }} unchanged {{ c.count === 1 ? 'line' : 'lines' }}
97+ </div>
98+ <div v-for="(r, ri) in (c.kind === 'rows' ? c.rows : [])" :key="`${ci}-${ri}`" class="grid grid-cols-2">
99+ <div class="grid grid-cols-[2.5rem_minmax(0,1fr)]" :class="r.left ? rowClass(r.left.op) : 'bg-sk-line2/40'">
100+ <span class="px-1.5 text-right text-sk-faint select-none">{{ r.left?.oldNo }}</span>
101+ <span class="pr-3 pl-1 break-words whitespace-pre-wrap">{{ r.left?.text }}</span>
102+ </div>
103+ <div class="grid grid-cols-[2.5rem_minmax(0,1fr)] border-l border-sk-line2" :class="r.right ? rowClass(r.right.op) : 'bg-sk-line2/40'">
104+ <span class="px-1.5 text-right text-sk-faint select-none">{{ r.right?.newNo }}</span>
105+ <span class="pr-3 pl-1 break-words whitespace-pre-wrap">{{ r.right?.text }}</span>
106+ </div>
107+ </div>
108+ </template>
109+ </template>
110+
111+ <div v-if="identical" class="px-3 py-3 text-center text-sk-faint">No differences.</div>
112+ </div>
113+ </div>
114+</template>
added packages/ui/app/components/sk/SkDitherTile.vue +35 -0
new file mode 100644
@@ -0,0 +1,35 @@
1+<script setup lang="ts">
2+import type { TileVariant } from '../../utils/tiles'
3+
4+/**
5+ * Gradient tile with a dither overlay. Hero stat tile: `value` (64px mono, white) + `caption`.
6+ * Small strips (suggestion tiles) use `:height="56" :rounded="false" :alpha=".45"`.
7+ * Variants: hero (clay horizon), blue, green, clay, plum. The gradients are identical in both themes.
8+ */
9+const props = withDefaults(defineProps<{
10+ variant?: TileVariant
11+ height?: number
12+ value?: number | string
13+ caption?: string
14+ rounded?: boolean
15+ alpha?: number
16+}>(), { variant: 'hero', height: 240, rounded: true, alpha: 0.55 })
17+
18+const style = computed(() => ({ height: `${props.height}px`, background: TILE_GRADIENTS[props.variant] }))
19+const dither = computed(() => ({
20+ backgroundImage: `radial-gradient(rgba(255,255,255,${props.alpha}) 1px,transparent 1.3px)`,
21+ backgroundSize: '5px 5px'
22+}))
23+</script>
24+
25+<template>
26+ <div class="relative overflow-hidden" :class="rounded && 'rounded-2xl'" :style="style">
27+ <div class="absolute inset-0" :style="dither" />
28+ <div v-if="value !== undefined || caption || $slots.default" class="absolute bottom-[18px] left-[22px] flex flex-col text-white">
29+ <slot>
30+ <span class="font-mono text-[64px] leading-none">{{ value }}</span>
31+ <span class="font-medium">{{ caption }}</span>
32+ </slot>
33+ </div>
34+ </div>
35+</template>
new file mode 100644
@@ -0,0 +1,35 @@
1+<script setup lang="ts">
2+import type { TileVariant } from '../../utils/tiles'
3+
4+/**
5+ * Gradient tile with a dither overlay. Hero stat tile: `value` (64px mono, white) + `caption`.
6+ * Small strips (suggestion tiles) use `:height="56" :rounded="false" :alpha=".45"`.
7+ * Variants: hero (clay horizon), blue, green, clay, plum. The gradients are identical in both themes.
8+ */
9+const props = withDefaults(defineProps<{
10+ variant?: TileVariant
11+ height?: number
12+ value?: number | string
13+ caption?: string
14+ rounded?: boolean
15+ alpha?: number
16+}>(), { variant: 'hero', height: 240, rounded: true, alpha: 0.55 })
17+
18+const style = computed(() => ({ height: `${props.height}px`, background: TILE_GRADIENTS[props.variant] }))
19+const dither = computed(() => ({
20+ backgroundImage: `radial-gradient(rgba(255,255,255,${props.alpha}) 1px,transparent 1.3px)`,
21+ backgroundSize: '5px 5px'
22+}))
23+</script>
24+
25+<template>
26+ <div class="relative overflow-hidden" :class="rounded && 'rounded-2xl'" :style="style">
27+ <div class="absolute inset-0" :style="dither" />
28+ <div v-if="value !== undefined || caption || $slots.default" class="absolute bottom-[18px] left-[22px] flex flex-col text-white">
29+ <slot>
30+ <span class="font-mono text-[64px] leading-none">{{ value }}</span>
31+ <span class="font-medium">{{ caption }}</span>
32+ </slot>
33+ </div>
34+ </div>
35+</template>
added packages/ui/app/components/sk/SkDrawer.vue +18 -0
new file mode 100644
@@ -0,0 +1,18 @@
1+<script setup lang="ts">
2+/**
3+ * Right slide-over (item drawer, review drawer): a floating panel with 20px radius.
4+ * `#header` replaces the title block, default slot = body, `#footer` = sticky actions.
5+ */
6+const props = withDefaults(defineProps<{ title?: string, description?: string, width?: 'md' | 'lg' | 'xl' }>(), { width: 'md' })
7+const open = defineModel<boolean>('open', { default: false })
8+const WIDTHS = { md: 'sm:max-w-[520px]', lg: 'sm:max-w-[640px]', xl: 'sm:max-w-[820px]' }
9+const ui = computed(() => ({ content: WIDTHS[props.width] }))
10+</script>
11+
12+<template>
13+ <USlideover v-model:open="open" side="right" inset :title="title" :description="description" :close="false" :ui="ui">
14+ <template v-if="$slots.header" #header><slot name="header" /></template>
15+ <template #body><slot /></template>
16+ <template v-if="$slots.footer" #footer><slot name="footer" /></template>
17+ </USlideover>
18+</template>
new file mode 100644
@@ -0,0 +1,18 @@
1+<script setup lang="ts">
2+/**
3+ * Right slide-over (item drawer, review drawer): a floating panel with 20px radius.
4+ * `#header` replaces the title block, default slot = body, `#footer` = sticky actions.
5+ */
6+const props = withDefaults(defineProps<{ title?: string, description?: string, width?: 'md' | 'lg' | 'xl' }>(), { width: 'md' })
7+const open = defineModel<boolean>('open', { default: false })
8+const WIDTHS = { md: 'sm:max-w-[520px]', lg: 'sm:max-w-[640px]', xl: 'sm:max-w-[820px]' }
9+const ui = computed(() => ({ content: WIDTHS[props.width] }))
10+</script>
11+
12+<template>
13+ <USlideover v-model:open="open" side="right" inset :title="title" :description="description" :close="false" :ui="ui">
14+ <template v-if="$slots.header" #header><slot name="header" /></template>
15+ <template #body><slot /></template>
16+ <template v-if="$slots.footer" #footer><slot name="footer" /></template>
17+ </USlideover>
18+</template>
added packages/ui/app/components/sk/SkEmpty.vue +17 -0
new file mode 100644
@@ -0,0 +1,17 @@
1+<script setup lang="ts">
2+/** Empty state: a centred faint sentence (default slot or `text`). `dashed` draws the dashed outline for standalone use. */
3+defineProps<{ text?: string, title?: string, dashed?: boolean }>()
4+</script>
5+
6+<template>
7+ <div
8+ class="flex flex-col items-center gap-3 px-6 py-10 text-center text-sk-faint"
9+ :class="dashed && 'rounded-[14px] border border-dashed border-sk-line'"
10+ >
11+ <p v-if="title" class="m-0 font-semibold text-sk-muted">{{ title }}</p>
12+ <p class="m-0 max-w-md text-pretty"><slot>{{ text }}</slot></p>
13+ <div v-if="$slots.actions" class="flex flex-wrap items-center justify-center gap-2">
14+ <slot name="actions" />
15+ </div>
16+ </div>
17+</template>
new file mode 100644
@@ -0,0 +1,17 @@
1+<script setup lang="ts">
2+/** Empty state: a centred faint sentence (default slot or `text`). `dashed` draws the dashed outline for standalone use. */
3+defineProps<{ text?: string, title?: string, dashed?: boolean }>()
4+</script>
5+
6+<template>
7+ <div
8+ class="flex flex-col items-center gap-3 px-6 py-10 text-center text-sk-faint"
9+ :class="dashed && 'rounded-[14px] border border-dashed border-sk-line'"
10+ >
11+ <p v-if="title" class="m-0 font-semibold text-sk-muted">{{ title }}</p>
12+ <p class="m-0 max-w-md text-pretty"><slot>{{ text }}</slot></p>
13+ <div v-if="$slots.actions" class="flex flex-wrap items-center justify-center gap-2">
14+ <slot name="actions" />
15+ </div>
16+ </div>
17+</template>
added packages/ui/app/components/sk/SkItemChip.vue +13 -0
new file mode 100644
@@ -0,0 +1,13 @@
1+<script setup lang="ts">
2+import type { ItemType } from '@skilly/core'
3+
4+/** Item chip: name + mono type (preset cards, "Used in"). */
5+defineProps<{ name: string, type?: ItemType }>()
6+</script>
7+
8+<template>
9+ <span class="inline-flex items-center gap-1.5 rounded-md border border-sk-line2 bg-sk-bg px-2 py-[3px] text-xs">
10+ {{ name }}
11+ <SkTypeTag v-if="type" :type="type" class="text-[10px]!" />
12+ </span>
13+</template>
new file mode 100644
@@ -0,0 +1,13 @@
1+<script setup lang="ts">
2+import type { ItemType } from '@skilly/core'
3+
4+/** Item chip: name + mono type (preset cards, "Used in"). */
5+defineProps<{ name: string, type?: ItemType }>()
6+</script>
7+
8+<template>
9+ <span class="inline-flex items-center gap-1.5 rounded-md border border-sk-line2 bg-sk-bg px-2 py-[3px] text-xs">
10+ {{ name }}
11+ <SkTypeTag v-if="type" :type="type" class="text-[10px]!" />
12+ </span>
13+</template>
added packages/ui/app/components/sk/SkLinkAction.vue +16 -0
new file mode 100644
@@ -0,0 +1,16 @@
1+<script setup lang="ts">
2+/** Inline accent action next to a status: "Update", "Resolve", "Import". */
3+defineProps<{ disabled?: boolean }>()
4+defineEmits<{ click: [e: MouseEvent] }>()
5+</script>
6+
7+<template>
8+ <button
9+ type="button"
10+ class="cursor-pointer border-0 bg-transparent p-0 font-medium whitespace-nowrap text-sk-accent-text hover:underline focus-visible:underline focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
11+ :disabled="disabled"
12+ @click="$emit('click', $event)"
13+ >
14+ <slot />
15+ </button>
16+</template>
new file mode 100644
@@ -0,0 +1,16 @@
1+<script setup lang="ts">
2+/** Inline accent action next to a status: "Update", "Resolve", "Import". */
3+defineProps<{ disabled?: boolean }>()
4+defineEmits<{ click: [e: MouseEvent] }>()
5+</script>
6+
7+<template>
8+ <button
9+ type="button"
10+ class="cursor-pointer border-0 bg-transparent p-0 font-medium whitespace-nowrap text-sk-accent-text hover:underline focus-visible:underline focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
11+ :disabled="disabled"
12+ @click="$emit('click', $event)"
13+ >
14+ <slot />
15+ </button>
16+</template>
added packages/ui/app/components/sk/SkLogo.vue +17 -0
new file mode 100644
@@ -0,0 +1,17 @@
1+<script setup lang="ts">
2+/** In-app logo mark: a 2x2 grid (design/BRIEF.md 1.9). Not the gradient app icon (that one is for favicon/installer). */
3+withDefaults(defineProps<{ size?: number }>(), { size: 26 })
4+</script>
5+
6+<template>
7+ <span
8+ class="grid shrink-0 grid-cols-2 gap-[3px] rounded-[7px] bg-sk-ink p-[5px]"
9+ :style="{ width: `${size}px`, height: `${size}px` }"
10+ aria-hidden="true"
11+ >
12+ <span class="rounded-[1px] bg-sk-onink" />
13+ <span />
14+ <span class="rounded-[1px] bg-sk-accent" />
15+ <span class="rounded-[1px] bg-sk-onink" />
16+ </span>
17+</template>
new file mode 100644
@@ -0,0 +1,17 @@
1+<script setup lang="ts">
2+/** In-app logo mark: a 2x2 grid (design/BRIEF.md 1.9). Not the gradient app icon (that one is for favicon/installer). */
3+withDefaults(defineProps<{ size?: number }>(), { size: 26 })
4+</script>
5+
6+<template>
7+ <span
8+ class="grid shrink-0 grid-cols-2 gap-[3px] rounded-[7px] bg-sk-ink p-[5px]"
9+ :style="{ width: `${size}px`, height: `${size}px` }"
10+ aria-hidden="true"
11+ >
12+ <span class="rounded-[1px] bg-sk-onink" />
13+ <span />
14+ <span class="rounded-[1px] bg-sk-accent" />
15+ <span class="rounded-[1px] bg-sk-onink" />
16+ </span>
17+</template>
added packages/ui/app/components/sk/SkMeta.vue +11 -0
new file mode 100644
@@ -0,0 +1,11 @@
1+<script setup lang="ts">
2+/** One row of a SkMetaGrid: mono caps key + value (default slot). `mono` renders the value in mono (paths, env vars). */
3+defineProps<{ label: string, mono?: boolean }>()
4+</script>
5+
6+<template>
7+ <dt class="font-mono text-[11px] tracking-[.06em] text-sk-faint uppercase">{{ label }}</dt>
8+ <dd class="m-0 min-w-0 break-words" :class="mono && 'font-mono text-xs'">
9+ <slot />
10+ </dd>
11+</template>
new file mode 100644
@@ -0,0 +1,11 @@
1+<script setup lang="ts">
2+/** One row of a SkMetaGrid: mono caps key + value (default slot). `mono` renders the value in mono (paths, env vars). */
3+defineProps<{ label: string, mono?: boolean }>()
4+</script>
5+
6+<template>
7+ <dt class="font-mono text-[11px] tracking-[.06em] text-sk-faint uppercase">{{ label }}</dt>
8+ <dd class="m-0 min-w-0 break-words" :class="mono && 'font-mono text-xs'">
9+ <slot />
10+ </dd>
11+</template>
added packages/ui/app/components/sk/SkMetaGrid.vue +10 -0
new file mode 100644
@@ -0,0 +1,10 @@
1+<script setup lang="ts">
2+/** Key/value grid for detail panels and drawers. Children are <SkMeta label="...">value</SkMeta>. */
3+withDefaults(defineProps<{ labelWidth?: string }>(), { labelWidth: '110px' })
4+</script>
5+
6+<template>
7+ <dl class="m-0 grid items-baseline gap-x-4 gap-y-2.5" :style="{ gridTemplateColumns: `${labelWidth} minmax(0,1fr)` }">
8+ <slot />
9+ </dl>
10+</template>
new file mode 100644
@@ -0,0 +1,10 @@
1+<script setup lang="ts">
2+/** Key/value grid for detail panels and drawers. Children are <SkMeta label="...">value</SkMeta>. */
3+withDefaults(defineProps<{ labelWidth?: string }>(), { labelWidth: '110px' })
4+</script>
5+
6+<template>
7+ <dl class="m-0 grid items-baseline gap-x-4 gap-y-2.5" :style="{ gridTemplateColumns: `${labelWidth} minmax(0,1fr)` }">
8+ <slot />
9+ </dl>
10+</template>
added packages/ui/app/components/sk/SkModal.vue +23 -0
new file mode 100644
@@ -0,0 +1,23 @@
1+<script setup lang="ts">
2+/**
3+ * Centered modal in the mockup style: 20px radius, max 480px (`sm`) or 520px (`md`), hairline sections,
4+ * footer actions right-aligned (Cancel secondary, confirm primary). Clicking the overlay closes it.
5+ * <SkModal v-model:open="open" title="Add presets">
6+ * ...body...
7+ * <template #footer><SkButton @click="open = false">Cancel</SkButton><SkButton variant="primary">Add</SkButton></template>
8+ * </SkModal>
9+ * Use `#header` for a custom header, `flush` to remove the body padding (lists with their own rows).
10+ */
11+const props = withDefaults(defineProps<{ title?: string, description?: string, width?: 'sm' | 'md' | 'lg', flush?: boolean }>(), { width: 'md' })
12+const open = defineModel<boolean>('open', { default: false })
13+const WIDTHS = { sm: 'max-w-[480px]', md: 'max-w-[520px]', lg: 'max-w-[680px]' }
14+const ui = computed(() => ({ content: WIDTHS[props.width], body: props.flush ? 'p-0 sm:p-0' : '' }))
15+</script>
16+
17+<template>
18+ <UModal v-model:open="open" :title="title" :description="description" :close="false" :ui="ui">
19+ <template v-if="$slots.header" #header><slot name="header" /></template>
20+ <template #body><slot /></template>
21+ <template v-if="$slots.footer" #footer><slot name="footer" /></template>
22+ </UModal>
23+</template>
new file mode 100644
@@ -0,0 +1,23 @@
1+<script setup lang="ts">
2+/**
3+ * Centered modal in the mockup style: 20px radius, max 480px (`sm`) or 520px (`md`), hairline sections,
4+ * footer actions right-aligned (Cancel secondary, confirm primary). Clicking the overlay closes it.
5+ * <SkModal v-model:open="open" title="Add presets">
6+ * ...body...
7+ * <template #footer><SkButton @click="open = false">Cancel</SkButton><SkButton variant="primary">Add</SkButton></template>
8+ * </SkModal>
9+ * Use `#header` for a custom header, `flush` to remove the body padding (lists with their own rows).
10+ */
11+const props = withDefaults(defineProps<{ title?: string, description?: string, width?: 'sm' | 'md' | 'lg', flush?: boolean }>(), { width: 'md' })
12+const open = defineModel<boolean>('open', { default: false })
13+const WIDTHS = { sm: 'max-w-[480px]', md: 'max-w-[520px]', lg: 'max-w-[680px]' }
14+const ui = computed(() => ({ content: WIDTHS[props.width], body: props.flush ? 'p-0 sm:p-0' : '' }))
15+</script>
16+
17+<template>
18+ <UModal v-model:open="open" :title="title" :description="description" :close="false" :ui="ui">
19+ <template v-if="$slots.header" #header><slot name="header" /></template>
20+ <template #body><slot /></template>
21+ <template v-if="$slots.footer" #footer><slot name="footer" /></template>
22+ </UModal>
23+</template>
added packages/ui/app/components/sk/SkNote.vue +24 -0
new file mode 100644
@@ -0,0 +1,24 @@
1+<script setup lang="ts">
2+import type { Tone } from '../../utils/status'
3+
4+/**
5+ * Info box. tone "accent" = clay notice (warnings, missing folder, "This writes into your Home folders");
6+ * other tones = neutral box with a status dot ("Restored 17 items and 5 presets" with an ok dot).
7+ * `#aside` for a trailing mono value / action.
8+ */
9+withDefaults(defineProps<{ tone?: Tone }>(), { tone: 'faint' })
10+</script>
11+
12+<template>
13+ <div
14+ class="flex items-start gap-2.5 rounded-[9px] border px-3.5 py-3"
15+ :class="tone === 'accent'
16+ ? 'border-sk-accent/30 bg-sk-accent-bg text-sk-accent-text'
17+ : 'border-sk-line bg-sk-bg text-sk-text'"
18+ role="note"
19+ >
20+ <SkStatusDot v-if="tone !== 'accent'" :tone="tone" class="mt-[6px]" />
21+ <div class="min-w-0 flex-1 text-pretty"><slot /></div>
22+ <div v-if="$slots.aside" class="shrink-0"><slot name="aside" /></div>
23+ </div>
24+</template>
new file mode 100644
@@ -0,0 +1,24 @@
1+<script setup lang="ts">
2+import type { Tone } from '../../utils/status'
3+
4+/**
5+ * Info box. tone "accent" = clay notice (warnings, missing folder, "This writes into your Home folders");
6+ * other tones = neutral box with a status dot ("Restored 17 items and 5 presets" with an ok dot).
7+ * `#aside` for a trailing mono value / action.
8+ */
9+withDefaults(defineProps<{ tone?: Tone }>(), { tone: 'faint' })
10+</script>
11+
12+<template>
13+ <div
14+ class="flex items-start gap-2.5 rounded-[9px] border px-3.5 py-3"
15+ :class="tone === 'accent'
16+ ? 'border-sk-accent/30 bg-sk-accent-bg text-sk-accent-text'
17+ : 'border-sk-line bg-sk-bg text-sk-text'"
18+ role="note"
19+ >
20+ <SkStatusDot v-if="tone !== 'accent'" :tone="tone" class="mt-[6px]" />
21+ <div class="min-w-0 flex-1 text-pretty"><slot /></div>
22+ <div v-if="$slots.aside" class="shrink-0"><slot name="aside" /></div>
23+ </div>
24+</template>
added packages/ui/app/components/sk/SkPage.vue +13 -0
new file mode 100644
@@ -0,0 +1,13 @@
1+<script setup lang="ts">
2+/** Page container: 1120px column, 22px gap between blocks. `wide` = full width (Catalog two-pane layouts). */
3+withDefaults(defineProps<{ wide?: boolean }>(), { wide: false })
4+</script>
5+
6+<template>
7+ <div
8+ class="mx-auto flex w-full flex-col gap-[22px] pt-7 pb-[60px]"
9+ :class="wide ? 'px-5' : 'max-w-[1120px] px-4 sm:px-9'"
10+ >
11+ <slot />
12+ </div>
13+</template>
new file mode 100644
@@ -0,0 +1,13 @@
1+<script setup lang="ts">
2+/** Page container: 1120px column, 22px gap between blocks. `wide` = full width (Catalog two-pane layouts). */
3+withDefaults(defineProps<{ wide?: boolean }>(), { wide: false })
4+</script>
5+
6+<template>
7+ <div
8+ class="mx-auto flex w-full flex-col gap-[22px] pt-7 pb-[60px]"
9+ :class="wide ? 'px-5' : 'max-w-[1120px] px-4 sm:px-9'"
10+ >
11+ <slot />
12+ </div>
13+</template>
added packages/ui/app/components/sk/SkPageHeader.vue +34 -0
new file mode 100644
@@ -0,0 +1,34 @@
1+<script setup lang="ts">
2+/**
3+ * Page header: mono H1 (22px), muted subtitle, actions on the right.
4+ * - `#lead`: something before the title (e.g. the `~` SkAvatar on Home)
5+ * - `#breadcrumb`: above the title (SkBreadcrumb)
6+ * - `#subtitle` or `subtitle` prop; `aside` puts the subtitle on the right of the title row (Home style)
7+ * - `#actions`: buttons / SkSeg tabs on the right
8+ */
9+withDefaults(defineProps<{ title: string, subtitle?: string, aside?: boolean }>(), { aside: false })
10+</script>
11+
12+<template>
13+ <header class="flex flex-wrap items-center gap-x-3 gap-y-2">
14+ <slot name="lead" />
15+ <div class="min-w-0">
16+ <div v-if="$slots.breadcrumb" class="mb-1.5 text-sk-muted">
17+ <slot name="breadcrumb" />
18+ </div>
19+ <h1 class="m-0 truncate font-mono text-[22px] leading-[1.25] font-normal tracking-[-0.01em] text-sk-text">
20+ {{ title }}
21+ </h1>
22+ <p v-if="!aside && (subtitle || $slots.subtitle)" class="mt-1 mb-0 text-pretty text-sk-muted">
23+ <slot name="subtitle">{{ subtitle }}</slot>
24+ </p>
25+ </div>
26+ <span class="flex-1" />
27+ <span v-if="aside && (subtitle || $slots.subtitle)" class="text-pretty text-sk-muted">
28+ <slot name="subtitle">{{ subtitle }}</slot>
29+ </span>
30+ <div v-if="$slots.actions" class="flex flex-wrap items-center gap-2">
31+ <slot name="actions" />
32+ </div>
33+ </header>
34+</template>
new file mode 100644
@@ -0,0 +1,34 @@
1+<script setup lang="ts">
2+/**
3+ * Page header: mono H1 (22px), muted subtitle, actions on the right.
4+ * - `#lead`: something before the title (e.g. the `~` SkAvatar on Home)
5+ * - `#breadcrumb`: above the title (SkBreadcrumb)
6+ * - `#subtitle` or `subtitle` prop; `aside` puts the subtitle on the right of the title row (Home style)
7+ * - `#actions`: buttons / SkSeg tabs on the right
8+ */
9+withDefaults(defineProps<{ title: string, subtitle?: string, aside?: boolean }>(), { aside: false })
10+</script>
11+
12+<template>
13+ <header class="flex flex-wrap items-center gap-x-3 gap-y-2">
14+ <slot name="lead" />
15+ <div class="min-w-0">
16+ <div v-if="$slots.breadcrumb" class="mb-1.5 text-sk-muted">
17+ <slot name="breadcrumb" />
18+ </div>
19+ <h1 class="m-0 truncate font-mono text-[22px] leading-[1.25] font-normal tracking-[-0.01em] text-sk-text">
20+ {{ title }}
21+ </h1>
22+ <p v-if="!aside && (subtitle || $slots.subtitle)" class="mt-1 mb-0 text-pretty text-sk-muted">
23+ <slot name="subtitle">{{ subtitle }}</slot>
24+ </p>
25+ </div>
26+ <span class="flex-1" />
27+ <span v-if="aside && (subtitle || $slots.subtitle)" class="text-pretty text-sk-muted">
28+ <slot name="subtitle">{{ subtitle }}</slot>
29+ </span>
30+ <div v-if="$slots.actions" class="flex flex-wrap items-center gap-2">
31+ <slot name="actions" />
32+ </div>
33+ </header>
34+</template>
added packages/ui/app/components/sk/SkPanel.vue +23 -0
new file mode 100644
@@ -0,0 +1,23 @@
1+<script setup lang="ts">
2+/**
3+ * White rounded panel outlined by a 1px ring (never a shadow). Everything "card-like" is a SkPanel.
4+ * - `hero`: 22px radius (Home main panel, onboarding shell); default 20px
5+ * - `padded`: 22px padding (use rows with their own padding otherwise: SkRow, SkRowCard)
6+ * - `interactive`: ring darkens on hover (clickable cards)
7+ */
8+withDefaults(defineProps<{ hero?: boolean, padded?: boolean, interactive?: boolean, as?: string }>(), { as: 'div' })
9+</script>
10+
11+<template>
12+ <component
13+ :is="as"
14+ class="overflow-hidden bg-sk-panel shadow-sk-ring"
15+ :class="[
16+ hero ? 'rounded-[22px]' : 'rounded-[20px]',
17+ padded && 'p-[22px]',
18+ interactive && 'cursor-pointer transition-shadow hover:shadow-sk-ring-hover'
19+ ]"
20+ >
21+ <slot />
22+ </component>
23+</template>
new file mode 100644
@@ -0,0 +1,23 @@
1+<script setup lang="ts">
2+/**
3+ * White rounded panel outlined by a 1px ring (never a shadow). Everything "card-like" is a SkPanel.
4+ * - `hero`: 22px radius (Home main panel, onboarding shell); default 20px
5+ * - `padded`: 22px padding (use rows with their own padding otherwise: SkRow, SkRowCard)
6+ * - `interactive`: ring darkens on hover (clickable cards)
7+ */
8+withDefaults(defineProps<{ hero?: boolean, padded?: boolean, interactive?: boolean, as?: string }>(), { as: 'div' })
9+</script>
10+
11+<template>
12+ <component
13+ :is="as"
14+ class="overflow-hidden bg-sk-panel shadow-sk-ring"
15+ :class="[
16+ hero ? 'rounded-[22px]' : 'rounded-[20px]',
17+ padded && 'p-[22px]',
18+ interactive && 'cursor-pointer transition-shadow hover:shadow-sk-ring-hover'
19+ ]"
20+ >
21+ <slot />
22+ </component>
23+</template>
added packages/ui/app/components/sk/SkRow.vue +31 -0
new file mode 100644
@@ -0,0 +1,31 @@
1+<script setup lang="ts">
2+/**
3+ * Table-like row inside a SkPanel: grid columns, 14px gap, hairline on top.
4+ * Use `head` for the mono caps header row (no top border, labels in faint mono).
5+ * <SkPanel>
6+ * <SkRow head cols="2fr 1fr 80px"><span>Project</span><span>Agents</span><span>Items</span></SkRow>
7+ * <SkRow v-for="p in projects" interactive cols="2fr 1fr 80px" @click="open(p)"> ... </SkRow>
8+ * </SkPanel>
9+ * `selected` uses the --sel tint (two-pane lists), `faded` is for excluded items.
10+ */
11+defineProps<{ cols?: string, head?: boolean, interactive?: boolean, selected?: boolean, faded?: boolean, as?: string }>()
12+</script>
13+
14+<template>
15+ <component
16+ :is="as ?? 'div'"
17+ class="items-center gap-[14px] px-4"
18+ :class="[
19+ cols ? 'grid' : 'flex',
20+ head
21+ ? 'py-2.5 font-mono text-[11px] tracking-[.06em] text-sk-faint uppercase'
22+ : 'border-t border-sk-line2 py-[9px] first:border-t-0',
23+ interactive && 'cursor-pointer transition-colors hover:bg-sk-hover',
24+ selected && 'bg-sk-sel hover:bg-sk-sel',
25+ faded && 'opacity-55'
26+ ]"
27+ :style="cols ? { gridTemplateColumns: cols } : undefined"
28+ >
29+ <slot />
30+ </component>
31+</template>
new file mode 100644
@@ -0,0 +1,31 @@
1+<script setup lang="ts">
2+/**
3+ * Table-like row inside a SkPanel: grid columns, 14px gap, hairline on top.
4+ * Use `head` for the mono caps header row (no top border, labels in faint mono).
5+ * <SkPanel>
6+ * <SkRow head cols="2fr 1fr 80px"><span>Project</span><span>Agents</span><span>Items</span></SkRow>
7+ * <SkRow v-for="p in projects" interactive cols="2fr 1fr 80px" @click="open(p)"> ... </SkRow>
8+ * </SkPanel>
9+ * `selected` uses the --sel tint (two-pane lists), `faded` is for excluded items.
10+ */
11+defineProps<{ cols?: string, head?: boolean, interactive?: boolean, selected?: boolean, faded?: boolean, as?: string }>()
12+</script>
13+
14+<template>
15+ <component
16+ :is="as ?? 'div'"
17+ class="items-center gap-[14px] px-4"
18+ :class="[
19+ cols ? 'grid' : 'flex',
20+ head
21+ ? 'py-2.5 font-mono text-[11px] tracking-[.06em] text-sk-faint uppercase'
22+ : 'border-t border-sk-line2 py-[9px] first:border-t-0',
23+ interactive && 'cursor-pointer transition-colors hover:bg-sk-hover',
24+ selected && 'bg-sk-sel hover:bg-sk-sel',
25+ faded && 'opacity-55'
26+ ]"
27+ :style="cols ? { gridTemplateColumns: cols } : undefined"
28+ >
29+ <slot />
30+ </component>
31+</template>
added packages/ui/app/components/sk/SkRowCard.vue +22 -0
new file mode 100644
@@ -0,0 +1,22 @@
1+<script setup lang="ts">
2+/**
3+ * White row card inside a SkTray. `cols` is a CSS grid-template-columns value; without it the content is free-form.
4+ * <SkRowCard cols="minmax(0,1fr) 100px 96px auto"> ...4 cells... </SkRowCard>
5+ * `interactive` = clickable (ring on hover), `faded` = excluded item (55% opacity).
6+ */
7+defineProps<{ cols?: string, interactive?: boolean, faded?: boolean }>()
8+</script>
9+
10+<template>
11+ <div
12+ class="rounded-[11px] bg-sk-panel px-3 py-[9px]"
13+ :class="[
14+ cols && 'grid items-center gap-3',
15+ interactive && 'cursor-pointer transition-shadow hover:shadow-sk-ring-hover',
16+ faded && 'opacity-55'
17+ ]"
18+ :style="cols ? { gridTemplateColumns: cols } : undefined"
19+ >
20+ <slot />
21+ </div>
22+</template>
new file mode 100644
@@ -0,0 +1,22 @@
1+<script setup lang="ts">
2+/**
3+ * White row card inside a SkTray. `cols` is a CSS grid-template-columns value; without it the content is free-form.
4+ * <SkRowCard cols="minmax(0,1fr) 100px 96px auto"> ...4 cells... </SkRowCard>
5+ * `interactive` = clickable (ring on hover), `faded` = excluded item (55% opacity).
6+ */
7+defineProps<{ cols?: string, interactive?: boolean, faded?: boolean }>()
8+</script>
9+
10+<template>
11+ <div
12+ class="rounded-[11px] bg-sk-panel px-3 py-[9px]"
13+ :class="[
14+ cols && 'grid items-center gap-3',
15+ interactive && 'cursor-pointer transition-shadow hover:shadow-sk-ring-hover',
16+ faded && 'opacity-55'
17+ ]"
18+ :style="cols ? { gridTemplateColumns: cols } : undefined"
19+ >
20+ <slot />
21+ </div>
22+</template>
added packages/ui/app/components/sk/SkSection.vue +18 -0
new file mode 100644
@@ -0,0 +1,18 @@
1+<script setup lang="ts">
2+/**
3+ * Page section: 13px/600 heading, optional muted `hint` on the right (or `#actions` for buttons), then the content.
4+ * ("Presets", "Items in this project", "Inherited from Home" in the project page.)
5+ */
6+defineProps<{ title: string, hint?: string }>()
7+</script>
8+
9+<template>
10+ <section class="flex flex-col gap-2.5">
11+ <div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
12+ <h2 class="m-0 text-[13px] font-semibold">{{ title }}</h2>
13+ <span v-if="hint" class="text-pretty text-sk-muted">{{ hint }}</span>
14+ <div v-if="$slots.actions" class="flex items-center gap-2"><slot name="actions" /></div>
15+ </div>
16+ <slot />
17+ </section>
18+</template>
new file mode 100644
@@ -0,0 +1,18 @@
1+<script setup lang="ts">
2+/**
3+ * Page section: 13px/600 heading, optional muted `hint` on the right (or `#actions` for buttons), then the content.
4+ * ("Presets", "Items in this project", "Inherited from Home" in the project page.)
5+ */
6+defineProps<{ title: string, hint?: string }>()
7+</script>
8+
9+<template>
10+ <section class="flex flex-col gap-2.5">
11+ <div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
12+ <h2 class="m-0 text-[13px] font-semibold">{{ title }}</h2>
13+ <span v-if="hint" class="text-pretty text-sk-muted">{{ hint }}</span>
14+ <div v-if="$slots.actions" class="flex items-center gap-2"><slot name="actions" /></div>
15+ </div>
16+ <slot />
17+ </section>
18+</template>
added packages/ui/app/components/sk/SkSectionLabel.vue +15 -0
new file mode 100644
@@ -0,0 +1,15 @@
1+<script setup lang="ts">
2+/** Mono uppercase label ("SKILLS 5", table headers, meta keys). `inset` adds the tray group padding. */
3+withDefaults(defineProps<{ count?: number | string, inset?: boolean, as?: string }>(), { as: 'div' })
4+</script>
5+
6+<template>
7+ <component
8+ :is="as"
9+ class="flex items-center gap-2 font-mono text-[11px] leading-none font-normal tracking-[.06em] text-sk-faint uppercase"
10+ :class="inset && 'px-2.5 pt-2 pb-1'"
11+ >
12+ <slot />
13+ <span v-if="count !== undefined">{{ count }}</span>
14+ </component>
15+</template>
new file mode 100644
@@ -0,0 +1,15 @@
1+<script setup lang="ts">
2+/** Mono uppercase label ("SKILLS 5", table headers, meta keys). `inset` adds the tray group padding. */
3+withDefaults(defineProps<{ count?: number | string, inset?: boolean, as?: string }>(), { as: 'div' })
4+</script>
5+
6+<template>
7+ <component
8+ :is="as"
9+ class="flex items-center gap-2 font-mono text-[11px] leading-none font-normal tracking-[.06em] text-sk-faint uppercase"
10+ :class="inset && 'px-2.5 pt-2 pb-1'"
11+ >
12+ <slot />
13+ <span v-if="count !== undefined">{{ count }}</span>
14+ </component>
15+</template>
added packages/ui/app/components/sk/SkSeg.vue +53 -0
new file mode 100644
@@ -0,0 +1,53 @@
1+<script setup lang="ts" generic="T extends string | number">
2+/**
3+ * Segmented control: tabs, filters, Automatic / On demand, Light / Dark. Active segment is an ink pill.
4+ * <SkSeg v-model="tab" :options="[{ value: 'config', label: 'Configuration' }, { value: 'files', label: 'Files', count: 12 }]" />
5+ * Sizes: xs 22px (row mode toggle), sm 26px (tabs, default), md 28px (Home filters).
6+ * `mono` = mono labels (CLAUDE.md | AGENTS.md).
7+ */
8+export interface SkSegOption<V> {
9+ value: V
10+ label: string
11+ count?: number | string
12+ disabled?: boolean
13+ title?: string
14+}
15+
16+const props = withDefaults(defineProps<{
17+ options: SkSegOption<T>[]
18+ size?: 'xs' | 'sm' | 'md'
19+ mono?: boolean
20+ ariaLabel?: string
21+}>(), { size: 'sm' })
22+const model = defineModel<T>()
23+
24+const SIZES = {
25+ xs: 'h-[22px] px-2.5 text-xs',
26+ sm: 'h-[26px] px-3 text-[13px]',
27+ md: 'h-7 px-3 text-[13px]'
28+}
29+const sizeClass = computed(() => SIZES[props.size])
30+</script>
31+
32+<template>
33+ <div class="inline-flex shrink-0 gap-[2px] rounded-full bg-sk-line2 p-[3px]" role="group" :aria-label="ariaLabel">
34+ <button
35+ v-for="o in options"
36+ :key="String(o.value)"
37+ type="button"
38+ class="inline-flex cursor-pointer items-center gap-1.5 rounded-full border-0 font-medium whitespace-nowrap transition-colors focus-visible:outline-2 focus-visible:outline-sk-accent disabled:cursor-not-allowed disabled:opacity-45"
39+ :class="[
40+ sizeClass,
41+ mono && 'font-mono',
42+ model === o.value ? 'bg-sk-ink text-sk-onink' : 'bg-transparent text-sk-muted hover:text-sk-text'
43+ ]"
44+ :aria-pressed="model === o.value"
45+ :disabled="o.disabled"
46+ :title="o.title"
47+ @click="model = o.value"
48+ >
49+ {{ o.label }}
50+ <span v-if="o.count !== undefined" class="font-mono text-[11px] opacity-60">{{ o.count }}</span>
51+ </button>
52+ </div>
53+</template>
new file mode 100644
@@ -0,0 +1,53 @@
1+<script setup lang="ts" generic="T extends string | number">
2+/**
3+ * Segmented control: tabs, filters, Automatic / On demand, Light / Dark. Active segment is an ink pill.
4+ * <SkSeg v-model="tab" :options="[{ value: 'config', label: 'Configuration' }, { value: 'files', label: 'Files', count: 12 }]" />
5+ * Sizes: xs 22px (row mode toggle), sm 26px (tabs, default), md 28px (Home filters).
6+ * `mono` = mono labels (CLAUDE.md | AGENTS.md).
7+ */
8+export interface SkSegOption<V> {
9+ value: V
10+ label: string
11+ count?: number | string
12+ disabled?: boolean
13+ title?: string
14+}
15+
16+const props = withDefaults(defineProps<{
17+ options: SkSegOption<T>[]
18+ size?: 'xs' | 'sm' | 'md'
19+ mono?: boolean
20+ ariaLabel?: string
21+}>(), { size: 'sm' })
22+const model = defineModel<T>()
23+
24+const SIZES = {
25+ xs: 'h-[22px] px-2.5 text-xs',
26+ sm: 'h-[26px] px-3 text-[13px]',
27+ md: 'h-7 px-3 text-[13px]'
28+}
29+const sizeClass = computed(() => SIZES[props.size])
30+</script>
31+
32+<template>
33+ <div class="inline-flex shrink-0 gap-[2px] rounded-full bg-sk-line2 p-[3px]" role="group" :aria-label="ariaLabel">
34+ <button
35+ v-for="o in options"
36+ :key="String(o.value)"
37+ type="button"
38+ class="inline-flex cursor-pointer items-center gap-1.5 rounded-full border-0 font-medium whitespace-nowrap transition-colors focus-visible:outline-2 focus-visible:outline-sk-accent disabled:cursor-not-allowed disabled:opacity-45"
39+ :class="[
40+ sizeClass,
41+ mono && 'font-mono',
42+ model === o.value ? 'bg-sk-ink text-sk-onink' : 'bg-transparent text-sk-muted hover:text-sk-text'
43+ ]"
44+ :aria-pressed="model === o.value"
45+ :disabled="o.disabled"
46+ :title="o.title"
47+ @click="model = o.value"
48+ >
49+ {{ o.label }}
50+ <span v-if="o.count !== undefined" class="font-mono text-[11px] opacity-60">{{ o.count }}</span>
51+ </button>
52+ </div>
53+</template>
added packages/ui/app/components/sk/SkStatus.vue +48 -0
new file mode 100644
@@ -0,0 +1,48 @@
1+<script setup lang="ts">
2+import type { ItemStatus } from '@skilly/core'
3+import type { Tone } from '../../utils/status'
4+
5+/**
6+ * Status cell: dot + label + optional inline action ("Update available **Update**").
7+ * Pass `state` (an ItemStatus state, mapped by utils/status.ts) or a free `tone` + `label`
8+ * (plan action kinds: use planActionMeta(kind) to get both).
9+ * `actionable` shows the default action of the state ("Update", "Resolve", "Import"); `action` overrides the text,
10+ * the `#action` slot replaces it. `pill` renders a tinted badge instead of a plain cell.
11+ */
12+const props = withDefaults(defineProps<{
13+ state?: ItemStatus['state']
14+ tone?: Tone
15+ label?: string
16+ action?: string
17+ actionable?: boolean
18+ small?: boolean
19+ muted?: boolean
20+ pill?: boolean
21+ /** Row drawn at reduced opacity (excluded items). */
22+ faded?: boolean
23+}>(), {})
24+const emit = defineEmits<{ action: [] }>()
25+
26+const meta = computed(() => (props.state ? ITEM_STATE_META[props.state] : undefined))
27+const tone = computed<Tone>(() => props.tone ?? meta.value?.tone ?? 'faint')
28+const label = computed(() => props.label ?? meta.value?.label ?? '')
29+const actionLabel = computed(() => props.action ?? (props.actionable && props.state ? ITEM_STATE_ACTION[props.state] : undefined))
30+</script>
31+
32+<template>
33+ <span
34+ class="inline-flex items-center gap-1.5 whitespace-nowrap"
35+ :class="[
36+ pill && 'rounded-full bg-sk-line2 px-2.5 py-0.5',
37+ muted ? 'text-sk-muted' : 'text-sk-text',
38+ faded && 'opacity-55'
39+ ]"
40+ :title="meta?.hint"
41+ >
42+ <SkStatusDot :tone="tone" :small="small" />
43+ <span>{{ label }}</span>
44+ <slot name="action">
45+ <SkLinkAction v-if="actionLabel" @click.stop="emit('action')">{{ actionLabel }}</SkLinkAction>
46+ </slot>
47+ </span>
48+</template>
new file mode 100644
@@ -0,0 +1,48 @@
1+<script setup lang="ts">
2+import type { ItemStatus } from '@skilly/core'
3+import type { Tone } from '../../utils/status'
4+
5+/**
6+ * Status cell: dot + label + optional inline action ("Update available **Update**").
7+ * Pass `state` (an ItemStatus state, mapped by utils/status.ts) or a free `tone` + `label`
8+ * (plan action kinds: use planActionMeta(kind) to get both).
9+ * `actionable` shows the default action of the state ("Update", "Resolve", "Import"); `action` overrides the text,
10+ * the `#action` slot replaces it. `pill` renders a tinted badge instead of a plain cell.
11+ */
12+const props = withDefaults(defineProps<{
13+ state?: ItemStatus['state']
14+ tone?: Tone
15+ label?: string
16+ action?: string
17+ actionable?: boolean
18+ small?: boolean
19+ muted?: boolean
20+ pill?: boolean
21+ /** Row drawn at reduced opacity (excluded items). */
22+ faded?: boolean
23+}>(), {})
24+const emit = defineEmits<{ action: [] }>()
25+
26+const meta = computed(() => (props.state ? ITEM_STATE_META[props.state] : undefined))
27+const tone = computed<Tone>(() => props.tone ?? meta.value?.tone ?? 'faint')
28+const label = computed(() => props.label ?? meta.value?.label ?? '')
29+const actionLabel = computed(() => props.action ?? (props.actionable && props.state ? ITEM_STATE_ACTION[props.state] : undefined))
30+</script>
31+
32+<template>
33+ <span
34+ class="inline-flex items-center gap-1.5 whitespace-nowrap"
35+ :class="[
36+ pill && 'rounded-full bg-sk-line2 px-2.5 py-0.5',
37+ muted ? 'text-sk-muted' : 'text-sk-text',
38+ faded && 'opacity-55'
39+ ]"
40+ :title="meta?.hint"
41+ >
42+ <SkStatusDot :tone="tone" :small="small" />
43+ <span>{{ label }}</span>
44+ <slot name="action">
45+ <SkLinkAction v-if="actionLabel" @click.stop="emit('action')">{{ actionLabel }}</SkLinkAction>
46+ </slot>
47+ </span>
48+</template>
added packages/ui/app/components/sk/SkStatusDot.vue +10 -0
new file mode 100644
@@ -0,0 +1,10 @@
1+<script setup lang="ts">
2+import type { Tone } from '../../utils/status'
3+
4+/** 7px status dot (6px with `small`, used in Home rows). Colours come from the tone, never from the caller. */
5+withDefaults(defineProps<{ tone: Tone, small?: boolean }>(), { small: false })
6+</script>
7+
8+<template>
9+ <span class="inline-block shrink-0 rounded-full" :class="[TONE_BG[tone], small ? 'size-1.5' : 'size-[7px]']" aria-hidden="true" />
10+</template>
new file mode 100644
@@ -0,0 +1,10 @@
1+<script setup lang="ts">
2+import type { Tone } from '../../utils/status'
3+
4+/** 7px status dot (6px with `small`, used in Home rows). Colours come from the tone, never from the caller. */
5+withDefaults(defineProps<{ tone: Tone, small?: boolean }>(), { small: false })
6+</script>
7+
8+<template>
9+ <span class="inline-block shrink-0 rounded-full" :class="[TONE_BG[tone], small ? 'size-1.5' : 'size-[7px]']" aria-hidden="true" />
10+</template>
added packages/ui/app/components/sk/SkStepBadge.vue +9 -0
new file mode 100644
@@ -0,0 +1,9 @@
1+<script setup lang="ts">
2+/** Ink pill with a number ("1", "2"): numbered card headers and count badges. */
3+</script>
4+
5+<template>
6+ <span class="inline-flex shrink-0 items-center rounded-full bg-sk-ink px-[9px] py-[2px] text-[11px] leading-[1.45] font-semibold text-sk-onink">
7+ <slot />
8+ </span>
9+</template>
new file mode 100644
@@ -0,0 +1,9 @@
1+<script setup lang="ts">
2+/** Ink pill with a number ("1", "2"): numbered card headers and count badges. */
3+</script>
4+
5+<template>
6+ <span class="inline-flex shrink-0 items-center rounded-full bg-sk-ink px-[9px] py-[2px] text-[11px] leading-[1.45] font-semibold text-sk-onink">
7+ <slot />
8+ </span>
9+</template>
added packages/ui/app/components/sk/SkStepHeader.vue +13 -0
new file mode 100644
@@ -0,0 +1,13 @@
1+<script setup lang="ts">
2+/** Numbered card header: step badge + 15px/600 title + muted explanation (default slot or `hint`) + `#actions`. */
3+defineProps<{ step?: number | string, title: string, hint?: string }>()
4+</script>
5+
6+<template>
7+ <div class="flex flex-wrap items-center gap-x-2.5 gap-y-1.5">
8+ <SkStepBadge v-if="step !== undefined">{{ step }}</SkStepBadge>
9+ <span class="text-[15px] font-semibold">{{ title }}</span>
10+ <span class="min-w-0 flex-1 text-pretty text-sk-muted"><slot>{{ hint }}</slot></span>
11+ <slot name="actions" />
12+ </div>
13+</template>
new file mode 100644
@@ -0,0 +1,13 @@
1+<script setup lang="ts">
2+/** Numbered card header: step badge + 15px/600 title + muted explanation (default slot or `hint`) + `#actions`. */
3+defineProps<{ step?: number | string, title: string, hint?: string }>()
4+</script>
5+
6+<template>
7+ <div class="flex flex-wrap items-center gap-x-2.5 gap-y-1.5">
8+ <SkStepBadge v-if="step !== undefined">{{ step }}</SkStepBadge>
9+ <span class="text-[15px] font-semibold">{{ title }}</span>
10+ <span class="min-w-0 flex-1 text-pretty text-sk-muted"><slot>{{ hint }}</slot></span>
11+ <slot name="actions" />
12+ </div>
13+</template>
added packages/ui/app/components/sk/SkSuggestionTile.vue +21 -0
new file mode 100644
@@ -0,0 +1,21 @@
1+<script setup lang="ts">
2+import type { TileVariant } from '../../utils/tiles'
3+
4+/** Clickable suggestion tile: 56px gradient strip, name (500), muted hint. Rotate colours with suggestionVariant(i). */
5+withDefaults(defineProps<{ name: string, hint?: string, variant?: TileVariant }>(), { variant: 'blue' })
6+defineEmits<{ click: [e: MouseEvent] }>()
7+</script>
8+
9+<template>
10+ <button
11+ type="button"
12+ class="cursor-pointer overflow-hidden rounded-[14px] border-0 bg-sk-panel p-0 text-left text-sk-text shadow-sk-ring transition-shadow hover:shadow-sk-ring-hover focus-visible:outline-2 focus-visible:outline-sk-accent"
13+ @click="$emit('click', $event)"
14+ >
15+ <SkDitherTile :variant="variant" :height="56" :rounded="false" :alpha="0.45" />
16+ <span class="flex flex-col gap-px px-3 py-2.5">
17+ <span class="truncate font-medium">{{ name }}</span>
18+ <span v-if="hint" class="truncate text-xs text-sk-muted">{{ hint }}</span>
19+ </span>
20+ </button>
21+</template>
new file mode 100644
@@ -0,0 +1,21 @@
1+<script setup lang="ts">
2+import type { TileVariant } from '../../utils/tiles'
3+
4+/** Clickable suggestion tile: 56px gradient strip, name (500), muted hint. Rotate colours with suggestionVariant(i). */
5+withDefaults(defineProps<{ name: string, hint?: string, variant?: TileVariant }>(), { variant: 'blue' })
6+defineEmits<{ click: [e: MouseEvent] }>()
7+</script>
8+
9+<template>
10+ <button
11+ type="button"
12+ class="cursor-pointer overflow-hidden rounded-[14px] border-0 bg-sk-panel p-0 text-left text-sk-text shadow-sk-ring transition-shadow hover:shadow-sk-ring-hover focus-visible:outline-2 focus-visible:outline-sk-accent"
13+ @click="$emit('click', $event)"
14+ >
15+ <SkDitherTile :variant="variant" :height="56" :rounded="false" :alpha="0.45" />
16+ <span class="flex flex-col gap-px px-3 py-2.5">
17+ <span class="truncate font-medium">{{ name }}</span>
18+ <span v-if="hint" class="truncate text-xs text-sk-muted">{{ hint }}</span>
19+ </span>
20+ </button>
21+</template>
added packages/ui/app/components/sk/SkTag.vue +13 -0
new file mode 100644
@@ -0,0 +1,13 @@
1+<script setup lang="ts">
2+/** Small grey pill tag (catalog tags, "built-in", "symlink", "plugin"). `mono` for technical values. */
3+defineProps<{ mono?: boolean }>()
4+</script>
5+
6+<template>
7+ <span
8+ class="inline-flex items-center rounded-full bg-sk-line2 px-[7px] py-px text-sk-muted"
9+ :class="mono ? 'font-mono text-[11px]' : 'text-xs'"
10+ >
11+ <slot />
12+ </span>
13+</template>
new file mode 100644
@@ -0,0 +1,13 @@
1+<script setup lang="ts">
2+/** Small grey pill tag (catalog tags, "built-in", "symlink", "plugin"). `mono` for technical values. */
3+defineProps<{ mono?: boolean }>()
4+</script>
5+
6+<template>
7+ <span
8+ class="inline-flex items-center rounded-full bg-sk-line2 px-[7px] py-px text-sk-muted"
9+ :class="mono ? 'font-mono text-[11px]' : 'text-xs'"
10+ >
11+ <slot />
12+ </span>
13+</template>
added packages/ui/app/components/sk/SkToggleRow.vue +20 -0
new file mode 100644
@@ -0,0 +1,20 @@
1+<script setup lang="ts">
2+/**
3+ * Switch + label + hint. The switch is the Nuxt UI one (28x16, clay when on).
4+ * `#trailing` for status cells / mode toggles at the end of the row, `#label` to customise the label line.
5+ */
6+defineProps<{ label?: string, hint?: string, disabled?: boolean }>()
7+const model = defineModel<boolean>({ default: false })
8+const id = useId()
9+</script>
10+
11+<template>
12+ <div class="flex items-center gap-3" :class="disabled && 'opacity-40'">
13+ <USwitch :id="id" v-model="model" :disabled="disabled" :aria-label="label" />
14+ <label :for="id" class="flex min-w-0 flex-1 cursor-pointer flex-col" :class="disabled && 'cursor-not-allowed'">
15+ <span class="flex items-baseline gap-2 font-medium"><slot name="label">{{ label }}</slot></span>
16+ <span v-if="hint || $slots.hint" class="text-xs text-sk-muted"><slot name="hint">{{ hint }}</slot></span>
17+ </label>
18+ <slot name="trailing" />
19+ </div>
20+</template>
new file mode 100644
@@ -0,0 +1,20 @@
1+<script setup lang="ts">
2+/**
3+ * Switch + label + hint. The switch is the Nuxt UI one (28x16, clay when on).
4+ * `#trailing` for status cells / mode toggles at the end of the row, `#label` to customise the label line.
5+ */
6+defineProps<{ label?: string, hint?: string, disabled?: boolean }>()
7+const model = defineModel<boolean>({ default: false })
8+const id = useId()
9+</script>
10+
11+<template>
12+ <div class="flex items-center gap-3" :class="disabled && 'opacity-40'">
13+ <USwitch :id="id" v-model="model" :disabled="disabled" :aria-label="label" />
14+ <label :for="id" class="flex min-w-0 flex-1 cursor-pointer flex-col" :class="disabled && 'cursor-not-allowed'">
15+ <span class="flex items-baseline gap-2 font-medium"><slot name="label">{{ label }}</slot></span>
16+ <span v-if="hint || $slots.hint" class="text-xs text-sk-muted"><slot name="hint">{{ hint }}</slot></span>
17+ </label>
18+ <slot name="trailing" />
19+ </div>
20+</template>
added packages/ui/app/components/sk/SkTray.vue +9 -0
new file mode 100644
@@ -0,0 +1,9 @@
1+<script setup lang="ts">
2+/** Grey tray holding SkRowCard rows (Home inventory). Group headings inside: <SkSectionLabel inset :count>. */
3+</script>
4+
5+<template>
6+ <div class="flex flex-col gap-1 rounded-2xl bg-sk-hover p-1.5">
7+ <slot />
8+ </div>
9+</template>
new file mode 100644
@@ -0,0 +1,9 @@
1+<script setup lang="ts">
2+/** Grey tray holding SkRowCard rows (Home inventory). Group headings inside: <SkSectionLabel inset :count>. */
3+</script>
4+
5+<template>
6+ <div class="flex flex-col gap-1 rounded-2xl bg-sk-hover p-1.5">
7+ <slot />
8+ </div>
9+</template>
added packages/ui/app/components/sk/SkTypeTag.vue +10 -0
new file mode 100644
@@ -0,0 +1,10 @@
1+<script setup lang="ts">
2+import type { ItemType } from '@skilly/core'
3+
4+/** Item type as a mono text tag: Skill, Rule, MCP, Hook, Subagent (no icons in the design). `label` overrides the text. */
5+defineProps<{ type: ItemType, label?: string }>()
6+</script>
7+
8+<template>
9+ <span class="font-mono text-[11px] font-normal whitespace-nowrap text-sk-faint">{{ label ?? TYPE_TAG[type] }}</span>
10+</template>
new file mode 100644
@@ -0,0 +1,10 @@
1+<script setup lang="ts">
2+import type { ItemType } from '@skilly/core'
3+
4+/** Item type as a mono text tag: Skill, Rule, MCP, Hook, Subagent (no icons in the design). `label` overrides the text. */
5+defineProps<{ type: ItemType, label?: string }>()
6+</script>
7+
8+<template>
9+ <span class="font-mono text-[11px] font-normal whitespace-nowrap text-sk-faint">{{ label ?? TYPE_TAG[type] }}</span>
10+</template>
added packages/ui/app/composables/useShell.ts +11 -0
new file mode 100644
@@ -0,0 +1,11 @@
1+/** Global overlays of the app shell, openable from any page. */
2+
3+/** "Add project" modal (mounted once in the default layout). */
4+export function useAddProjectModal() {
5+ return useState('skilly-add-open', () => false)
6+}
7+
8+/** Ctrl+K quick switcher (mounted once in the default layout). */
9+export function useSwitcher() {
10+ return useState('skilly-switcher-open', () => false)
11+}
new file mode 100644
@@ -0,0 +1,11 @@
1+/** Global overlays of the app shell, openable from any page. */
2+
3+/** "Add project" modal (mounted once in the default layout). */
4+export function useAddProjectModal() {
5+ return useState('skilly-add-open', () => false)
6+}
7+
8+/** Ctrl+K quick switcher (mounted once in the default layout). */
9+export function useSwitcher() {
10+ return useState('skilly-switcher-open', () => false)
11+}
added packages/ui/app/layouts/bare.vue +9 -0
new file mode 100644
@@ -0,0 +1,9 @@
1+<script setup lang="ts">
2+// Full-page layout without header (onboarding). Gives the dot-grid page background and centres its content.
3+</script>
4+
5+<template>
6+ <div class="flex min-h-screen flex-col">
7+ <slot />
8+ </div>
9+</template>
new file mode 100644
@@ -0,0 +1,9 @@
1+<script setup lang="ts">
2+// Full-page layout without header (onboarding). Gives the dot-grid page background and centres its content.
3+</script>
4+
5+<template>
6+ <div class="flex min-h-screen flex-col">
7+ <slot />
8+ </div>
9+</template>
modified packages/ui/app/layouts/default.vue +15 -25
@@ -1,7 +1,7 @@
11 <script setup lang="ts">
22 const { refresh } = useProjects()
33 const { refresh: refreshCatalog } = useCatalog()
4-const addOpen = useState('skilly-add-open', () => false)
4+const addOpen = useAddProjectModal()
55
66 onMounted(() => {
77 refresh()
@@ -10,31 +10,21 @@ onMounted(() => {
1010 </script>
1111
1212 <template>
13- <UDashboardGroup unit="rem">
14- <!-- Sizes in rem: a percentage sidebar gets too narrow for project names on small windows -->
15- <UDashboardSidebar :default-size="15" :min-size="13" :max-size="22" resizable :ui="{ footer: 'border-t border-default' }">
16- <template #header>
17- <NuxtLink to="/" class="flex items-center gap-2 px-1 py-1">
18- <span class="grid size-8 place-items-center rounded-lg bg-primary text-inverted">
19- <UIcon name="i-lucide-wand-sparkles" class="size-5" />
20- </span>
21- <span class="text-lg font-semibold tracking-tight">skilly</span>
22- </NuxtLink>
23- </template>
24-
25- <AppSidebar @add="addOpen = true" />
26-
27- <template #footer>
28- <div class="flex w-full items-center justify-between">
29- <span class="text-xs text-muted">skilly</span>
30- <UColorModeButton />
31- </div>
32- </template>
33- </UDashboardSidebar>
34-
35- <slot />
13+ <div class="flex min-h-screen flex-col">
14+ <AppHeader />
15+ <main class="min-w-0 flex-1">
16+ <!--
17+ UDashboardGroup only provides the context that legacy pages (UDashboardPanel / UDashboardNavbar) need.
18+ It is restyled in app.config.ts to flow with the page. Pages built on SkPage don't depend on it.
19+ -->
20+ <UDashboardGroup unit="rem" :persistent="false">
21+ <slot />
22+ </UDashboardGroup>
23+ </main>
3624
3725 <AddProjectModal v-model:open="addOpen" />
26+ <!-- WP3: remove together with CatalogGitDialog once /sync replaces it. -->
3827 <CatalogGitDialog />
39- </UDashboardGroup>
28+ <AppSwitcher />
29+ </div>
4030 </template>
@@ -1,7 +1,7 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2 const { refresh } = useProjects()2 const { refresh } = useProjects()
3 const { refresh: refreshCatalog } = useCatalog()3 const { refresh: refreshCatalog } = useCatalog()
4-const addOpen = useState('skilly-add-open', () => false)4+const addOpen = useAddProjectModal()
5 5
6 onMounted(() => {6 onMounted(() => {
7 refresh()7 refresh()
@@ -10,31 +10,21 @@ onMounted(() => {
10 </script>10 </script>
11 11
12 <template>12 <template>
13- <UDashboardGroup unit="rem">13+ <div class="flex min-h-screen flex-col">
14- <!-- Sizes in rem: a percentage sidebar gets too narrow for project names on small windows -->14+ <AppHeader />
15- <UDashboardSidebar :default-size="15" :min-size="13" :max-size="22" resizable :ui="{ footer: 'border-t border-default' }">15+ <main class="min-w-0 flex-1">
16- <template #header>16+ <!--
17- <NuxtLink to="/" class="flex items-center gap-2 px-1 py-1">17+ UDashboardGroup only provides the context that legacy pages (UDashboardPanel / UDashboardNavbar) need.
18- <span class="grid size-8 place-items-center rounded-lg bg-primary text-inverted">18+ It is restyled in app.config.ts to flow with the page. Pages built on SkPage don't depend on it.
19- <UIcon name="i-lucide-wand-sparkles" class="size-5" />19+ -->
20- </span>20+ <UDashboardGroup unit="rem" :persistent="false">
21- <span class="text-lg font-semibold tracking-tight">skilly</span>21+ <slot />
22- </NuxtLink>22+ </UDashboardGroup>
23- </template>23+ </main>
24-
25- <AppSidebar @add="addOpen = true" />
26-
27- <template #footer>
28- <div class="flex w-full items-center justify-between">
29- <span class="text-xs text-muted">skilly</span>
30- <UColorModeButton />
31- </div>
32- </template>
33- </UDashboardSidebar>
34-
35- <slot />
36 24
37 <AddProjectModal v-model:open="addOpen" />25 <AddProjectModal v-model:open="addOpen" />
26+ <!-- WP3: remove together with CatalogGitDialog once /sync replaces it. -->
38 <CatalogGitDialog />27 <CatalogGitDialog />
39- </UDashboardGroup>28+ <AppSwitcher />
29+ </div>
40 </template>30 </template>
added packages/ui/app/middleware/redirects.global.ts +6 -0
new file mode 100644
@@ -0,0 +1,6 @@
1+// Old routes kept alive as redirects (bookmarks, links in docs).
2+export default defineNuxtRouteMiddleware((to) => {
3+ if (to.path === '/presets' || to.path.startsWith('/presets/')) {
4+ return navigateTo({ path: to.path.replace(/^\/presets/, '/catalog/presets'), query: to.query, hash: to.hash }, { replace: true })
5+ }
6+})
new file mode 100644
@@ -0,0 +1,6 @@
1+// Old routes kept alive as redirects (bookmarks, links in docs).
2+export default defineNuxtRouteMiddleware((to) => {
3+ if (to.path === '/presets' || to.path.startsWith('/presets/')) {
4+ return navigateTo({ path: to.path.replace(/^\/presets/, '/catalog/presets'), query: to.query, hash: to.hash }, { replace: true })
5+ }
6+})
renamed packages/ui/app/pages/catalog/presets/[name].vue +0 -0
similarity index 100%
rename from packages/ui/app/pages/presets/[name].vue
rename to packages/ui/app/pages/catalog/presets/[name].vue
similarity index 100%
rename from packages/ui/app/pages/presets/[name].vue
rename to packages/ui/app/pages/catalog/presets/[name].vue
renamed packages/ui/app/pages/catalog/presets/index.vue +3 -3
similarity index 95%
rename from packages/ui/app/pages/presets/index.vue
rename to packages/ui/app/pages/catalog/presets/index.vue
@@ -17,7 +17,7 @@ const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text
1717 <template #header>
1818 <UDashboardNavbar title="Presets" icon="i-lucide-layers">
1919 <template #right>
20- <UButton v-if="exists" label="New preset" icon="i-lucide-plus" to="/presets/new" />
20+ <UButton v-if="exists" label="New preset" icon="i-lucide-plus" to="/catalog/presets/new" />
2121 </template>
2222 </UDashboardNavbar>
2323 </template>
@@ -32,7 +32,7 @@ const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text
3232 <div v-if="!sorted.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center">
3333 <UIcon name="i-lucide-layers" class="size-8 text-dimmed" />
3434 <p class="text-sm text-muted">No preset yet.</p>
35- <UButton label="Create a preset" icon="i-lucide-plus" to="/presets/new" size="sm" />
35+ <UButton label="Create a preset" icon="i-lucide-plus" to="/catalog/presets/new" size="sm" />
3636 </div>
3737 <div v-else class="overflow-x-auto rounded-xl border border-default">
3838 <table class="w-full min-w-[720px] table-fixed text-sm">
@@ -45,7 +45,7 @@ const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text
4545 </tr>
4646 </thead>
4747 <tbody class="divide-y divide-default">
48- <tr v-for="p in sorted" :key="p.name" class="cursor-pointer transition-colors hover:bg-elevated/50" tabindex="0" @click="navigateTo(`/presets/${encodeURIComponent(p.name)}`)" @keydown.enter="navigateTo(`/presets/${encodeURIComponent(p.name)}`)">
48+ <tr v-for="p in sorted" :key="p.name" class="cursor-pointer transition-colors hover:bg-elevated/50" tabindex="0" @click="navigateTo(`/catalog/presets/${encodeURIComponent(p.name)}`)" @keydown.enter="navigateTo(`/catalog/presets/${encodeURIComponent(p.name)}`)">
4949 <td class="px-3 py-2 align-middle">
5050 <span class="block truncate font-medium text-highlighted">{{ p.name }}</span>
5151 <p v-if="p.description" class="line-clamp-1 text-xs text-muted" :title="p.description">{{ p.description }}</p>
similarity index 95%
rename from packages/ui/app/pages/presets/index.vue
rename to packages/ui/app/pages/catalog/presets/index.vue
@@ -17,7 +17,7 @@ const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text
17 <template #header>17 <template #header>
18 <UDashboardNavbar title="Presets" icon="i-lucide-layers">18 <UDashboardNavbar title="Presets" icon="i-lucide-layers">
19 <template #right>19 <template #right>
20- <UButton v-if="exists" label="New preset" icon="i-lucide-plus" to="/presets/new" />20+ <UButton v-if="exists" label="New preset" icon="i-lucide-plus" to="/catalog/presets/new" />
21 </template>21 </template>
22 </UDashboardNavbar>22 </UDashboardNavbar>
23 </template>23 </template>
@@ -32,7 +32,7 @@ const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text
32 <div v-if="!sorted.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center">32 <div v-if="!sorted.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center">
33 <UIcon name="i-lucide-layers" class="size-8 text-dimmed" />33 <UIcon name="i-lucide-layers" class="size-8 text-dimmed" />
34 <p class="text-sm text-muted">No preset yet.</p>34 <p class="text-sm text-muted">No preset yet.</p>
35- <UButton label="Create a preset" icon="i-lucide-plus" to="/presets/new" size="sm" />35+ <UButton label="Create a preset" icon="i-lucide-plus" to="/catalog/presets/new" size="sm" />
36 </div>36 </div>
37 <div v-else class="overflow-x-auto rounded-xl border border-default">37 <div v-else class="overflow-x-auto rounded-xl border border-default">
38 <table class="w-full min-w-[720px] table-fixed text-sm">38 <table class="w-full min-w-[720px] table-fixed text-sm">
@@ -45,7 +45,7 @@ const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text
45 </tr>45 </tr>
46 </thead>46 </thead>
47 <tbody class="divide-y divide-default">47 <tbody class="divide-y divide-default">
48- <tr v-for="p in sorted" :key="p.name" class="cursor-pointer transition-colors hover:bg-elevated/50" tabindex="0" @click="navigateTo(`/presets/${encodeURIComponent(p.name)}`)" @keydown.enter="navigateTo(`/presets/${encodeURIComponent(p.name)}`)">48+ <tr v-for="p in sorted" :key="p.name" class="cursor-pointer transition-colors hover:bg-elevated/50" tabindex="0" @click="navigateTo(`/catalog/presets/${encodeURIComponent(p.name)}`)" @keydown.enter="navigateTo(`/catalog/presets/${encodeURIComponent(p.name)}`)">
49 <td class="px-3 py-2 align-middle">49 <td class="px-3 py-2 align-middle">
50 <span class="block truncate font-medium text-highlighted">{{ p.name }}</span>50 <span class="block truncate font-medium text-highlighted">{{ p.name }}</span>
51 <p v-if="p.description" class="line-clamp-1 text-xs text-muted" :title="p.description">{{ p.description }}</p>51 <p v-if="p.description" class="line-clamp-1 text-xs text-muted" :title="p.description">{{ p.description }}</p>
renamed packages/ui/app/pages/catalog/presets/new.vue +0 -0
similarity index 100%
rename from packages/ui/app/pages/presets/new.vue
rename to packages/ui/app/pages/catalog/presets/new.vue
similarity index 100%
rename from packages/ui/app/pages/presets/new.vue
rename to packages/ui/app/pages/catalog/presets/new.vue
added packages/ui/app/pages/dev/kit.vue +26 -0
new file mode 100644
@@ -0,0 +1,26 @@
1+<script setup lang="ts">
2+// Living reference of the design system (docs: design/UI-GUIDE.md). Not linked from the nav.
3+const view = ref<'both' | 'light' | 'dark'>('both')
4+const modes = computed(() => (view.value === 'both' ? ['light', 'dark'] : [view.value]) as ('light' | 'dark')[])
5+</script>
6+
7+<template>
8+ <div class="flex flex-col">
9+ <div class="flex items-center justify-center gap-3 px-4 pb-1">
10+ <span class="font-mono text-[11px] tracking-[.06em] text-sk-faint uppercase">/dev/kit</span>
11+ <SkSeg v-model="view" :options="[{ value: 'both', label: 'Light + dark' }, { value: 'light', label: 'Light' }, { value: 'dark', label: 'Dark' }]" />
12+ </div>
13+ <div class="grid" :class="modes.length > 1 ? '2xl:grid-cols-2' : ''">
14+ <!-- A .light / .dark wrapper re-resolves every token locally, so both themes show side by side. -->
15+ <div
16+ v-for="m in modes"
17+ :key="m"
18+ :class="m"
19+ class="min-w-0 bg-sk-bg bg-[radial-gradient(var(--sk-dot)_1px,transparent_1.2px)] bg-[size:12px_12px] text-sk-text"
20+ >
21+ <p class="m-0 px-9 pt-4 font-mono text-[11px] tracking-[.06em] text-sk-faint uppercase">{{ m }}</p>
22+ <DevKitSheet />
23+ </div>
24+ </div>
25+ </div>
26+</template>
new file mode 100644
@@ -0,0 +1,26 @@
1+<script setup lang="ts">
2+// Living reference of the design system (docs: design/UI-GUIDE.md). Not linked from the nav.
3+const view = ref<'both' | 'light' | 'dark'>('both')
4+const modes = computed(() => (view.value === 'both' ? ['light', 'dark'] : [view.value]) as ('light' | 'dark')[])
5+</script>
6+
7+<template>
8+ <div class="flex flex-col">
9+ <div class="flex items-center justify-center gap-3 px-4 pb-1">
10+ <span class="font-mono text-[11px] tracking-[.06em] text-sk-faint uppercase">/dev/kit</span>
11+ <SkSeg v-model="view" :options="[{ value: 'both', label: 'Light + dark' }, { value: 'light', label: 'Light' }, { value: 'dark', label: 'Dark' }]" />
12+ </div>
13+ <div class="grid" :class="modes.length > 1 ? '2xl:grid-cols-2' : ''">
14+ <!-- A .light / .dark wrapper re-resolves every token locally, so both themes show side by side. -->
15+ <div
16+ v-for="m in modes"
17+ :key="m"
18+ :class="m"
19+ class="min-w-0 bg-sk-bg bg-[radial-gradient(var(--sk-dot)_1px,transparent_1.2px)] bg-[size:12px_12px] text-sk-text"
20+ >
21+ <p class="m-0 px-9 pt-4 font-mono text-[11px] tracking-[.06em] text-sk-faint uppercase">{{ m }}</p>
22+ <DevKitSheet />
23+ </div>
24+ </div>
25+ </div>
26+</template>
modified packages/ui/app/pages/index.vue +4 -145
@@ -1,150 +1,9 @@
11 <script setup lang="ts">
2-import type { ItemType, ScopeInventory, ScopeState } from '@skilly/core'
3-
4-const { projects, loaded, error: projectsError, refresh: refreshProjects } = useProjects()
5-const { home, loading: homeLoading, error: homeError, refresh: refreshHome } = useHomeInventory()
6-const { request } = useApi()
7-const addOpen = useState('skilly-add-open', () => false)
8-
9-interface Sync { label: string, color: 'success' | 'warning' | 'neutral' | 'error', icon: string }
10-const syncs = ref<Record<string, Sync | null>>({})
11-
12-function toSync(s: ScopeState): Sync {
13- if (!s.config) return { label: 'not managed', color: 'neutral', icon: 'i-lucide-circle-dashed' }
14- const pending = (s.plan?.actions ?? []).filter(a => a.kind !== 'unchanged')
15- const conflicts = pending.filter(a => a.kind === 'conflict').length
16- if (conflicts) return { label: `${conflicts} conflict${conflicts > 1 ? 's' : ''}`, color: 'warning', icon: 'i-lucide-triangle-alert' }
17- if (pending.length) return { label: `${pending.length} change${pending.length > 1 ? 's' : ''}`, color: 'warning', icon: 'i-lucide-refresh-cw' }
18- return { label: 'in sync', color: 'success', icon: 'i-lucide-check' }
19-}
20-
21-// Sync state needs a scan of each scope: fetch lazily, three at a time, and tolerate failures (the card then shows counts only).
22-async function loadSyncs() {
23- const ids = ['home', ...projects.value.filter(p => p.exists).map(p => p.id)]
24- const queue = [...ids]
25- const worker = async () => {
26- for (let id = queue.shift(); id; id = queue.shift()) {
27- try {
28- syncs.value[id] = toSync(await request<ScopeState>(`/api/scopes/${encodeURIComponent(id)}/state`, { silent: true }))
29- } catch {
30- syncs.value[id] = null
31- }
32- }
33- }
34- await Promise.all([worker(), worker(), worker()])
35-}
36-
37-onMounted(async () => {
38- if (!home.value) refreshHome()
39- // Projects may have changed on disk (e.g. a folder was deleted) since the layout loaded them.
40- if (loaded.value) await refreshProjects()
41- else await new Promise<void>((res) => { const stop = watch(loaded, (v) => { if (v) { stop(); res() } }) })
42- loadSyncs()
43-})
44-
45-function countItems(inv: ScopeInventory): Record<ItemType, number> {
46- const c = Object.fromEntries(TYPE_ORDER.map(t => [t, 0])) as Record<ItemType, number>
47- for (const i of inv.items) c[i.type]++
48- return c
49-}
50-
51-const homeCounts = computed(() => (home.value ? countItems(home.value) : null))
2+// The dashboard is gone: Projects is the landing page (brief section 5).
3+// WP3: redirect to /onboarding on first run (no catalog and no project yet) from here.
4+definePageMeta({ redirect: '/projects' })
525 </script>
536
547 <template>
55- <UDashboardPanel id="dashboard">
56- <template #header>
57- <UDashboardNavbar title="Dashboard">
58- <template #right>
59- <UButton icon="i-lucide-folder-plus" label="Add project" @click="addOpen = true" />
60- </template>
61- </UDashboardNavbar>
62- </template>
63-
64- <template #body>
65- <div class="mx-auto w-full max-w-5xl space-y-6">
66- <p class="text-sm text-muted">
67- Everything your coding agents can see: skills, rules, MCP servers, hooks, subagents and instruction files.
68- </p>
69-
70- <div class="grid gap-3 md:grid-cols-2">
71- <!-- Home -->
72- <NuxtLink to="/home" class="block rounded-xl border border-default p-3 transition-colors hover:bg-elevated/50 focus-visible:outline-2 focus-visible:outline-primary">
73- <div class="flex items-center gap-2">
74- <UIcon name="i-lucide-house" class="size-4 shrink-0 text-primary" />
75- <h2 class="font-semibold">Home</h2>
76- <span class="truncate text-xs text-muted">user-level configuration</span>
77- <UBadge v-if="syncs.home" :label="syncs.home.label" :color="syncs.home.color" :icon="syncs.home.icon" variant="subtle" size="sm" class="ml-auto shrink-0" />
78- <USkeleton v-else-if="syncs.home === undefined" class="ml-auto h-5 w-16" />
79- </div>
80- <div v-if="homeLoading && !home" class="mt-2 space-y-2"><USkeleton class="h-4 w-full" /></div>
81- <UAlert v-else-if="homeError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="homeError" class="mt-2" />
82- <div v-else-if="home && homeCounts" class="mt-2 space-y-2">
83- <p class="truncate font-mono text-xs text-muted" :title="home.root">{{ home.root }}</p>
84- <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5">
85- <span class="flex gap-1">
86- <AgentBadge v-for="a in home.agentsDetected" :key="a" :agent="a" compact />
87- <span v-if="!home.agentsDetected.length" class="text-xs text-muted">No agent</span>
88- </span>
89- <CountChips :counts="homeCounts" compact />
90- </div>
91- </div>
92- </NuxtLink>
93-
94- <!-- Projects -->
95- <template v-if="loaded">
96- <NuxtLink
97- v-for="p in projects"
98- :key="p.id"
99- :to="`/projects/${encodeURIComponent(p.id)}`"
100- class="block rounded-xl border border-default p-3 transition-colors hover:bg-elevated/50 focus-visible:outline-2 focus-visible:outline-primary"
101- >
102- <div class="flex items-center gap-2">
103- <UIcon name="i-lucide-folder" class="size-4 shrink-0 text-primary" />
104- <h2 class="truncate font-semibold">{{ p.name }}</h2>
105- <UBadge v-if="!p.exists" label="Folder missing" color="warning" variant="subtle" icon="i-lucide-triangle-alert" size="sm" class="ml-auto shrink-0" />
106- <UBadge v-else-if="syncs[p.id]" :label="syncs[p.id]!.label" :color="syncs[p.id]!.color" :icon="syncs[p.id]!.icon" variant="subtle" size="sm" class="ml-auto shrink-0" />
107- <USkeleton v-else-if="syncs[p.id] === undefined" class="ml-auto h-5 w-16" />
108- </div>
109- <div class="mt-2 space-y-2">
110- <p class="truncate font-mono text-xs text-muted" :title="p.path">{{ p.path }}</p>
111- <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5">
112- <span class="flex gap-1">
113- <AgentBadge v-for="a in p.agentsDetected" :key="a" :agent="a" compact />
114- <span v-if="!p.agentsDetected.length" class="text-xs text-muted">No agent</span>
115- </span>
116- <CountChips :counts="p.counts" compact />
117- </div>
118- </div>
119- </NuxtLink>
120- </template>
121- <template v-else>
122- <USkeleton class="h-24 w-full" />
123- </template>
124- </div>
125-
126- <UAlert
127- v-if="projectsError"
128- color="error"
129- variant="subtle"
130- icon="i-lucide-circle-alert"
131- title="Could not load projects"
132- :description="projectsError"
133- :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: refreshProjects }]"
134- />
135-
136- <div
137- v-else-if="loaded && !projects.length"
138- class="flex flex-col items-center gap-3 rounded-xl border border-dashed border-default py-14 text-center"
139- >
140- <UIcon name="i-lucide-folder-plus" class="size-10 text-dimmed" />
141- <h3 class="font-semibold">Add your first project</h3>
142- <p class="max-w-md text-sm text-muted">
143- Pick a folder to see which skills, rules, MCP servers and instructions Claude Code and Codex use there, then manage them from your catalog.
144- </p>
145- <UButton icon="i-lucide-folder-plus" label="Add project" @click="addOpen = true" />
146- </div>
147- </div>
148- </template>
149- </UDashboardPanel>
8+ <div />
1509 </template>
@@ -1,150 +1,9 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2-import type { ItemType, ScopeInventory, ScopeState } from '@skilly/core'2+// The dashboard is gone: Projects is the landing page (brief section 5).
3-3+// WP3: redirect to /onboarding on first run (no catalog and no project yet) from here.
4-const { projects, loaded, error: projectsError, refresh: refreshProjects } = useProjects()4+definePageMeta({ redirect: '/projects' })
5-const { home, loading: homeLoading, error: homeError, refresh: refreshHome } = useHomeInventory()
6-const { request } = useApi()
7-const addOpen = useState('skilly-add-open', () => false)
8-
9-interface Sync { label: string, color: 'success' | 'warning' | 'neutral' | 'error', icon: string }
10-const syncs = ref<Record<string, Sync | null>>({})
11-
12-function toSync(s: ScopeState): Sync {
13- if (!s.config) return { label: 'not managed', color: 'neutral', icon: 'i-lucide-circle-dashed' }
14- const pending = (s.plan?.actions ?? []).filter(a => a.kind !== 'unchanged')
15- const conflicts = pending.filter(a => a.kind === 'conflict').length
16- if (conflicts) return { label: `${conflicts} conflict${conflicts > 1 ? 's' : ''}`, color: 'warning', icon: 'i-lucide-triangle-alert' }
17- if (pending.length) return { label: `${pending.length} change${pending.length > 1 ? 's' : ''}`, color: 'warning', icon: 'i-lucide-refresh-cw' }
18- return { label: 'in sync', color: 'success', icon: 'i-lucide-check' }
19-}
20-
21-// Sync state needs a scan of each scope: fetch lazily, three at a time, and tolerate failures (the card then shows counts only).
22-async function loadSyncs() {
23- const ids = ['home', ...projects.value.filter(p => p.exists).map(p => p.id)]
24- const queue = [...ids]
25- const worker = async () => {
26- for (let id = queue.shift(); id; id = queue.shift()) {
27- try {
28- syncs.value[id] = toSync(await request<ScopeState>(`/api/scopes/${encodeURIComponent(id)}/state`, { silent: true }))
29- } catch {
30- syncs.value[id] = null
31- }
32- }
33- }
34- await Promise.all([worker(), worker(), worker()])
35-}
36-
37-onMounted(async () => {
38- if (!home.value) refreshHome()
39- // Projects may have changed on disk (e.g. a folder was deleted) since the layout loaded them.
40- if (loaded.value) await refreshProjects()
41- else await new Promise<void>((res) => { const stop = watch(loaded, (v) => { if (v) { stop(); res() } }) })
42- loadSyncs()
43-})
44-
45-function countItems(inv: ScopeInventory): Record<ItemType, number> {
46- const c = Object.fromEntries(TYPE_ORDER.map(t => [t, 0])) as Record<ItemType, number>
47- for (const i of inv.items) c[i.type]++
48- return c
49-}
50-
51-const homeCounts = computed(() => (home.value ? countItems(home.value) : null))
52 </script>5 </script>
53 6
54 <template>7 <template>
55- <UDashboardPanel id="dashboard">8+ <div />
56- <template #header>
57- <UDashboardNavbar title="Dashboard">
58- <template #right>
59- <UButton icon="i-lucide-folder-plus" label="Add project" @click="addOpen = true" />
60- </template>
61- </UDashboardNavbar>
62- </template>
63-
64- <template #body>
65- <div class="mx-auto w-full max-w-5xl space-y-6">
66- <p class="text-sm text-muted">
67- Everything your coding agents can see: skills, rules, MCP servers, hooks, subagents and instruction files.
68- </p>
69-
70- <div class="grid gap-3 md:grid-cols-2">
71- <!-- Home -->
72- <NuxtLink to="/home" class="block rounded-xl border border-default p-3 transition-colors hover:bg-elevated/50 focus-visible:outline-2 focus-visible:outline-primary">
73- <div class="flex items-center gap-2">
74- <UIcon name="i-lucide-house" class="size-4 shrink-0 text-primary" />
75- <h2 class="font-semibold">Home</h2>
76- <span class="truncate text-xs text-muted">user-level configuration</span>
77- <UBadge v-if="syncs.home" :label="syncs.home.label" :color="syncs.home.color" :icon="syncs.home.icon" variant="subtle" size="sm" class="ml-auto shrink-0" />
78- <USkeleton v-else-if="syncs.home === undefined" class="ml-auto h-5 w-16" />
79- </div>
80- <div v-if="homeLoading && !home" class="mt-2 space-y-2"><USkeleton class="h-4 w-full" /></div>
81- <UAlert v-else-if="homeError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="homeError" class="mt-2" />
82- <div v-else-if="home && homeCounts" class="mt-2 space-y-2">
83- <p class="truncate font-mono text-xs text-muted" :title="home.root">{{ home.root }}</p>
84- <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5">
85- <span class="flex gap-1">
86- <AgentBadge v-for="a in home.agentsDetected" :key="a" :agent="a" compact />
87- <span v-if="!home.agentsDetected.length" class="text-xs text-muted">No agent</span>
88- </span>
89- <CountChips :counts="homeCounts" compact />
90- </div>
91- </div>
92- </NuxtLink>
93-
94- <!-- Projects -->
95- <template v-if="loaded">
96- <NuxtLink
97- v-for="p in projects"
98- :key="p.id"
99- :to="`/projects/${encodeURIComponent(p.id)}`"
100- class="block rounded-xl border border-default p-3 transition-colors hover:bg-elevated/50 focus-visible:outline-2 focus-visible:outline-primary"
101- >
102- <div class="flex items-center gap-2">
103- <UIcon name="i-lucide-folder" class="size-4 shrink-0 text-primary" />
104- <h2 class="truncate font-semibold">{{ p.name }}</h2>
105- <UBadge v-if="!p.exists" label="Folder missing" color="warning" variant="subtle" icon="i-lucide-triangle-alert" size="sm" class="ml-auto shrink-0" />
106- <UBadge v-else-if="syncs[p.id]" :label="syncs[p.id]!.label" :color="syncs[p.id]!.color" :icon="syncs[p.id]!.icon" variant="subtle" size="sm" class="ml-auto shrink-0" />
107- <USkeleton v-else-if="syncs[p.id] === undefined" class="ml-auto h-5 w-16" />
108- </div>
109- <div class="mt-2 space-y-2">
110- <p class="truncate font-mono text-xs text-muted" :title="p.path">{{ p.path }}</p>
111- <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5">
112- <span class="flex gap-1">
113- <AgentBadge v-for="a in p.agentsDetected" :key="a" :agent="a" compact />
114- <span v-if="!p.agentsDetected.length" class="text-xs text-muted">No agent</span>
115- </span>
116- <CountChips :counts="p.counts" compact />
117- </div>
118- </div>
119- </NuxtLink>
120- </template>
121- <template v-else>
122- <USkeleton class="h-24 w-full" />
123- </template>
124- </div>
125-
126- <UAlert
127- v-if="projectsError"
128- color="error"
129- variant="subtle"
130- icon="i-lucide-circle-alert"
131- title="Could not load projects"
132- :description="projectsError"
133- :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: refreshProjects }]"
134- />
135-
136- <div
137- v-else-if="loaded && !projects.length"
138- class="flex flex-col items-center gap-3 rounded-xl border border-dashed border-default py-14 text-center"
139- >
140- <UIcon name="i-lucide-folder-plus" class="size-10 text-dimmed" />
141- <h3 class="font-semibold">Add your first project</h3>
142- <p class="max-w-md text-sm text-muted">
143- Pick a folder to see which skills, rules, MCP servers and instructions Claude Code and Codex use there, then manage them from your catalog.
144- </p>
145- <UButton icon="i-lucide-folder-plus" label="Add project" @click="addOpen = true" />
146- </div>
147- </div>
148- </template>
149- </UDashboardPanel>
150 </template>9 </template>
added packages/ui/app/pages/onboarding.vue +12 -0
new file mode 100644
@@ -0,0 +1,12 @@
1+<script setup lang="ts">
2+// PLACEHOLDER (WP3 owns this page). Uses the bare layout (no header): the 860px card of the mockup goes here.
3+definePageMeta({ layout: 'bare' })
4+</script>
5+
6+<template>
7+ <div class="flex flex-1 items-center justify-center p-8">
8+ <SkPanel hero class="w-full max-w-[860px]">
9+ <SkEmpty title="Onboarding" text="Not built yet (WP3)." />
10+ </SkPanel>
11+ </div>
12+</template>
new file mode 100644
@@ -0,0 +1,12 @@
1+<script setup lang="ts">
2+// PLACEHOLDER (WP3 owns this page). Uses the bare layout (no header): the 860px card of the mockup goes here.
3+definePageMeta({ layout: 'bare' })
4+</script>
5+
6+<template>
7+ <div class="flex flex-1 items-center justify-center p-8">
8+ <SkPanel hero class="w-full max-w-[860px]">
9+ <SkEmpty title="Onboarding" text="Not built yet (WP3)." />
10+ </SkPanel>
11+ </div>
12+</template>
added packages/ui/app/pages/projects/index.vue +150 -0
new file mode 100644
@@ -0,0 +1,150 @@
1+<script setup lang="ts">
2+import type { ItemType, ScopeInventory, ScopeState } from '@skilly/core'
3+
4+const { projects, loaded, error: projectsError, refresh: refreshProjects } = useProjects()
5+const { home, loading: homeLoading, error: homeError, refresh: refreshHome } = useHomeInventory()
6+const { request } = useApi()
7+const addOpen = useState('skilly-add-open', () => false)
8+
9+interface Sync { label: string, color: 'success' | 'warning' | 'neutral' | 'error', icon: string }
10+const syncs = ref<Record<string, Sync | null>>({})
11+
12+function toSync(s: ScopeState): Sync {
13+ if (!s.config) return { label: 'not managed', color: 'neutral', icon: 'i-lucide-circle-dashed' }
14+ const pending = (s.plan?.actions ?? []).filter(a => a.kind !== 'unchanged')
15+ const conflicts = pending.filter(a => a.kind === 'conflict').length
16+ if (conflicts) return { label: `${conflicts} conflict${conflicts > 1 ? 's' : ''}`, color: 'warning', icon: 'i-lucide-triangle-alert' }
17+ if (pending.length) return { label: `${pending.length} change${pending.length > 1 ? 's' : ''}`, color: 'warning', icon: 'i-lucide-refresh-cw' }
18+ return { label: 'in sync', color: 'success', icon: 'i-lucide-check' }
19+}
20+
21+// Sync state needs a scan of each scope: fetch lazily, three at a time, and tolerate failures (the card then shows counts only).
22+async function loadSyncs() {
23+ const ids = ['home', ...projects.value.filter(p => p.exists).map(p => p.id)]
24+ const queue = [...ids]
25+ const worker = async () => {
26+ for (let id = queue.shift(); id; id = queue.shift()) {
27+ try {
28+ syncs.value[id] = toSync(await request<ScopeState>(`/api/scopes/${encodeURIComponent(id)}/state`, { silent: true }))
29+ } catch {
30+ syncs.value[id] = null
31+ }
32+ }
33+ }
34+ await Promise.all([worker(), worker(), worker()])
35+}
36+
37+onMounted(async () => {
38+ if (!home.value) refreshHome()
39+ // Projects may have changed on disk (e.g. a folder was deleted) since the layout loaded them.
40+ if (loaded.value) await refreshProjects()
41+ else await new Promise<void>((res) => { const stop = watch(loaded, (v) => { if (v) { stop(); res() } }) })
42+ loadSyncs()
43+})
44+
45+function countItems(inv: ScopeInventory): Record<ItemType, number> {
46+ const c = Object.fromEntries(TYPE_ORDER.map(t => [t, 0])) as Record<ItemType, number>
47+ for (const i of inv.items) c[i.type]++
48+ return c
49+}
50+
51+const homeCounts = computed(() => (home.value ? countItems(home.value) : null))
52+</script>
53+
54+<template>
55+ <UDashboardPanel id="projects">
56+ <template #header>
57+ <UDashboardNavbar title="Projects">
58+ <template #right>
59+ <UButton icon="i-lucide-folder-plus" label="Add project" @click="addOpen = true" />
60+ </template>
61+ </UDashboardNavbar>
62+ </template>
63+
64+ <template #body>
65+ <div class="mx-auto w-full max-w-5xl space-y-6">
66+ <p class="text-sm text-muted">
67+ Everything your coding agents can see: skills, rules, MCP servers, hooks, subagents and instruction files.
68+ </p>
69+
70+ <div class="grid gap-3 md:grid-cols-2">
71+ <!-- Home -->
72+ <NuxtLink to="/home" class="block rounded-xl border border-default p-3 transition-colors hover:bg-elevated/50 focus-visible:outline-2 focus-visible:outline-primary">
73+ <div class="flex items-center gap-2">
74+ <UIcon name="i-lucide-house" class="size-4 shrink-0 text-primary" />
75+ <h2 class="font-semibold">Home</h2>
76+ <span class="truncate text-xs text-muted">user-level configuration</span>
77+ <UBadge v-if="syncs.home" :label="syncs.home.label" :color="syncs.home.color" :icon="syncs.home.icon" variant="subtle" size="sm" class="ml-auto shrink-0" />
78+ <USkeleton v-else-if="syncs.home === undefined" class="ml-auto h-5 w-16" />
79+ </div>
80+ <div v-if="homeLoading && !home" class="mt-2 space-y-2"><USkeleton class="h-4 w-full" /></div>
81+ <UAlert v-else-if="homeError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="homeError" class="mt-2" />
82+ <div v-else-if="home && homeCounts" class="mt-2 space-y-2">
83+ <p class="truncate font-mono text-xs text-muted" :title="home.root">{{ home.root }}</p>
84+ <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5">
85+ <span class="flex gap-1">
86+ <AgentBadge v-for="a in home.agentsDetected" :key="a" :agent="a" compact />
87+ <span v-if="!home.agentsDetected.length" class="text-xs text-muted">No agent</span>
88+ </span>
89+ <CountChips :counts="homeCounts" compact />
90+ </div>
91+ </div>
92+ </NuxtLink>
93+
94+ <!-- Projects -->
95+ <template v-if="loaded">
96+ <NuxtLink
97+ v-for="p in projects"
98+ :key="p.id"
99+ :to="`/projects/${encodeURIComponent(p.id)}`"
100+ class="block rounded-xl border border-default p-3 transition-colors hover:bg-elevated/50 focus-visible:outline-2 focus-visible:outline-primary"
101+ >
102+ <div class="flex items-center gap-2">
103+ <UIcon name="i-lucide-folder" class="size-4 shrink-0 text-primary" />
104+ <h2 class="truncate font-semibold">{{ p.name }}</h2>
105+ <UBadge v-if="!p.exists" label="Folder missing" color="warning" variant="subtle" icon="i-lucide-triangle-alert" size="sm" class="ml-auto shrink-0" />
106+ <UBadge v-else-if="syncs[p.id]" :label="syncs[p.id]!.label" :color="syncs[p.id]!.color" :icon="syncs[p.id]!.icon" variant="subtle" size="sm" class="ml-auto shrink-0" />
107+ <USkeleton v-else-if="syncs[p.id] === undefined" class="ml-auto h-5 w-16" />
108+ </div>
109+ <div class="mt-2 space-y-2">
110+ <p class="truncate font-mono text-xs text-muted" :title="p.path">{{ p.path }}</p>
111+ <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5">
112+ <span class="flex gap-1">
113+ <AgentBadge v-for="a in p.agentsDetected" :key="a" :agent="a" compact />
114+ <span v-if="!p.agentsDetected.length" class="text-xs text-muted">No agent</span>
115+ </span>
116+ <CountChips :counts="p.counts" compact />
117+ </div>
118+ </div>
119+ </NuxtLink>
120+ </template>
121+ <template v-else>
122+ <USkeleton class="h-24 w-full" />
123+ </template>
124+ </div>
125+
126+ <UAlert
127+ v-if="projectsError"
128+ color="error"
129+ variant="subtle"
130+ icon="i-lucide-circle-alert"
131+ title="Could not load projects"
132+ :description="projectsError"
133+ :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: refreshProjects }]"
134+ />
135+
136+ <div
137+ v-else-if="loaded && !projects.length"
138+ class="flex flex-col items-center gap-3 rounded-xl border border-dashed border-default py-14 text-center"
139+ >
140+ <UIcon name="i-lucide-folder-plus" class="size-10 text-dimmed" />
141+ <h3 class="font-semibold">Add your first project</h3>
142+ <p class="max-w-md text-sm text-muted">
143+ Pick a folder to see which skills, rules, MCP servers and instructions Claude Code and Codex use there, then manage them from your catalog.
144+ </p>
145+ <UButton icon="i-lucide-folder-plus" label="Add project" @click="addOpen = true" />
146+ </div>
147+ </div>
148+ </template>
149+ </UDashboardPanel>
150+</template>
new file mode 100644
@@ -0,0 +1,150 @@
1+<script setup lang="ts">
2+import type { ItemType, ScopeInventory, ScopeState } from '@skilly/core'
3+
4+const { projects, loaded, error: projectsError, refresh: refreshProjects } = useProjects()
5+const { home, loading: homeLoading, error: homeError, refresh: refreshHome } = useHomeInventory()
6+const { request } = useApi()
7+const addOpen = useState('skilly-add-open', () => false)
8+
9+interface Sync { label: string, color: 'success' | 'warning' | 'neutral' | 'error', icon: string }
10+const syncs = ref<Record<string, Sync | null>>({})
11+
12+function toSync(s: ScopeState): Sync {
13+ if (!s.config) return { label: 'not managed', color: 'neutral', icon: 'i-lucide-circle-dashed' }
14+ const pending = (s.plan?.actions ?? []).filter(a => a.kind !== 'unchanged')
15+ const conflicts = pending.filter(a => a.kind === 'conflict').length
16+ if (conflicts) return { label: `${conflicts} conflict${conflicts > 1 ? 's' : ''}`, color: 'warning', icon: 'i-lucide-triangle-alert' }
17+ if (pending.length) return { label: `${pending.length} change${pending.length > 1 ? 's' : ''}`, color: 'warning', icon: 'i-lucide-refresh-cw' }
18+ return { label: 'in sync', color: 'success', icon: 'i-lucide-check' }
19+}
20+
21+// Sync state needs a scan of each scope: fetch lazily, three at a time, and tolerate failures (the card then shows counts only).
22+async function loadSyncs() {
23+ const ids = ['home', ...projects.value.filter(p => p.exists).map(p => p.id)]
24+ const queue = [...ids]
25+ const worker = async () => {
26+ for (let id = queue.shift(); id; id = queue.shift()) {
27+ try {
28+ syncs.value[id] = toSync(await request<ScopeState>(`/api/scopes/${encodeURIComponent(id)}/state`, { silent: true }))
29+ } catch {
30+ syncs.value[id] = null
31+ }
32+ }
33+ }
34+ await Promise.all([worker(), worker(), worker()])
35+}
36+
37+onMounted(async () => {
38+ if (!home.value) refreshHome()
39+ // Projects may have changed on disk (e.g. a folder was deleted) since the layout loaded them.
40+ if (loaded.value) await refreshProjects()
41+ else await new Promise<void>((res) => { const stop = watch(loaded, (v) => { if (v) { stop(); res() } }) })
42+ loadSyncs()
43+})
44+
45+function countItems(inv: ScopeInventory): Record<ItemType, number> {
46+ const c = Object.fromEntries(TYPE_ORDER.map(t => [t, 0])) as Record<ItemType, number>
47+ for (const i of inv.items) c[i.type]++
48+ return c
49+}
50+
51+const homeCounts = computed(() => (home.value ? countItems(home.value) : null))
52+</script>
53+
54+<template>
55+ <UDashboardPanel id="projects">
56+ <template #header>
57+ <UDashboardNavbar title="Projects">
58+ <template #right>
59+ <UButton icon="i-lucide-folder-plus" label="Add project" @click="addOpen = true" />
60+ </template>
61+ </UDashboardNavbar>
62+ </template>
63+
64+ <template #body>
65+ <div class="mx-auto w-full max-w-5xl space-y-6">
66+ <p class="text-sm text-muted">
67+ Everything your coding agents can see: skills, rules, MCP servers, hooks, subagents and instruction files.
68+ </p>
69+
70+ <div class="grid gap-3 md:grid-cols-2">
71+ <!-- Home -->
72+ <NuxtLink to="/home" class="block rounded-xl border border-default p-3 transition-colors hover:bg-elevated/50 focus-visible:outline-2 focus-visible:outline-primary">
73+ <div class="flex items-center gap-2">
74+ <UIcon name="i-lucide-house" class="size-4 shrink-0 text-primary" />
75+ <h2 class="font-semibold">Home</h2>
76+ <span class="truncate text-xs text-muted">user-level configuration</span>
77+ <UBadge v-if="syncs.home" :label="syncs.home.label" :color="syncs.home.color" :icon="syncs.home.icon" variant="subtle" size="sm" class="ml-auto shrink-0" />
78+ <USkeleton v-else-if="syncs.home === undefined" class="ml-auto h-5 w-16" />
79+ </div>
80+ <div v-if="homeLoading && !home" class="mt-2 space-y-2"><USkeleton class="h-4 w-full" /></div>
81+ <UAlert v-else-if="homeError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="homeError" class="mt-2" />
82+ <div v-else-if="home && homeCounts" class="mt-2 space-y-2">
83+ <p class="truncate font-mono text-xs text-muted" :title="home.root">{{ home.root }}</p>
84+ <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5">
85+ <span class="flex gap-1">
86+ <AgentBadge v-for="a in home.agentsDetected" :key="a" :agent="a" compact />
87+ <span v-if="!home.agentsDetected.length" class="text-xs text-muted">No agent</span>
88+ </span>
89+ <CountChips :counts="homeCounts" compact />
90+ </div>
91+ </div>
92+ </NuxtLink>
93+
94+ <!-- Projects -->
95+ <template v-if="loaded">
96+ <NuxtLink
97+ v-for="p in projects"
98+ :key="p.id"
99+ :to="`/projects/${encodeURIComponent(p.id)}`"
100+ class="block rounded-xl border border-default p-3 transition-colors hover:bg-elevated/50 focus-visible:outline-2 focus-visible:outline-primary"
101+ >
102+ <div class="flex items-center gap-2">
103+ <UIcon name="i-lucide-folder" class="size-4 shrink-0 text-primary" />
104+ <h2 class="truncate font-semibold">{{ p.name }}</h2>
105+ <UBadge v-if="!p.exists" label="Folder missing" color="warning" variant="subtle" icon="i-lucide-triangle-alert" size="sm" class="ml-auto shrink-0" />
106+ <UBadge v-else-if="syncs[p.id]" :label="syncs[p.id]!.label" :color="syncs[p.id]!.color" :icon="syncs[p.id]!.icon" variant="subtle" size="sm" class="ml-auto shrink-0" />
107+ <USkeleton v-else-if="syncs[p.id] === undefined" class="ml-auto h-5 w-16" />
108+ </div>
109+ <div class="mt-2 space-y-2">
110+ <p class="truncate font-mono text-xs text-muted" :title="p.path">{{ p.path }}</p>
111+ <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5">
112+ <span class="flex gap-1">
113+ <AgentBadge v-for="a in p.agentsDetected" :key="a" :agent="a" compact />
114+ <span v-if="!p.agentsDetected.length" class="text-xs text-muted">No agent</span>
115+ </span>
116+ <CountChips :counts="p.counts" compact />
117+ </div>
118+ </div>
119+ </NuxtLink>
120+ </template>
121+ <template v-else>
122+ <USkeleton class="h-24 w-full" />
123+ </template>
124+ </div>
125+
126+ <UAlert
127+ v-if="projectsError"
128+ color="error"
129+ variant="subtle"
130+ icon="i-lucide-circle-alert"
131+ title="Could not load projects"
132+ :description="projectsError"
133+ :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: refreshProjects }]"
134+ />
135+
136+ <div
137+ v-else-if="loaded && !projects.length"
138+ class="flex flex-col items-center gap-3 rounded-xl border border-dashed border-default py-14 text-center"
139+ >
140+ <UIcon name="i-lucide-folder-plus" class="size-10 text-dimmed" />
141+ <h3 class="font-semibold">Add your first project</h3>
142+ <p class="max-w-md text-sm text-muted">
143+ Pick a folder to see which skills, rules, MCP servers and instructions Claude Code and Codex use there, then manage them from your catalog.
144+ </p>
145+ <UButton icon="i-lucide-folder-plus" label="Add project" @click="addOpen = true" />
146+ </div>
147+ </div>
148+ </template>
149+ </UDashboardPanel>
150+</template>
added packages/ui/app/pages/sync.vue +20 -0
new file mode 100644
@@ -0,0 +1,20 @@
1+<script setup lang="ts">
2+// PLACEHOLDER (WP3 owns this page). Until the real Sync page exists, the old git dialog is the way to manage the
3+// catalog repository; it is opened from here so the header pill always leads somewhere useful.
4+const git = useCatalogGit()
5+const { git: status } = useCatalog()
6+
7+onMounted(() => { git.open.value = true })
8+</script>
9+
10+<template>
11+ <SkPage>
12+ <SkPageHeader title="Sync" :subtitle="status?.remote ? `Catalog repository · ${status.remote}${status.branch ? ' · ' + status.branch : ''}` : 'Catalog repository'" />
13+ <SkPanel padded class="flex flex-col items-start gap-3">
14+ <SkEmpty text="Not designed yet. The catalog git dialog is still the way to commit, pull and push." class="self-stretch" />
15+ <div class="flex w-full justify-center">
16+ <SkButton variant="primary" label="Open git dialog" @click="git.open.value = true" />
17+ </div>
18+ </SkPanel>
19+ </SkPage>
20+</template>
new file mode 100644
@@ -0,0 +1,20 @@
1+<script setup lang="ts">
2+// PLACEHOLDER (WP3 owns this page). Until the real Sync page exists, the old git dialog is the way to manage the
3+// catalog repository; it is opened from here so the header pill always leads somewhere useful.
4+const git = useCatalogGit()
5+const { git: status } = useCatalog()
6+
7+onMounted(() => { git.open.value = true })
8+</script>
9+
10+<template>
11+ <SkPage>
12+ <SkPageHeader title="Sync" :subtitle="status?.remote ? `Catalog repository · ${status.remote}${status.branch ? ' · ' + status.branch : ''}` : 'Catalog repository'" />
13+ <SkPanel padded class="flex flex-col items-start gap-3">
14+ <SkEmpty text="Not designed yet. The catalog git dialog is still the way to commit, pull and push." class="self-stretch" />
15+ <div class="flex w-full justify-center">
16+ <SkButton variant="primary" label="Open git dialog" @click="git.open.value = true" />
17+ </div>
18+ </SkPanel>
19+ </SkPage>
20+</template>
added packages/ui/app/utils/diff.ts +167 -0
new file mode 100644
@@ -0,0 +1,167 @@
1+/**
2+ * Small line diff (LCS based), no dependency. Used by SkDiff and by the drift resolution UI.
3+ *
4+ * const lines = diffLines(catalogText, projectText)
5+ * diffStats(lines) // { added: 3, removed: 1 }
6+ * toSideBySide(lines) // rows with a left (old) and right (new) cell
7+ * collapseContext(lines, 3) // unified view with "N unchanged lines" separators
8+ */
9+
10+export type DiffOp = 'eq' | 'add' | 'del'
11+
12+export interface DiffLine {
13+ op: DiffOp
14+ text: string
15+ /** 1-based line number in the old text (absent for added lines). */
16+ oldNo?: number
17+ /** 1-based line number in the new text (absent for deleted lines). */
18+ newNo?: number
19+}
20+
21+export interface DiffStats { added: number, removed: number }
22+
23+/** Above this many cells the quadratic LCS table is skipped and the changed middle is shown as one replace block. */
24+const MAX_LCS_CELLS = 6_000_000
25+
26+/** Splits text into lines, tolerating CRLF; a trailing newline does not create an empty last line. */
27+export function splitLines(text: string): string[] {
28+ if (text === '') return []
29+ const lines = text.replace(/\r\n?/g, '\n').split('\n')
30+ if (lines[lines.length - 1] === '') lines.pop()
31+ return lines
32+}
33+
34+/** Line diff between `a` (old) and `b` (new). Common prefix and suffix are trimmed before the LCS. */
35+export function diffLines(a: string, b: string): DiffLine[] {
36+ const A = splitLines(a)
37+ const B = splitLines(b)
38+
39+ let start = 0
40+ while (start < A.length && start < B.length && A[start] === B[start]) start++
41+ let endA = A.length
42+ let endB = B.length
43+ while (endA > start && endB > start && A[endA - 1] === B[endB - 1]) { endA--; endB-- }
44+
45+ const out: DiffLine[] = []
46+ for (let i = 0; i < start; i++) out.push({ op: 'eq', text: A[i]!, oldNo: i + 1, newNo: i + 1 })
47+
48+ const midA = A.slice(start, endA)
49+ const midB = B.slice(start, endB)
50+ for (const m of lcsDiff(midA, midB)) {
51+ out.push({
52+ op: m.op,
53+ text: m.text,
54+ ...(m.op !== 'add' ? { oldNo: start + m.i + 1 } : {}),
55+ ...(m.op !== 'del' ? { newNo: start + m.j + 1 } : {})
56+ })
57+ }
58+
59+ for (let k = 0; k < A.length - endA; k++) {
60+ out.push({ op: 'eq', text: A[endA + k]!, oldNo: endA + k + 1, newNo: endB + k + 1 })
61+ }
62+ return out
63+}
64+
65+interface Match { op: DiffOp, text: string, i: number, j: number }
66+
67+/** Classic LCS table on the (already trimmed) middle. `i`/`j` are 0-based indexes in the middle slices. */
68+function lcsDiff(A: string[], B: string[]): Match[] {
69+ const n = A.length
70+ const m = B.length
71+ const res: Match[] = []
72+ if (n === 0 || m === 0 || (n + 1) * (m + 1) > MAX_LCS_CELLS) {
73+ // Pure insertion / deletion, or too big for the table: show deletions then additions.
74+ for (let i = 0; i < n; i++) res.push({ op: 'del', text: A[i]!, i, j: 0 })
75+ for (let j = 0; j < m; j++) res.push({ op: 'add', text: B[j]!, i: 0, j })
76+ return res
77+ }
78+
79+ const w = m + 1
80+ // lcs[i][j] = length of the LCS of A[i..] and B[j..]
81+ const lcs = new Uint32Array((n + 1) * w)
82+ for (let i = n - 1; i >= 0; i--) {
83+ for (let j = m - 1; j >= 0; j--) {
84+ lcs[i * w + j] = A[i] === B[j]
85+ ? lcs[(i + 1) * w + j + 1]! + 1
86+ : Math.max(lcs[(i + 1) * w + j]!, lcs[i * w + j + 1]!)
87+ }
88+ }
89+
90+ let i = 0
91+ let j = 0
92+ while (i < n && j < m) {
93+ if (A[i] === B[j]) { res.push({ op: 'eq', text: A[i]!, i, j }); i++; j++ }
94+ // Prefer deletions first so a replaced line reads "- old" then "+ new".
95+ else if (lcs[(i + 1) * w + j]! >= lcs[i * w + j + 1]!) { res.push({ op: 'del', text: A[i]!, i, j }); i++ }
96+ else { res.push({ op: 'add', text: B[j]!, i, j }); j++ }
97+ }
98+ while (i < n) { res.push({ op: 'del', text: A[i]!, i, j }); i++ }
99+ while (j < m) { res.push({ op: 'add', text: B[j]!, i, j }); j++ }
100+ return res
101+}
102+
103+export function diffStats(lines: DiffLine[]): DiffStats {
104+ let added = 0
105+ let removed = 0
106+ for (const l of lines) {
107+ if (l.op === 'add') added++
108+ else if (l.op === 'del') removed++
109+ }
110+ return { added, removed }
111+}
112+
113+/** "3 lines added, 1 removed" / "identical". */
114+export function describeDiff(stats: DiffStats): string {
115+ const { added, removed } = stats
116+ if (!added && !removed) return 'identical'
117+ const parts: string[] = []
118+ if (added) parts.push(`${added} ${added === 1 ? 'line' : 'lines'} added`)
119+ if (removed) parts.push(`${removed} removed`)
120+ return parts.join(', ')
121+}
122+
123+export interface SideRow {
124+ left?: DiffLine
125+ right?: DiffLine
126+}
127+
128+/** Pairs lines for a side-by-side view: runs of deletions are aligned with the following run of additions. */
129+export function toSideBySide(lines: DiffLine[]): SideRow[] {
130+ const rows: SideRow[] = []
131+ let i = 0
132+ while (i < lines.length) {
133+ const l = lines[i]!
134+ if (l.op === 'eq') { rows.push({ left: l, right: l }); i++; continue }
135+ const dels: DiffLine[] = []
136+ const adds: DiffLine[] = []
137+ while (i < lines.length && lines[i]!.op === 'del') dels.push(lines[i++]!)
138+ while (i < lines.length && lines[i]!.op === 'add') adds.push(lines[i++]!)
139+ for (let k = 0; k < Math.max(dels.length, adds.length); k++) rows.push({ left: dels[k], right: adds[k] })
140+ }
141+ return rows
142+}
143+
144+export type DiffChunk =
145+ | { kind: 'lines', lines: DiffLine[] }
146+ | { kind: 'skip', count: number }
147+
148+/** Keeps `context` unchanged lines around each change and folds the rest into `skip` chunks. */
149+export function collapseContext(lines: DiffLine[], context = 3): DiffChunk[] {
150+ const keep = new Array<boolean>(lines.length).fill(false)
151+ lines.forEach((l, idx) => {
152+ if (l.op === 'eq') return
153+ for (let k = Math.max(0, idx - context); k <= Math.min(lines.length - 1, idx + context); k++) keep[k] = true
154+ })
155+ if (!lines.some(l => l.op !== 'eq')) return lines.length ? [{ kind: 'skip', count: lines.length }] : []
156+
157+ const chunks: DiffChunk[] = []
158+ let idx = 0
159+ while (idx < lines.length) {
160+ const start = idx
161+ const kept = keep[idx]!
162+ while (idx < lines.length && keep[idx] === kept) idx++
163+ if (kept) chunks.push({ kind: 'lines', lines: lines.slice(start, idx) })
164+ else chunks.push({ kind: 'skip', count: idx - start })
165+ }
166+ return chunks
167+}
new file mode 100644
@@ -0,0 +1,167 @@
1+/**
2+ * Small line diff (LCS based), no dependency. Used by SkDiff and by the drift resolution UI.
3+ *
4+ * const lines = diffLines(catalogText, projectText)
5+ * diffStats(lines) // { added: 3, removed: 1 }
6+ * toSideBySide(lines) // rows with a left (old) and right (new) cell
7+ * collapseContext(lines, 3) // unified view with "N unchanged lines" separators
8+ */
9+
10+export type DiffOp = 'eq' | 'add' | 'del'
11+
12+export interface DiffLine {
13+ op: DiffOp
14+ text: string
15+ /** 1-based line number in the old text (absent for added lines). */
16+ oldNo?: number
17+ /** 1-based line number in the new text (absent for deleted lines). */
18+ newNo?: number
19+}
20+
21+export interface DiffStats { added: number, removed: number }
22+
23+/** Above this many cells the quadratic LCS table is skipped and the changed middle is shown as one replace block. */
24+const MAX_LCS_CELLS = 6_000_000
25+
26+/** Splits text into lines, tolerating CRLF; a trailing newline does not create an empty last line. */
27+export function splitLines(text: string): string[] {
28+ if (text === '') return []
29+ const lines = text.replace(/\r\n?/g, '\n').split('\n')
30+ if (lines[lines.length - 1] === '') lines.pop()
31+ return lines
32+}
33+
34+/** Line diff between `a` (old) and `b` (new). Common prefix and suffix are trimmed before the LCS. */
35+export function diffLines(a: string, b: string): DiffLine[] {
36+ const A = splitLines(a)
37+ const B = splitLines(b)
38+
39+ let start = 0
40+ while (start < A.length && start < B.length && A[start] === B[start]) start++
41+ let endA = A.length
42+ let endB = B.length
43+ while (endA > start && endB > start && A[endA - 1] === B[endB - 1]) { endA--; endB-- }
44+
45+ const out: DiffLine[] = []
46+ for (let i = 0; i < start; i++) out.push({ op: 'eq', text: A[i]!, oldNo: i + 1, newNo: i + 1 })
47+
48+ const midA = A.slice(start, endA)
49+ const midB = B.slice(start, endB)
50+ for (const m of lcsDiff(midA, midB)) {
51+ out.push({
52+ op: m.op,
53+ text: m.text,
54+ ...(m.op !== 'add' ? { oldNo: start + m.i + 1 } : {}),
55+ ...(m.op !== 'del' ? { newNo: start + m.j + 1 } : {})
56+ })
57+ }
58+
59+ for (let k = 0; k < A.length - endA; k++) {
60+ out.push({ op: 'eq', text: A[endA + k]!, oldNo: endA + k + 1, newNo: endB + k + 1 })
61+ }
62+ return out
63+}
64+
65+interface Match { op: DiffOp, text: string, i: number, j: number }
66+
67+/** Classic LCS table on the (already trimmed) middle. `i`/`j` are 0-based indexes in the middle slices. */
68+function lcsDiff(A: string[], B: string[]): Match[] {
69+ const n = A.length
70+ const m = B.length
71+ const res: Match[] = []
72+ if (n === 0 || m === 0 || (n + 1) * (m + 1) > MAX_LCS_CELLS) {
73+ // Pure insertion / deletion, or too big for the table: show deletions then additions.
74+ for (let i = 0; i < n; i++) res.push({ op: 'del', text: A[i]!, i, j: 0 })
75+ for (let j = 0; j < m; j++) res.push({ op: 'add', text: B[j]!, i: 0, j })
76+ return res
77+ }
78+
79+ const w = m + 1
80+ // lcs[i][j] = length of the LCS of A[i..] and B[j..]
81+ const lcs = new Uint32Array((n + 1) * w)
82+ for (let i = n - 1; i >= 0; i--) {
83+ for (let j = m - 1; j >= 0; j--) {
84+ lcs[i * w + j] = A[i] === B[j]
85+ ? lcs[(i + 1) * w + j + 1]! + 1
86+ : Math.max(lcs[(i + 1) * w + j]!, lcs[i * w + j + 1]!)
87+ }
88+ }
89+
90+ let i = 0
91+ let j = 0
92+ while (i < n && j < m) {
93+ if (A[i] === B[j]) { res.push({ op: 'eq', text: A[i]!, i, j }); i++; j++ }
94+ // Prefer deletions first so a replaced line reads "- old" then "+ new".
95+ else if (lcs[(i + 1) * w + j]! >= lcs[i * w + j + 1]!) { res.push({ op: 'del', text: A[i]!, i, j }); i++ }
96+ else { res.push({ op: 'add', text: B[j]!, i, j }); j++ }
97+ }
98+ while (i < n) { res.push({ op: 'del', text: A[i]!, i, j }); i++ }
99+ while (j < m) { res.push({ op: 'add', text: B[j]!, i, j }); j++ }
100+ return res
101+}
102+
103+export function diffStats(lines: DiffLine[]): DiffStats {
104+ let added = 0
105+ let removed = 0
106+ for (const l of lines) {
107+ if (l.op === 'add') added++
108+ else if (l.op === 'del') removed++
109+ }
110+ return { added, removed }
111+}
112+
113+/** "3 lines added, 1 removed" / "identical". */
114+export function describeDiff(stats: DiffStats): string {
115+ const { added, removed } = stats
116+ if (!added && !removed) return 'identical'
117+ const parts: string[] = []
118+ if (added) parts.push(`${added} ${added === 1 ? 'line' : 'lines'} added`)
119+ if (removed) parts.push(`${removed} removed`)
120+ return parts.join(', ')
121+}
122+
123+export interface SideRow {
124+ left?: DiffLine
125+ right?: DiffLine
126+}
127+
128+/** Pairs lines for a side-by-side view: runs of deletions are aligned with the following run of additions. */
129+export function toSideBySide(lines: DiffLine[]): SideRow[] {
130+ const rows: SideRow[] = []
131+ let i = 0
132+ while (i < lines.length) {
133+ const l = lines[i]!
134+ if (l.op === 'eq') { rows.push({ left: l, right: l }); i++; continue }
135+ const dels: DiffLine[] = []
136+ const adds: DiffLine[] = []
137+ while (i < lines.length && lines[i]!.op === 'del') dels.push(lines[i++]!)
138+ while (i < lines.length && lines[i]!.op === 'add') adds.push(lines[i++]!)
139+ for (let k = 0; k < Math.max(dels.length, adds.length); k++) rows.push({ left: dels[k], right: adds[k] })
140+ }
141+ return rows
142+}
143+
144+export type DiffChunk =
145+ | { kind: 'lines', lines: DiffLine[] }
146+ | { kind: 'skip', count: number }
147+
148+/** Keeps `context` unchanged lines around each change and folds the rest into `skip` chunks. */
149+export function collapseContext(lines: DiffLine[], context = 3): DiffChunk[] {
150+ const keep = new Array<boolean>(lines.length).fill(false)
151+ lines.forEach((l, idx) => {
152+ if (l.op === 'eq') return
153+ for (let k = Math.max(0, idx - context); k <= Math.min(lines.length - 1, idx + context); k++) keep[k] = true
154+ })
155+ if (!lines.some(l => l.op !== 'eq')) return lines.length ? [{ kind: 'skip', count: lines.length }] : []
156+
157+ const chunks: DiffChunk[] = []
158+ let idx = 0
159+ while (idx < lines.length) {
160+ const start = idx
161+ const kept = keep[idx]!
162+ while (idx < lines.length && keep[idx] === kept) idx++
163+ if (kept) chunks.push({ kind: 'lines', lines: lines.slice(start, idx) })
164+ else chunks.push({ kind: 'skip', count: idx - start })
165+ }
166+ return chunks
167+}
added packages/ui/app/utils/status.ts +142 -0
new file mode 100644
@@ -0,0 +1,142 @@
1+import type { ConflictKind, GitStatus, ItemStatus, ItemType, PlanActionKind } from '@skilly/core'
2+
3+/**
4+ * Status vocabulary shared by every screen (design/BRIEF.md 1.2 "Status colour semantics" and 3, 4.1, 4.2).
5+ * Screens never pick colours themselves: they ask for a tone and let SkStatusDot / SkStatus paint it.
6+ */
7+
8+/** ok = green, info = blue, accent = clay (drift, conflict, error), faint = grey (local only, inactive). */
9+export type Tone = 'ok' | 'info' | 'accent' | 'faint'
10+
11+/** Tailwind classes per tone (full literals so Tailwind can see them). */
12+export const TONE_BG: Record<Tone, string> = {
13+ ok: 'bg-sk-ok',
14+ info: 'bg-sk-info',
15+ accent: 'bg-sk-accent',
16+ faint: 'bg-sk-faint'
17+}
18+export const TONE_TEXT: Record<Tone, string> = {
19+ ok: 'text-sk-ok',
20+ info: 'text-sk-info',
21+ accent: 'text-sk-accent-text',
22+ faint: 'text-sk-faint'
23+}
24+
25+export interface StatusMeta {
26+ tone: Tone
27+ label: string
28+ /** One sentence for tooltips. */
29+ hint: string
30+}
31+
32+/** ItemStatus.state -> mockup status (brief section 3, "Status per item"). */
33+export const ITEM_STATE_META: Record<ItemStatus['state'], StatusMeta> = {
34+ 'in-sync': { tone: 'ok', label: 'Up to date', hint: 'Identical to the catalog version.' },
35+ 'catalog-updated': { tone: 'info', label: 'Update available', hint: 'The catalog has a newer version.' },
36+ 'modified': { tone: 'accent', label: 'Drift', hint: 'Edited in this scope since the last sync.' },
37+ 'differs-from-catalog': { tone: 'accent', label: 'Differs from catalog', hint: 'Same name as a catalog item, different content, not managed by skilly.' },
38+ 'not-in-catalog': { tone: 'faint', label: 'Local only', hint: 'Local item, absent from the catalog.' }
39+}
40+
41+/** Action the status cell offers inline next to the label ("Update", "Resolve", "Import"). */
42+export const ITEM_STATE_ACTION: Partial<Record<ItemStatus['state'], string>> = {
43+ 'catalog-updated': 'Update',
44+ 'modified': 'Resolve',
45+ 'differs-from-catalog': 'Resolve',
46+ 'not-in-catalog': 'Import'
47+}
48+
49+/** Short mono form for per-agent mini statuses ("claude ● Up to date"). */
50+export const ITEM_STATE_SHORT: Record<ItemStatus['state'], string> = {
51+ 'in-sync': 'Up to date',
52+ 'catalog-updated': 'Update',
53+ 'modified': 'Drift',
54+ 'differs-from-catalog': 'Differs',
55+ 'not-in-catalog': 'Local'
56+}
57+
58+export function itemStateMeta(state: ItemStatus['state']): StatusMeta {
59+ return ITEM_STATE_META[state]
60+}
61+
62+/** Plan action kind (review & apply, brief 4.1) -> status. `catalogUpdated` refines `update`. */
63+export function planActionMeta(kind: PlanActionKind, opts: { catalogUpdated?: boolean } = {}): StatusMeta {
64+ switch (kind) {
65+ case 'add': return { tone: 'info', label: 'Add', hint: 'Will be written.' }
66+ case 'update': return opts.catalogUpdated
67+ ? { tone: 'info', label: 'Update', hint: 'The catalog has a newer version.' }
68+ : { tone: 'info', label: 'Update', hint: 'Will be rewritten.' }
69+ case 'remove': return { tone: 'accent', label: 'Remove', hint: 'Will be deleted.' }
70+ case 'conflict': return { tone: 'accent', label: 'Conflict', hint: 'Needs a decision before it can be applied.' }
71+ case 'unchanged': return { tone: 'ok', label: 'Up to date', hint: 'Nothing to do.' }
72+ }
73+}
74+
75+/** Headline per conflict kind (brief 4.2). */
76+export const CONFLICT_HEADLINE: Record<ConflictKind, string> = {
77+ 'modified-locally': 'Edited in this project since the last sync',
78+ 'exists-unmanaged': 'A file already exists here and is not managed by skilly',
79+ 'missing-in-catalog': 'No longer in the catalog'
80+}
81+
82+/** Group order and labels of the review drawer (mono caps). */
83+export const PLAN_GROUPS: { kind: PlanActionKind, label: string }[] = [
84+ { kind: 'conflict', label: 'Conflicts' },
85+ { kind: 'add', label: 'Add' },
86+ { kind: 'update', label: 'Update' },
87+ { kind: 'remove', label: 'Remove' }
88+]
89+
90+/** Type tag text: mono, singular ("Subagent" rather than "Agent", brief 4.12). */
91+export const TYPE_TAG: Record<ItemType, string> = {
92+ skill: 'Skill',
93+ rule: 'Rule',
94+ mcp: 'MCP',
95+ hook: 'Hook',
96+ agent: 'Subagent',
97+ instructions: 'Instructions'
98+}
99+
100+/** Plural headings ("Subagents", "MCP servers"). */
101+export const TYPE_PLURAL: Record<ItemType, string> = {
102+ skill: 'Skills',
103+ rule: 'Rules',
104+ mcp: 'MCP servers',
105+ hook: 'Hooks',
106+ agent: 'Subagents',
107+ instructions: 'Instructions'
108+}
109+
110+/* ----------------------------------------------------------------- catalog sync pill (header) */
111+
112+export interface GitPillState {
113+ tone: Tone
114+ label: string
115+ /** Tooltip. */
116+ title: string
117+ /** What a click does: run pull & push, or open the /sync page. */
118+ click: 'sync' | 'page'
119+}
120+
121+/** State of the header pill from the catalog git status (brief 3.1 "Sync"). */
122+export function gitPillState(git: GitStatus | null, opts: { catalogExists: boolean, busy: boolean }): GitPillState {
123+ if (opts.busy) return { tone: 'info', label: 'Syncing…', title: 'Pulling and pushing the catalog', click: 'page' }
124+ if (!opts.catalogExists) return { tone: 'faint', label: 'No catalog', title: 'The catalog has not been created yet', click: 'page' }
125+ if (!git) return { tone: 'faint', label: 'Catalog', title: 'Loading the catalog status', click: 'page' }
126+ if (!git.isRepo) return { tone: 'faint', label: 'Not a git repo', title: 'The catalog folder is not a git repository', click: 'page' }
127+ if (git.conflicts.length) {
128+ const n = git.conflicts.length
129+ return { tone: 'accent', label: `${n} conflict${n > 1 ? 's' : ''}`, title: 'Resolve the conflicts in the Sync page', click: 'page' }
130+ }
131+ if (!git.remote) {
132+ return git.dirty.length
133+ ? { tone: 'faint', label: `${git.dirty.length} uncommitted`, title: 'No remote configured: set one in the Sync page', click: 'page' }
134+ : { tone: 'faint', label: 'No remote', title: 'Set a remote in the Sync page to share the catalog', click: 'page' }
135+ }
136+ const parts: string[] = []
137+ if (git.ahead) parts.push(`${git.ahead} to push`)
138+ if (git.behind) parts.push(`${git.behind} to pull`)
139+ if (parts.length) return { tone: 'info', label: parts.join(' · '), title: 'Pull and push the catalog', click: 'sync' }
140+ if (git.dirty.length) return { tone: 'faint', label: `${git.dirty.length} uncommitted`, title: 'Commit, pull and push the catalog', click: 'sync' }
141+ return { tone: 'ok', label: 'Catalog synced', title: `${git.branch ?? 'catalog'} · up to date with the remote`, click: 'sync' }
142+}
new file mode 100644
@@ -0,0 +1,142 @@
1+import type { ConflictKind, GitStatus, ItemStatus, ItemType, PlanActionKind } from '@skilly/core'
2+
3+/**
4+ * Status vocabulary shared by every screen (design/BRIEF.md 1.2 "Status colour semantics" and 3, 4.1, 4.2).
5+ * Screens never pick colours themselves: they ask for a tone and let SkStatusDot / SkStatus paint it.
6+ */
7+
8+/** ok = green, info = blue, accent = clay (drift, conflict, error), faint = grey (local only, inactive). */
9+export type Tone = 'ok' | 'info' | 'accent' | 'faint'
10+
11+/** Tailwind classes per tone (full literals so Tailwind can see them). */
12+export const TONE_BG: Record<Tone, string> = {
13+ ok: 'bg-sk-ok',
14+ info: 'bg-sk-info',
15+ accent: 'bg-sk-accent',
16+ faint: 'bg-sk-faint'
17+}
18+export const TONE_TEXT: Record<Tone, string> = {
19+ ok: 'text-sk-ok',
20+ info: 'text-sk-info',
21+ accent: 'text-sk-accent-text',
22+ faint: 'text-sk-faint'
23+}
24+
25+export interface StatusMeta {
26+ tone: Tone
27+ label: string
28+ /** One sentence for tooltips. */
29+ hint: string
30+}
31+
32+/** ItemStatus.state -> mockup status (brief section 3, "Status per item"). */
33+export const ITEM_STATE_META: Record<ItemStatus['state'], StatusMeta> = {
34+ 'in-sync': { tone: 'ok', label: 'Up to date', hint: 'Identical to the catalog version.' },
35+ 'catalog-updated': { tone: 'info', label: 'Update available', hint: 'The catalog has a newer version.' },
36+ 'modified': { tone: 'accent', label: 'Drift', hint: 'Edited in this scope since the last sync.' },
37+ 'differs-from-catalog': { tone: 'accent', label: 'Differs from catalog', hint: 'Same name as a catalog item, different content, not managed by skilly.' },
38+ 'not-in-catalog': { tone: 'faint', label: 'Local only', hint: 'Local item, absent from the catalog.' }
39+}
40+
41+/** Action the status cell offers inline next to the label ("Update", "Resolve", "Import"). */
42+export const ITEM_STATE_ACTION: Partial<Record<ItemStatus['state'], string>> = {
43+ 'catalog-updated': 'Update',
44+ 'modified': 'Resolve',
45+ 'differs-from-catalog': 'Resolve',
46+ 'not-in-catalog': 'Import'
47+}
48+
49+/** Short mono form for per-agent mini statuses ("claude ● Up to date"). */
50+export const ITEM_STATE_SHORT: Record<ItemStatus['state'], string> = {
51+ 'in-sync': 'Up to date',
52+ 'catalog-updated': 'Update',
53+ 'modified': 'Drift',
54+ 'differs-from-catalog': 'Differs',
55+ 'not-in-catalog': 'Local'
56+}
57+
58+export function itemStateMeta(state: ItemStatus['state']): StatusMeta {
59+ return ITEM_STATE_META[state]
60+}
61+
62+/** Plan action kind (review & apply, brief 4.1) -> status. `catalogUpdated` refines `update`. */
63+export function planActionMeta(kind: PlanActionKind, opts: { catalogUpdated?: boolean } = {}): StatusMeta {
64+ switch (kind) {
65+ case 'add': return { tone: 'info', label: 'Add', hint: 'Will be written.' }
66+ case 'update': return opts.catalogUpdated
67+ ? { tone: 'info', label: 'Update', hint: 'The catalog has a newer version.' }
68+ : { tone: 'info', label: 'Update', hint: 'Will be rewritten.' }
69+ case 'remove': return { tone: 'accent', label: 'Remove', hint: 'Will be deleted.' }
70+ case 'conflict': return { tone: 'accent', label: 'Conflict', hint: 'Needs a decision before it can be applied.' }
71+ case 'unchanged': return { tone: 'ok', label: 'Up to date', hint: 'Nothing to do.' }
72+ }
73+}
74+
75+/** Headline per conflict kind (brief 4.2). */
76+export const CONFLICT_HEADLINE: Record<ConflictKind, string> = {
77+ 'modified-locally': 'Edited in this project since the last sync',
78+ 'exists-unmanaged': 'A file already exists here and is not managed by skilly',
79+ 'missing-in-catalog': 'No longer in the catalog'
80+}
81+
82+/** Group order and labels of the review drawer (mono caps). */
83+export const PLAN_GROUPS: { kind: PlanActionKind, label: string }[] = [
84+ { kind: 'conflict', label: 'Conflicts' },
85+ { kind: 'add', label: 'Add' },
86+ { kind: 'update', label: 'Update' },
87+ { kind: 'remove', label: 'Remove' }
88+]
89+
90+/** Type tag text: mono, singular ("Subagent" rather than "Agent", brief 4.12). */
91+export const TYPE_TAG: Record<ItemType, string> = {
92+ skill: 'Skill',
93+ rule: 'Rule',
94+ mcp: 'MCP',
95+ hook: 'Hook',
96+ agent: 'Subagent',
97+ instructions: 'Instructions'
98+}
99+
100+/** Plural headings ("Subagents", "MCP servers"). */
101+export const TYPE_PLURAL: Record<ItemType, string> = {
102+ skill: 'Skills',
103+ rule: 'Rules',
104+ mcp: 'MCP servers',
105+ hook: 'Hooks',
106+ agent: 'Subagents',
107+ instructions: 'Instructions'
108+}
109+
110+/* ----------------------------------------------------------------- catalog sync pill (header) */
111+
112+export interface GitPillState {
113+ tone: Tone
114+ label: string
115+ /** Tooltip. */
116+ title: string
117+ /** What a click does: run pull & push, or open the /sync page. */
118+ click: 'sync' | 'page'
119+}
120+
121+/** State of the header pill from the catalog git status (brief 3.1 "Sync"). */
122+export function gitPillState(git: GitStatus | null, opts: { catalogExists: boolean, busy: boolean }): GitPillState {
123+ if (opts.busy) return { tone: 'info', label: 'Syncing…', title: 'Pulling and pushing the catalog', click: 'page' }
124+ if (!opts.catalogExists) return { tone: 'faint', label: 'No catalog', title: 'The catalog has not been created yet', click: 'page' }
125+ if (!git) return { tone: 'faint', label: 'Catalog', title: 'Loading the catalog status', click: 'page' }
126+ if (!git.isRepo) return { tone: 'faint', label: 'Not a git repo', title: 'The catalog folder is not a git repository', click: 'page' }
127+ if (git.conflicts.length) {
128+ const n = git.conflicts.length
129+ return { tone: 'accent', label: `${n} conflict${n > 1 ? 's' : ''}`, title: 'Resolve the conflicts in the Sync page', click: 'page' }
130+ }
131+ if (!git.remote) {
132+ return git.dirty.length
133+ ? { tone: 'faint', label: `${git.dirty.length} uncommitted`, title: 'No remote configured: set one in the Sync page', click: 'page' }
134+ : { tone: 'faint', label: 'No remote', title: 'Set a remote in the Sync page to share the catalog', click: 'page' }
135+ }
136+ const parts: string[] = []
137+ if (git.ahead) parts.push(`${git.ahead} to push`)
138+ if (git.behind) parts.push(`${git.behind} to pull`)
139+ if (parts.length) return { tone: 'info', label: parts.join(' · '), title: 'Pull and push the catalog', click: 'sync' }
140+ if (git.dirty.length) return { tone: 'faint', label: `${git.dirty.length} uncommitted`, title: 'Commit, pull and push the catalog', click: 'sync' }
141+ return { tone: 'ok', label: 'Catalog synced', title: `${git.branch ?? 'catalog'} · up to date with the remote`, click: 'sync' }
142+}
added packages/ui/app/utils/tiles.ts +16 -0
new file mode 100644
@@ -0,0 +1,16 @@
1+/** Fixed decorative gradients (identical in light and dark), see design/BRIEF.md 1.2. */
2+export type TileVariant = 'hero' | 'blue' | 'green' | 'clay' | 'plum'
3+
4+export const TILE_GRADIENTS: Record<TileVariant, string> = {
5+ // The hard stop at 55/56% draws a diagonal "horizon".
6+ hero: 'linear-gradient(160deg,#f3d2b6 0%,#efe4cf 55%,#e98a63 56%,#de6f45 100%)',
7+ blue: 'linear-gradient(120deg,#3b5a8c,#9fb6d8)',
8+ green: 'linear-gradient(120deg,#2f4a37,#a9c3a0)',
9+ clay: 'linear-gradient(120deg,#e08a5f,#f3d6b8)',
10+ plum: 'linear-gradient(120deg,#6a4a7c,#d2b6d8)'
11+}
12+
13+/** Suggestion tiles rotate through these in order. */
14+export const SUGGESTION_VARIANTS: TileVariant[] = ['blue', 'green', 'clay', 'plum']
15+
16+export const suggestionVariant = (index: number): TileVariant => SUGGESTION_VARIANTS[index % SUGGESTION_VARIANTS.length]!
new file mode 100644
@@ -0,0 +1,16 @@
1+/** Fixed decorative gradients (identical in light and dark), see design/BRIEF.md 1.2. */
2+export type TileVariant = 'hero' | 'blue' | 'green' | 'clay' | 'plum'
3+
4+export const TILE_GRADIENTS: Record<TileVariant, string> = {
5+ // The hard stop at 55/56% draws a diagonal "horizon".
6+ hero: 'linear-gradient(160deg,#f3d2b6 0%,#efe4cf 55%,#e98a63 56%,#de6f45 100%)',
7+ blue: 'linear-gradient(120deg,#3b5a8c,#9fb6d8)',
8+ green: 'linear-gradient(120deg,#2f4a37,#a9c3a0)',
9+ clay: 'linear-gradient(120deg,#e08a5f,#f3d6b8)',
10+ plum: 'linear-gradient(120deg,#6a4a7c,#d2b6d8)'
11+}
12+
13+/** Suggestion tiles rotate through these in order. */
14+export const SUGGESTION_VARIANTS: TileVariant[] = ['blue', 'green', 'clay', 'plum']
15+
16+export const suggestionVariant = (index: number): TileVariant => SUGGESTION_VARIANTS[index % SUGGESTION_VARIANTS.length]!
modified packages/ui/nuxt.config.ts +1 -1
@@ -7,7 +7,7 @@ export default defineNuxtConfig({
77 app: {
88 head: {
99 title: 'skilly',
10- link: [{ rel: 'icon', href: 'data:,' }]
10+ link: [{ rel: 'icon', type: 'image/png', href: '/skilly-icon.png' }]
1111 }
1212 },
1313 icon: {
@@ -7,7 +7,7 @@ export default defineNuxtConfig({
7 app: {7 app: {
8 head: {8 head: {
9 title: 'skilly',9 title: 'skilly',
10- link: [{ rel: 'icon', href: 'data:,' }]10+ link: [{ rel: 'icon', type: 'image/png', href: '/skilly-icon.png' }]
11 }11 }
12 },12 },
13 icon: {13 icon: {
modified packages/ui/package.json +2 -0
@@ -9,6 +9,8 @@
99 "postinstall": "nuxt prepare || true"
1010 },
1111 "dependencies": {
12+ "@fontsource/ibm-plex-mono": "^5.3.0",
13+ "@fontsource/instrument-sans": "^5.3.0",
1214 "@iconify-json/lucide": "^1.2.0",
1315 "@nuxt/ui": "^4.12.0",
1416 "@skilly/core": "workspace:*",
@@ -9,6 +9,8 @@
9 "postinstall": "nuxt prepare || true"9 "postinstall": "nuxt prepare || true"
10 },10 },
11 "dependencies": {11 "dependencies": {
12+ "@fontsource/ibm-plex-mono": "^5.3.0",
13+ "@fontsource/instrument-sans": "^5.3.0",
12 "@iconify-json/lucide": "^1.2.0",14 "@iconify-json/lucide": "^1.2.0",
13 "@nuxt/ui": "^4.12.0",15 "@nuxt/ui": "^4.12.0",
14 "@skilly/core": "workspace:*",16 "@skilly/core": "workspace:*",
added packages/ui/public/skilly-icon.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/ui/public/skilly-icon.png differ
new file mode 100644
Binary files /dev/null and b/packages/ui/public/skilly-icon.png differBinary files /dev/null and b/packages/ui/public/skilly-icon.png differ
added packages/ui/test/diff.test.ts +91 -0
new file mode 100644
@@ -0,0 +1,91 @@
1+import { describe, expect, it } from 'vitest'
2+import { collapseContext, describeDiff, diffLines, diffStats, splitLines, toSideBySide } from '../app/utils/diff'
3+
4+const apply = (lines: ReturnType<typeof diffLines>, side: 'old' | 'new') =>
5+ lines.filter(l => l.op === 'eq' || l.op === (side === 'old' ? 'del' : 'add')).map(l => l.text)
6+
7+describe('splitLines', () => {
8+ it('handles empty text, CRLF and a trailing newline', () => {
9+ expect(splitLines('')).toEqual([])
10+ expect(splitLines('a\r\nb\n')).toEqual(['a', 'b'])
11+ expect(splitLines('a\n\nb')).toEqual(['a', '', 'b'])
12+ })
13+})
14+
15+describe('diffLines', () => {
16+ it('reports identical texts as all-equal', () => {
17+ const d = diffLines('a\nb\nc', 'a\nb\nc')
18+ expect(d.every(l => l.op === 'eq')).toBe(true)
19+ expect(describeDiff(diffStats(d))).toBe('identical')
20+ })
21+
22+ it('detects additions and deletions with line numbers', () => {
23+ const d = diffLines('a\nb\nc\nd', 'a\nc\nd\ne')
24+ expect(diffStats(d)).toEqual({ added: 1, removed: 1 })
25+ expect(d.find(l => l.op === 'del')).toMatchObject({ text: 'b', oldNo: 2 })
26+ expect(d.find(l => l.op === 'add')).toMatchObject({ text: 'e', newNo: 4 })
27+ expect(d.find(l => l.text === 'c')).toMatchObject({ op: 'eq', oldNo: 3, newNo: 2 })
28+ })
29+
30+ it('rebuilds both sides from the diff (round trip)', () => {
31+ const a = 'one\ntwo\nthree\nfour\nfive\nsix'
32+ const b = 'one\n2\nthree\nfive\nsix\nseven\neight'
33+ const d = diffLines(a, b)
34+ expect(apply(d, 'old').join('\n')).toBe(a)
35+ expect(apply(d, 'new').join('\n')).toBe(b)
36+ })
37+
38+ it('handles empty sides', () => {
39+ expect(diffStats(diffLines('', 'x\ny'))).toEqual({ added: 2, removed: 0 })
40+ expect(diffStats(diffLines('x\ny', ''))).toEqual({ added: 0, removed: 2 })
41+ expect(diffLines('', '')).toEqual([])
42+ })
43+
44+ it('puts a deletion before the matching addition on a replaced line', () => {
45+ const d = diffLines('a\nold\nz', 'a\nnew\nz')
46+ expect(d.map(l => l.op)).toEqual(['eq', 'del', 'add', 'eq'])
47+ })
48+
49+ it('describes the change in words', () => {
50+ expect(describeDiff({ added: 3, removed: 1 })).toBe('3 lines added, 1 removed')
51+ expect(describeDiff({ added: 1, removed: 0 })).toBe('1 line added')
52+ })
53+
54+ it('stays usable on large inputs (falls back to replace)', () => {
55+ const a = Array.from({ length: 3000 }, (_, i) => `a${i}`).join('\n')
56+ const b = Array.from({ length: 3000 }, (_, i) => `b${i}`).join('\n')
57+ const d = diffLines(a, b)
58+ expect(diffStats(d)).toEqual({ added: 3000, removed: 3000 })
59+ })
60+})
61+
62+describe('toSideBySide', () => {
63+ it('aligns replaced lines on the same row', () => {
64+ const rows = toSideBySide(diffLines('a\nold\nz', 'a\nnew\nz'))
65+ expect(rows).toHaveLength(3)
66+ expect(rows[1]!.left?.text).toBe('old')
67+ expect(rows[1]!.right?.text).toBe('new')
68+ })
69+
70+ it('leaves the other side empty for pure additions', () => {
71+ const rows = toSideBySide(diffLines('a', 'a\nb\nc'))
72+ expect(rows.slice(1).every(r => r.left === undefined && r.right !== undefined)).toBe(true)
73+ })
74+})
75+
76+describe('collapseContext', () => {
77+ it('folds long unchanged runs and keeps context around changes', () => {
78+ const base = Array.from({ length: 20 }, (_, i) => `l${i}`)
79+ const changed = [...base]
80+ changed[10] = 'CHANGED'
81+ const chunks = collapseContext(diffLines(base.join('\n'), changed.join('\n')), 2)
82+ expect(chunks.map(c => c.kind)).toEqual(['skip', 'lines', 'skip'])
83+ expect(chunks[0]).toEqual({ kind: 'skip', count: 8 })
84+ const mid = chunks[1] as { kind: 'lines', lines: unknown[] }
85+ expect(mid.lines).toHaveLength(2 + 2 + 1 + 1)
86+ })
87+
88+ it('returns a single skip chunk for identical text', () => {
89+ expect(collapseContext(diffLines('a\nb', 'a\nb'))).toEqual([{ kind: 'skip', count: 2 }])
90+ })
91+})
new file mode 100644
@@ -0,0 +1,91 @@
1+import { describe, expect, it } from 'vitest'
2+import { collapseContext, describeDiff, diffLines, diffStats, splitLines, toSideBySide } from '../app/utils/diff'
3+
4+const apply = (lines: ReturnType<typeof diffLines>, side: 'old' | 'new') =>
5+ lines.filter(l => l.op === 'eq' || l.op === (side === 'old' ? 'del' : 'add')).map(l => l.text)
6+
7+describe('splitLines', () => {
8+ it('handles empty text, CRLF and a trailing newline', () => {
9+ expect(splitLines('')).toEqual([])
10+ expect(splitLines('a\r\nb\n')).toEqual(['a', 'b'])
11+ expect(splitLines('a\n\nb')).toEqual(['a', '', 'b'])
12+ })
13+})
14+
15+describe('diffLines', () => {
16+ it('reports identical texts as all-equal', () => {
17+ const d = diffLines('a\nb\nc', 'a\nb\nc')
18+ expect(d.every(l => l.op === 'eq')).toBe(true)
19+ expect(describeDiff(diffStats(d))).toBe('identical')
20+ })
21+
22+ it('detects additions and deletions with line numbers', () => {
23+ const d = diffLines('a\nb\nc\nd', 'a\nc\nd\ne')
24+ expect(diffStats(d)).toEqual({ added: 1, removed: 1 })
25+ expect(d.find(l => l.op === 'del')).toMatchObject({ text: 'b', oldNo: 2 })
26+ expect(d.find(l => l.op === 'add')).toMatchObject({ text: 'e', newNo: 4 })
27+ expect(d.find(l => l.text === 'c')).toMatchObject({ op: 'eq', oldNo: 3, newNo: 2 })
28+ })
29+
30+ it('rebuilds both sides from the diff (round trip)', () => {
31+ const a = 'one\ntwo\nthree\nfour\nfive\nsix'
32+ const b = 'one\n2\nthree\nfive\nsix\nseven\neight'
33+ const d = diffLines(a, b)
34+ expect(apply(d, 'old').join('\n')).toBe(a)
35+ expect(apply(d, 'new').join('\n')).toBe(b)
36+ })
37+
38+ it('handles empty sides', () => {
39+ expect(diffStats(diffLines('', 'x\ny'))).toEqual({ added: 2, removed: 0 })
40+ expect(diffStats(diffLines('x\ny', ''))).toEqual({ added: 0, removed: 2 })
41+ expect(diffLines('', '')).toEqual([])
42+ })
43+
44+ it('puts a deletion before the matching addition on a replaced line', () => {
45+ const d = diffLines('a\nold\nz', 'a\nnew\nz')
46+ expect(d.map(l => l.op)).toEqual(['eq', 'del', 'add', 'eq'])
47+ })
48+
49+ it('describes the change in words', () => {
50+ expect(describeDiff({ added: 3, removed: 1 })).toBe('3 lines added, 1 removed')
51+ expect(describeDiff({ added: 1, removed: 0 })).toBe('1 line added')
52+ })
53+
54+ it('stays usable on large inputs (falls back to replace)', () => {
55+ const a = Array.from({ length: 3000 }, (_, i) => `a${i}`).join('\n')
56+ const b = Array.from({ length: 3000 }, (_, i) => `b${i}`).join('\n')
57+ const d = diffLines(a, b)
58+ expect(diffStats(d)).toEqual({ added: 3000, removed: 3000 })
59+ })
60+})
61+
62+describe('toSideBySide', () => {
63+ it('aligns replaced lines on the same row', () => {
64+ const rows = toSideBySide(diffLines('a\nold\nz', 'a\nnew\nz'))
65+ expect(rows).toHaveLength(3)
66+ expect(rows[1]!.left?.text).toBe('old')
67+ expect(rows[1]!.right?.text).toBe('new')
68+ })
69+
70+ it('leaves the other side empty for pure additions', () => {
71+ const rows = toSideBySide(diffLines('a', 'a\nb\nc'))
72+ expect(rows.slice(1).every(r => r.left === undefined && r.right !== undefined)).toBe(true)
73+ })
74+})
75+
76+describe('collapseContext', () => {
77+ it('folds long unchanged runs and keeps context around changes', () => {
78+ const base = Array.from({ length: 20 }, (_, i) => `l${i}`)
79+ const changed = [...base]
80+ changed[10] = 'CHANGED'
81+ const chunks = collapseContext(diffLines(base.join('\n'), changed.join('\n')), 2)
82+ expect(chunks.map(c => c.kind)).toEqual(['skip', 'lines', 'skip'])
83+ expect(chunks[0]).toEqual({ kind: 'skip', count: 8 })
84+ const mid = chunks[1] as { kind: 'lines', lines: unknown[] }
85+ expect(mid.lines).toHaveLength(2 + 2 + 1 + 1)
86+ })
87+
88+ it('returns a single skip chunk for identical text', () => {
89+ expect(collapseContext(diffLines('a\nb', 'a\nb'))).toEqual([{ kind: 'skip', count: 2 }])
90+ })
91+})
modified pnpm-lock.yaml +16 -0
@@ -56,6 +56,12 @@ importers:
5656
5757 packages/ui:
5858 dependencies:
59+ '@fontsource/ibm-plex-mono':
60+ specifier: ^5.3.0
61+ version: 5.3.0
62+ '@fontsource/instrument-sans':
63+ specifier: ^5.3.0
64+ version: 5.3.0
5965 '@iconify-json/lucide':
6066 specifier: ^1.2.0
6167 version: 1.2.147
@@ -586,6 +592,12 @@ packages:
586592 peerDependencies:
587593 vue: '>=3.3.0'
588594
595+ '@fontsource/ibm-plex-mono@5.3.0':
596+ resolution: {integrity: sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==}
597+
598+ '@fontsource/instrument-sans@5.3.0':
599+ resolution: {integrity: sha512-QwXc4hb/px3XvSPS2CPAOgey8nyrQFxgxrmXQQ+pN+P51hKdAcchxpg8rSbjANFfPu6VKXhDqkVDucXMZ9CM5g==}
600+
589601 '@hono/node-server@2.1.5':
590602 resolution: {integrity: sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==}
591603 engines: {node: '>=20'}
@@ -4649,6 +4661,10 @@ snapshots:
46494661 '@floating-ui/utils': 0.2.12
46504662 vue: 3.5.44(typescript@5.9.3)
46514663
4664+ '@fontsource/ibm-plex-mono@5.3.0': {}
4665+
4666+ '@fontsource/instrument-sans@5.3.0': {}
4667+
46524668 '@hono/node-server@2.1.5(hono@4.13.13)':
46534669 dependencies:
46544670 hono: 4.13.13
@@ -56,6 +56,12 @@ importers:
56 56
57 packages/ui:57 packages/ui:
58 dependencies:58 dependencies:
59+ '@fontsource/ibm-plex-mono':
60+ specifier: ^5.3.0
61+ version: 5.3.0
62+ '@fontsource/instrument-sans':
63+ specifier: ^5.3.0
64+ version: 5.3.0
59 '@iconify-json/lucide':65 '@iconify-json/lucide':
60 specifier: ^1.2.066 specifier: ^1.2.0
61 version: 1.2.14767 version: 1.2.147
@@ -586,6 +592,12 @@ packages:
586 peerDependencies:592 peerDependencies:
587 vue: '>=3.3.0'593 vue: '>=3.3.0'
588 594
595+ '@fontsource/ibm-plex-mono@5.3.0':
596+ resolution: {integrity: sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==}
597+
598+ '@fontsource/instrument-sans@5.3.0':
599+ resolution: {integrity: sha512-QwXc4hb/px3XvSPS2CPAOgey8nyrQFxgxrmXQQ+pN+P51hKdAcchxpg8rSbjANFfPu6VKXhDqkVDucXMZ9CM5g==}
600+
589 '@hono/node-server@2.1.5':601 '@hono/node-server@2.1.5':
590 resolution: {integrity: sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==}602 resolution: {integrity: sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==}
591 engines: {node: '>=20'}603 engines: {node: '>=20'}
@@ -4649,6 +4661,10 @@ snapshots:
4649 '@floating-ui/utils': 0.2.124661 '@floating-ui/utils': 0.2.12
4650 vue: 3.5.44(typescript@5.9.3)4662 vue: 3.5.44(typescript@5.9.3)
4651 4663
4664+ '@fontsource/ibm-plex-mono@5.3.0': {}
4665+
4666+ '@fontsource/instrument-sans@5.3.0': {}
4667+
4652 '@hono/node-server@2.1.5(hono@4.13.13)':4668 '@hono/node-server@2.1.5(hono@4.13.13)':
4653 dependencies:4669 dependencies:
4654 hono: 4.13.134670 hono: 4.13.13