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>
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 | + | |
| 1 | 6 | export default defineAppConfig({ |
| 2 | 7 | 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 | + } | |
| 5 | 161 | } |
| 6 | 162 | }) |
| @@ -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 @@ | ||
| 1 | 1 | <template> |
| 2 | - <UApp> | |
| 2 | + <UApp :toaster="{ position: 'bottom-center', duration: 2600 }"> | |
| 3 | 3 | <NuxtLayout> |
| 4 | 4 | <NuxtPage /> |
| 5 | 5 | </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 @@ | ||
| 1 | 1 | @import "tailwindcss"; |
| 2 | 2 | @import "@nuxt/ui"; |
| 3 | 3 | |
| 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; } | |
| 7 | 218 | .md-body h3, .md-body h4 { font-size: 0.95rem; font-weight: 600; margin: 0.75rem 0 0.4rem; } |
| 8 | 219 | .md-body p { margin: 0.5rem 0; } |
| 9 | 220 | .md-body ul { list-style: disc; padding-left: 1.25rem; margin: 0.5rem 0; } |
| 10 | 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; } | |
| 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; } | |
| 14 | 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 | 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; } | |
| 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 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | 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 | 5 | withDefaults(defineProps<{ agent: AgentId, compact?: boolean }>(), { compact: false }) |
| 5 | 6 | </script> |
| 6 | 7 | |
| 7 | 8 | <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" /> | |
| 16 | 10 | </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 | - <UBadge | 9 | + <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() { | ||
| 82 | 82 | toast.add({ title: isNew.value ? 'Preset created' : 'Preset saved', description: form.name, icon: 'i-lucide-check', color: 'success' }) |
| 83 | 83 | await catalog.refresh() |
| 84 | 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 | 86 | } catch (e) { |
| 87 | 87 | saveError.value = (e as Error).message |
| 88 | 88 | } finally { |
| @@ -105,7 +105,7 @@ async function doDelete(force = false) { | ||
| 105 | 105 | toast.add({ title: 'Preset deleted', description: props.name, icon: 'i-lucide-check', color: 'success' }) |
| 106 | 106 | delOpen.value = false |
| 107 | 107 | await catalog.refresh() |
| 108 | - await navigateTo('/presets') | |
| 108 | + await navigateTo('/catalog/presets') | |
| 109 | 109 | } catch (e) { |
| 110 | 110 | const err = e as { status?: number, message: string } |
| 111 | 111 | if (err.status === 409) delConflict.value = err.message |
| @@ -125,7 +125,7 @@ function askDelete() { | ||
| 125 | 125 | <template #header> |
| 126 | 126 | <UDashboardNavbar icon="i-lucide-layers"> |
| 127 | 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 | 129 | <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" /> |
| 130 | 130 | <span class="truncate">{{ isNew ? 'New' : name }}</span> |
| 131 | 131 | </template> |
| @@ -139,7 +139,7 @@ function askDelete() { | ||
| 139 | 139 | |
| 140 | 140 | <template #body> |
| 141 | 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 | 144 | <div v-else class="grid items-start gap-6 xl:grid-cols-2"> |
| 145 | 145 | <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).message | 87 | 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 = false | 106 | 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.message | 111 | 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 | ||
| 62 | 62 | </UTooltip> |
| 63 | 63 | <span v-if="!presets.length && !unknown.length" class="text-xs text-muted"> |
| 64 | 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 | 66 | </span> |
| 67 | 67 | </div> |
| 68 | 68 | </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 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | 2 | import type { ItemStatus } from '@skilly/core' |
| 3 | 3 | |
| 4 | +// Legacy wrapper kept for existing screens; new screens use SkStatus directly. | |
| 4 | 5 | defineProps<{ status?: ItemStatus }>() |
| 5 | 6 | </script> |
| 6 | 7 | |
| 7 | 8 | <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> | |
| 15 | 12 | </div> |
| 16 | - <span v-else class="text-xs text-dimmed">-</span> | |
| 13 | + <span v-else class="text-xs text-sk-faint">-</span> | |
| 17 | 14 | </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 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 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 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | 2 | const { refresh } = useProjects() |
| 3 | 3 | const { refresh: refreshCatalog } = useCatalog() |
| 4 | -const addOpen = useState('skilly-add-open', () => false) | |
| 4 | +const addOpen = useAddProjectModal() | |
| 5 | 5 | |
| 6 | 6 | onMounted(() => { |
| 7 | 7 | refresh() |
| @@ -10,31 +10,21 @@ onMounted(() => { | ||
| 10 | 10 | </script> |
| 11 | 11 | |
| 12 | 12 | <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> | |
| 36 | 24 | |
| 37 | 25 | <AddProjectModal v-model:open="addOpen" /> |
| 26 | + <!-- WP3: remove together with CatalogGitDialog once /sync replaces it. --> | |
| 38 | 27 | <CatalogGitDialog /> |
| 39 | - </UDashboardGroup> | |
| 28 | + <AppSwitcher /> | |
| 29 | + </div> | |
| 40 | 30 | </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 | ||
| 17 | 17 | <template #header> |
| 18 | 18 | <UDashboardNavbar title="Presets" icon="i-lucide-layers"> |
| 19 | 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 | 21 | </template> |
| 22 | 22 | </UDashboardNavbar> |
| 23 | 23 | </template> |
| @@ -32,7 +32,7 @@ const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text | ||
| 32 | 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 | 33 | <UIcon name="i-lucide-layers" class="size-8 text-dimmed" /> |
| 34 | 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 | 36 | </div> |
| 37 | 37 | <div v-else class="overflow-x-auto rounded-xl border border-default"> |
| 38 | 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 | 45 | </tr> |
| 46 | 46 | </thead> |
| 47 | 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 | 49 | <td class="px-3 py-2 align-middle"> |
| 50 | 50 | <span class="block truncate font-medium text-highlighted">{{ p.name }}</span> |
| 51 | 51 | <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 @@ | ||
| 1 | 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)) | |
| 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' }) | |
| 52 | 5 | </script> |
| 53 | 6 | |
| 54 | 7 | <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 /> | |
| 150 | 9 | </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({ | ||
| 7 | 7 | app: { |
| 8 | 8 | head: { |
| 9 | 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 | 13 | 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 @@ | ||
| 9 | 9 | "postinstall": "nuxt prepare || true" |
| 10 | 10 | }, |
| 11 | 11 | "dependencies": { |
| 12 | + "@fontsource/ibm-plex-mono": "^5.3.0", | |
| 13 | + "@fontsource/instrument-sans": "^5.3.0", | |
| 12 | 14 | "@iconify-json/lucide": "^1.2.0", |
| 13 | 15 | "@nuxt/ui": "^4.12.0", |
| 14 | 16 | "@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 differ | Binary 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: | ||
| 56 | 56 | |
| 57 | 57 | packages/ui: |
| 58 | 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 | 65 | '@iconify-json/lucide': |
| 60 | 66 | specifier: ^1.2.0 |
| 61 | 67 | version: 1.2.147 |
| @@ -586,6 +592,12 @@ packages: | ||
| 586 | 592 | peerDependencies: |
| 587 | 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 | 601 | '@hono/node-server@2.1.5': |
| 590 | 602 | resolution: {integrity: sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==} |
| 591 | 603 | engines: {node: '>=20'} |
| @@ -4649,6 +4661,10 @@ snapshots: | ||
| 4649 | 4661 | '@floating-ui/utils': 0.2.12 |
| 4650 | 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 | 4668 | '@hono/node-server@2.1.5(hono@4.13.13)': |
| 4653 | 4669 | dependencies: |
| 4654 | 4670 | 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.0 | 66 | specifier: ^1.2.0 |
| 61 | version: 1.2.147 | 67 | 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.12 | 4661 | '@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.13 | 4670 | hono: 4.13.13 |