Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .agents/RULES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Icon Generation Rules

## Generate Icon Rule:
- size: 48x48px
- path: Clean Code
- color: all type of icons must be currentColor case icons has 2 colors using currentColor for main for white for foreground
- must: background have opacity 0.1 of main color for each icon (rounded)
- category: brands, regular, solid, thin, light, ...

## Must have icon title

### Example
```xml
<svg width="48" height="48" viewBox="-10.2941 -10.2941 44.5884 44.5884" xmlns="http://www.w3.org/2000/svg">
<rect x="-10.2941" y="-10.2941" width="44.5884" height="44.5884" rx="15" fill="currentColor" opacity="0.1"/>
<title>Sample Title</title>
<path fill="currentColor" d="M16.712 17.711H7.288l-1.204 2.916L12 24l5.916-3.373-1.204-2.916ZM14.692 0l7.832 16.855.814-12.856L14.692 0ZM9.308 0 .662 3.999l.814 12.856L9.308 0Zm-.405 13.93h6.198L12 6.396 8.903 13.93Z"/>
</svg>
```
107 changes: 107 additions & 0 deletions .github/scripts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Icons Studio

Local multi-page UI for browsing, visualizing, and editing every SVG icon in this repo.

## Quick start

From the repo root:

```bash
npm --prefix .github/scripts install # first time only
npm --prefix .github/scripts run demo
```

Then open http://localhost:5173/ (redirects to `/browse`). Override the port with `PORT=8080`.

## Pages

Each tab is a real URL — deep-link, bookmark, or share.

### `/browse`

Grid view of every icon. Filter by category, search by name, recolor via the color picker (swaps `currentColor`), and resize with the slider. Click a tile to open the detail dialog with copyable SVG markup and an "Open in Studio" shortcut.

### `/studio` &nbsp;·&nbsp; `/studio?icon=<category>/<name>` for deep links

Vector editor for SVG icons.

- **Left panel** — searchable icon picker. Includes both registry icons and any `custom/*` icons you have created (persisted in `localStorage`).
- **Tools palette** (floating, top-left of canvas) — pick a tool then click+drag on the canvas to create a shape. Tools: Select, Rectangle, Ellipse, Line. After a shape is drawn the tool auto-switches back to Select.
- **Canvas** — the SVG is mounted live. Click any shape to select it. Drag to reposition. Background click (with Select tool) deselects.
- **Right panel — Element Tree** — flat list of every shape.
- **Right panel — Properties** — always shows the SVG root section (`title`, `width`, `height`, `viewBox`). When a shape is selected, sections for `fill`/`stroke`/`opacity`, `transform`, and per-element actions appear.

#### Toolbar

- `+ New Icon` — blank 48&times;48 SVG under a `custom` category, persisted to `localStorage`
- `Undo` / `Redo` — 100-step per-icon history
- `Reset` — restore the original SVG (clears history)
- `Center Selected` — clear the transform on the selected element
- `Zoom` — scale canvas rendering (source `viewBox` unchanged)
- `Copy SVG` / `Download` — export the edited SVG

#### Keyboard shortcuts

| Shortcut | Action |
| --- | --- |
| `V` | Select tool |
| `R` / `O` / `L` | Rectangle / Ellipse / Line tool |
| `Ctrl/Cmd + Z` | Undo |
| `Ctrl + Y` / `Ctrl + Shift + Z` | Redo |
| `Ctrl + C` / `Ctrl + V` | Copy / paste selected shape |
| `Ctrl + D` | Duplicate selected shape (offset by 10px) |
| `Delete` / `Backspace` | Remove selected shape |
| Arrow keys | Nudge selected shape 1px (Shift = 10px) |
| `Ctrl + ]` / `Ctrl + [` | Bring forward / send backward |
| `Ctrl + Shift + ]` / `Ctrl + Shift + [` | Bring to front / send to back |

Registry files are never modified. Use `Copy SVG` or `Download` to persist changes off-server.

### `/graph`

Read-only dashboard of registry-wide metrics:

- Total icons, categories, average shapes/icon, total & average SVG source size
- Icons per category (horizontal bar chart)
- Complexity distribution (bucketed by shape count)
- Size distribution (bucketed by SVG source bytes)
- Top 10 largest icons

## Project layout

```
.github/scripts/
├── demo-server.ts # Static file server (paths + fallback to repo root)
├── demo/
│ ├── browse.html # /browse
│ ├── studio.html # /studio
│ ├── graph.html # /graph
│ └── assets/
│ ├── css/
│ │ └── main.css # Shared styles for all three pages
│ └── js/
│ ├── data.js # Icon loading (index.json → *.json → SVGs)
│ ├── shell.js # Nav active state, stats badge, toast
│ ├── browse.js # /browse logic
│ ├── studio.js # /studio editor (drag, undo/redo, props, new icon)
│ └── graph.js # /graph metrics + charts
├── update-category.ts # Regenerates <category>.json index files
├── package.json # Scripts: demo, update-category, build
└── README.md
```

