@ethlete/agent-rules 0.1.0-next.0 → 0.1.0-next.10
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 +102 -0
- package/README.md +363 -16
- package/content/git-hooks/post-checkout.sh +10 -0
- package/content/git-hooks/pre-push.sh +5 -0
- package/content/hooks/context-warning.py +430 -0
- package/content/output-styles/ste-clarity.md +131 -0
- package/content/rules/comments.md +50 -20
- package/content/skills/angular-patterns/SKILL.md +1 -1
- package/content/skills/api-source/SKILL.md +118 -0
- 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/git-commit/SKILL.md +6 -7
- package/content/skills/git-flow/SKILL.md +87 -0
- package/content/skills/handoff/SKILL.md +4 -0
- package/content/skills/query/SKILL.md +23 -13
- package/content/skills/rxjs-signals/SKILL.md +1 -1
- package/content/skills/sdk-docs/SKILL.md +10 -2
- package/content/skills/sdk-local-build/SKILL.md +115 -0
- package/content/skills/sdk-source/SKILL.md +133 -0
- package/content/skills/styleguide/STYLEGUIDE.md +2 -2
- package/content/skills/theming/SKILL.md +1 -1
- package/content/skills/timetrack/SKILL.md +66 -0
- package/package.json +12 -1
- package/src/index.js +35 -14
- package/src/index.js.map +1 -1
- package/src/lib/commitlint.d.ts +10 -0
- package/src/lib/commitlint.js +51 -0
- package/src/lib/commitlint.js.map +1 -0
- package/src/lib/config.d.ts +64 -2
- package/src/lib/config.js +54 -9
- package/src/lib/config.js.map +1 -1
- package/src/lib/git-flow/build.d.ts +35 -0
- package/src/lib/git-flow/build.js +24 -0
- package/src/lib/git-flow/build.js.map +1 -0
- package/src/lib/git-flow/config.d.ts +59 -0
- package/src/lib/git-flow/config.js +50 -0
- package/src/lib/git-flow/config.js.map +1 -0
- package/src/lib/git-flow/index.d.ts +6 -0
- package/src/lib/git-flow/index.js +10 -0
- package/src/lib/git-flow/index.js.map +1 -0
- package/src/lib/git-flow/parse.d.ts +49 -0
- package/src/lib/git-flow/parse.js +274 -0
- package/src/lib/git-flow/parse.js.map +1 -0
- package/src/lib/git-flow/rename.d.ts +24 -0
- package/src/lib/git-flow/rename.js +70 -0
- package/src/lib/git-flow/rename.js.map +1 -0
- package/src/lib/git-flow/start.d.ts +49 -0
- package/src/lib/git-flow/start.js +57 -0
- package/src/lib/git-flow/start.js.map +1 -0
- package/src/lib/git-flow/validate.d.ts +34 -0
- package/src/lib/git-flow/validate.js +72 -0
- package/src/lib/git-flow/validate.js.map +1 -0
- package/src/lib/git-flow-command.d.ts +4 -0
- package/src/lib/git-flow-command.js +157 -0
- package/src/lib/git-flow-command.js.map +1 -0
- package/src/lib/git-flow-repair.d.ts +17 -0
- package/src/lib/git-flow-repair.js +146 -0
- package/src/lib/git-flow-repair.js.map +1 -0
- package/src/lib/git-flow-start.d.ts +20 -0
- package/src/lib/git-flow-start.js +132 -0
- package/src/lib/git-flow-start.js.map +1 -0
- package/src/lib/git.d.ts +27 -0
- package/src/lib/git.js +49 -0
- package/src/lib/git.js.map +1 -0
- package/src/lib/gitlab.d.ts +35 -0
- package/src/lib/gitlab.js +98 -0
- package/src/lib/gitlab.js.map +1 -0
- package/src/lib/index.d.ts +2 -0
- package/src/lib/index.js +2 -0
- package/src/lib/index.js.map +1 -1
- package/src/lib/migrate.d.ts +6 -0
- package/src/lib/migrate.js +155 -0
- package/src/lib/migrate.js.map +1 -0
- package/src/lib/output-style-command.d.ts +3 -0
- package/src/lib/output-style-command.js +69 -0
- package/src/lib/output-style-command.js.map +1 -0
- package/src/lib/output-style.d.ts +38 -0
- package/src/lib/output-style.js +126 -0
- package/src/lib/output-style.js.map +1 -0
- package/src/lib/owned-paths.js +22 -1
- package/src/lib/owned-paths.js.map +1 -1
- package/src/lib/plan.d.ts +4 -1
- package/src/lib/plan.js +141 -8
- package/src/lib/plan.js.map +1 -1
- package/src/lib/prompt.d.ts +8 -0
- package/src/lib/prompt.js +27 -0
- package/src/lib/prompt.js.map +1 -0
- package/src/lib/render.d.ts +20 -2
- package/src/lib/render.js +31 -8
- package/src/lib/render.js.map +1 -1
- package/src/lib/sync.d.ts +0 -1
- package/src/lib/sync.js +8 -2
- package/src/lib/sync.js.map +1 -1
- package/src/lib/targets/agents-skills.d.ts +8 -0
- package/src/lib/targets/agents-skills.js +18 -0
- package/src/lib/targets/agents-skills.js.map +1 -0
- package/src/lib/targets/claude-hooks.d.ts +11 -0
- package/src/lib/targets/claude-hooks.js +28 -0
- package/src/lib/targets/claude-hooks.js.map +1 -0
- package/src/lib/targets/claude.d.ts +3 -1
- package/src/lib/targets/claude.js +14 -21
- package/src/lib/targets/claude.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/codex.d.ts +5 -3
- package/src/lib/targets/codex.js +9 -8
- package/src/lib/targets/codex.js.map +1 -1
- package/src/lib/targets/copilot.d.ts +3 -3
- package/src/lib/targets/copilot.js +6 -29
- package/src/lib/targets/copilot.js.map +1 -1
- package/src/lib/targets/cursor.d.ts +3 -3
- package/src/lib/targets/cursor.js +7 -12
- package/src/lib/targets/cursor.js.map +1 -1
- package/src/lib/targets/git-hooks.d.ts +24 -0
- package/src/lib/targets/git-hooks.js +70 -0
- package/src/lib/targets/git-hooks.js.map +1 -0
- package/src/lib/targets/hooks-shared.d.ts +37 -0
- package/src/lib/targets/hooks-shared.js +95 -0
- package/src/lib/targets/hooks-shared.js.map +1 -0
- package/src/lib/targets/shared.d.ts +22 -13
- package/src/lib/targets/shared.js +32 -15
- package/src/lib/targets/shared.js.map +1 -1
- package/src/lib/timetrack-command.d.ts +11 -0
- package/src/lib/timetrack-command.js +199 -0
- package/src/lib/timetrack-command.js.map +1 -0
- package/src/lib/timetrack.d.ts +86 -0
- package/src/lib/timetrack.js +112 -0
- package/src/lib/timetrack.js.map +1 -0
- package/src/lib/targets/neutral.d.ts +0 -8
- package/src/lib/targets/neutral.js +0 -29
- package/src/lib/targets/neutral.js.map +0 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: api-source
|
|
3
|
+
description: How to read the source of the API an app in this repo talks to, from a local backend checkout named in ethlete-agents.config.local.json. Read when a response shape, a status code, an error body, an enum or an auth rule has to be confirmed rather than guessed - and never edit that checkout as part of work in this repo.
|
|
4
|
+
kind: skill
|
|
5
|
+
scope: consumer
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Reading the API source
|
|
9
|
+
|
|
10
|
+
The client's types describe what the frontend _expects_. When the two disagree, the
|
|
11
|
+
server is right, so a question about the contract is answered in the API repository -
|
|
12
|
+
not by reading the frontend's own models harder.
|
|
13
|
+
|
|
14
|
+
Reach for the checkout when:
|
|
15
|
+
|
|
16
|
+
- a response arrives with a field, a shape or a `null` the frontend types do not allow
|
|
17
|
+
- you need the exact status code, error body or validation message for a failure path
|
|
18
|
+
- an enum, a permission or a filter parameter has to match the server's list exactly
|
|
19
|
+
- a request fails and you cannot tell whether the client or the server is wrong
|
|
20
|
+
- you are about to report or fix something in the API itself
|
|
21
|
+
|
|
22
|
+
Do not read it to design frontend code that the API does not serve yet. An endpoint in
|
|
23
|
+
the checkout is not deployed until the API team ships it.
|
|
24
|
+
|
|
25
|
+
## 1. Resolve the checkout for your app
|
|
26
|
+
|
|
27
|
+
One repository can hold several apps, each with its own API, so the paths are a map from
|
|
28
|
+
app to checkout. They are per machine, so the map lives in the gitignored
|
|
29
|
+
`ethlete-agents.config.local.json` at the repo root:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"apiRepoPaths": {
|
|
34
|
+
"hub": "../fut-hub-backend"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Read that file before searching anywhere. The rules:
|
|
40
|
+
|
|
41
|
+
- **The key is the app** as this repo names it - the workspace project name, which is
|
|
42
|
+
normally also the folder under `apps/`. Match the app you are working in.
|
|
43
|
+
- **A relative path resolves from the repo root**, not from the app folder.
|
|
44
|
+
- **One entry means one API.** If the map holds a single entry, use it whatever it is
|
|
45
|
+
called.
|
|
46
|
+
- **No matching entry - stop and ask.** Do not guess a sibling folder and do not clone
|
|
47
|
+
the repository. Say which app you needed the API for and offer the snippet above; the
|
|
48
|
+
file is gitignored, so adding it changes nothing for anyone else.
|
|
49
|
+
|
|
50
|
+
Without a checkout, fall back to what the running API tells you: the generated API
|
|
51
|
+
description if the project serves one (`/openapi.json`, `/swagger`, `/api/doc`), and the
|
|
52
|
+
real response body of the call you are debugging.
|
|
53
|
+
|
|
54
|
+
## 2. Check the branch before you read anything
|
|
55
|
+
|
|
56
|
+
A checkout sits on whatever branch its developer left it on, and the code you would
|
|
57
|
+
quote must be the code that serves this app:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
git -C <apiRepoPath> fetch --quiet # read-only, safe
|
|
61
|
+
git -C <apiRepoPath> status -sb # branch, ahead/behind, dirty files
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**The state to expect is the API's own development branch, up to date with its remote.**
|
|
65
|
+
Anything else, and you are describing a different API than the one the app calls.
|
|
66
|
+
|
|
67
|
+
When it is not in that state, **say so and ask** - never switch, pull, stash or reset it
|
|
68
|
+
yourself:
|
|
69
|
+
|
|
70
|
+
- **On another branch** - name it and ask. A feature branch may be exactly the endpoint
|
|
71
|
+
you were sent to look at, but it is not what the app talks to today.
|
|
72
|
+
- **Behind its remote** - report how far. The behaviour you are about to call a bug may
|
|
73
|
+
already be fixed upstream.
|
|
74
|
+
- **Dirty** - it holds someone's work in progress. Say so rather than quoting it as API
|
|
75
|
+
behaviour.
|
|
76
|
+
|
|
77
|
+
## 3. The checkout is not the environment the app calls
|
|
78
|
+
|
|
79
|
+
Even a clean, current checkout is only the source. The app talks to a deployed
|
|
80
|
+
environment, which can lag it:
|
|
81
|
+
|
|
82
|
+
- Read the app's API base URL from its environment config, and say which environment
|
|
83
|
+
your answer is about.
|
|
84
|
+
- When the source and the observed response disagree, **the observed response wins** for
|
|
85
|
+
what the app has to handle today. Report the difference instead of writing client code
|
|
86
|
+
against the newer source.
|
|
87
|
+
|
|
88
|
+
## 4. Search it, don't read it whole
|
|
89
|
+
|
|
90
|
+
The stack is whatever the API team chose, so search by the thing you already know - the
|
|
91
|
+
path, the field name, the error message - rather than by an expected file layout:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
rg -n "api/v1/matches" <apiRepoPath> --glob '!*test*' # the route
|
|
95
|
+
rg -n "kickoffAt" <apiRepoPath> # a field of the payload
|
|
96
|
+
rg -n "MATCH_NOT_FOUND" <apiRepoPath> # an error code you received
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Two things to find first, because they answer most questions on their own:
|
|
100
|
+
|
|
101
|
+
- **The API description** the project generates or checks in (OpenAPI, Swagger, GraphQL
|
|
102
|
+
schema, `.http` files). It is the contract, and it is cheaper to read than the code.
|
|
103
|
+
- **The serializer, resource or DTO** for the entity - the field list the client sees,
|
|
104
|
+
which is usually a subset of the database model, and the place where a name is
|
|
105
|
+
rewritten between the two.
|
|
106
|
+
|
|
107
|
+
The API's own tests describe intended behaviour more directly than the implementation:
|
|
108
|
+
they name the status code and the body for each case.
|
|
109
|
+
|
|
110
|
+
## 5. The checkout is read-only from here
|
|
111
|
+
|
|
112
|
+
It is a different repository with its own branch, review and release process. Never edit
|
|
113
|
+
it while working on a task in this repo, and never change its git state without being
|
|
114
|
+
asked (`fetch` is fine).
|
|
115
|
+
|
|
116
|
+
When the fix belongs in the API, say so precisely: the endpoint, the field, and the
|
|
117
|
+
behaviour it should have. Then handle the API as it is today - a client workaround for a
|
|
118
|
+
server bug is a decision for the user, and it needs a comment naming what it waits on.
|
|
@@ -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()
|