feat: components
@@ -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);
|
||||
|
After Width: | Height: | Size: 710 KiB |
|
After Width: | Height: | Size: 819 KiB |
|
After Width: | Height: | Size: 817 KiB |
|
After Width: | Height: | Size: 880 KiB |
|
After Width: | Height: | Size: 988 KiB |
|
After Width: | Height: | Size: 1006 KiB |
|
After Width: | Height: | Size: 860 KiB |
|
After Width: | Height: | Size: 755 KiB |
|
After Width: | Height: | Size: 784 KiB |
|
After Width: | Height: | Size: 962 KiB |
|
After Width: | Height: | Size: 737 KiB |
|
After Width: | Height: | Size: 718 KiB |
|
After Width: | Height: | Size: 1009 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 862 KiB |
|
After Width: | Height: | Size: 820 KiB |
|
Before Width: | Height: | Size: 1012 KiB |
|
After Width: | Height: | Size: 868 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1011 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 972 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 997 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 995 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 998 KiB |
|
After Width: | Height: | Size: 895 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 987 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1015 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1022 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1005 KiB |
|
After Width: | Height: | Size: 907 KiB |
|
After Width: | Height: | Size: 890 KiB |
|
After Width: | Height: | Size: 911 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 979 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
@@ -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`);
|
||||
}));
|
||||