## How the server routes requests

`demo-server.ts` handles four kinds of requests:

1. `/` — 302 redirect to `/browse`
2. `/browse`, `/studio`, `/graph` — served from `demo/*.html`
3. `/assets/**` — served from `demo/assets/**`
4. Anything else — served from the repo root (so `/index.json`, `/brands.json`, `/brands/react.json` etc. all work)

Path traversal is blocked by requiring the resolved path to stay inside the intended root.

## Notes

- No build step, no runtime dependencies. Everything is vanilla ES modules loaded directly by the browser.
- Custom icons are stored under the `kfe-custom-icons` `localStorage` key. Clear browser storage to reset.
114 changes: 114 additions & 0 deletions .github/scripts/demo-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import http from 'http';
import fs from 'fs';
import path from 'path';

const PORT = Number(process.env.PORT) || 5173;
const REPO_ROOT = path.resolve(__dirname, '../..');
const DEMO_ROOT = path.join(__dirname, 'demo');

const MIME: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
};

// Route → HTML page under demo/
const PAGE_ROUTES: Record<string, string> = {
'/browse': 'browse.html',
'/browse/': 'browse.html',
'/studio': 'studio.html',
'/studio/': 'studio.html',
'/graph': 'graph.html',
'/graph/': 'graph.html',
};

const SLUG = /^[a-z0-9][a-z0-9-]*$/;

const server = http.createServer((req, res) => {
const url = decodeURIComponent((req.url ?? '/').split('?')[0]);

// POST /api/save — writes <repo-root>/<category>/<name>.json
if (req.method === 'POST' && url === '/api/save') {
let body = '';
req.on('data', chunk => (body += chunk));
req.on('end', () => {
try {
const { category, name, content } = JSON.parse(body || '{}');
if (typeof category !== 'string' || typeof name !== 'string' || typeof content !== 'string') {
return send(res, 400, 'application/json', JSON.stringify({ error: 'category, name, and content are required strings' }));
}
if (!SLUG.test(name) || !SLUG.test(category)) {
return send(res, 400, 'application/json', JSON.stringify({ error: 'Invalid slug (a-z, 0-9, hyphens)' }));
}
const dir = path.join(REPO_ROOT, category);
const file = path.join(dir, `${name}.json`);
// Path traversal guard: resolved path must stay inside REPO_ROOT.
if (!file.startsWith(REPO_ROOT + path.sep) && file !== REPO_ROOT) {
return send(res, 403, 'application/json', JSON.stringify({ error: 'Forbidden path' }));
}
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(file, content);
send(res, 200, 'application/json', JSON.stringify({ ok: true, path: `${category}/${name}.json` }));
} catch (err) {
send(res, 500, 'application/json', JSON.stringify({ error: String((err as Error)?.message ?? err) }));
}
});
return;
}

// Root → redirect to /browse
if (url === '/' || url === '') {
res.writeHead(302, { Location: '/browse' });
res.end();
return;
}

// Named routes → HTML pages
if (PAGE_ROUTES[url]) {
servePath(res, path.join(DEMO_ROOT, PAGE_ROUTES[url]));
return;
}

// /assets/* → demo/assets/*
if (url.startsWith('/assets/')) {
const rel = url.slice('/assets/'.length);
const full = path.join(DEMO_ROOT, 'assets', rel);
if (!full.startsWith(path.join(DEMO_ROOT, 'assets'))) return send(res, 403, 'text/plain', 'Forbidden');
servePath(res, full);
return;
}

// Fallback: serve from repo root (index.json, brands.json, brands/*.json, ...)
const safe = path.normalize(url).replace(/^([/\\])+/, '');
const full = path.join(REPO_ROOT, safe);
if (!full.startsWith(REPO_ROOT)) return send(res, 403, 'text/plain', 'Forbidden');
servePath(res, full);
});

function servePath(res: http.ServerResponse, full: string) {
fs.stat(full, (err, stat) => {
if (err || !stat.isFile()) return send(res, 404, 'text/plain', 'Not found');
fs.readFile(full, (err2, data) => {
if (err2) return send(res, 500, 'text/plain', 'Read error');
const mime = MIME[path.extname(full).toLowerCase()] ?? 'application/octet-stream';
send(res, 200, mime, data);
});
});
}

function send(res: http.ServerResponse, status: number, type: string, body: string | Buffer) {
res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' });
res.end(body);
}

server.listen(PORT, () => {
console.log(`KFE icons demo → http://localhost:${PORT}/`);
console.log(` demo root: ${DEMO_ROOT}`);
console.log(` repo root: ${REPO_ROOT}`);
});
82 changes: 82 additions & 0 deletions .github/scripts/demo/assets/js/browse.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { loadAll, recolor } from './data.js';
import { initShell, setStats, showToast } from './shell.js';

