feat: components

This commit is contained in:
2026-04-30 14:54:15 +07:00
parent 7689adf095
commit 2319f2549b
73 changed files with 718 additions and 0 deletions
@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Component Editor — sonic-diamond</title>
<style>
:root {
--bg: #f5f5f7;
--panel: #ffffff;
--line: #e5e5ea;
--text: #1c1c1e;
--muted: #8e8e93;
--accent: #0a84ff;
--invalid: #fafafa;
}
* { box-sizing: border-box; }
body { margin: 0; font: 13px/1.4 -apple-system, system-ui, sans-serif;
background: var(--bg); color: var(--text); }
header { position: sticky; top: 0; z-index: 10;
background: var(--panel); border-bottom: 1px solid var(--line);
padding: 12px 20px; display: flex; align-items: center; gap: 16px; }
header h1 { margin: 0; font-size: 15px; font-weight: 600; }
header .meta { color: var(--muted); font-size: 12px; }
header button { font: inherit; padding: 6px 14px;
background: var(--accent); color: white;
border: none; border-radius: 6px; cursor: pointer; }
header button:hover { opacity: 0.9; }
main { padding: 20px; }
section { margin-bottom: 32px; }
section > h2 { margin: 0 0 12px; font-size: 13px; font-weight: 600;
color: var(--muted); text-transform: uppercase;
letter-spacing: 0.5px; }
.grid { display: grid; gap: 12px; background: var(--panel);
padding: 16px; border-radius: 10px; border: 1px solid var(--line); }
.cell { display: flex; flex-direction: column; align-items: center;
gap: 6px; padding: 12px; border: 1px solid var(--line);
border-radius: 8px; background: white; min-height: 110px;
justify-content: center; }
.cell.invalid { background: var(--invalid); opacity: 0.4; }
.cell .preview { position: relative; width: 24mm; height: 24mm;
display: flex; align-items: center; justify-content: center; }
/* Stage component absolutely inside preview, centered. The component
uses absolute positioning, so we offset to center it visually. */
.cell .preview sonic-diamond { position: absolute !important;
left: 50% !important; top: 50% !important;
transform: translate(-50%, -50%) !important; }
.cell .label { font-size: 10px; color: var(--muted);
text-align: center; line-height: 1.3; }
.cell .key { font-family: ui-monospace, Menlo, monospace;
font-size: 9px; color: var(--text); }
#toast { position: fixed; bottom: 20px; left: 50%;
transform: translateX(-50%); padding: 10px 20px;
background: var(--text); color: white; border-radius: 8px;
opacity: 0; pointer-events: none; transition: opacity 0.2s;
font-size: 12px; }
#toast.show { opacity: 1; }
</style>
<script type="module" src="./diamond.mjs"></script>
</head>
<body>
<header>
<h1>sonic-diamond</h1>
<span class="meta" id="meta"></span>
<span style="flex: 1"></span>
<button id="btn-copy">Copy snapshot</button>
</header>
<main id="main"></main>
<div id="toast">Snapshot copied</div>
<script type="module" src="./diamond.editor.mjs"></script>
</body>
</html>
@@ -0,0 +1,115 @@
/**
* Component-editor for sonic-diamond.
*
* MVP: matrix view only. Diamonds need no per-variant tuning (all images
* are pre-aligned), so single-mode and anchor editing are not implemented.
* The "Copy snapshot" button still works — it produces the current
* `anchorsDefault` and `variants` ready to paste between marker comments.
*/
await customElements.whenDefined('sonic-diamond');
const Comp = customElements.get('sonic-diamond');
const { presetProps, chippedShapes } = Comp;
document.getElementById('meta').textContent =
`${Object.keys(Comp.variants).length} variants · baseSize ${Comp.baseSize.w}×${Comp.baseSize.h}mm · origin (${Comp.origin.x}, ${Comp.origin.y})`;
const main = document.getElementById('main');
renderGrid('Full', false);
renderGrid('Chipped', true);
document.getElementById('btn-copy').addEventListener('click', async () => {
const text = serializeSnapshot(Comp.anchorsDefault, Comp.variants);
await navigator.clipboard.writeText(text);
showToast('Snapshot copied');
});
function renderGrid(title, chipped) {
const shapes = chipped
? presetProps.shape.filter(s => chippedShapes.has(s))
: presetProps.shape;
const colors = presetProps.color;
const section = document.createElement('section');
const h2 = document.createElement('h2');
h2.textContent = `${title} — ${shapes.length} × ${colors.length}`;
section.appendChild(h2);
const grid = document.createElement('div');
grid.className = 'grid';
grid.style.gridTemplateColumns = `repeat(${colors.length}, 1fr)`;
for (const shape of shapes) {
for (const color of colors) {
grid.appendChild(makeCell(shape, color, chipped));
}
}
section.appendChild(grid);
main.appendChild(section);
}
function makeCell(shape, color, chipped) {
const cell = document.createElement('div');
cell.className = 'cell';
const preview = document.createElement('div');
preview.className = 'preview';
const inst = document.createElement('sonic-diamond');
inst.setAttribute('shape', shape);
inst.setAttribute('color', color);
inst.setAttribute('chipped', String(chipped));
preview.appendChild(inst);
const label = document.createElement('div');
label.className = 'label';
label.innerHTML = `<div>${shape} · ${color}</div>`;
const key = document.createElement('div');
key.className = 'key';
key.textContent = inst._variantKey();
cell.appendChild(preview);
cell.appendChild(label);
cell.appendChild(key);
return cell;
}
function serializeSnapshot(anchorsDefault, variants) {
const lines = [];
lines.push(' // @editor:anchors-start');
lines.push(` static anchorsDefault = ${formatAnchors(anchorsDefault, ' ')};`);
lines.push(' // @editor:anchors-end');
lines.push('');
lines.push(' // @editor:variants-start');
lines.push(' static variants = {');
for (const [key, v] of Object.entries(variants)) {
const a = formatAnchorsInline(v.anchors || {});
lines.push(` ${key}: { img: ${JSON.stringify(v.img)}, dx: ${v.dx}, dy: ${v.dy}, scale: ${v.scale}, anchors: ${a} },`);
}
lines.push(' };');
lines.push(' // @editor:variants-end');
return lines.join('\n');
}
function formatAnchors(anchors, indent) {
const keys = Object.keys(anchors);
if (keys.length === 0) return '{}';
const inner = keys.map(k => `${indent} ${k}: { x: ${anchors[k].x}, y: ${anchors[k].y} }`).join(',\n');
return `{\n${inner},\n${indent}}`;
}
function formatAnchorsInline(anchors) {
const keys = Object.keys(anchors);
if (keys.length === 0) return '{}';
const parts = keys.map(k => `${k}: { x: ${anchors[k].x}, y: ${anchors[k].y} }`);
return `{ ${parts.join(', ')} }`;
}
function showToast(msg) {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 1500);
}
@@ -0,0 +1,85 @@
import { FunPenComponent } from '/src/components/base.mjs';
class SonicDiamond extends FunPenComponent {
static origin = { x: 9, y: 9 };
static baseSize = { w: 18, h: 18 };
static presetProps = {
shape: ['classic', 'elongated', 'flat', 'round', 'raw'],
color: ['blue', 'cyan', 'green', 'magenta', 'pink', 'red'],
chipped: ['false', 'true'],
};
// Chipped variants exist only for these shapes. Other combinations
// silently fall back to the non-chipped variant.
static chippedShapes = new Set(['classic', 'round']);
// @editor:anchors-start
static anchorsDefault = {};
// @editor:anchors-end
// @editor:variants-start
static variants = {
classic_blue: { img: '/assets/themes/sonic/items/diamonds/01-classic-blue.png', dx: 0, dy: 0, scale: 1, anchors: {} },
classic_cyan: { img: '/assets/themes/sonic/items/diamonds/01-classic-cyan.png', dx: 0, dy: 0, scale: 1, anchors: {} },
classic_green: { img: '/assets/themes/sonic/items/diamonds/01-classic-green.png', dx: 0, dy: 0, scale: 1, anchors: {} },
classic_magenta: { img: '/assets/themes/sonic/items/diamonds/01-classic-magenta.png', dx: 0, dy: 0, scale: 1, anchors: {} },
classic_pink: { img: '/assets/themes/sonic/items/diamonds/01-classic-pink.png', dx: 0, dy: 0, scale: 1, anchors: {} },
classic_red: { img: '/assets/themes/sonic/items/diamonds/01-classic-red.png', dx: 0, dy: 0, scale: 1, anchors: {} },
elongated_blue: { img: '/assets/themes/sonic/items/diamonds/02-elongated-blue.png', dx: 0, dy: 0, scale: 1, anchors: {} },
elongated_cyan: { img: '/assets/themes/sonic/items/diamonds/02-elongated-cyan.png', dx: 0, dy: 0, scale: 1, anchors: {} },
elongated_green: { img: '/assets/themes/sonic/items/diamonds/02-elongated-green.png', dx: 0, dy: 0, scale: 1, anchors: {} },
elongated_magenta: { img: '/assets/themes/sonic/items/diamonds/02-elongated-magenta.png', dx: 0, dy: 0, scale: 1, anchors: {} },
elongated_pink: { img: '/assets/themes/sonic/items/diamonds/02-elongated-pink.png', dx: 0, dy: 0, scale: 1, anchors: {} },
elongated_red: { img: '/assets/themes/sonic/items/diamonds/02-elongated-red.png', dx: 0, dy: 0, scale: 1, anchors: {} },
flat_blue: { img: '/assets/themes/sonic/items/diamonds/03-flat-blue.png', dx: 0, dy: 0, scale: 1, anchors: {} },
flat_cyan: { img: '/assets/themes/sonic/items/diamonds/03-flat-cyan.png', dx: 0, dy: 0, scale: 1, anchors: {} },
flat_green: { img: '/assets/themes/sonic/items/diamonds/03-flat-green.png', dx: 0, dy: 0, scale: 1, anchors: {} },
flat_magenta: { img: '/assets/themes/sonic/items/diamonds/03-flat-magenta.png', dx: 0, dy: 0, scale: 1, anchors: {} },
flat_pink: { img: '/assets/themes/sonic/items/diamonds/03-flat-pink.png', dx: 0, dy: 0, scale: 1, anchors: {} },
flat_red: { img: '/assets/themes/sonic/items/diamonds/03-flat-red.png', dx: 0, dy: 0, scale: 1, anchors: {} },
round_blue: { img: '/assets/themes/sonic/items/diamonds/04-round-blue.png', dx: 0, dy: 0, scale: 1, anchors: {} },
round_cyan: { img: '/assets/themes/sonic/items/diamonds/04-round-cyan.png', dx: 0, dy: 0, scale: 1, anchors: {} },
round_green: { img: '/assets/themes/sonic/items/diamonds/04-round-green.png', dx: 0, dy: 0, scale: 1, anchors: {} },
round_magenta: { img: '/assets/themes/sonic/items/diamonds/04-round-magenta.png', dx: 0, dy: 0, scale: 1, anchors: {} },
round_pink: { img: '/assets/themes/sonic/items/diamonds/04-round-pink.png', dx: 0, dy: 0, scale: 1, anchors: {} },
round_red: { img: '/assets/themes/sonic/items/diamonds/04-round-red.png', dx: 0, dy: 0, scale: 1, anchors: {} },
raw_blue: { img: '/assets/themes/sonic/items/diamonds/05-raw-blue.png', dx: 0, dy: 0, scale: 1, anchors: {} },
raw_cyan: { img: '/assets/themes/sonic/items/diamonds/05-raw-cyan.png', dx: 0, dy: 0, scale: 1, anchors: {} },
raw_green: { img: '/assets/themes/sonic/items/diamonds/05-raw-green.png', dx: 0, dy: 0, scale: 1, anchors: {} },
raw_magenta: { img: '/assets/themes/sonic/items/diamonds/05-raw-magenta.png', dx: 0, dy: 0, scale: 1, anchors: {} },
raw_pink: { img: '/assets/themes/sonic/items/diamonds/05-raw-pink.png', dx: 0, dy: 0, scale: 1, anchors: {} },
raw_red: { img: '/assets/themes/sonic/items/diamonds/05-raw-red.png', dx: 0, dy: 0, scale: 1, anchors: {} },
classic_blue_chipped: { img: '/assets/themes/sonic/items/diamonds/06-classic-blue-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
classic_cyan_chipped: { img: '/assets/themes/sonic/items/diamonds/06-classic-cyan-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
classic_green_chipped: { img: '/assets/themes/sonic/items/diamonds/06-classic-green-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
classic_magenta_chipped: { img: '/assets/themes/sonic/items/diamonds/06-classic-magenta-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
classic_pink_chipped: { img: '/assets/themes/sonic/items/diamonds/06-classic-pink-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
classic_red_chipped: { img: '/assets/themes/sonic/items/diamonds/06-classic-red-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
round_blue_chipped: { img: '/assets/themes/sonic/items/diamonds/07-round-blue-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
round_cyan_chipped: { img: '/assets/themes/sonic/items/diamonds/07-round-cyan-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
round_green_chipped: { img: '/assets/themes/sonic/items/diamonds/07-round-green-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
round_magenta_chipped: { img: '/assets/themes/sonic/items/diamonds/07-round-magenta-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
round_pink_chipped: { img: '/assets/themes/sonic/items/diamonds/07-round-pink-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
round_red_chipped: { img: '/assets/themes/sonic/items/diamonds/07-round-red-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
};
// @editor:variants-end
_variantKey() {
const shape = this.getAttribute('shape') ?? this.constructor.presetProps.shape[0];
const color = this.getAttribute('color') ?? this.constructor.presetProps.color[0];
const chipped = this.getAttribute('chipped') === 'true';
const supportsChipped = this.constructor.chippedShapes.has(shape);
return supportsChipped && chipped
? `${shape}_${color}_chipped`
: `${shape}_${color}`;
}
}
customElements.define('sonic-diamond', SonicDiamond);
Binary file not shown.

After

Width:  |  Height:  |  Size: 710 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 880 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 988 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 962 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 718 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1009 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1011 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 995 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 895 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 987 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1015 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 907 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 979 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+15
View File
@@ -0,0 +1,15 @@
/**
* Sonic theme component loader.
* Scans the document for `sonic-*` custom elements and lazy-imports
* only those component modules. See src/components/CLAUDE.md.
*/
const PREFIX = 'sonic-';
const tags = new Set();
document.querySelectorAll('*').forEach(el => {
const t = el.tagName.toLowerCase();
if (t.startsWith(PREFIX)) tags.add(t);
});
await Promise.all([...tags].map(t => {
const name = t.slice(PREFIX.length);
return import(`./components/${name}/${name}.mjs`);
}));