@topy-ai/maggie 0.7.61 → 0.8.1

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 (29) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +5 -5
  3. package/bundled-references/agent-goal-design.md +92 -0
  4. package/bundled-skills/catalog.json +4 -0
  5. package/bundled-skills/maggie-deployment/SKILL.md +21 -1
  6. package/bundled-skills/maggie-design/SKILL.md +29 -1
  7. package/bundled-skills/maggie-security/SKILL.md +10 -2
  8. package/bundled-skills/maggie-security/references/commands.md +10 -3
  9. package/bundled-skills/maggie-security/references/explaining-findings.md +7 -0
  10. package/bundled-skills/maggie-security/references/tools-and-licences.md +8 -0
  11. package/bundled-skills/maggie-security/registry/security-tools.registry.json +748 -652
  12. package/bundled-skills/maggie-spirit/SKILL.md +128 -0
  13. package/bundled-tools/clis/maggie_dash.py +12 -4
  14. package/bundled-tools/clis/maggie_security.py +1 -1
  15. package/bundled-tools/clis/maggie_security_code.py +49 -1
  16. package/bundled-tools/clis/maggie_security_deps.py +95 -0
  17. package/bundled-tools/clis/maggie_security_mcp.py +67 -1
  18. package/bundled-tools/clis/maggie_security_report.py +31 -2
  19. package/bundled-tools/clis/maggie_security_scan.py +168 -0
  20. package/bundled-tools/clis/maggie_security_web.py +45 -1
  21. package/bundled-tools/clis/maggie_spirit.py +143 -0
  22. package/bundled-tools/clis/security_data/tools.lock.json +36 -7
  23. package/bundled-tools/runtime/maggie_dash_pg.py +126 -0
  24. package/bundled-tools/runtime/maggie_dash_store.py +17 -5
  25. package/bundled-tools/runtime/spirit_agent.py +262 -0
  26. package/bundled-tools/runtime/spirit_base.py +256 -0
  27. package/bundled-tools/runtime/spirit_face.py +222 -0
  28. package/package.json +1 -1
  29. package/references/agent-goal-design.md +92 -0
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env python3
2
+ """Animate the Core-Agent faces through the 32-state system.
3
+
4
+ The agent's face (hair / eyes / prop) is the fixed identity; its ONEMAN O-rim
5
+ is the animatable ring — it reuses the exact motion vocabulary + palette +
6
+ badges from sprite_system.CATALOG (thinking sweeps, loading fills, critical
7
+ blinks, success turns green, awaiting shows the clock badge, …). `speaking`
8
+ also opens the mouth. Emits a PNG frame strip + still per state, a sprite sheet
9
+ and states.json per agent, into brand/<role>/.
10
+ """
11
+ import sys, os, json, math
12
+ HERE=os.path.dirname(os.path.abspath(__file__)); sys.path.insert(0,HERE)
13
+ OUT_BASE=HERE
14
+ import spirit_face as F
15
+ from spirit_base import CATALOG, GLYPH, palette, GROUPS
16
+ from PIL import Image
17
+
18
+ W,H,cx,cy=F.W,F.H,F.cx,F.cy
19
+ R_IN,R_OUT=F.R_IN,F.R_OUT
20
+ shade=F.shade; OUT=F.OUT
21
+
22
+ # rim cells (annulus) ordered by angle from top, clockwise
23
+ RIM=[]
24
+ for y in range(H):
25
+ for x in range(W):
26
+ d=math.hypot(x-cx,y-cy)
27
+ if R_IN-0.5<=d<=R_OUT: RIM.append((x,y))
28
+ def _ang(p): return (math.atan2(p[1]-cy,p[0]-cx)+math.pi/2)%(2*math.pi)
29
+ RIM.sort(key=_ang)
30
+ ANG={p:_ang(p) for p in RIM}
31
+
32
+ BX,BY=22,0
33
+ def put_badge(px,name,bg,fg):
34
+ for yy in range(0,6):
35
+ for xx in range(21,27): px[(xx,yy)]=None
36
+ g=GLYPH[name]
37
+ for r in range(5):
38
+ for c in range(5): px[(BX+c,BY+r)]=fg if g[r][c]=="1" else bg
39
+ def tri(t,nf): x=(2*t/nf); return x if x<=1 else 2-x
40
+
41
+ WHT=F.WHT; EYE=F.EYE
42
+ EYEPROPS={"glasses","shades","monocle"} # cover the eyes -> don't redraw eyes under them
43
+ MOUTHPROPS={"cigar"} # at the mouth -> keep base mouth
44
+
45
+ # ---- expression layer: per-state face animation (generic, reusable) ----
46
+ # state -> (eye-mode, mouth-mode). Blink is added automatically on longer loops.
47
+ EXPR={
48
+ "off":("closed","flat"),"booting":("wake","small"),"idle":("open","smile_s"),
49
+ "active":("open","smile_s"),"paused":("half","flat"),"offline":("closed","flat"),
50
+ "thinking":("up","small"),"loading":("open","flat"),"syncing":("dart","flat"),
51
+ "writing":("down","small"),"streaming":("open","talk"),"queued":("open","flat"),
52
+ "scanning":("dart","flat"),"deploying":("determined","flat"),
53
+ "speaking":("open","talk"),"listening":("side","small"),
54
+ "notification":("wide","small"),"mention":("side","smile"),"recording":("open","flat"),
55
+ "success":("happy","grin"),"warning":("worried","frown"),"critical":("wide","frown"),
56
+ "blocked":("side","flat"),"error":("x","frown"),"retrying":("determined","flat"),
57
+ "awaiting":("up","small"),"approved":("happy","smile"),"rejected":("worried","frown"),
58
+ "secure":("open","smile_s"),"vulnerable":("worried","frown"),"maintenance":("down","flat"),
59
+ "celebrating":("happy","grin"),
60
+ }
61
+ def _clear(px,x0,y0,x1,y1,SK):
62
+ for y in range(y0,y1+1):
63
+ for x in range(x0,x1+1): px[(x,y)]=SK
64
+ def draw_eyes_expr(px,mode,t,nf,SK,lash,blink):
65
+ for s in(-1,1):
66
+ _clear(px,cx+s*3 if s>0 else cx-6, cy-3, cx+6 if s>0 else cx-3, cy, SK)
67
+ if blink: mode="closed"
68
+ def whites(s):
69
+ for x in(cx+s*3,cx+s*4,cx+s*5): px[(x,cy-2)]=WHT; px[(x,cy-1)]=WHT
70
+ def lashmark(s):
71
+ if lash: px[(cx+s*6,cy-2)]=OUT; px[(cx+s*5,cy-3)]=OUT
72
+ for s in(-1,1):
73
+ if mode=="closed":
74
+ for x in(cx+s*3,cx+s*4,cx+s*5): px[(x,cy-1)]=OUT
75
+ lashmark(s); continue
76
+ if mode=="half":
77
+ for x in(cx+s*3,cx+s*4,cx+s*5): px[(x,cy-1)]=WHT
78
+ px[(cx+s*4,cy-1)]=EYE
79
+ for x in(cx+s*3,cx+s*4,cx+s*5): px[(x,cy-2)]=OUT
80
+ continue
81
+ if mode=="happy":
82
+ px[(cx+s*5,cy-1)]=OUT; px[(cx+s*4,cy-2)]=OUT; px[(cx+s*3,cy-1)]=OUT; continue
83
+ if mode=="x":
84
+ px[(cx+s*3,cy-2)]=OUT; px[(cx+s*4,cy-1)]=OUT; px[(cx+s*5,cy)]=OUT
85
+ px[(cx+s*5,cy-2)]=OUT; px[(cx+s*3,cy)]=OUT; continue
86
+ whites(s); lashmark(s)
87
+ pdx, pdy = 0, 0
88
+ if mode=="up": pdy=-1
89
+ elif mode=="down": pdy=0; px[(cx+s*3,cy-2)]=EYE # lowered lid feel
90
+ elif mode=="side": pdx=1
91
+ elif mode=="dart": pdx=1 if (t%2==0) else -1
92
+ elif mode=="wide": pass
93
+ elif mode=="worried": px[(cx+s*4 if s<0 else cx+s*3,cy-3)]=OUT # inner brow down
94
+ elif mode=="determined": px[(cx+s*5,cy-3)]=OUT # outer brow
95
+ px[(cx+s*(4+pdx), cy-1+pdy)]=EYE
96
+ if mode not in("wide","up"): px[(cx+s*4, cy-2)]=EYE if pdy==0 else WHT
97
+ if mode=="wake" and t< nf//2:
98
+ for x in(cx+s*3,cx+s*4,cx+s*5): px[(x,cy-1)]=OUT; px[(x,cy-2)]=SK
99
+ def draw_mouth_expr(px,mode,t,SK,mcol):
100
+ _clear(px,cx-2,cy+3,cx+2,cy+5,SK)
101
+ if mode=="smile" or mode=="smile_s":
102
+ px[(cx-2,cy+4)]=OUT;px[(cx-1,cy+5)]=OUT;px[(cx,cy+5)]=OUT;px[(cx+1,cy+5)]=OUT;px[(cx+2,cy+4)]=OUT
103
+ elif mode=="grin":
104
+ px[(cx-2,cy+4)]=OUT;px[(cx+2,cy+4)]=OUT
105
+ for x in range(cx-1,cx+2): px[(x,cy+4)]=OUT; px[(x,cy+5)]=WHT
106
+ elif mode=="flat":
107
+ for x in range(cx-2,cx+2): px[(x,cy+4)]=OUT
108
+ elif mode=="frown":
109
+ px[(cx-2,cy+5)]=OUT;px[(cx-1,cy+4)]=OUT;px[(cx,cy+4)]=OUT;px[(cx+1,cy+4)]=OUT;px[(cx+2,cy+5)]=OUT
110
+ elif mode=="small":
111
+ px[(cx,cy+4)]=OUT; px[(cx-1,cy+4)]=OUT
112
+ elif mode=="talk":
113
+ if t%2:
114
+ for x in range(cx-1,cx+2): px[(x,cy+4)]=OUT;px[(x,cy+5)]=OUT
115
+ px[(cx,cy+5)]=F._rgb('7a1a12')
116
+ else:
117
+ for x in range(cx-1,cx+2): px[(x,cy+4)]=OUT
118
+ if mcol and mode in("smile","smile_s","flat","grin"): # lipstick tint
119
+ for x in range(cx-2,cx+3):
120
+ if px.get((x,cy+4))==OUT: px[(x,cy+4)]=mcol
121
+
122
+ # ---- per-agent signature micro-animation on the prop (always-on identity) ----
123
+ def prop_anim(px,prop,t,nf,propcol):
124
+ ph=t%max(1,nf); frac=t/max(1,nf); rgb=F._rgb
125
+ if prop=="cigar":
126
+ for k in range(2): # smoke curl rising
127
+ yy=(cy+2)-((ph+k*2)%6); xx=cx+9+((ph+k)%2)
128
+ if yy>=cy-4: px[(xx,yy)]=rgb('9a9aa8') if k==0 else rgb('6a6a7a')
129
+ px[(cx+8,cy+4)]=rgb('ff4422') if ph%2==0 else rgb('ff7a44') # ember flicker
130
+ elif prop=="headset":
131
+ px[(cx-6,cy+4)]=rgb('ff4422') if ph%2==0 else rgb('5a1a10') # mic light blink
132
+ elif prop in("shades","glasses"):
133
+ gx=cx-5+int(frac*11) # glint sweeps both lenses
134
+ for lx0,lx1 in((cx-5,cx-1),(cx+2,cx+6)):
135
+ if lx0<=gx<=lx1: px[(gx,cy-1)]=WHT
136
+ elif prop=="monocle":
137
+ gx=cx+2+int(frac*4)
138
+ if cx+2<=gx<=cx+6: px[(gx,cy-1)]=WHT
139
+ px[(cx+4+(1 if ph%2 else 0),cy+2)]=rgb('d4af37') # chain sway
140
+ elif prop=="pen":
141
+ if ph%2==0: px[(cx+8,cy-1)]=rgb('f2c75a') # write spark at the nib
142
+ elif prop=="earbuds":
143
+ w=rgb('9fe8ff')
144
+ if ph%2==0: px[(cx-10,cy)]=w; px[(cx+10,cy)]=w # sound waves pulse out
145
+ else: px[(cx-11,cy-1)]=w; px[(cx+11,cy-1)]=w
146
+ elif prop=="bandana":
147
+ dy=1 if ph%2 else 0; BC=rgb(propcol or 'ef4444')
148
+ px[(cx-11,cy+dy)]=BC; px[(cx-11,cy+1+dy)]=shade(BC,-22) # tail flutter
149
+
150
+ def founder_anim(px,t,nf):
151
+ rgb=F._rgb; ph=t%max(1,nf); frac=t/max(1,nf)
152
+ gx=cx-5+int(frac*11) # glasses glint sweep
153
+ for lx0,lx1 in((cx-5,cx-3),(cx+3,cx+5)):
154
+ if lx0<=gx<=lx1: px[(gx,cy-1)]=WHT
155
+ GL = rgb('39d353') if ph%2==0 else rgb('1f7a3a') # green mask-vent pulse
156
+ for k in range(3): px[(cx+3,cy+4+k)]=GL
157
+ BC=rgb('e23b3b'); BCd=shade(BC,-24); w=1 if ph%2 else 0 # right-side tails ripple in the wind
158
+ for strand in(0,1):
159
+ for k in range(2,6):
160
+ xx=cx+8+k; yy=(cy-2-strand*2)-(k*2)//3-(w if k>=3 else 0)
161
+ if 0<=xx<W and 0<=yy<H: px[(xx,yy)]=BC if k<3 else BCd
162
+
163
+ NF={"breathe":10,"breathe_slow":10,"pulse":8,"blink":2,"arc":12,"scan":12,"spin":10,
164
+ "dualarc":12,"fill":12,"boot":10,"dots":6,"sparkout":4,"sparkin":4,"celebrate":8}
165
+
166
+ def frames(base, rim_live, state, P, ctx):
167
+ grp,role,motion,badge,fps=CATALOG[state]
168
+ col=P[role] if role in P else P["accent"]
169
+ nf=NF[motion]; out=[]
170
+ SK=F.SKINS[ctx["skin"]]; lash=(ctx["gender"]=="f")
171
+ eyemode,mouthmode=EXPR[state]
172
+ for t in range(nf):
173
+ px=dict(base)
174
+ def setrim(fn):
175
+ for i,p in enumerate(rim_live): px[p]=fn(i,p)
176
+ n=len(rim_live)
177
+ if motion in("breathe","breathe_slow"):
178
+ cc=shade(col,(10 if motion=="breathe_slow" else 16)*tri(t,nf)); setrim(lambda i,p:cc)
179
+ elif motion=="pulse":
180
+ cc=shade(col,42*tri(t,nf)); setrim(lambda i,p:cc)
181
+ elif motion=="blink":
182
+ cc=col if t%2==0 else shade(col,-60); setrim(lambda i,p:cc)
183
+ elif motion in("arc","scan","spin"):
184
+ win={"arc":80,"scan":34,"spin":54}[motion]; a0=(t/nf)*2*math.pi; w=math.radians(win)
185
+ setrim(lambda i,p:(P["acc_hot"] if min((ANG[p]-a0)%(2*math.pi),(a0-ANG[p])%(2*math.pi))<=w/2 else P["acc_dim"]))
186
+ elif motion in("fill","boot"):
187
+ prog=(t+1)/nf; setrim(lambda i,p:(col if (i/max(1,n))<=prog else P["acc_dim"]))
188
+ elif motion=="dualarc":
189
+ a0=(t/nf)*2*math.pi; w=math.radians(70)
190
+ def cm(i,p):
191
+ for b in(a0,a0+math.pi):
192
+ if min((ANG[p]-b)%(2*math.pi),(b-ANG[p])%(2*math.pi))<=w/2: return P["acc_hot"]
193
+ return P["acc_dim"]
194
+ setrim(cm)
195
+ elif motion=="dots":
196
+ cc=shade(col,14*tri(t,nf)); setrim(lambda i,p:cc)
197
+ elif motion=="sparkin":
198
+ cc=shade(col,16*tri(t,nf)); setrim(lambda i,p:cc)
199
+ elif motion=="celebrate":
200
+ cyc=[P["green"],P["amber"],P["accent"],P["acc_spark"]]; cc=cyc[t%len(cyc)]; setrim(lambda i,p:cc)
201
+ else: # sparkout + fallbacks: steady rim
202
+ setrim(lambda i,p:col)
203
+ # ---- personalized expression (eyes + mouth per state) ----
204
+ blink = (nf>=6 and t==nf-1) # a blink each loop on longer states
205
+ eyeCov=(ctx["prop"] in EYEPROPS) or ctx.get("specs")
206
+ mouthCov=(ctx["prop"] in MOUTHPROPS) or ctx.get("mask")
207
+ if not eyeCov:
208
+ draw_eyes_expr(px,eyemode,t,nf,SK,lash,blink)
209
+ if not mouthCov:
210
+ mcol=F._rgb('c0392b') if ctx.get("mouth")=="lips" else None
211
+ draw_mouth_expr(px,mouthmode,t,SK,mcol)
212
+ if ctx.get("founder"): founder_anim(px,t,nf) # founder signature anim
213
+ else: prop_anim(px, ctx["prop"], t, nf, ctx.get("propcol"))
214
+ if badge:
215
+ nm,bg,fg=badge; put_badge(px,nm,P[bg],P[fg])
216
+ out.append(px)
217
+ return out, fps
218
+
219
+ def gen_agent(role, accent, gender, params, scale=8):
220
+ P=palette(accent)
221
+ base=F.face(accent, gender=gender, **params)
222
+ rim_live=[p for p in RIM if base.get(p) in (F._rgb(accent), shade(F._rgb(accent),-30))]
223
+ ctx={"skin":params.get("skin","mid"),"gender":gender,"prop":params.get("prop"),
224
+ "mouth":params.get("mouth"),"propcol":params.get("propcol"),
225
+ "specs":params.get("specs"),"mask":params.get("mask"),"founder":(role=="founder")}
226
+ out=os.path.join(OUT_BASE, role); os.makedirs(out, exist_ok=True)
227
+ # wipe old O+1 agent files
228
+ for fn in os.listdir(out):
229
+ if fn.endswith(('.png','.svg','.json')): os.remove(os.path.join(out,fn))
230
+ meta={"title":role.upper(),"accent":accent,"gender":gender,"W":W,"H":H,"scale":scale,"groups":GROUPS,"states":{}}
231
+ def render(px): return F.render(px,scale)
232
+ for s in CATALOG:
233
+ frs,fps=frames(base,rim_live,s,P,ctx)
234
+ imgs=[render(px) for px in frs]
235
+ w=sum(i.width for i in imgs); strip=Image.new("RGBA",(w,imgs[0].height),(0,0,0,0)); x=0
236
+ for i in imgs: strip.paste(i,(x,0)); x+=i.width
237
+ strip.save(os.path.join(out,f"anim-{s}.png"))
238
+ render(frs[0]).save(os.path.join(out,f"still-{s}.png"))
239
+ grp,role_,motion,badge,_=CATALOG[s]
240
+ meta["states"][s]={"group":grp,"frames":len(frs),"fps":fps,"motion":motion,"badge":badge[0] if badge else None}
241
+ # sprite sheet (8x4, representative frame)
242
+ def rep(m,n): return {"blink":0,"fill":int(n*.62),"boot":int(n*.62),"arc":n//4,"scan":n//4,"spin":n//4,"dualarc":n//4,"dots":1}.get(m,n//2)
243
+ cols,rows,cell=8,4,6
244
+ tw=W*cell*0+W; # overview uses small renders
245
+ cellimg=lambda px: F.render(px, 5)
246
+ order=[s for g in GROUPS for s in CATALOG if CATALOG[s][0]==g]
247
+ tile=W*5; pad=6
248
+ sheet=Image.new("RGBA",(cols*tile+(cols+1)*pad, rows*(H*5)+(rows+1)*pad),(16,16,24,255))
249
+ for i,s in enumerate(order):
250
+ frs,_=frames(base,rim_live,s,P,ctx); im=cellimg(frs[rep(CATALOG[s][2],len(frs))])
251
+ X=pad+(i%cols)*(tile+pad); Y=pad+(i//cols)*(H*5+pad); sheet.paste(im,(X,Y),im)
252
+ sheet.save(os.path.join(out,"sprite-sheet.png"))
253
+ json.dump(meta, open(os.path.join(out,"states.json"),"w"), indent=1)
254
+ return meta
255
+
256
+ if __name__=="__main__":
257
+ only=sys.argv[1] if len(sys.argv)>1 else None
258
+ for role,acc,g,p in list(F.CAST)+[F.FOUNDER]:
259
+ r=role.lower()
260
+ if only and r!=only: continue
261
+ m=gen_agent(r,acc,g,p)
262
+ print(f"{role} {acc} {g}: {len(m['states'])} states -> brand/{r}/")
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env python3
2
+ """ONEMAN sprite system — the reusable animated status-sprite basic set.
3
+
4
+ Every ONEMAN agent (Core or Service) shares ONE visual language: a 17x17 flat
5
+ pixel motif, a fixed semantic palette tinted by the agent's accent, a catalog of
6
+ 32 common states, and a vocabulary of animation primitives. Give this generator
7
+ a Motif (the agent's mark) + an accent colour and it emits the whole set in a
8
+ consistent style: an animated PNG frame strip per state, a still SVG per state,
9
+ a combined overview sheet, and states.json (frames + fps) to drive CSS/canvas.
10
+
11
+ The ONEMAN mark (a blocky O ring + central 1 bar, accent #ff1212 = topy.ai) is
12
+ the default motif. A new agent reuses everything here and supplies only its own
13
+ motif grid + accent, so the whole roster animates and reads identically.
14
+
15
+ Usage:
16
+ python3 sprite_system.py # regenerate the ONEMAN set
17
+ from sprite_system import Motif, generate # build another agent's set
18
+ generate(Motif.oneman(), accent="#ff1212", out="oneman", title="ONEMAN")
19
+
20
+ PIL only (no cairosvg).
21
+ """
22
+ from __future__ import annotations
23
+ import json, math, os
24
+ from PIL import Image, ImageDraw
25
+
26
+ HERE = os.path.dirname(os.path.abspath(__file__))
27
+ OUT_BASE = HERE
28
+
29
+ # ---------------------------------------------------------------- palette ----
30
+ def _rgb(h): h=h.lstrip("#"); return (int(h[0:2],16),int(h[2:4],16),int(h[4:6],16))
31
+ def shade(c, amt):
32
+ """Lighten (amt>0) or darken (amt<0) an rgb tuple toward white/black, in %."""
33
+ f=lambda v: max(0,min(255,round(v+(amt/100)*((255-v) if amt>0 else v))))
34
+ return (f(c[0]),f(c[1]),f(c[2]))
35
+
36
+ # fixed semantic colours shared by every agent (danger/success/etc are universal)
37
+ SEM = {"red":_rgb("ff1212"), "green":_rgb("36c76a"), "amber":_rgb("f2b01e"),
38
+ "blue":_rgb("3a9bff"), "grey":_rgb("4a4a58"), "ink":_rgb("14141e"),
39
+ "white":_rgb("f5f5fa")}
40
+
41
+ def palette(accent_hex):
42
+ a=_rgb(accent_hex)
43
+ p=dict(SEM)
44
+ p.update(accent=a, acc_dim=shade(a,-52), acc_deep=shade(a,-66),
45
+ acc_hot=shade(a,34), acc_spark=shade(a,72),
46
+ grey_d=shade(SEM["grey"],-42), green_d=shade(SEM["green"],-70),
47
+ amber_d=shade(SEM["amber"],-78), red_deep=shade(SEM["red"],-66))
48
+ return p
49
+
50
+ # ------------------------------------------------------------------ motif ----
51
+ class Motif:
52
+ """A 17x17 mark + the cell roles the animation vocabulary needs."""
53
+ def __init__(self, grid, core=None, dots=None, sparks=None):
54
+ self.grid=grid; self.N=len(grid); self.cx=self.cy=(self.N-1)/2
55
+ self.filled=[(x,y) for y in range(self.N) for x in range(self.N) if grid[y][x]]
56
+ self.core=core if core is not None else []
57
+ coreset=set(self.core)
58
+ ringc=[(x,y) for (x,y) in self.filled if (x,y) not in coreset]
59
+ # order ring cells by angle from top, clockwise — the animatable path
60
+ self.ring=sorted(ringc, key=lambda p:(math.atan2(p[1]-self.cy,p[0]-self.cx)+math.pi/2)%(2*math.pi))
61
+ self.dots=dots if dots is not None else []
62
+ self.sparks=sparks if sparks is not None else []
63
+ def ang(self,x,y): return (math.atan2(y-self.cy,x-self.cx))%(2*math.pi)
64
+
65
+ @staticmethod
66
+ def oneman():
67
+ g=[[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0],
68
+ [0,0,0,0,1,1,0,1,1,1,0,1,1,0,0,0,0],[0,0,0,1,1,1,0,1,1,1,0,1,1,1,0,0,0],
69
+ [0,0,1,1,1,1,0,1,1,1,0,1,1,1,1,0,0],[0,1,1,1,1,0,0,1,1,1,0,0,1,1,1,1,0],
70
+ [0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0],[0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0],
71
+ [0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0],[0,1,1,1,0,0,0,0,0,0,0,0,0,1,1,1,0],
72
+ [0,1,1,1,0,0,0,0,0,0,0,0,0,1,1,1,0],[0,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,0],
73
+ [0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0],[0,0,0,1,1,1,1,1,1,1,1,1,1,1,0,0,0],
74
+ [0,0,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0],[0,0,0,0,0,1,1,1,1,1,1,1,0,0,0,0,0],
75
+ [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]]
76
+ core=[(x,y) for y in range(17) for x in range(17) if g[y][x] and x in (7,8,9) and y<=9]
77
+ dots=[(6,10),(8,10),(10,10)]
78
+ sparks_lv=[[],[(8,0),(1,5),(15,5),(0,8),(16,8)],
79
+ [(8,0),(7,0),(9,0),(1,4),(15,4),(0,8),(16,8),(1,12),(15,12)]]
80
+ m=Motif(g, core=core, dots=dots, sparks=sparks_lv); return m
81
+
82
+ # --------------------------------------------------------------- badges ------
83
+ GLYPH = {
84
+ "dot":["00000","00000","00100","00000","00000"],"check":["00000","00001","00010","10100","01000"],
85
+ "x":["10001","01010","00100","01010","10001"],"pause":["01010","01010","01010","01010","01010"],
86
+ "slash":["00001","00010","00100","01000","10000"],"clock":["01110","10101","10111","10001","01110"],
87
+ "lock":["01110","01010","11111","11011","11111"],"shield":["11111","11111","01110","01110","00100"],
88
+ "up":["00100","01110","11111","00100","00100"],"gear":["01010","11111","01110","11111","01010"],
89
+ }
90
+ def put_badge(px, name, bg, fg, N):
91
+ for yy in range(0,6):
92
+ for xx in range(11, min(17,N)): px[(xx,yy)]=None
93
+ g=GLYPH[name]
94
+ for r in range(5):
95
+ for c in range(5): px[(12+c,r)] = fg if g[r][c]=="1" else bg
96
+
97
+ # ------------------------------------------------- animation primitives ------
98
+ # Each returns {(x,y): rgb|None} for frame t of nf. `col` is the state's base.
99
+ def _fillall(m,col): return {p:col for p in m.filled}
100
+ def tri(t,nf): x=(2*t/nf); return x if x<=1 else 2-x # 0..1..0 triangle
101
+
102
+ def mo_breathe(m,col,t,nf,P,amt=16):
103
+ return _fillall(m, shade(col, amt*tri(t,nf)))
104
+ def mo_breathe_slow(m,col,t,nf,P): return mo_breathe(m,col,t,nf,P,amt=10)
105
+ def mo_pulse(m,col,t,nf,P):
106
+ k=tri(t,nf); return _fillall(m, tuple(round(col[i]+(P["acc_hot"][i] if col==P["accent"] else shade(col,55)[i]-col[i])*0 + (shade(col,42)[i]-col[i])*k) for i in range(3)))
107
+ def mo_blink(m,col,t,nf,P): return _fillall(m, col if t%2==0 else shade(col,-60))
108
+ def mo_arc(m,col,t,nf,P,win=80,hot=None):
109
+ px=_fillall(m,P["acc_dim"]); hot=hot or P["acc_hot"]; ang0=(t/nf)*2*math.pi; w=math.radians(win)
110
+ for (x,y) in m.ring:
111
+ a=m.ang(x,y); da=min((a-ang0)%(2*math.pi),(ang0-a)%(2*math.pi))
112
+ if da<=w/2: px[(x,y)]=hot
113
+ for p in m.core: px[p]=col
114
+ return px
115
+ def mo_scan(m,col,t,nf,P): return mo_arc(m,col,t,nf,P,win=34,hot=P["acc_spark"])
116
+ def mo_spin(m,col,t,nf,P): return mo_arc(m,col,t,nf,P,win=54)
117
+ def mo_dualarc(m,col,t,nf,P):
118
+ px=_fillall(m,P["acc_dim"]); ang0=(t/nf)*2*math.pi; w=math.radians(70)
119
+ for (x,y) in m.ring:
120
+ a=m.ang(x,y)
121
+ for b in (ang0,ang0+math.pi):
122
+ if min((a-b)%(2*math.pi),(b-a)%(2*math.pi))<=w/2: px[(x,y)]=P["acc_hot"]
123
+ for p in m.core: px[p]=col
124
+ return px
125
+ def mo_fill(m,col,t,nf,P,core_from=0.0):
126
+ px=_fillall(m,P["acc_dim"]); prog=(t+1)/nf
127
+ for (x,y) in m.ring:
128
+ a=(m.ang(x,y)+math.pi/2)%(2*math.pi)
129
+ if a/(2*math.pi)<=prog: px[(x,y)]=col
130
+ for p in m.core:
131
+ if prog>=core_from: px[p]=col
132
+ return px
133
+ def mo_boot(m,col,t,nf,P): return mo_fill(m,col,t,nf,P,core_from=0.7)
134
+ def mo_dots(m,col,t,nf,P):
135
+ px=_fillall(m,col); lit=t%max(1,len(m.dots))
136
+ for i,p in enumerate(m.dots): px[p]=P["acc_spark"] if i==lit else P["acc_deep"]
137
+ return px
138
+ def mo_sparkout(m,col,t,nf,P):
139
+ px=_fillall(m,col); step=[0,1,2,1][t%4]
140
+ for p in m.sparks[step]: px[p]=P["acc_spark"] if step==2 else P["acc_hot"]
141
+ return px
142
+ def mo_sparkin(m,col,t,nf,P):
143
+ px=_fillall(m,col); step=[2,1,0,1][t%4]
144
+ for p in m.sparks[step]: px[p]=P["acc_spark"]
145
+ return px
146
+ def mo_celebrate(m,col,t,nf,P):
147
+ cyc=[P["green"],P["amber"],P["accent"],P["acc_spark"]]
148
+ px=_fillall(m,cyc[t%len(cyc)]); step=[0,1,2,2][t%4]
149
+ for p in m.sparks[step]: px[p]=P["acc_spark"] if t%2 else P["white"]
150
+ return px
151
+
152
+ MOTION = {"breathe":mo_breathe,"breathe_slow":mo_breathe_slow,"pulse":mo_pulse,"blink":mo_blink,
153
+ "arc":mo_arc,"scan":mo_scan,"spin":mo_spin,"dualarc":mo_dualarc,"fill":mo_fill,"boot":mo_boot,
154
+ "dots":mo_dots,"sparkout":mo_sparkout,"sparkin":mo_sparkin,"celebrate":mo_celebrate}
155
+
156
+ # ------------------------------------------------- state catalog (32) --------
157
+ # state: (group, colour-role, motion, badge|None, fps)
158
+ A="accent"
159
+ CATALOG = {
160
+ # power / lifecycle
161
+ "off":("power","grey","breathe_slow",None,6), "booting":("power",A,"boot",None,8),
162
+ "idle":("power","acc_dim","breathe",None,6), "active":("power",A,"breathe",None,7),
163
+ "paused":("power","amber","breathe",("pause","amber_d","white"),6),
164
+ "offline":("power","grey","breathe_slow",("slash","grey_d","white"),4),
165
+ # work / process
166
+ "thinking":("work",A,"arc",None,11), "loading":("work",A,"fill",None,9),
167
+ "syncing":("work",A,"dualarc",None,10), "writing":("work",A,"dots",None,5),
168
+ "streaming":("work",A,"pulse",None,6), "queued":("work","amber","breathe",("clock","amber_d","white"),6),
169
+ "scanning":("work",A,"scan",None,16), "deploying":("work",A,"fill",("up","ink","white"),9),
170
+ # communication
171
+ "speaking":("comms",A,"sparkout",None,5), "listening":("comms","blue","sparkin",None,5),
172
+ "notification":("comms",A,"pulse",("dot","white","accent"),6),
173
+ "mention":("comms","blue","pulse",("dot","white","blue"),6),
174
+ "recording":("comms","red","pulse",("dot","white","red"),7),
175
+ # outcomes
176
+ "success":("outcome","green","breathe",None,7), "warning":("outcome","amber","pulse",None,6),
177
+ "critical":("outcome","red","blink",None,2), "blocked":("outcome","red","blink",("lock","ink","white"),2),
178
+ "error":("outcome","red","blink",("x","ink","white"),2),
179
+ "retrying":("outcome",A,"spin",None,12),
180
+ # governance / domain
181
+ "awaiting":("gov","amber","breathe",("clock","amber_d","white"),5),
182
+ "approved":("gov","green","breathe",("check","white","green"),7),
183
+ "rejected":("gov","red","breathe",("x","ink","white"),6),
184
+ "secure":("gov","green","breathe",("shield","green_d","white"),7),
185
+ "vulnerable":("gov","red","pulse",("shield","ink","white"),6),
186
+ "maintenance":("gov","grey","breathe",("gear","grey_d","white"),5),
187
+ "celebrating":("gov","green","celebrate",None,7),
188
+ }
189
+ GROUPS=["power","work","comms","outcome","gov"]
190
+
191
+ def _color(role,P): return P[role] if role in P else P.get(role, P["accent"])
192
+
193
+ def frames_for(m,state,P):
194
+ grp,role,motion,badge,fps=CATALOG[state]
195
+ col=_color(role,P); fn=MOTION[motion]
196
+ nf={"breathe":10,"breathe_slow":10,"pulse":8,"blink":2,"arc":12,"scan":12,"spin":10,
197
+ "dualarc":12,"fill":12,"boot":10,"dots":max(2,len(m.dots)*2),"sparkout":4,"sparkin":4,"celebrate":8}[motion]
198
+ out=[]
199
+ for t in range(nf):
200
+ px=fn(m,col,t,nf,P)
201
+ if badge:
202
+ nm,bg,fg=badge
203
+ put_badge(px, nm, P[bg], P[fg], m.N)
204
+ out.append(px)
205
+ return out, fps
206
+
207
+ # ------------------------------------------------------------- renderers -----
208
+ def render(px,N,scale):
209
+ img=Image.new("RGBA",(N*scale,N*scale),(0,0,0,0)); d=ImageDraw.Draw(img)
210
+ for (x,y),c in px.items():
211
+ if c is not None: d.rectangle([x*scale,y*scale,(x+1)*scale-1,(y+1)*scale-1],fill=c+(255,))
212
+ return img
213
+ def svg_of(px,N):
214
+ from collections import defaultdict
215
+ by=defaultdict(list)
216
+ for (x,y),c in px.items():
217
+ if c is not None: by[c].append((x,y))
218
+ parts=[]
219
+ for c,cells in by.items():
220
+ rs="".join(f'<rect x="{x}" y="{y}" width="1" height="1"/>' for x,y in sorted(cells))
221
+ parts.append(f'<g fill="#%02x%02x%02x">{rs}</g>'%c)
222
+ return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {N} {N}" '
223
+ f'shape-rendering="crispEdges" role="img">{"".join(parts)}</svg>\n')
224
+
225
+ def generate(m:Motif, accent="#ff1212", out="oneman", title="ONEMAN", scale=9):
226
+ P=palette(accent); base=os.path.join(OUT_BASE, out); os.makedirs(base, exist_ok=True)
227
+ meta={"title":title,"accent":accent,"N":m.N,"scale":scale,"groups":GROUPS,"states":{}}
228
+ for s in CATALOG:
229
+ frames,fps=frames_for(m,s,P)
230
+ imgs=[render(px,m.N,scale) for px in frames]
231
+ w=sum(i.width for i in imgs); strip=Image.new("RGBA",(w,imgs[0].height),(0,0,0,0)); x=0
232
+ for i in imgs: strip.paste(i,(x,0)); x+=i.width
233
+ strip.save(os.path.join(base,f"anim-{s}.png"))
234
+ open(os.path.join(base,f"mark-{s}.svg"),"w").write(svg_of(frames[0],m.N)) # still fallback
235
+ grp,role,motion,badge,_=CATALOG[s]
236
+ meta["states"][s]={"group":grp,"frames":len(frames),"fps":fps,"motion":motion,
237
+ "badge":badge[0] if badge else None}
238
+ # overview sheet (representative frame 2 of each)
239
+ cols,rows,cell=8,4,7; tile=m.N*cell; pad=cell
240
+ sheet=Image.new("RGBA",(cols*tile+(cols+1)*pad,rows*tile+(rows+1)*pad),(16,16,24,255))
241
+ order=[s for g in GROUPS for s in CATALOG if CATALOG[s][0]==g]
242
+ def rep_idx(motion,n):
243
+ return {"blink":0,"fill":int(n*0.62),"boot":int(n*0.62),"arc":n//4,"scan":n//4,
244
+ "spin":n//4,"dualarc":n//4,"dots":1}.get(motion, n//2)
245
+ for i,s in enumerate(order):
246
+ fr,_=frames_for(m,s,P); im=render(fr[rep_idx(CATALOG[s][2],len(fr))],m.N,cell)
247
+ cx=pad+(i%cols)*(tile+pad); cy=pad+(i//cols)*(tile+pad); sheet.paste(im,(cx,cy),im)
248
+ sheet.save(os.path.join(base,"sprite-sheet.png"))
249
+ json.dump(meta, open(os.path.join(base,"states.json"),"w"), indent=1)
250
+ return meta
251
+
252
+ if __name__=="__main__":
253
+ meta=generate(Motif.oneman(), accent="#ff1212", out="oneman", title="ONEMAN")
254
+ print(f"{title if (title:=meta['title']) else ''}: {len(meta['states'])} states, all animated")
255
+ print("groups:", ", ".join(GROUPS))
256
+ print("states:", ", ".join(meta["states"]))