initShell('browse');

const state = {
icons: [],
filter: '',
category: 'all',
color: '#7cc4ff',
size: 48,
};

const grid = document.getElementById('grid');
const search = document.getElementById('search');
const catSel = document.getElementById('category');
const colorInput = document.getElementById('color');
const sizeInput = document.getElementById('size');
const sizeLabel = document.getElementById('sizeLabel');
const detail = document.getElementById('detail');
const dTitle = document.getElementById('d-title');
const dPreview = document.getElementById('d-preview');
const dCode = document.getElementById('d-code');
const dCopy = document.getElementById('d-copy');
const dEdit = document.getElementById('d-edit');

function render() {
const q = state.filter.toLowerCase();
const filtered = state.icons.filter(i =>
(state.category === 'all' || i.category === state.category) &&
(!q || i.name.toLowerCase().includes(q))
);
setStats(`${filtered.length} of ${state.icons.length} icons`);
document.documentElement.style.setProperty('--icon-size', state.size + 'px');

if (filtered.length === 0) { grid.innerHTML = '<div class="col-span-full text-center text-on-surface-variant py-8">No matches.</div>'; return; }
const frag = document.createDocumentFragment();
for (const icon of filtered) {
const tile = document.createElement('div');
tile.className = 'tile';
tile.innerHTML = recolor(icon.svg, state.color) + `<div class="name">${icon.name}</div>`;
tile.addEventListener('click', () => openDetail(icon));
frag.appendChild(tile);
}
grid.replaceChildren(frag);
}

function openDetail(icon) {
dTitle.textContent = `${icon.category} / ${icon.name}`;
dPreview.innerHTML = recolor(icon.svg, state.color);
dCode.value = icon.svg;
dCopy.onclick = async () => { await navigator.clipboard.writeText(icon.svg); showToast('Copied'); };
dEdit.onclick = () => {
location.href = `/studio?icon=${encodeURIComponent(icon.category + '/' + icon.name)}`;
};
detail.showModal();
}

search.addEventListener('input', e => { state.filter = e.target.value; render(); });
catSel.addEventListener('change', e => { state.category = e.target.value; render(); });
colorInput.addEventListener('input', e => { state.color = e.target.value; render(); });
sizeInput.addEventListener('input', e => {
state.size = +e.target.value;
sizeLabel.textContent = state.size;
render();
});

(async () => {
try {
const { categories, icons } = await loadAll();
state.icons = icons;
for (const cat of categories) {
const opt = document.createElement('option');
opt.value = cat.name;
opt.textContent = `${cat.name} (${cat.items.length})`;
catSel.appendChild(opt);
}
render();
} catch (err) {
grid.innerHTML = `<div class="col-span-full text-center text-red-400 py-8">Failed to load: ${err.message}</div>`;
}
})();
61 changes: 61 additions & 0 deletions .github/scripts/demo/assets/js/data.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
export const REPO_ROOT = '/';
export const CUSTOM_STORAGE_KEY = 'kfe-custom-icons';

export async function fetchJSON(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`${url} → ${res.status}`);
return res.json();
}

export function loadCustomIcons() {
try {
const raw = localStorage.getItem(CUSTOM_STORAGE_KEY);
if (!raw) return [];
const items = JSON.parse(raw);
return Array.isArray(items) ? items : [];
} catch { return []; }
}

export function saveCustomIcons(items) {
try { localStorage.setItem(CUSTOM_STORAGE_KEY, JSON.stringify(items)); }
catch (err) { console.warn('Could not persist custom icons:', err); }
}

export async function loadAll() {
const index = await fetchJSON(REPO_ROOT + 'index.json');
const categories = await Promise.all(index.map(async (cat) => {
try {
const items = await fetchJSON(REPO_ROOT + cat.target);
return { name: cat.name, items };
} catch { return { name: cat.name, items: [] }; }
}));

const entries = [];
for (const cat of categories) for (const item of cat.items) entries.push({ ...item, category: cat.name });

const icons = (await Promise.all(entries.map(async (e) => {
try {
const data = await fetchJSON(REPO_ROOT + e.target);
const file = Array.isArray(data.files) ? data.files[0] : null;
if (!file?.content) return null;
return { name: e.name, category: e.category, svg: file.content };
} catch { return null; }
}))).filter(Boolean);

const custom = loadCustomIcons();
if (custom.length) {
categories.push({ name: 'custom', items: custom.map(c => ({ name: c.name, type: 'custom' })) });
for (const c of custom) icons.push({ name: c.name, category: 'custom', svg: c.svg });
}

return { categories, icons };
}

export function recolor(svg, color) {
return svg.replace(/currentColor/g, color);
}

export function countShapes(svg) {
const m = svg.match(/<(path|rect|circle|ellipse|polygon|polyline|line)\b/g);
return m ? m.length : 0;
}
Loading
Loading