feat: добавить skill для SLM Design
All checks were successful
CI/CD Pipeline / build (push) Successful in 43s
CI/CD Pipeline / docker (push) Successful in 1m18s
CI/CD Pipeline / deploy (push) Successful in 6s

- добавлена сборка self-contained skill для Claude Code и opencode

- добавлен install-ready архив skill в public/slm-design/skill

- обновлена карточка SLM Design с меню действий открыть/скачать

- добавлен static fallback главной страницы из общего конфига

- подключены Mantine Menu и Phosphor Icons для действий карточки
This commit is contained in:
2026-05-22 23:23:14 +03:00
parent bdb99ade62
commit 9a962f37b5
13 changed files with 1186 additions and 164 deletions

View File

@@ -359,6 +359,99 @@
gap: 8px;
}
.docMenu {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: space-between;
width: 100%;
pointer-events: auto;
}
.docMenuButton {
--button-bg: var(--page-bg);
--button-bd: 1px solid var(--border-soft);
--button-color: var(--text-primary);
--button-hover: var(--page-bg);
--button-hover-color: var(--doc-accent);
--button-padding-x: 10px;
min-height: 33px;
height: 33px;
padding: 6px 10px;
border: 1px solid var(--border-soft) !important;
border-radius: 8px;
color: var(--text-primary) !important;
background: var(--page-bg) !important;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
font-weight: 500;
line-height: normal;
transition: border-color 150ms ease, color 150ms ease;
}
.docMenuButton:hover {
border-color: var(--doc-accent) !important;
color: var(--doc-accent) !important;
background: var(--page-bg) !important;
}
.docMenuItem {
text-decoration: none;
}
.docMenuFallback {
display: grid;
gap: 14px;
width: 100%;
pointer-events: auto;
}
.docMenuFallbackSection,
.docMenuFallbackGroup {
display: grid;
gap: 8px;
}
.docMenuFallbackSectionTitle,
.docMenuFallbackTitle {
color: var(--doc-accent);
font-size: 11px;
font-weight: 760;
letter-spacing: 0.08em;
line-height: 1;
text-transform: uppercase;
}
.docMenuFallbackTitle {
color: var(--text-muted);
font-size: 10px;
}
.docMenuFallbackLinks {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.docMenuFallbackLink {
padding: 6px 10px;
border: 1px solid var(--border-soft);
border-radius: 8px;
color: var(--text-primary);
background: var(--page-bg);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
font-weight: 500;
text-decoration: none;
transition: border-color 150ms ease, color 150ms ease;
}
.docMenuFallbackLink:hover {
border-color: var(--doc-accent);
color: var(--doc-accent);
}
.docLink {
padding: 6px 10px;
border: 1px solid var(--border-soft);
@@ -477,4 +570,10 @@
.docLink {
flex: 1;
}
.docMenu,
.docMenu .mantine-Menu-root,
.docMenu .mantine-Button-root {
width: 100%;
}
}

View File

@@ -1,6 +1,12 @@
import { useEffect, useLayoutEffect, useState } from 'react'
import { Fragment, useEffect, useLayoutEffect, useState, type ReactNode } from 'react'
import { Button, createTheme, MantineProvider, Menu } from '@mantine/core'
import { ArrowSquareOutIcon } from '@phosphor-icons/react/ArrowSquareOut'
import { DownloadSimpleIcon } from '@phosphor-icons/react/DownloadSimple'
import { EyeIcon } from '@phosphor-icons/react/Eye'
import { FileTextIcon } from '@phosphor-icons/react/FileText'
import { FileZipIcon } from '@phosphor-icons/react/FileZip'
import { docs } from './config/docs.config'
import { docs, type DocAction, type DocActionCollection, type DocActionGroup, type DocActionGroups } from './config/docs.config'
import './App.css'
type ThemeMode = 'auto' | 'dark' | 'light'
@@ -11,6 +17,10 @@ const THEME_STORAGE_KEY = 'vitepress-theme-appearance'
const LEGACY_THEME_STORAGE_KEY = 'all-docs-theme'
const repositoryUrl = 'https://gromlab.ru/gromov/docs'
const authorUrl = 'https://gromlab.ru/gromov'
const mantineTheme = createTheme({
fontFamily: 'var(--sans)',
primaryColor: 'indigo',
})
const themeOptions: ReadonlyArray<{
value: Exclude<ThemeMode, 'auto'>
@@ -149,8 +159,11 @@ function useTheme() {
return { theme, resolvedTheme, setTheme }
}
function ThemeToggle() {
const { theme, resolvedTheme, setTheme } = useTheme()
function ThemeToggle({ theme, resolvedTheme, setTheme }: {
theme: ThemeMode
resolvedTheme: ResolvedTheme
setTheme: (theme: ThemeMode) => void
}) {
const toggleTheme = (value: Exclude<ThemeMode, 'auto'>) => {
setTheme(theme === value ? 'auto' : value)
}
@@ -183,6 +196,157 @@ function GithubIcon() {
)
}
function getActionIcon(type: 'download' | 'open'): ReactNode {
if (type === 'download') return <FileZipIcon size={16} />
return <FileTextIcon size={16} />
}
function isActionGroup(action: DocAction | DocActionGroup): action is DocActionGroup {
return 'actions' in action
}
function splitActionCollection(collection: DocActionCollection | undefined) {
if (!collection?.length) return { actions: [], groups: [] }
if (isActionGroup(collection[0])) {
const groups = collection as DocActionGroup[]
return {
actions: [],
groups: groups.filter((group) => group.actions.length > 0),
}
}
return {
actions: collection as DocAction[],
groups: [],
}
}
function DocActionsMenuButton({ actions = [], groups = [], label, type }: {
actions?: DocAction[]
groups?: DocActionGroup[]
label: string
type: 'download' | 'open'
}) {
const [opened, setOpened] = useState(false)
const buttonIcon = type === 'download' ? <DownloadSimpleIcon size={16} /> : <EyeIcon size={16} />
const renderAction = (action: DocAction, keyPrefix: string) => (
<Menu.Item
component="a"
href={action.href}
download={type === 'download' ? '' : undefined}
target={type === 'open' ? '_blank' : undefined}
rel={type === 'open' ? 'noopener noreferrer' : undefined}
leftSection={getActionIcon(type)}
rightSection={type === 'open' && action.href.endsWith('.md') ? <ArrowSquareOutIcon size={16} /> : undefined}
key={`${keyPrefix}-${action.href}`}
>
{action.label}
</Menu.Item>
)
return (
<Menu
opened={opened}
onChange={setOpened}
position="bottom-start"
shadow="md"
width={260}
withinPortal
classNames={{ item: 'docMenuItem' }}
>
<Menu.Target>
<Button className={`docMenuButton docMenuButton-${type}`} leftSection={buttonIcon} size="xs" variant="outline">
{label}
</Button>
</Menu.Target>
<Menu.Dropdown>
{groups.length > 0
? groups.map((group, groupIndex) => (
<Fragment key={group.title}>
{groupIndex > 0 && <Menu.Divider />}
<Menu.Label>{group.title}</Menu.Label>
{group.actions.map((action) => renderAction(action, group.title))}
</Fragment>
))
: actions.map((action) => renderAction(action, label))}
</Menu.Dropdown>
</Menu>
)
}
function DocActionsFallback({ groups }: { groups: DocActionGroups }) {
const open = splitActionCollection(groups.open)
const download = splitActionCollection(groups.download)
const renderActions = (actions: DocAction[], type: 'download' | 'open') => actions.map((action) => (
<a
className="docMenuFallbackLink"
href={action.href}
target={type === 'open' ? '_blank' : undefined}
rel={type === 'open' ? 'noopener noreferrer' : undefined}
download={type === 'download' ? '' : undefined}
key={`${type}-${action.href}`}
>
{action.label}
</a>
))
const renderGroups = (groups: DocActionGroup[], type: 'download' | 'open') => groups.map((group) => (
<div className="docMenuFallbackGroup" key={`${type}-${group.title}`}>
<div className="docMenuFallbackTitle">{group.title}</div>
<div className="docMenuFallbackLinks">{renderActions(group.actions, type)}</div>
</div>
))
return (
<div className="docMenuFallback" aria-label="Действия">
{(open.groups.length > 0 || open.actions.length > 0) && (
<div className="docMenuFallbackSection">
<div className="docMenuFallbackSectionTitle">Открыть</div>
{open.groups.length > 0
? renderGroups(open.groups, 'open')
: <div className="docMenuFallbackLinks">{renderActions(open.actions, 'open')}</div>}
</div>
)}
{(download.groups.length > 0 || download.actions.length > 0) && (
<div className="docMenuFallbackSection">
<div className="docMenuFallbackSectionTitle">Скачать</div>
{download.groups.length > 0
? renderGroups(download.groups, 'download')
: <div className="docMenuFallbackLinks">{renderActions(download.actions, 'download')}</div>}
</div>
)}
</div>
)
}
function DocActionsMenu({ groups }: { groups: DocActionGroups }) {
const [isHydrated, setIsHydrated] = useState(false)
const open = splitActionCollection(groups.open)
const download = splitActionCollection(groups.download)
useEffect(() => {
const timeoutId = window.setTimeout(() => setIsHydrated(true), 0)
return () => window.clearTimeout(timeoutId)
}, [])
if (!isHydrated) return <DocActionsFallback groups={groups} />
return (
<div className="docMenu">
{(open.groups.length > 0 || open.actions.length > 0) && (
<DocActionsMenuButton actions={open.actions} groups={open.groups} label="Открыть" type="open" />
)}
{(download.groups.length > 0 || download.actions.length > 0) && (
<DocActionsMenuButton actions={download.actions} groups={download.groups} label="Скачать" type="download" />
)}
</div>
)
}
function DocIcon({ mark }: { mark: string }) {
if (mark === 'SLM') {
return (
@@ -237,86 +401,93 @@ function DocIcon({ mark }: { mark: string }) {
}
function App() {
const theme = useTheme()
return (
<main className="page">
<section className="hero" aria-labelledby="page-title">
<h1 className="title" id="page-title">Документация</h1>
<p className="lead">
Единое пространство для идей, черновиков и первых версий документаций,
которые ещё формируются и постепенно становятся самостоятельными материалами.
</p>
<div className="controls">
<a className="repoLink" href={repositoryUrl} target="_blank" rel="noopener noreferrer">
<GithubIcon />
<span>Репозиторий</span>
</a>
<ThemeToggle />
</div>
</section>
<MantineProvider theme={mantineTheme} forceColorScheme={theme.resolvedTheme}>
<main className="page">
<section className="hero" aria-labelledby="page-title">
<h1 className="title" id="page-title">Документация</h1>
<p className="lead">
Единое пространство для идей, черновиков и первых версий документаций,
которые ещё формируются и постепенно становятся самостоятельными материалами.
</p>
<div className="controls">
<a className="repoLink" href={repositoryUrl} target="_blank" rel="noopener noreferrer">
<GithubIcon />
<span>Репозиторий</span>
</a>
<ThemeToggle {...theme} />
</div>
</section>
<section className="docsPanel" aria-label="Быстрые переходы">
<div className="docsPanelHeader">
<span>Документация</span>
<span>{docs.length} направления</span>
</div>
<section className="docsPanel" aria-label="Быстрые переходы">
<div className="docsPanelHeader">
<span>Документация</span>
<span>{docs.length} направления</span>
</div>
{docs.map((doc) => {
const isAvailable = Boolean(doc.href)
{docs.map((doc) => {
const isAvailable = Boolean(doc.href)
const hasActionGroups = Boolean(doc.actionGroups?.open?.length || doc.actionGroups?.download?.length)
return (
<article
className="docItem"
data-accent={doc.accent}
data-state={isAvailable ? 'available' : 'planned'}
key={doc.href ?? doc.title}
>
{isAvailable && (
<a className="docCardLink" href={doc.href} aria-label={`Открыть ${doc.title}`} />
)}
return (
<article
className="docItem"
data-accent={doc.accent}
data-state={isAvailable ? 'available' : 'planned'}
key={doc.title}
>
{isAvailable && (
<a className="docCardLink" href={doc.href} aria-label={`Открыть ${doc.title}`} />
)}
<div className="docMain">
<span className="docMark" aria-hidden="true">
<DocIcon mark={doc.mark} />
</span>
<div>
<div className="docMeta">
{doc.label}
</div>
<h2>{doc.title}</h2>
<p>{doc.description}</p>
</div>
</div>
<div className="docActions">
{isAvailable ? (
<a className="docStatus docStatusLink" href={doc.href}>
Открыть -&gt;
</a>
) : (
<span className="docStatus" aria-disabled="true">
{doc.status}
<div className="docMain">
<span className="docMark" aria-hidden="true">
<DocIcon mark={doc.mark} />
</span>
)}
{doc.links.length > 0 && (
<div className="docLinks" aria-label="LLM-артефакты">
{doc.links.map((link) => (
<a className="docLink" href={link.href} key={link.href}>
{link.label}
</a>
))}
<div>
<div className="docMeta">
{doc.label}
</div>
<h2>{doc.title}</h2>
<p>{doc.description}</p>
</div>
)}
</div>
</article>
)
})}
</section>
</div>
<footer className="footer">
Автор документации: <a href={authorUrl}>Сергей Громов</a>
</footer>
</main>
<div className="docActions">
{hasActionGroups ? (
<DocActionsMenu groups={doc.actionGroups ?? {}} />
) : isAvailable ? (
<a className="docStatus docStatusLink" href={doc.href}>
Открыть -&gt;
</a>
) : (
<span className="docStatus" aria-disabled="true">
{doc.status}
</span>
)}
{doc.links.length > 0 && (
<div className="docLinks" aria-label="LLM-артефакты">
{doc.links.map((link) => (
<a className="docLink" href={link.href} key={link.href}>
{link.label}
</a>
))}
</div>
)}
</div>
</article>
)
})}
</section>
<footer className="footer">
Автор документации: <a href={authorUrl}>Сергей Громов</a>
</footer>
</main>
</MantineProvider>
)
}

View File

@@ -3,6 +3,23 @@ export type DocLink = {
href: string
}
export type DocAction = {
label: string
href: string
}
export type DocActionGroup = {
title: string
actions: DocAction[]
}
export type DocActionCollection = DocAction[] | DocActionGroup[]
export type DocActionGroups = {
open?: DocActionCollection
download?: DocActionCollection
}
export type DocCard = {
title: string
label: string
@@ -12,6 +29,7 @@ export type DocCard = {
status: string
accent: string
links: DocLink[]
actionGroups?: DocActionGroups
}
export const docs: DocCard[] = [
@@ -23,10 +41,44 @@ export const docs: DocCard[] = [
href: '/slm-design/',
status: 'Доступно',
accent: 'violet',
links: [
{ label: 'llms.txt', href: '/slm-design/llms.txt' },
{ label: 'llms-full.txt', href: '/slm-design/llms-full.txt' },
],
links: [],
actionGroups: {
open: [
{
title: 'Читать',
actions: [
{ label: 'SLM Документация', href: '/slm-design/' },
],
},
{
title: 'Skill для CLI-агентов',
actions: [
{ label: 'slm-design/SKILL.md', href: '/slm-design/skill/.opencode/skills/slm-design/SKILL.md' },
],
},
{
title: 'AI агентам',
actions: [
{ label: 'llms.txt', href: '/slm-design/llms.txt' },
{ label: 'llms-full.txt', href: '/slm-design/llms-full.txt' },
],
},
],
download: [
{
title: 'Документация MD',
actions: [
{ label: 'slm-design.zip', href: '/slm-design/slm-design.zip' },
],
},
{
title: 'Skills (Claude code / OpenCode)',
actions: [
{ label: 'slm-design.skill.zip', href: '/slm-design/skill/slm-design.skill.zip' },
],
},
],
},
},
{
title: 'NextJS Style Guide',

View File

@@ -1,5 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '@mantine/core/styles/baseline.css'
import '@mantine/core/styles/default-css-variables.css'
import '@mantine/core/styles/global.css'
import '@mantine/core/styles/Button.css'
import '@mantine/core/styles/Menu.css'
import '@mantine/core/styles/Popover.css'
import './index.css'
import App from './App.tsx'