@ethlete/agent-rules 0.1.0-next.3 → 0.1.0-next.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +27 -10
- package/content/hooks/context-warning.py +256 -69
- package/content/skills/angular-patterns/SKILL.md +1 -1
- package/content/skills/figma-export/SKILL.md +193 -0
- package/content/skills/figma-export/dump-figma-layers.py +83 -0
- package/content/skills/figma-export/dump-figma-svg.py +235 -0
- package/content/skills/figma-export/measure-template.mjs +87 -0
- package/content/skills/query/SKILL.md +6 -0
- package/content/skills/rxjs-signals/SKILL.md +1 -1
- package/content/skills/theming/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/lib/config.d.ts +5 -1
- package/src/lib/config.js +1 -1
- package/src/lib/config.js.map +1 -1
- package/src/lib/owned-paths.js +19 -1
- package/src/lib/owned-paths.js.map +1 -1
- package/src/lib/plan.js +14 -4
- package/src/lib/plan.js.map +1 -1
- package/src/lib/targets/claude-hooks.d.ts +1 -23
- package/src/lib/targets/claude-hooks.js +15 -84
- package/src/lib/targets/claude-hooks.js.map +1 -1
- package/src/lib/targets/codex-hooks.d.ts +12 -0
- package/src/lib/targets/codex-hooks.js +33 -0
- package/src/lib/targets/codex-hooks.js.map +1 -0
- package/src/lib/targets/hooks-shared.d.ts +38 -0
- package/src/lib/targets/hooks-shared.js +95 -0
- package/src/lib/targets/hooks-shared.js.map +1 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: figma-export
|
|
3
|
+
description: Reconcile a component against a Figma export - which exports to ask the designer for (an .svg frame and its "copy as CSS" dump), how to dump the geometry out of each, which of their numbers are authoritative, and how to measure the real rendered result against them headlessly. Use whenever a design export is dropped next to a component and the component has to be matched to it.
|
|
4
|
+
kind: skill
|
|
5
|
+
scope: both
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Reconcile a component against a Figma export
|
|
9
|
+
|
|
10
|
+
Three things arrive from Figma, and each answers a different question:
|
|
11
|
+
|
|
12
|
+
| Export | Gives you | Cannot tell you |
|
|
13
|
+
| ------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------- |
|
|
14
|
+
| **`.svg`** (Export frame) | Exact geometry with nesting, exact fills, and a picture once you rasterise it | Layer names, auto-layout intent, font sizes |
|
|
15
|
+
| **`.css`** (Copy as CSS) | Named layers, auto-layout properties, typography metrics, design-token names | Any hierarchy at all — the dump is flat |
|
|
16
|
+
| **`.png`** (a screenshot) | Figma's own blue measurement overlays, and what the designer chose to frame | Nothing machine-readable |
|
|
17
|
+
|
|
18
|
+
**Ask for the `.svg` and the `.css` together, and do not start until you have both.** The two
|
|
19
|
+
are complements, not alternatives: the SVG is the only export you can both look at and
|
|
20
|
+
measure, and the CSS is the only one that names layers and records type. A PNG earns its place
|
|
21
|
+
only when it is a _screenshot_ carrying dev-mode annotations — a PNG _render_ of the same frame
|
|
22
|
+
adds nothing the SVG does not. None of the three tells you the colours.
|
|
23
|
+
|
|
24
|
+
Say what you are missing and what it would settle, in one line — "I have the SVG; the `.css`
|
|
25
|
+
export would give me the font sizes and whether these cards Hug or Fill" — and wait. Every
|
|
26
|
+
number in your diff has to trace back to something in an export or to a token; a plausible
|
|
27
|
+
`17px` you inferred from a 12px outlined glyph is worse than an open question, because it
|
|
28
|
+
survives review as though it had been specified. If the pair genuinely cannot be produced, say
|
|
29
|
+
in the write-up which numbers are therefore guesses.
|
|
30
|
+
|
|
31
|
+
## 1. Read the export before touching code
|
|
32
|
+
|
|
33
|
+
### From an `.svg` — look at it, then measure it
|
|
34
|
+
|
|
35
|
+
Rasterise it and actually open the image. Figma outlines text on export, so this needs no
|
|
36
|
+
webfonts and matches the frame exactly:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
magick -density 144 <export.svg> <scratch>/frame.png # then read frame.png
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
(If the render looks wrong, ImageMagick fell back to its own SVG renderer — check for
|
|
43
|
+
`RSVG` in `magick -list format | grep SVG`, or screenshot the file in Playwright instead.)
|
|
44
|
+
|
|
45
|
+
Then dump the geometry with {%resource:dump-figma-svg.py%} — every shape in document order,
|
|
46
|
+
indented by group nesting, with absolute coordinates:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
python3 dump-figma-svg.py <export.svg>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
It closes with two summaries worth reading first: repeated rect sizes, which say _one
|
|
53
|
+
component rendered N times_ where the CSS dump would show N unrelated frames; and the measured
|
|
54
|
+
gaps between rects that share a top edge, which is the auto-layout gap without having to
|
|
55
|
+
believe a label.
|
|
56
|
+
|
|
57
|
+
What the SVG does **not** carry:
|
|
58
|
+
|
|
59
|
+
- **Layer names.** Nothing is called `Card` or `Badge`; you match shapes to the picture.
|
|
60
|
+
- **Auto-layout intent.** `Hug` vs `Fixed` vs `Fill` is gone, so a width you read off is the
|
|
61
|
+
width _at this one size_, not a rule. Derive padding and gaps from the coordinates, and
|
|
62
|
+
treat a child width as content-driven until the picture or the CSS dump says otherwise.
|
|
63
|
+
- **Typography.** Outlined text is a `<path>`; the dumper labels the wide ones `text?` and
|
|
64
|
+
boxes them, which places a text run but tells you nothing about `font-size` or
|
|
65
|
+
`line-height`. If the designer exported with _Outline Text_ off, `<text>` nodes survive and
|
|
66
|
+
the dumper prints their font metrics and strings — take them when you get them.
|
|
67
|
+
|
|
68
|
+
Two numbers to read carefully: a **stroke is centred**, so a 32px circle with a 1px border
|
|
69
|
+
exports as `31 × 31` at `.5` offsets — add the stroke width back before comparing. And an
|
|
70
|
+
**outlined glyph box is the ink**, not the line box: a 17px/20px title measures ~12px tall,
|
|
71
|
+
the same cap-trim the CSS dump reports, so never match a text node's height.
|
|
72
|
+
|
|
73
|
+
### From a `.css` dump — names, intent and type metrics
|
|
74
|
+
|
|
75
|
+
Never grep the raw file — its layout defeats naive parsing (see the traps below). Run
|
|
76
|
+
{%resource:dump-figma-layers.py%}:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
python3 dump-figma-layers.py <export.css> # every layer, artwork dropped
|
|
80
|
+
python3 dump-figma-layers.py <export.css> '^(Widget|Frame|Card)' # only what you name
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Then work out the frame's box model top-down — outer frame width and padding, then each
|
|
84
|
+
child's `width`/`gap`/`flex-grow` — and write the ladder down before you start editing, so you
|
|
85
|
+
can tell a deliberate design decision from a Figma artefact.
|
|
86
|
+
|
|
87
|
+
### Whatever you have, look at the picture before you decide what to build
|
|
88
|
+
|
|
89
|
+
The CSS dump is a **flat** sequence of layers with no nesting whatsoever, so the tree is not in
|
|
90
|
+
it; the SVG has the tree but no names. The image is what disambiguates:
|
|
91
|
+
|
|
92
|
+
- **Hierarchy and reading order** — which layer contains which. The only other clue in the CSS
|
|
93
|
+
is `order:` inside a sibling run, which does not cross frames.
|
|
94
|
+
- **Repetition vs distinct layers.** Six blocks with identical declarations are one component
|
|
95
|
+
rendered six times. The CSS looks like six unrelated frames.
|
|
96
|
+
- **Multiple states in one board.** Frames are routinely laid out side by side as
|
|
97
|
+
default / hover / selected / empty, with a pink cursor marking the interaction. The CSS
|
|
98
|
+
gives you every state flattened together with nothing saying which is which, so a number
|
|
99
|
+
read blind may belong to a hover state you are not building.
|
|
100
|
+
- **Figma's own measurement overlays.** Blue annotations like `396 × 401 Hug` or `347 × 23`
|
|
101
|
+
are authoritative and frequently _absent_ from the CSS — `Hug` in particular tells you a
|
|
102
|
+
dimension is content-driven, which no declaration in the export records. These live only in
|
|
103
|
+
a canvas _screenshot_; a frame export of any format drops them.
|
|
104
|
+
- **What is decoration.** Cursors, callout arrows and section captions painted on the board
|
|
105
|
+
are not part of the component, but they do emit layers.
|
|
106
|
+
|
|
107
|
+
### Traps in the `.css` export itself
|
|
108
|
+
|
|
109
|
+
- **Sub-comments carry the real properties.** Figma writes `/* Auto layout */`,
|
|
110
|
+
`/* Inside auto layout */`, `/* or 16px */` and token names like
|
|
111
|
+
`/* Brand/Chalk White/500 */` _between_ a layer's declarations. A parser that starts a new
|
|
112
|
+
layer at every comment reports frames with **zero properties** and swallows the geometry.
|
|
113
|
+
The dumper folds them in — it decides by the blank line that follows a real layer name, not
|
|
114
|
+
by the name, so component layers called `Profile/Badge` survive.
|
|
115
|
+
- **Frame labels lie.** A frame _named_ "Widget 636px" routinely has `width: 640px`, and a
|
|
116
|
+
label like "8 rows = 564px" often does not reconcile with the grid it sits in. Trust the
|
|
117
|
+
declarations, never the label.
|
|
118
|
+
- **Text heights are cap-trimmed.** With `leading-trim: both; text-edge: cap`, a 17px/20px
|
|
119
|
+
title reports `height: 12px`. Compare `font-size`, `line-height` and `letter-spacing`;
|
|
120
|
+
never compare a text node's height.
|
|
121
|
+
- **Absolute `left`/`top`** are canvas coordinates of the whole board. Only the _differences_
|
|
122
|
+
within one frame mean anything.
|
|
123
|
+
|
|
124
|
+
## 2. Decide what the export is allowed to dictate
|
|
125
|
+
|
|
126
|
+
- **Authoritative:** geometry and typography metrics — widths, padding, gaps, `flex-grow`,
|
|
127
|
+
border radius, font size / weight / line-height / letter-spacing, and the breakpoints at
|
|
128
|
+
which the layout changes.
|
|
129
|
+
- **Never authoritative: colour.** Backgrounds, text, borders and interaction states resolve
|
|
130
|
+
from the surface and colour theming tokens — see {%skill:theming%}. A hex in the
|
|
131
|
+
export is information about the _designer's_ palette, not a value to paste. Where the
|
|
132
|
+
export's colour and the token disagree, keep the token and note the delta for the design
|
|
133
|
+
review; the export can be wrong about contrast in a way the tokens are not. This holds
|
|
134
|
+
hardest for an SVG, whose fills are exact and therefore tempting: an exact wrong answer is
|
|
135
|
+
still wrong.
|
|
136
|
+
- **Radius and spacing snap to the scale.** Round to the nearest token rather than emitting an
|
|
137
|
+
arbitrary value, and say so if the export's number is more than a step away.
|
|
138
|
+
|
|
139
|
+
## 3. Ask before making a structural choice
|
|
140
|
+
|
|
141
|
+
Matching numbers is mechanical; the choices around them are not. Stop and ask when the export
|
|
142
|
+
implies:
|
|
143
|
+
|
|
144
|
+
- a **breakpoint strategy** — container queries with an exact ladder vs a retuned
|
|
145
|
+
`auto-fill`/`auto-fit` grid;
|
|
146
|
+
- **shared-component churn** — a new variant on a component other screens already use, vs a
|
|
147
|
+
local override;
|
|
148
|
+
- a **scroll region** — which part scrolls and what stays pinned;
|
|
149
|
+
- **fixed sizing** where the component is currently fluid, or the reverse;
|
|
150
|
+
- anything the export shows that the API does not yet return.
|
|
151
|
+
|
|
152
|
+
Colour deltas are informational — report them, do not block on them.
|
|
153
|
+
|
|
154
|
+
## 4. Measure the real thing, don't eyeball it
|
|
155
|
+
|
|
156
|
+
A build passing proves nothing about geometry. Render the component's real markup against the
|
|
157
|
+
**production stylesheet** and read the computed boxes back. {%resource:measure-template.mjs%}
|
|
158
|
+
is a working starting point; copy it into a scratch directory with the compiled stylesheet
|
|
159
|
+
beside it:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
# whatever your build emits — the point is a real, fully compiled stylesheet
|
|
163
|
+
cp dist/apps/<app>/browser/styles-*.css <scratch>/styles.css
|
|
164
|
+
node <scratch>/measure-template.mjs
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Confirm the arbitrary utilities you wrote actually compiled — a typo in
|
|
168
|
+
`@min-[392px]` fails silently:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
grep -oE '@container \(width >= [0-9]+px\)|\.(h-15|rounded-sm)\{[^}]*\}' dist/apps/<app>/browser/styles-*.css | sort -u
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Harness gotchas, each of which will cost you an hour:
|
|
175
|
+
|
|
176
|
+
- **`page.setContent()` renders on `about:blank`, which blocks `file://` subresources** — the
|
|
177
|
+
stylesheet silently never loads. Write a real HTML file and `page.goto('file://…')`.
|
|
178
|
+
- **Never lay the probes out in a flex _row_.** They shrink, and container queries then report
|
|
179
|
+
results for a width the component would never see. Stack them in a column at explicit widths.
|
|
180
|
+
- **The harness has no webfonts** unless you copy the `@font-face` sources too. Text runs
|
|
181
|
+
~1px wide of reality, so a label that wraps in the harness may well fit in the app. Check
|
|
182
|
+
before calling a wrap a defect.
|
|
183
|
+
- Playwright is CommonJS and unresolvable from a scratch directory — `createRequire` against
|
|
184
|
+
the repo root, as the template does. The same applies when driving a story:
|
|
185
|
+
{%skill:verify-in-storybook%}.
|
|
186
|
+
|
|
187
|
+
## 5. Close the loop
|
|
188
|
+
|
|
189
|
+
Report the measured numbers next to the export's, per width — not "matches the design". Say
|
|
190
|
+
explicitly which parts of the export you deliberately did **not** implement and why (colour
|
|
191
|
+
kept as tokens, a label the product decided never to render, a field the API lacks). Then
|
|
192
|
+
delete the export files once their component is signed off, so the folder always shows only
|
|
193
|
+
what is still outstanding.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Dump the properties of every layer in a Figma "copy as CSS" export, in document order.
|
|
3
|
+
|
|
4
|
+
python3 dump-figma-layers.py <export.css> ['<name regex>']
|
|
5
|
+
|
|
6
|
+
Without a regex every layer is printed except obvious vector artwork. With one, only the
|
|
7
|
+
layers whose name matches it.
|
|
8
|
+
|
|
9
|
+
Figma emits sub-comments — `/* Auto layout */`, `/* Inside auto layout */`, `/* or 16px */`
|
|
10
|
+
and design-token names like `/* Brand/Chalk White/500 (base) */` — that carry properties
|
|
11
|
+
belonging to the layer named above them. They are folded into that layer rather than treated
|
|
12
|
+
as new ones: a parser that splits on every comment reports frames with zero properties.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
COMMENT = re.compile(r'^/\* (.+) \*/$')
|
|
19
|
+
LAYOUT_NOTE = re.compile(r'^(Auto layout|Inside auto layout|or [\d.]+px|Hug|Fixed|Fill|Effect style)$')
|
|
20
|
+
ARTWORK = re.compile(
|
|
21
|
+
r'^(Polygon|Vector|Ellipse|Rectangle \d|Star|Union|Subtract|Group|Mask|Clip'
|
|
22
|
+
r'|Texture|Frame \d{6,})'
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_blocks(path):
|
|
27
|
+
"""Yield ('comment', name) and ('prop', text), skipping Figma's multi-line prose notes."""
|
|
28
|
+
lines = [line.rstrip('\n') for line in open(path)]
|
|
29
|
+
index, total = 0, len(lines)
|
|
30
|
+
|
|
31
|
+
while index < total:
|
|
32
|
+
stripped = lines[index].strip()
|
|
33
|
+
|
|
34
|
+
if stripped.startswith('/*') and not stripped.endswith('*/'):
|
|
35
|
+
while index < total and not lines[index].rstrip().endswith('*/'):
|
|
36
|
+
index += 1
|
|
37
|
+
index += 1
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
comment = COMMENT.match(stripped)
|
|
41
|
+
|
|
42
|
+
if comment:
|
|
43
|
+
following = lines[index + 1].strip() if index + 1 < total else ''
|
|
44
|
+
yield 'comment', comment.group(1), following
|
|
45
|
+
elif ':' in stripped and not stripped.startswith('/*'):
|
|
46
|
+
yield 'prop', stripped, ''
|
|
47
|
+
|
|
48
|
+
index += 1
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main():
|
|
52
|
+
if len(sys.argv) < 2:
|
|
53
|
+
sys.exit(__doc__)
|
|
54
|
+
|
|
55
|
+
path = sys.argv[1]
|
|
56
|
+
match = re.compile(sys.argv[2]).search if len(sys.argv) > 2 else None
|
|
57
|
+
show, index = False, 0
|
|
58
|
+
|
|
59
|
+
for kind, text, following in read_blocks(path):
|
|
60
|
+
if kind == 'prop':
|
|
61
|
+
if show:
|
|
62
|
+
print(f' {text}')
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
# A layer name is followed by a blank line; a sub-comment sits directly on top of the
|
|
66
|
+
# declarations it annotates. The name list covers the few notes Figma writes before
|
|
67
|
+
# another comment, where that spacing rule cannot decide.
|
|
68
|
+
is_declaration = ':' in following and not following.startswith('/*')
|
|
69
|
+
|
|
70
|
+
if is_declaration or LAYOUT_NOTE.match(text):
|
|
71
|
+
if show:
|
|
72
|
+
print(f' /* {text} */')
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
index += 1
|
|
76
|
+
show = match(text) is not None if match else not ARTWORK.match(text)
|
|
77
|
+
|
|
78
|
+
if show:
|
|
79
|
+
print(f'\n--- [{index}] {text}')
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == '__main__':
|
|
83
|
+
main()
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Dump the box tree of a Figma SVG export: every shape with absolute coordinates.
|
|
3
|
+
|
|
4
|
+
python3 dump-figma-svg.py <export.svg>
|
|
5
|
+
|
|
6
|
+
Prints one line per drawn element in document order, indented by group nesting, then a
|
|
7
|
+
summary of repeated shapes and of the gaps between shapes that share a row.
|
|
8
|
+
|
|
9
|
+
An SVG export has no layer names and — because Figma outlines text on export — no strings
|
|
10
|
+
or font metrics. What it does have is exact geometry: every rect carries its own x/y/
|
|
11
|
+
width/height/rx, so padding and gaps are differences you can read off rather than numbers
|
|
12
|
+
you have to trust a label for. Outlined text is measured by the bounding box of its path
|
|
13
|
+
coordinates, which runs a fraction of a pixel wide because Bezier control points sit
|
|
14
|
+
outside the curve — enough to place a text run, not to size a glyph.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
import xml.etree.ElementTree as ET
|
|
20
|
+
|
|
21
|
+
SVG_NS = '{http://www.w3.org/2000/svg}'
|
|
22
|
+
TRANSLATE = re.compile(r'translate\(\s*([-\d.]+)[\s,]+([-\d.]+)\s*\)')
|
|
23
|
+
TOKEN = re.compile(r'([MmLlHhVvCcSsQqTtAaZz])|(-?\d*\.?\d+(?:e[-+]?\d+)?)', re.IGNORECASE)
|
|
24
|
+
ARITY = {'m': 2, 'l': 2, 'h': 1, 'v': 1, 'c': 6, 's': 4, 'q': 4, 't': 2, 'a': 7, 'z': 0}
|
|
25
|
+
SKIP = {'defs', 'clipPath', 'mask', 'filter', 'linearGradient', 'radialGradient', 'pattern'}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def tag_of(element):
|
|
29
|
+
return element.tag[len(SVG_NS) :] if element.tag.startswith(SVG_NS) else element.tag
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def number(element, name, fallback=0.0):
|
|
33
|
+
try:
|
|
34
|
+
return float(element.get(name, fallback))
|
|
35
|
+
except ValueError:
|
|
36
|
+
return fallback
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def translation(element):
|
|
40
|
+
match = TRANSLATE.search(element.get('transform') or '')
|
|
41
|
+
|
|
42
|
+
return (float(match.group(1)), float(match.group(2))) if match else (0.0, 0.0)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def path_points(data):
|
|
46
|
+
"""Every point a path visits, control points included. `H`/`V`/`A` take counts of their
|
|
47
|
+
own, so pairing the numbers off two at a time reports a box the shape never occupies."""
|
|
48
|
+
tokens = TOKEN.findall(data)
|
|
49
|
+
index, command, x, y = 0, 'm', 0.0, 0.0
|
|
50
|
+
|
|
51
|
+
while index < len(tokens):
|
|
52
|
+
letter, value = tokens[index]
|
|
53
|
+
|
|
54
|
+
if letter:
|
|
55
|
+
command = letter
|
|
56
|
+
index += 1
|
|
57
|
+
|
|
58
|
+
if command.lower() == 'z':
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
arity = ARITY[command.lower()]
|
|
62
|
+
args = [float(tokens[index + step][1]) for step in range(arity) if index + step < len(tokens)]
|
|
63
|
+
|
|
64
|
+
if len(args) < arity:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
index += arity
|
|
68
|
+
relative = command.islower()
|
|
69
|
+
|
|
70
|
+
if command.lower() == 'h':
|
|
71
|
+
x = x + args[0] if relative else args[0]
|
|
72
|
+
elif command.lower() == 'v':
|
|
73
|
+
y = y + args[0] if relative else args[0]
|
|
74
|
+
else:
|
|
75
|
+
pairs = [(args[5], args[6])] if command.lower() == 'a' else list(zip(args[0::2], args[1::2]))
|
|
76
|
+
|
|
77
|
+
for point_x, point_y in pairs:
|
|
78
|
+
yield (x + point_x, y + point_y) if relative else (point_x, point_y)
|
|
79
|
+
|
|
80
|
+
x, y = (x + pairs[-1][0], y + pairs[-1][1]) if relative else pairs[-1]
|
|
81
|
+
|
|
82
|
+
yield x, y
|
|
83
|
+
|
|
84
|
+
if command == 'M':
|
|
85
|
+
command = 'L'
|
|
86
|
+
elif command == 'm':
|
|
87
|
+
command = 'l'
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def path_box(data):
|
|
91
|
+
points = list(path_points(data))
|
|
92
|
+
xs, ys = [point[0] for point in points], [point[1] for point in points]
|
|
93
|
+
|
|
94
|
+
if not xs:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
return min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def box_of(element):
|
|
101
|
+
name = tag_of(element)
|
|
102
|
+
|
|
103
|
+
if name in ('rect', 'image'):
|
|
104
|
+
return number(element, 'x'), number(element, 'y'), number(element, 'width'), number(element, 'height')
|
|
105
|
+
|
|
106
|
+
if name == 'path':
|
|
107
|
+
return path_box(element.get('d') or '')
|
|
108
|
+
|
|
109
|
+
if name in ('polygon', 'polyline'):
|
|
110
|
+
return path_box('M' + (element.get('points') or ''))
|
|
111
|
+
|
|
112
|
+
if name == 'circle':
|
|
113
|
+
radius = number(element, 'r')
|
|
114
|
+
|
|
115
|
+
return number(element, 'cx') - radius, number(element, 'cy') - radius, radius * 2, radius * 2
|
|
116
|
+
|
|
117
|
+
if name == 'ellipse':
|
|
118
|
+
rx, ry = number(element, 'rx'), number(element, 'ry')
|
|
119
|
+
|
|
120
|
+
return number(element, 'cx') - rx, number(element, 'cy') - ry, rx * 2, ry * 2
|
|
121
|
+
|
|
122
|
+
if name == 'line':
|
|
123
|
+
x1, y1 = number(element, 'x1'), number(element, 'y1')
|
|
124
|
+
|
|
125
|
+
return x1, y1, number(element, 'x2') - x1, number(element, 'y2') - y1
|
|
126
|
+
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def trim(value):
|
|
131
|
+
return f'{value:.3f}'.rstrip('0').rstrip('.')
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def walk(element, offset, depth, boxes):
|
|
135
|
+
for child in element:
|
|
136
|
+
name = tag_of(child)
|
|
137
|
+
|
|
138
|
+
if name in SKIP:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
dx, dy = translation(child)
|
|
142
|
+
origin = (offset[0] + dx, offset[1] + dy)
|
|
143
|
+
|
|
144
|
+
if name in ('g', 'svg'):
|
|
145
|
+
print(f'{" " * depth}<{name}>' + (f' translate({trim(dx)} {trim(dy)})' if (dx or dy) else ''))
|
|
146
|
+
walk(child, origin, depth + 1, boxes)
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
if name == 'text':
|
|
150
|
+
content = ' '.join(''.join(child.itertext()).split())
|
|
151
|
+
metrics = ' '.join(
|
|
152
|
+
f'{key}={child.get(key)}'
|
|
153
|
+
for key in ('font-family', 'font-size', 'font-weight', 'letter-spacing')
|
|
154
|
+
if child.get(key)
|
|
155
|
+
)
|
|
156
|
+
x, y = number(child, 'x') + origin[0], number(child, 'y') + origin[1]
|
|
157
|
+
print(f'{" " * depth}{"text":<6} {trim(x):>9} {trim(y):>8} {metrics} "{content}"')
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
box = box_of(child)
|
|
161
|
+
|
|
162
|
+
if box is None:
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
x, y, width, height = box[0] + origin[0], box[1] + origin[1], box[2], box[3]
|
|
166
|
+
radius = child.get('rx') if name == 'rect' else None
|
|
167
|
+
paint = child.get('fill') or child.get('stroke') or ''
|
|
168
|
+
detail = f' r={radius}' if radius else ''
|
|
169
|
+
detail += f' {"stroke" if child.get("stroke") else "fill"}={paint}' if paint and paint != 'none' else ''
|
|
170
|
+
label = 'text?' if name == 'path' and width >= height * 3 else name
|
|
171
|
+
|
|
172
|
+
print(f'{" " * depth}{label:<6} {trim(x):>9} {trim(y):>8} {trim(width):>8} × {trim(height):<8}{detail}'.rstrip())
|
|
173
|
+
boxes.append((x, y, width, height, name, radius, paint))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def summarise(boxes):
|
|
177
|
+
shapes = {}
|
|
178
|
+
|
|
179
|
+
for x, y, width, height, name, radius, paint in boxes:
|
|
180
|
+
if name != 'rect':
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
shapes.setdefault((round(width, 2), round(height, 2), radius), []).append((x, y, paint))
|
|
184
|
+
|
|
185
|
+
repeats = {key: hits for key, hits in shapes.items() if len(hits) > 1}
|
|
186
|
+
|
|
187
|
+
if repeats:
|
|
188
|
+
print('\nRepeated rects — one component rendered N times, not N designs:')
|
|
189
|
+
|
|
190
|
+
for (width, height, radius), hits in sorted(repeats.items(), key=lambda item: -len(item[1])):
|
|
191
|
+
fills = {paint for _, _, paint in hits}
|
|
192
|
+
varies = f', {len(fills)} fills' if len(fills) > 1 else ''
|
|
193
|
+
print(f' {len(hits)}× {trim(width)} × {trim(height)}' + (f' r={radius}' if radius else '') + varies)
|
|
194
|
+
|
|
195
|
+
rows = {}
|
|
196
|
+
|
|
197
|
+
for x, y, width, height, name, _, _ in boxes:
|
|
198
|
+
if name == 'rect':
|
|
199
|
+
rows.setdefault(round(y, 2), set()).add((x, width))
|
|
200
|
+
|
|
201
|
+
printed = False
|
|
202
|
+
|
|
203
|
+
for y, hits in sorted(rows.items()):
|
|
204
|
+
spans, edge = [], None
|
|
205
|
+
|
|
206
|
+
for x, width in sorted(hits):
|
|
207
|
+
if edge is not None and x >= edge:
|
|
208
|
+
spans.append(round(x - edge, 2))
|
|
209
|
+
|
|
210
|
+
edge = max(edge or 0, x + width)
|
|
211
|
+
|
|
212
|
+
if not spans:
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
if not printed:
|
|
216
|
+
print('\nGaps between rects sharing a top edge — the auto-layout gap, measured:')
|
|
217
|
+
printed = True
|
|
218
|
+
|
|
219
|
+
print(f' y={trim(y):<8} {len(spans) + 1} columns, gaps: {", ".join(trim(span) for span in spans)}')
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def main():
|
|
223
|
+
if len(sys.argv) < 2:
|
|
224
|
+
sys.exit(__doc__)
|
|
225
|
+
|
|
226
|
+
root = ET.parse(sys.argv[1]).getroot()
|
|
227
|
+
boxes = []
|
|
228
|
+
|
|
229
|
+
print(f'canvas {root.get("width")} × {root.get("height")} viewBox={root.get("viewBox")}\n')
|
|
230
|
+
walk(root, (0.0, 0.0), 0, boxes)
|
|
231
|
+
summarise(boxes)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
if __name__ == '__main__':
|
|
235
|
+
main()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measure real rendered geometry against the numbers in a Figma export.
|
|
3
|
+
*
|
|
4
|
+
* Copy into a scratch directory next to a `styles.css` copied out of a production build,
|
|
5
|
+
* replace MARKUP / WIDTHS / DESIGN / probe(), then `node measure-template.mjs`.
|
|
6
|
+
*/
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { writeFileSync } from 'node:fs';
|
|
9
|
+
|
|
10
|
+
// This runs from a scratch directory, so Playwright cannot be resolved by name — point
|
|
11
|
+
// createRequire at the repo root. Playwright is CommonJS; a named import fails.
|
|
12
|
+
const REPO_ROOT = '/absolute/path/to/repo'; // <-- change me
|
|
13
|
+
const { chromium } = createRequire(`${REPO_ROOT}/`)('playwright');
|
|
14
|
+
|
|
15
|
+
/** One instance of the thing under test. Keep the real component classes verbatim. */
|
|
16
|
+
const MARKUP = (id) => `
|
|
17
|
+
<div id="${id}-card" class="…">
|
|
18
|
+
<h4 id="${id}-title" class="…">Some realistically long label</h4>
|
|
19
|
+
</div>`;
|
|
20
|
+
|
|
21
|
+
/** Container widths to probe, taken from the export's frames — not from viewport breakpoints. */
|
|
22
|
+
const WIDTHS = [956, 640];
|
|
23
|
+
|
|
24
|
+
/** What the export says each width should produce. */
|
|
25
|
+
const DESIGN = {
|
|
26
|
+
956: { cols: 4, cardW: 219, cardH: 60 },
|
|
27
|
+
640: { cols: 3, cardW: 192, cardH: 60 },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Column, never row: in a flex row the harness items shrink, and container queries then
|
|
31
|
+
// report results for a width the component would never actually see.
|
|
32
|
+
const page = (blocks) => `<!doctype html><html class="et-surface--dark"><head><meta charset="utf-8">
|
|
33
|
+
<link rel="stylesheet" href="./styles.css"></head>
|
|
34
|
+
<body class="et-surface--dark" style="margin:0;padding:24px;display:flex;flex-direction:column;gap:24px;align-items:flex-start">
|
|
35
|
+
${blocks}
|
|
36
|
+
</body></html>`;
|
|
37
|
+
|
|
38
|
+
const block = (width) => `<div id="w${width}" class="@container" style="width:${width}px">
|
|
39
|
+
<div class="…">${MARKUP(`w${width}`)}</div>
|
|
40
|
+
</div>`;
|
|
41
|
+
|
|
42
|
+
// page.setContent() renders on about:blank, which blocks file:// subresources — the
|
|
43
|
+
// stylesheet would silently never load. Write a real file and navigate to it.
|
|
44
|
+
writeFileSync('harness.html', page(WIDTHS.map(block).join('\n')));
|
|
45
|
+
|
|
46
|
+
const browser = await chromium.launch();
|
|
47
|
+
const tab = await browser.newPage({ viewport: { width: 1200, height: 900 }, deviceScaleFactor: 2 });
|
|
48
|
+
|
|
49
|
+
await tab.goto(`file://${process.cwd()}/harness.html`);
|
|
50
|
+
await tab.waitForTimeout(300);
|
|
51
|
+
|
|
52
|
+
const measured = await tab.evaluate((widths) => {
|
|
53
|
+
const round = (value) => Math.round(value * 100) / 100;
|
|
54
|
+
const box = (id) => document.getElementById(id).getBoundingClientRect();
|
|
55
|
+
const font = (id) => getComputedStyle(document.getElementById(id));
|
|
56
|
+
|
|
57
|
+
return widths.map((width) => {
|
|
58
|
+
const card = box(`w${width}-card`);
|
|
59
|
+
const title = font(`w${width}-title`);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
width,
|
|
63
|
+
cardW: round(card.width),
|
|
64
|
+
cardH: round(card.height),
|
|
65
|
+
titleInset: round(box(`w${width}-title`).left - card.left),
|
|
66
|
+
fontSize: title.fontSize,
|
|
67
|
+
lineHeight: title.lineHeight,
|
|
68
|
+
letterSpacing: title.letterSpacing,
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
}, WIDTHS);
|
|
72
|
+
|
|
73
|
+
let failures = 0;
|
|
74
|
+
|
|
75
|
+
for (const row of measured) {
|
|
76
|
+
const want = DESIGN[row.width];
|
|
77
|
+
const ok = Math.abs(row.cardW - want.cardW) < 1 && Math.abs(row.cardH - want.cardH) < 0.5;
|
|
78
|
+
|
|
79
|
+
if (!ok) failures++;
|
|
80
|
+
|
|
81
|
+
console.log(`${String(row.width).padStart(4)}px | ${ok ? 'OK ' : 'BAD'} |`, row);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
console.log(failures === 0 ? '\nALL GEOMETRY MATCHES' : `\n${failures} MISMATCHES`);
|
|
85
|
+
|
|
86
|
+
await tab.screenshot({ path: 'verify.png', fullPage: true });
|
|
87
|
+
await browser.close();
|
|
@@ -79,6 +79,12 @@ raw `toObservable`). It emits `null` first - `pipe(filter(r => r !== null))`.
|
|
|
79
79
|
drive **search-as-you-type**: back it with a search signal
|
|
80
80
|
(`withArgs(() => ({ queryParams: { search: this.search() } }))`). Return
|
|
81
81
|
`CLEAR_QUERY_ARGS` to reset args to `null` (pauses polling/auto-refresh).
|
|
82
|
+
- **Prefer `withArgs` over passing `args` to `execute()`.** Args declared on the query
|
|
83
|
+
stay reactive: a `GET` re-executes itself when they change, and `withPolling` /
|
|
84
|
+
`withAutoRefresh` restart off the same signal - none of which happens for args handed
|
|
85
|
+
to `execute()`. A function route additionally throws without it. With `withArgs` in
|
|
86
|
+
place a mutation is just `.execute()`, which reuses the current `args()`. Reserve
|
|
87
|
+
`execute({ args })` for a one-off payload no signal holds (a form submit).
|
|
82
88
|
- `withPolling({ interval })`, `withAutoRefresh({ onSignalChanges: [...] })`.
|
|
83
89
|
- Side-effects: `withSuccessHandling`, `withErrorHandling`, `withLogging`.
|
|
84
90
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: rxjs-signals
|
|
3
|
-
description: How to choose between signals and RxJS, and use each correctly - synchronous state vs asynchronous work, unsubscribing, and avoiding RxJS inside effects/computeds. Read when adding reactive state, wiring up an observable, or deciding whether something should be a signal or a stream.
|
|
3
|
+
description: How to choose between signals and RxJS, and use each correctly - synchronous state vs asynchronous work, unsubscribing, and avoiding RxJS inside effects/computeds. Read when adding reactive state, wiring up an observable, or deciding whether something should be a signal or a stream.
|
|
4
4
|
kind: skill
|
|
5
5
|
scope: both
|
|
6
6
|
requires: ['@ethlete/core']
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: theming
|
|
3
|
-
description: The two runtime theming systems in @ethlete/core - surface theming (elevation-aware neutrals) and color theming (semantic accent palettes)
|
|
3
|
+
description: The two runtime theming systems in @ethlete/core - surface theming (elevation-aware neutrals) and color theming (semantic accent palettes). Read BEFORE writing or reviewing CSS involving color, background, border, or interaction state, and when wiring theme context across overlay/portal boundaries.
|
|
4
4
|
kind: skill
|
|
5
5
|
scope: consumer
|
|
6
6
|
requires: ['@ethlete/core']
|
package/package.json
CHANGED
package/src/lib/config.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export type SyncConfig = {
|
|
|
16
16
|
* `AGENTS.md` marker block, and a second copy would load twice.
|
|
17
17
|
*/
|
|
18
18
|
claudeMdImportsAgentsMd: boolean;
|
|
19
|
-
/** Opt-in
|
|
19
|
+
/** Opt-in agent hooks (they run commands on the developer's machine, so never default). */
|
|
20
20
|
hooks: string[];
|
|
21
21
|
};
|
|
22
22
|
/**
|
|
@@ -26,11 +26,15 @@ export type SyncConfig = {
|
|
|
26
26
|
* so nothing here may change what gets emitted:
|
|
27
27
|
*
|
|
28
28
|
* - `disableHooks: true` silences every generated hook, `["context-warning"]` just the named ones.
|
|
29
|
+
* - `disableAutoHandoffSave: true` keeps the context-warning hook's normal tiered messages (including
|
|
30
|
+
* in auto mode), but at the critical tier in auto mode it falls back to just recommending
|
|
31
|
+
* a handoff instead of writing the handoff file automatically.
|
|
29
32
|
* - `sdkSourcePath` points at a local `ethlete-sdk` checkout, which the SDK source and local-build
|
|
30
33
|
* skills read when they need the SDK's own sources instead of the published package.
|
|
31
34
|
*/
|
|
32
35
|
export type LocalConfig = {
|
|
33
36
|
disableHooks?: boolean | string[];
|
|
37
|
+
disableAutoHandoffSave?: boolean;
|
|
34
38
|
sdkSourcePath?: string;
|
|
35
39
|
};
|
|
36
40
|
export type LocalConfigState = {
|
package/src/lib/config.js
CHANGED
|
@@ -13,7 +13,7 @@ const readRawConfig = (root) => {
|
|
|
13
13
|
return {};
|
|
14
14
|
return JSON.parse((0, fs_1.readFileSync)(path, 'utf8'));
|
|
15
15
|
};
|
|
16
|
-
const LOCAL_CONFIG_KEYS = ['disableHooks', 'sdkSourcePath'];
|
|
16
|
+
const LOCAL_CONFIG_KEYS = ['disableHooks', 'disableAutoHandoffSave', 'sdkSourcePath'];
|
|
17
17
|
const readLocalConfig = (root) => {
|
|
18
18
|
const path = (0, path_1.join)(root, exports.LOCAL_CONFIG_FILE_NAME);
|
|
19
19
|
if (!(0, fs_1.existsSync)(path))
|