feat: components

This commit is contained in:
Oleg Proskurin 2026-04-30 14:54:15 +07:00
parent 7689adf095
commit 2319f2549b
73 changed files with 718 additions and 0 deletions

View File

@ -0,0 +1 @@
{"sessionId":"dec1e8b5-dc16-43d2-b20f-0fad3fe0354e","pid":148363,"procStart":"635354","acquiredAt":1777523259923}

View File

@ -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>

View File

@ -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);
}

View File

@ -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

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`);
}));

306
src/components/CLAUDE.md Normal file
View File

@ -0,0 +1,306 @@
# Components System
Themed Web Components for FunPen worksheets. Components wrap images so that:
- visual variants are picked via props (e.g. `shape="hex" color="blue"`)
- per-image positioning offsets are baked into the component file once and applied automatically
- key points (anchors / connectors) are exposed via a stable DOM API for document-level composition
This folder holds the **shared infrastructure** (base class, editor framework). Actual components live next to their assets under `assets/themes/{theme}/components/{name}/`.
---
## Where things live
```
src/components/ — shared infrastructure (this folder)
CLAUDE.md — decisions and conventions (this file)
base.mjs — FunPenComponent base class
editor/ — reusable component-editor framework
matrix-view.mjs — matrix mode (grid of preset variants)
single-view.mjs — single mode (tune one variant)
write-back.mjs — saves edits back into component .mjs
styles.css — shared editor styles
README-editor.md — how to build a component-editor
assets/themes/{theme}/
loader.mjs — tiny per-theme on-demand loader (~10 lines)
components/
{component}/
{component}.mjs — self-contained Web Component (final)
{component}.editor.html — component-editor page (dev only)
{component}.editor.mjs — component-editor logic (dev only)
assets/themes/{theme}/items/{cat}/ — image assets (UNCHANGED — components reference these)
```
The editor framework files (`editor/*`) are written iteratively as we build the first component. Don't pre-create them empty — extract reusable pieces from the first concrete editor.
---
## Decisions log
These were agreed during the design discussion and should not be changed without revisiting:
1. **Web Components with Shadow DOM** — for true encapsulation. Doc-editor cannot peek inside, no style bleed, real isolation. Single `<script type="module">` per document, the rest is plain HTML.
2. **Self-contained component files** — after tuning, the `.mjs` file holds all data (variants, anchors, offsets) as static constants. No external JSON, no runtime fetches.
3. **Theme-prefixed tag names**`sonic-crystal`, `nms-asteroid`. Theme is part of the component identity, no cross-theme namespace conflicts.
4. **On-demand loader** — per-theme `loader.mjs` scans the document for used tags and dynamically imports only what's referenced. Documents pay only for what they use.
5. **Units: mm everywhere** — origin, baseSize, anchors, per-variant offsets. No percentages, no pixels.
6. **Composition at document level** — components are leaf elements. They expose anchors via DOM API; documents place children using that information. Components never hardcode child types or fixed inner composition.
7. **Per-variant image positioning is the central data** — different image files have different intrinsic offsets/scales within the component bounds. The component-editor's primary purpose is fine-tuning those per-variant adjustments and baking them into the source file.
8. **Anchors: global default + per-variant overrides** — each component declares default anchor positions; each variant can override only the points that differ. Resolved as `{ ...default, ...variantOverride }`.
9. **Generate.mjs is not involved** — Web Components are runtime, the build pipeline stays untouched. Templates with components are valid HTML and render as-is.
10. **Doc-editor is not involved** — it sees components as atomic positioned blocks via their host attributes (`x`, `y`, `scale`, `rotation`). No special handling needed.
11. **Component-editor is a dev-time tool** — committed to git, lives next to the component, but does not participate in document lifecycle. No production code path imports it.
12. **Asset paths unchanged** — components reference existing images at `assets/themes/{theme}/items/{cat}/{file}`. No new asset folders for components.
13. **No automated write-back from editor to source** — editor produces a JS source snapshot, the AI agent pastes it between marker comments and verifies the result. Following the project's orchestrator pattern: scripts produce data, the agent makes decisions.
---
## Component anatomy
A component is one self-contained `.mjs` file. Skeleton:
```js
import { FunPenComponent } from '/src/components/base.mjs';
class SonicCrystal extends FunPenComponent {
// ===== Static metadata =====
// Insertion point in component-local mm. When document places the
// component at (x, y), THIS point lands at (x, y).
static origin = { x: 15, y: 15 };
// Natural size at scale=1, in mm.
static baseSize = { w: 30, h: 30 };
// Props that drive variant selection. Cartesian product = matrix view
// in component-editor.
static presetProps = {
shape: ['hex', 'round', 'tri'],
color: ['blue', 'red', 'green'],
};
// Default anchor coordinates in component-local mm. Each variant may
// override individual anchors; resolution merges default + override.
// @editor:anchors-start
static anchorsDefault = {
tip: { x: 15, y: 0 },
base: { x: 15, y: 30 },
};
// @editor:anchors-end
// Per-variant data: image path + position adjustments + anchor overrides.
// Variant key = preset-prop values joined by '_' in declaration order.
// @editor:variants-start
static variants = {
hex_blue: { img: '/assets/themes/sonic/items/crystal/hex-blue.png', dx: -1.5, dy: 0.0, scale: 1.00, anchors: {} },
hex_red: { img: '/assets/themes/sonic/items/crystal/hex-red.png', dx: -1.2, dy: -0.3, scale: 1.00, anchors: { tip: { x: 14.5, y: -0.2 } } },
round_blue: { img: '/assets/themes/sonic/items/crystal/round-blue.png', dx: 0.0, dy: 0.0, scale: 0.92, anchors: {} },
// ...
};
// @editor:variants-end
}
customElements.define('sonic-crystal', SonicCrystal);
```
That's it for the simple case. The base class provides default rendering (single image positioned per the variant). Override `render(variant)` if a component needs more (multiple images, decorative SVG overlays, etc.).
---
## Base class API
`FunPenComponent` (in `base.mjs`) provides:
### Standard attributes (auto-handled)
| Attribute | Meaning |
|------------|---------|
| `x` | Origin's X position in document mm |
| `y` | Origin's Y position in document mm |
| `scale` | Multiplier on baseSize, default `1` |
| `rotation` | Degrees, default `0` |
These are observed; changing them re-applies the host transform without re-rendering the inner content.
Preset props (declared in `presetProps`) are also observed; changing them triggers re-render with the new variant.
### Public DOM API
```js
const el = document.querySelector('sonic-crystal');
el.getOrigin(); // → { x, y } in component-local mm
el.getAnchor('tip'); // → { x, y } in component-local mm, with variant override applied
el.getAnchorDocPosition('tip'); // → { x, y } in document mm, accounting for x/y/scale/rotation
```
Use `getAnchorDocPosition` to place neighboring document elements relative to anchor points.
---
## Composition (document-level)
Components are leaves. To place one component at another's anchor, read the anchor in the document and use it as input for the next placement:
```html
<!-- Document author, possibly via doc-editor: -->
<sonic-crystal id="c1" x="100" y="60" shape="hex" color="blue"></sonic-crystal>
<sonic-crystal id="c2" x="100" y="90" shape="round" color="red"></sonic-crystal>
```
If snapping is needed at runtime (rare for static documents), JS can read `c1.getAnchorDocPosition('base')` and write the result into `c2`'s `x/y` attributes. For most static layouts the document author hardcodes coordinates; anchor metadata is an aid for the doc-editor when snapping/aligning.
**No `<slot>`-based composition inside components.** Components don't presume what goes near them. Documents decide.
---
## Per-theme loader
Each theme has a tiny loader that scans the document and lazy-imports used components:
```js
// assets/themes/sonic/loader.mjs
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`);
}));
```
Documents include it once:
```html
<head>
<script type="module" src="/assets/themes/sonic/loader.mjs"></script>
</head>
```
The loader runs as `module` → deferred by default → the DOM is parsed before it scans. If a component is added to the page after load (very rare in our static documents), the loader does not pick it up — that's fine for now.
---
## Component-editor
Two modes, one page (`{component}.editor.html`):
### Matrix mode (default on open)
Cartesian product of all `presetProps` rendered as a grid. Each cell is a live instance of the component with that prop combination plus a label. Clicking a cell enters single mode for those props.
Free props (`x`, `y`, `scale`, `rotation`) are NOT iterated — they don't affect the variant.
### Single mode
One large instance of the selected variant. Sidebar with:
- Sliders/inputs for the selected variant's `dx`, `dy`, `scale` (adjustments to the image inside the component bounds — this is the main work)
- Drag handles on each anchor point — moving them stores per-variant overrides on top of `anchorsDefault`
- "Edit defaults" toggle: adjust `anchorsDefault` for ALL variants at once
- Save button → write changes back into the component's `.mjs` file
- Back button → return to matrix
### Save mechanism — Claude in the middle, no automated write-back
Following the project's orchestrator pattern: the editor produces data, the AI agent applies it. **No `/api/save-component` endpoint, no auto-rewrite of the source file.**
The component's `.mjs` file marks two sections that contain editor-tunable data:
```js
// @editor:anchors-start
static anchorsDefault = { ... };
// @editor:anchors-end
// @editor:variants-start
static variants = { ... };
// @editor:variants-end
```
Workflow:
1. User tunes in component-editor (matrix → single → adjusts dx/dy/scale and anchors)
2. User clicks **"Copy snapshot"** in the editor toolbar
3. Editor copies a JS source fragment to clipboard — the new `anchorsDefault = { ... };` and `variants = { ... };` blocks, formatted to drop in between the markers
4. User pastes into chat / asks Claude to apply
5. Claude opens the component `.mjs` file, replaces ONLY the content between `@editor:anchors-start/end` and `@editor:variants-start/end` markers using the Edit tool
6. Claude verifies: re-opens the editor, compares visual result against what the user tuned (screenshots of the matrix before/after), confirms nothing else in the file changed (`git diff`)
This intentionally avoids chained automation. The cost of a 30-second manual paste-and-edit is much lower than the cost of a write-back script silently corrupting handwritten code, and the agent's review pass catches mistakes the editor cannot see (markers gone, ordering changed, formatting drift).
If you need to add or rename a marker section, edit the `.mjs` file by hand.
### What lives in `src/components/editor/`
Reusable pieces extracted from the first concrete editor. Expected modules:
- `matrix-view.mjs` — given a component class, render the cartesian grid of preset combinations
- `single-view.mjs` — given a component instance + sidebar root, wire up controls for `dx`/`dy`/`scale` and anchor drag
- `serialize.mjs` — turns the editor's in-memory `anchorsDefault` and `variants` into a formatted JS source fragment ready to paste between markers. Used by the "Copy snapshot" button — entirely client-side, no server round-trip.
- `styles.css` — common styles (toolbar, sidebar, grid, drag handles)
Build these incrementally. Don't pre-create empty files.
---
## Adding a new component
1. Create `assets/themes/{theme}/components/{name}/{name}.mjs` from the skeleton above
2. Fill `origin`, `baseSize`, `presetProps`, `anchorsDefault`
3. Fill `variants` with rough defaults (any non-zero starting point — the editor refines them)
4. Create `{name}.editor.html` from the editor template (see `src/components/editor/`)
5. Open `http://localhost:3300/assets/themes/{theme}/components/{name}/{name}.editor.html`
6. Tune in matrix → single → "Copy snapshot" → ask Claude to paste between markers in `{name}.mjs`
7. Claude verifies the result matches the editor (re-open, compare screenshots) before considering the tune-pass done
8. Use the component in any document via `<{theme}-{name} x="..." y="..." {presetProps...}></{theme}-{name}>`
---
## Adding a new theme
1. Create `assets/themes/{theme}/loader.mjs` (copy from existing, change `PREFIX`)
2. Add components under `components/`
Nothing else changes — the base class and editor framework are theme-agnostic.
---
## Document-side rules
**To use components in a document:**
1. Add loader once to the document `<head>`:
```html
<script type="module" src="/assets/themes/{theme}/loader.mjs"></script>
```
2. Place components anywhere in the body:
```html
<sonic-crystal x="120" y="80" shape="hex" color="blue"></sonic-crystal>
```
**Compatibility with doc-editor and generate.mjs:**
- Doc-editor moves components by their `x`/`y` attributes (or wraps them in style.left/top — TBD on first integration). Components are atomic; the doc-editor never touches their internals.
- generate.mjs treats the component tag as plain HTML and copies it through. No special handling.
- Puppeteer (PDF) needs to wait for component registration before snapshotting. `generate-pdf.mjs` should `waitForFunction(() => customElements.get('sonic-crystal'))` for each used tag, or `waitForNetworkIdle()` is usually enough.
**Compatibility with the existing "static HTML" rule:**
The project rule "OUTPUT MUST BE STATIC HTML — no embedded `<script>` that computes content at runtime" admits Tailwind CDN as an exception. Theme component loaders are a similar registration-only exception: they don't compute document content, they only define custom elements. Nothing in the document body is a `<script>` tag.
---
## What NOT to do
- Don't put theme-specific logic in `base.mjs`. The base class is shared across all themes.
- Don't hardcode child component types inside a parent component. Composition is the document's job.
- Don't fetch external JSON at runtime. Components are self-contained after tuning.
- Don't add a `<script>` per component to the document. Use the per-theme loader.
- Don't bake `x`/`y`/`scale`/`rotation` into the component file — those are per-instance attributes.
- Don't duplicate assets into `components/{name}/assets/`. Reference existing paths under `items/`.
- Don't add automated write-back from the editor to the source file. Editor produces a snapshot, Claude applies it manually between marker comments and verifies. This is the project's orchestrator pattern: scripts are tools, not autonomous actors.
- Don't touch code outside the marker-bracketed sections when applying an editor snapshot. Anchors and variants are the only data the editor produces; everything else is handwritten.

125
src/components/base.mjs Normal file
View File

@ -0,0 +1,125 @@
/**
* FunPenComponent base class for FunPen themed Web Components.
*
* See src/components/CLAUDE.md for the full architecture and conventions.
*
* Subclass contract:
* static origin { x, y } insertion point in component-local mm
* static baseSize { w, h } natural size at scale=1, in mm
* static presetProps { name: [enum values] }
* static anchorsDefault { name: { x, y } } local-mm
* static variants { variantKey: { img, dx, dy, scale, anchors } }
*
* Variant key is preset-prop values joined by '_' in declaration order.
*
* Standard observed attributes: x, y, scale, rotation. Plus every preset prop.
*
* Optional override:
* render(variant) custom rendering. Default renders a single positioned image.
*/
export class FunPenComponent extends HTMLElement {
static origin = { x: 0, y: 0 };
static baseSize = { w: 0, h: 0 };
static presetProps = {};
static anchorsDefault = {};
static variants = {};
static get observedAttributes() {
return ['x', 'y', 'scale', 'rotation', ...Object.keys(this.presetProps)];
}
connectedCallback() {
this._applyTransform();
this._render();
}
attributeChangedCallback(name) {
if (!this.isConnected) return;
if (name === 'x' || name === 'y' || name === 'scale' || name === 'rotation') {
this._applyTransform();
} else {
this._render();
}
}
_applyTransform() {
const x = parseFloat(this.getAttribute('x')) || 0;
const y = parseFloat(this.getAttribute('y')) || 0;
const s = parseFloat(this.getAttribute('scale')) || 1;
const r = parseFloat(this.getAttribute('rotation')) || 0;
const o = this.constructor.origin;
this.style.position = 'absolute';
this.style.left = `${x}mm`;
this.style.top = `${y}mm`;
this.style.transformOrigin = '0 0';
this.style.transform = `rotate(${r}deg) scale(${s}) translate(${-o.x}mm, ${-o.y}mm)`;
}
_render() {
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
const variant = this._currentVariant();
if (!variant) {
this.shadowRoot.innerHTML = `<div style="color:red;font:8pt sans-serif;padding:2mm;">[${this.tagName.toLowerCase()}: variant '${this._variantKey()}' not found]</div>`;
return;
}
this.render(variant);
}
render(variant) {
const { w, h } = this.constructor.baseSize;
const { img, dx = 0, dy = 0, scale = 1 } = variant;
this.shadowRoot.innerHTML = `
<style>
:host { display: block; width: ${w}mm; height: ${h}mm; }
img {
position: absolute;
left: ${dx}mm;
top: ${dy}mm;
width: ${w * scale}mm;
height: ${h * scale}mm;
}
</style>
<img src="${img}" alt="">
`;
}
_variantKey() {
return Object.keys(this.constructor.presetProps)
.map(prop => this.getAttribute(prop) ?? this.constructor.presetProps[prop][0])
.join('_');
}
_currentVariant() {
return this.constructor.variants[this._variantKey()];
}
// ---- Public DOM API ----
getOrigin() {
return { ...this.constructor.origin };
}
/** Anchor in component-local mm, with variant override merged onto defaults. */
getAnchor(name) {
const fromDefault = this.constructor.anchorsDefault[name];
if (!fromDefault) return null;
const override = this._currentVariant()?.anchors?.[name];
return { ...fromDefault, ...override };
}
/** Anchor position in document mm, accounting for x/y/scale/rotation. */
getAnchorDocPosition(name) {
const anchor = this.getAnchor(name);
if (!anchor) return null;
const x = parseFloat(this.getAttribute('x')) || 0;
const y = parseFloat(this.getAttribute('y')) || 0;
const s = parseFloat(this.getAttribute('scale')) || 1;
const rad = ((parseFloat(this.getAttribute('rotation')) || 0) * Math.PI) / 180;
const o = this.constructor.origin;
const lx = (anchor.x - o.x) * s;
const ly = (anchor.y - o.y) * s;
const rx = lx * Math.cos(rad) - ly * Math.sin(rad);
const ry = lx * Math.sin(rad) + ly * Math.cos(rad);
return { x: x + rx, y: y + ry };
}
}