@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.
Files changed (134) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +363 -16
  3. package/content/git-hooks/post-checkout.sh +10 -0
  4. package/content/git-hooks/pre-push.sh +5 -0
  5. package/content/hooks/context-warning.py +430 -0
  6. package/content/output-styles/ste-clarity.md +131 -0
  7. package/content/rules/comments.md +50 -20
  8. package/content/skills/angular-patterns/SKILL.md +1 -1
  9. package/content/skills/api-source/SKILL.md +118 -0
  10. package/content/skills/figma-export/SKILL.md +193 -0
  11. package/content/skills/figma-export/dump-figma-layers.py +83 -0
  12. package/content/skills/figma-export/dump-figma-svg.py +235 -0
  13. package/content/skills/figma-export/measure-template.mjs +87 -0
  14. package/content/skills/git-commit/SKILL.md +6 -7
  15. package/content/skills/git-flow/SKILL.md +87 -0
  16. package/content/skills/handoff/SKILL.md +4 -0
  17. package/content/skills/query/SKILL.md +23 -13
  18. package/content/skills/rxjs-signals/SKILL.md +1 -1
  19. package/content/skills/sdk-docs/SKILL.md +10 -2
  20. package/content/skills/sdk-local-build/SKILL.md +115 -0
  21. package/content/skills/sdk-source/SKILL.md +133 -0
  22. package/content/skills/styleguide/STYLEGUIDE.md +2 -2
  23. package/content/skills/theming/SKILL.md +1 -1
  24. package/content/skills/timetrack/SKILL.md +66 -0
  25. package/package.json +12 -1
  26. package/src/index.js +35 -14
  27. package/src/index.js.map +1 -1
  28. package/src/lib/commitlint.d.ts +10 -0
  29. package/src/lib/commitlint.js +51 -0
  30. package/src/lib/commitlint.js.map +1 -0
  31. package/src/lib/config.d.ts +64 -2
  32. package/src/lib/config.js +54 -9
  33. package/src/lib/config.js.map +1 -1
  34. package/src/lib/git-flow/build.d.ts +35 -0
  35. package/src/lib/git-flow/build.js +24 -0
  36. package/src/lib/git-flow/build.js.map +1 -0
  37. package/src/lib/git-flow/config.d.ts +59 -0
  38. package/src/lib/git-flow/config.js +50 -0
  39. package/src/lib/git-flow/config.js.map +1 -0
  40. package/src/lib/git-flow/index.d.ts +6 -0
  41. package/src/lib/git-flow/index.js +10 -0
  42. package/src/lib/git-flow/index.js.map +1 -0
  43. package/src/lib/git-flow/parse.d.ts +49 -0
  44. package/src/lib/git-flow/parse.js +274 -0
  45. package/src/lib/git-flow/parse.js.map +1 -0
  46. package/src/lib/git-flow/rename.d.ts +24 -0
  47. package/src/lib/git-flow/rename.js +70 -0
  48. package/src/lib/git-flow/rename.js.map +1 -0
  49. package/src/lib/git-flow/start.d.ts +49 -0
  50. package/src/lib/git-flow/start.js +57 -0
  51. package/src/lib/git-flow/start.js.map +1 -0
  52. package/src/lib/git-flow/validate.d.ts +34 -0
  53. package/src/lib/git-flow/validate.js +72 -0
  54. package/src/lib/git-flow/validate.js.map +1 -0
  55. package/src/lib/git-flow-command.d.ts +4 -0
  56. package/src/lib/git-flow-command.js +157 -0
  57. package/src/lib/git-flow-command.js.map +1 -0
  58. package/src/lib/git-flow-repair.d.ts +17 -0
  59. package/src/lib/git-flow-repair.js +146 -0
  60. package/src/lib/git-flow-repair.js.map +1 -0
  61. package/src/lib/git-flow-start.d.ts +20 -0
  62. package/src/lib/git-flow-start.js +132 -0
  63. package/src/lib/git-flow-start.js.map +1 -0
  64. package/src/lib/git.d.ts +27 -0
  65. package/src/lib/git.js +49 -0
  66. package/src/lib/git.js.map +1 -0
  67. package/src/lib/gitlab.d.ts +35 -0
  68. package/src/lib/gitlab.js +98 -0
  69. package/src/lib/gitlab.js.map +1 -0
  70. package/src/lib/index.d.ts +2 -0
  71. package/src/lib/index.js +2 -0
  72. package/src/lib/index.js.map +1 -1
  73. package/src/lib/migrate.d.ts +6 -0
  74. package/src/lib/migrate.js +155 -0
  75. package/src/lib/migrate.js.map +1 -0
  76. package/src/lib/output-style-command.d.ts +3 -0
  77. package/src/lib/output-style-command.js +69 -0
  78. package/src/lib/output-style-command.js.map +1 -0
  79. package/src/lib/output-style.d.ts +38 -0
  80. package/src/lib/output-style.js +126 -0
  81. package/src/lib/output-style.js.map +1 -0
  82. package/src/lib/owned-paths.js +22 -1
  83. package/src/lib/owned-paths.js.map +1 -1
  84. package/src/lib/plan.d.ts +4 -1
  85. package/src/lib/plan.js +141 -8
  86. package/src/lib/plan.js.map +1 -1
  87. package/src/lib/prompt.d.ts +8 -0
  88. package/src/lib/prompt.js +27 -0
  89. package/src/lib/prompt.js.map +1 -0
  90. package/src/lib/render.d.ts +20 -2
  91. package/src/lib/render.js +31 -8
  92. package/src/lib/render.js.map +1 -1
  93. package/src/lib/sync.d.ts +0 -1
  94. package/src/lib/sync.js +8 -2
  95. package/src/lib/sync.js.map +1 -1
  96. package/src/lib/targets/agents-skills.d.ts +8 -0
  97. package/src/lib/targets/agents-skills.js +18 -0
  98. package/src/lib/targets/agents-skills.js.map +1 -0
  99. package/src/lib/targets/claude-hooks.d.ts +11 -0
  100. package/src/lib/targets/claude-hooks.js +28 -0
  101. package/src/lib/targets/claude-hooks.js.map +1 -0
  102. package/src/lib/targets/claude.d.ts +3 -1
  103. package/src/lib/targets/claude.js +14 -21
  104. package/src/lib/targets/claude.js.map +1 -1
  105. package/src/lib/targets/codex-hooks.d.ts +12 -0
  106. package/src/lib/targets/codex-hooks.js +33 -0
  107. package/src/lib/targets/codex-hooks.js.map +1 -0
  108. package/src/lib/targets/codex.d.ts +5 -3
  109. package/src/lib/targets/codex.js +9 -8
  110. package/src/lib/targets/codex.js.map +1 -1
  111. package/src/lib/targets/copilot.d.ts +3 -3
  112. package/src/lib/targets/copilot.js +6 -29
  113. package/src/lib/targets/copilot.js.map +1 -1
  114. package/src/lib/targets/cursor.d.ts +3 -3
  115. package/src/lib/targets/cursor.js +7 -12
  116. package/src/lib/targets/cursor.js.map +1 -1
  117. package/src/lib/targets/git-hooks.d.ts +24 -0
  118. package/src/lib/targets/git-hooks.js +70 -0
  119. package/src/lib/targets/git-hooks.js.map +1 -0
  120. package/src/lib/targets/hooks-shared.d.ts +37 -0
  121. package/src/lib/targets/hooks-shared.js +95 -0
  122. package/src/lib/targets/hooks-shared.js.map +1 -0
  123. package/src/lib/targets/shared.d.ts +22 -13
  124. package/src/lib/targets/shared.js +32 -15
  125. package/src/lib/targets/shared.js.map +1 -1
  126. package/src/lib/timetrack-command.d.ts +11 -0
  127. package/src/lib/timetrack-command.js +199 -0
  128. package/src/lib/timetrack-command.js.map +1 -0
  129. package/src/lib/timetrack.d.ts +86 -0
  130. package/src/lib/timetrack.js +112 -0
  131. package/src/lib/timetrack.js.map +1 -0
  132. package/src/lib/targets/neutral.d.ts +0 -8
  133. package/src/lib/targets/neutral.js +0 -29
  134. 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()