@springbrand/message-panel 0.1.3-alpha.8 → 0.2.0-alpha.37

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 (67) hide show
  1. package/cloud-os/README.md +4 -3
  2. package/cloud-os/assets/followup-arrow.svg +3 -0
  3. package/cloud-os/assets/loading-corner.svg +3 -0
  4. package/cloud-os/assets/loading-mark.svg +16 -0
  5. package/cloud-os/assets/loading-spark.svg +3 -0
  6. package/cloud-os/assets/model-selected.svg +5 -0
  7. package/cloud-os/assets/thinking-star.svg +9 -0
  8. package/cloud-os/capability-chip.tsx +1 -1
  9. package/cloud-os/chat/cloud-os-chat-messages.tsx +441 -98
  10. package/cloud-os/chat/code-runner.tsx +106 -0
  11. package/cloud-os/chat/image-generation.tsx +76 -0
  12. package/cloud-os/chat/loading-state.tsx +25 -0
  13. package/cloud-os/chat/markdown-message.tsx +144 -41
  14. package/cloud-os/chat/range.ts +8 -0
  15. package/cloud-os/chat/rich-blocks.tsx +285 -227
  16. package/cloud-os/chat/surfaces.tsx +90 -0
  17. package/cloud-os/chat/tool-call.tsx +94 -0
  18. package/cloud-os/chat/tool-presentation.ts +31 -22
  19. package/cloud-os/chat/tool-rows.tsx +195 -143
  20. package/cloud-os/chat/tool-timeline.tsx +123 -0
  21. package/cloud-os/chat/transcript-model.ts +258 -20
  22. package/cloud-os/chat/web-search.tsx +135 -0
  23. package/cloud-os/composer/cloud-os-chat-input.tsx +38 -88
  24. package/cloud-os/composer/cloud-os-model-select.tsx +105 -0
  25. package/cloud-os/file-view.tsx +195 -12
  26. package/cloud-os/index.ts +24 -3
  27. package/cloud-os/layout/cloud-os-workspace-split.tsx +60 -3
  28. package/cloud-os/primitives/collapsible.tsx +21 -0
  29. package/cloud-os/primitives/workshop-controls.tsx +2 -2
  30. package/cloud-os/styles/cloud-os.css +132 -1
  31. package/demo/camel-chat-showcase.tsx +21 -13
  32. package/demo/chat-scenarios.ts +214 -40
  33. package/demo/cloud-os-chat-showcase.tsx +37 -14
  34. package/demo/fixtures.ts +4 -5
  35. package/demo/message-panel-gallery.tsx +16 -2
  36. package/package.json +8 -4
  37. package/springbrand-pet/LICENSE.bloub +21 -0
  38. package/springbrand-pet/bot/cycles.test.ts +221 -0
  39. package/springbrand-pet/bot/cycles.ts +225 -0
  40. package/springbrand-pet/bot/decor.ts +285 -0
  41. package/springbrand-pet/bot/engine.test.ts +543 -0
  42. package/springbrand-pet/bot/engine.ts +558 -0
  43. package/springbrand-pet/bot/expressions.test.ts +135 -0
  44. package/springbrand-pet/bot/expressions.ts +192 -0
  45. package/springbrand-pet/bot/eyefit.ts +455 -0
  46. package/springbrand-pet/bot/face.test.ts +101 -0
  47. package/springbrand-pet/bot/face.ts +179 -0
  48. package/springbrand-pet/bot/math.ts +42 -0
  49. package/springbrand-pet/bot/profiles.ts +22 -0
  50. package/springbrand-pet/bot/repere.ts +31 -0
  51. package/springbrand-pet/bot/shape.test.ts +97 -0
  52. package/springbrand-pet/bot/shape.ts +310 -0
  53. package/springbrand-pet/bot/skins.test.ts +325 -0
  54. package/springbrand-pet/bot/skins.ts +161 -0
  55. package/springbrand-pet/bot/states.ts +616 -0
  56. package/springbrand-pet/drag.test.ts +19 -0
  57. package/springbrand-pet/index.tsx +569 -0
  58. package/springbrand-pet/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  59. package/springbrand-pet/vitest.config.ts +9 -0
  60. package/src/camel/camel-chat-messages.tsx +78 -78
  61. package/src/camel/camel-tool-presentation.tsx +19 -15
  62. package/src/camel/camel-turn.ts +1 -17
  63. package/src/composer/composer-attachments.tsx +1 -1
  64. package/src/composer/composer.tsx +2 -2
  65. package/src/contracts.ts +0 -2
  66. package/src/message-panel.tsx +1 -24
  67. package/src/parts/index.tsx +103 -64
@@ -0,0 +1,221 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ MAX_BLOCK,
4
+ MAX_BLOCS,
5
+ MAX_CYCLES,
6
+ MIN_BLOCK,
7
+ blockAt,
8
+ blocksWith,
9
+ clampDuration,
10
+ defaultCycle,
11
+ makeBlock,
12
+ minDurationOf,
13
+ moveBlock,
14
+ nextCycleId,
15
+ parseCycles,
16
+ totalDuration,
17
+ type Cycle,
18
+ uniqueName
19
+ } from './cycles'
20
+ import { SEQUENCE, STATES, STATE_BY_ID } from './states'
21
+
22
+ describe('cycle par defaut', () => {
23
+ it('reprend la sequence relevee sur la video, dans l ordre', () => {
24
+ expect(defaultCycle().blocks.map((b) => b.state)).toEqual(SEQUENCE)
25
+ })
26
+
27
+ it('tient chaque etat sa duree mesuree', () => {
28
+ for (const block of defaultCycle().blocks) {
29
+ expect(block.duration).toBe(STATE_BY_ID.get(block.state)!.duration)
30
+ }
31
+ })
32
+
33
+ it('est reconstruit a l identique a chaque appel', () => {
34
+ expect(defaultCycle()).toEqual(defaultCycle())
35
+ // ...sans partager d'objet, sinon editer un montage toucherait l amorce
36
+ expect(defaultCycle().blocks[0]).not.toBe(defaultCycle().blocks[0])
37
+ })
38
+
39
+ it('laisse toujours de la place pour un nouveau cycle, sans collision', () => {
40
+ const reference = defaultCycle()
41
+ const un: Cycle = {
42
+ id: nextCycleId([reference]),
43
+ name: uniqueName('Mon cycle', [reference]),
44
+ blocks: []
45
+ }
46
+ const deux: Cycle = {
47
+ id: nextCycleId([reference, un]),
48
+ name: uniqueName('Mon cycle', [reference, un]),
49
+ blocks: []
50
+ }
51
+ expect(un.id).not.toBe(reference.id)
52
+ expect(deux.id).not.toBe(un.id)
53
+ expect(deux.name).not.toBe(un.name)
54
+ })
55
+ })
56
+
57
+ describe('durees', () => {
58
+ it('ne descend pas sous le plancher du moteur', () => {
59
+ // en dessous, le bloc est plus court que le fondu d entree du suivant
60
+ expect(clampDuration('idle', 0.1)).toBe(MIN_BLOCK)
61
+ expect(clampDuration('idle', -5)).toBe(MIN_BLOCK)
62
+ })
63
+
64
+ it('respecte la mesure des etats qui ont besoin d aboutir', () => {
65
+ // le "!" revient a 2.0, le corps se recompose a 2.4
66
+ expect(minDurationOf('alert')).toBe(2)
67
+ expect(minDurationOf('burst')).toBe(2.4)
68
+ expect(clampDuration('orbit', 1)).toBe(2.5)
69
+ // un etat qui ignore le temps n a que le plancher
70
+ expect(minDurationOf('idle')).toBe(MIN_BLOCK)
71
+ })
72
+
73
+ it('n autorise aucun etat a descendre sous sa mesure', () => {
74
+ for (const state of SEQUENCE) {
75
+ expect(clampDuration(state, 0)).toBeGreaterThanOrEqual(minDurationOf(state))
76
+ }
77
+ })
78
+
79
+ it('plafonne et tombe sur le pas, sans trainee de flottants', () => {
80
+ expect(clampDuration('idle', 999)).toBe(MAX_BLOCK)
81
+ expect(clampDuration('idle', 2.44)).toBe(2.4)
82
+ expect(clampDuration('idle', 2.46)).toBe(2.5)
83
+ })
84
+ })
85
+
86
+ describe('lecture', () => {
87
+ const cycle: Cycle = {
88
+ id: 'c1',
89
+ name: 'Test',
90
+ blocks: [
91
+ { state: 'idle', duration: 2 },
92
+ { state: 'wink', duration: 1 },
93
+ { state: 'egg', duration: 3 }
94
+ ]
95
+ }
96
+
97
+ it('additionne les blocs', () => {
98
+ expect(totalDuration(cycle.blocks)).toBe(6)
99
+ })
100
+
101
+ it('trouve le bloc joue et le temps ecoule dedans', () => {
102
+ expect(blockAt(cycle.blocks, 0)).toEqual({ index: 0, elapsed: 0 })
103
+ expect(blockAt(cycle.blocks, 1.9)).toEqual({ index: 0, elapsed: 1.9 })
104
+ // la borne appartient au bloc suivant
105
+ expect(blockAt(cycle.blocks, 2)).toEqual({ index: 1, elapsed: 0 })
106
+ expect(blockAt(cycle.blocks, 3.5)).toEqual({ index: 2, elapsed: 0.5 })
107
+ })
108
+
109
+ it('boucle au-dela du dernier bloc', () => {
110
+ expect(blockAt(cycle.blocks, 6)).toEqual({ index: 0, elapsed: 0 })
111
+ expect(blockAt(cycle.blocks, 8)).toEqual({ index: 1, elapsed: 0 })
112
+ })
113
+
114
+ it('ne casse pas sur un cycle vide', () => {
115
+ expect(blockAt([], 3)).toEqual({ index: 0, elapsed: 0 })
116
+ expect(totalDuration([])).toBe(0)
117
+ })
118
+
119
+ it('deplace un bloc sans toucher la liste d origine', () => {
120
+ const blocks = cycle.blocks
121
+ expect(moveBlock(blocks, 0, 2).map((b) => b.state)).toEqual(['wink', 'egg', 'idle'])
122
+ expect(moveBlock(blocks, 2, 0).map((b) => b.state)).toEqual(['egg', 'idle', 'wink'])
123
+ expect(blocks.map((b) => b.state)).toEqual(['idle', 'wink', 'egg'])
124
+ })
125
+ })
126
+
127
+ describe('relecture du stockage', () => {
128
+ it('ne casse pas sur du vide ou du JSON invalide', () => {
129
+ expect(parseCycles(null)).toEqual([])
130
+ expect(parseCycles('')).toEqual([])
131
+ expect(parseCycles('{pas du json')).toEqual([])
132
+ expect(parseCycles('{"id":"c1"}')).toEqual([])
133
+ })
134
+
135
+ it('jette les blocs dont l etat n existe plus', () => {
136
+ const raw = '[{"id":"c1","name":"A","blocks":[{"state":"idle","duration":2},' +
137
+ '{"state":"disparu","duration":2}]}]'
138
+ expect(parseCycles(raw)[0]!.blocks.map((b) => b.state)).toEqual(['idle'])
139
+ })
140
+
141
+ it('ramene les durees aberrantes dans leurs bornes', () => {
142
+ const raw = '[{"id":"c1","name":"A","blocks":[{"state":"idle","duration":-4},' +
143
+ '{"state":"egg","duration":9999}]}]'
144
+ expect(parseCycles(raw)[0]!.blocks.map((b) => b.duration)).toEqual([MIN_BLOCK, MAX_BLOCK])
145
+ })
146
+
147
+ it('jette un cycle vide, sans nom, ou en double', () => {
148
+ expect(parseCycles('[{"id":"c1","name":"A","blocks":[]}]')).toEqual([])
149
+ expect(parseCycles('[{"id":"c1","blocks":[{"state":"idle","duration":2}]}]')).toEqual([])
150
+ const doublon = '[{"id":"c1","name":"A","blocks":[{"state":"idle","duration":2}]},' +
151
+ '{"id":"c1","name":"B","blocks":[{"state":"egg","duration":2}]}]'
152
+ expect(parseCycles(doublon).map((c) => c.name)).toEqual(['A'])
153
+ })
154
+
155
+ it('ne garde que les champs du modele, pas ce qu on lui glisse en plus', () => {
156
+ const raw = '[{"id":"defaut","name":"Mon montage","locked":true,"secret":1,' +
157
+ '"blocks":[{"state":"idle","duration":2,"vitesse":3}]}]'
158
+ const cycle = parseCycles(raw)[0]!
159
+ expect(Object.keys(cycle).sort()).toEqual(['blocks', 'id', 'name'])
160
+ expect(Object.keys(cycle.blocks[0]!).sort()).toEqual(['duration', 'state'])
161
+ })
162
+
163
+ /*
164
+ * Le stockage est modifiable et tient quelques megaoctets, alors que rien en aval n'est
165
+ * dimensionne pour ca. Un seul cycle de 150 000 blocs — environ 4 Mo de JSON, donc dans
166
+ * le budget — donnait 1 500 000 s de duree, autant de graduations a allouer et une piste
167
+ * de 29 700 000 px : l'onglet figeait en entrant dans la vue Animations.
168
+ */
169
+ it('borne la taille d un montage relu', () => {
170
+ const blocs = Array.from({ length: 200_000 }, () => ({ state: 'idle', duration: 10 }))
171
+ const raw = JSON.stringify([{ id: 'c1', name: 'A', blocks: blocs }])
172
+ expect(parseCycles(raw)[0]!.blocks).toHaveLength(MAX_BLOCS)
173
+ })
174
+
175
+ /*
176
+ * Le plancher est DERIVE du plus long `morph`, il n'est plus ecrit a la main. Ce test
177
+ * garde le lien visible : il valait 0,6 en dur, ce qui ne marchait que parce que 0,6
178
+ * etait justement le morph d'`orbit`. Un etat qui morphe plus lentement le suit.
179
+ */
180
+ it('le plancher de bloc couvre le plus long fondu du catalogue', () => {
181
+ const plusLong = Math.max(...STATES.map((s) => s.morph))
182
+ expect(MIN_BLOCK).toBeGreaterThanOrEqual(plusLong)
183
+ // et il n'est pas gratuitement plus grand : c'est exactement ce fondu
184
+ expect(MIN_BLOCK).toBe(plusLong)
185
+ })
186
+
187
+ it('borne aussi l ajout depuis l editeur, pas seulement la relecture', () => {
188
+ let blocs = Array.from({ length: MAX_BLOCS }, () => makeBlock('idle'))
189
+ expect(blocksWith(blocs, 'egg')).toHaveLength(MAX_BLOCS)
190
+ // et il reste possible d'ajouter juste en dessous de la borne
191
+ blocs = blocs.slice(0, MAX_BLOCS - 1)
192
+ expect(blocksWith(blocs, 'egg')).toHaveLength(MAX_BLOCS)
193
+ })
194
+
195
+ it('borne le nombre de montages relus', () => {
196
+ const raw = JSON.stringify(
197
+ Array.from({ length: 5000 }, (_, i) => ({
198
+ id: `c${i}`,
199
+ name: `A${i}`,
200
+ blocks: [{ state: 'idle', duration: 2 }]
201
+ }))
202
+ )
203
+ expect(parseCycles(raw)).toHaveLength(MAX_CYCLES)
204
+ })
205
+
206
+ /*
207
+ * `swirl` est la transition d'entree des reglages, deliberement hors de `SEQUENCE` : un
208
+ * test la garde hors de la palette et de la planche. Un montage utilisateur ne se
209
+ * construit qu'a partir de la palette, donc elle ne peut arriver ici que par un stockage
210
+ * bricole a la main — et on l'y refuse comme partout ailleurs.
211
+ */
212
+ it('refuse un etat hors catalogue, `swirl` compris', () => {
213
+ const raw = '[{"id":"c1","name":"A","blocks":[{"state":"swirl","duration":2},' +
214
+ '{"state":"idle","duration":2}]}]'
215
+ expect(parseCycles(raw)[0]!.blocks.map((b) => b.state)).toEqual(['idle'])
216
+ // un montage qui n'en contiendrait QUE devient vide, donc tombe
217
+ expect(parseCycles('[{"id":"c1","name":"A","blocks":[{"state":"swirl","duration":2}]}]')).toEqual(
218
+ []
219
+ )
220
+ })
221
+ })
@@ -0,0 +1,225 @@
1
+ import { SEQUENCE, STATES, STATE_BY_ID, type StateId } from './states'
2
+
3
+ /**
4
+ * Un cycle est un montage : une suite de blocs, chacun un etat tenu pendant une
5
+ * duree choisie. C'est la partie "editeur" du dossier, et elle en garde les
6
+ * regles — donnees pures, aucune horloge, aucun import Vue : le meme cycle doit
7
+ * pouvoir etre relu par les tests, par le lecteur et par la timeline.
8
+ *
9
+ * Un bloc n'a pas d'identifiant : c'est une position dans une liste, la cle de
10
+ * rendu est l'index. Ca garde le JSON du localStorage lisible et les tests
11
+ * deterministes.
12
+ */
13
+ export interface Block {
14
+ state: StateId
15
+ duration: number
16
+ }
17
+
18
+ export interface Cycle {
19
+ id: string
20
+ name: string
21
+ blocks: Block[]
22
+ }
23
+
24
+ /**
25
+ * Plancher commun a tous les blocs. Le moteur ne garde qu'une case d'historique
26
+ * (`BotEngine.setState` ecrase `prev`), donc un bloc plus court que le fondu d'entree du
27
+ * bloc suivant saute a l'image au lieu de se fondre.
28
+ *
29
+ * DERIVE du catalogue et non ecrit a la main. La valeur etait 0,6, ce qui marchait
30
+ * uniquement parce que 0,6 se trouvait etre le plus long `morph` du catalogue — celui
31
+ * d'`orbit`. Rien ne le garantissait : ajouter un etat qui morphe en 0,8 s aurait fait
32
+ * trembler l'editeur sans qu'aucun test ne bronche. Maintenant le plancher suit.
33
+ */
34
+ export const MIN_BLOCK = Math.max(...STATES.map((s) => s.morph))
35
+
36
+ /**
37
+ * Garde-fou d'editeur, pas une mesure : allonger un bloc est sans risque (les
38
+ * etats saturent leurs rampes et tiennent leur pose finale), mais une piste de
39
+ * blocs d'une minute n'est plus lisible.
40
+ */
41
+ export const MAX_BLOCK = 10
42
+
43
+ /**
44
+ * Combien de blocs et de montages on accepte, a l'edition comme a la relecture.
45
+ *
46
+ * Ce ne sont pas des limites de produit mais des bornes contre un stockage hostile, qui
47
+ * est modifiable et tient quelques megaoctets alors que rien en aval n'est dimensionne
48
+ * pour ca : un seul cycle de 150 000 blocs, soit environ 4 Mo de JSON, donne 1 500 000 s
49
+ * de duree, autant de graduations a allouer et une piste de 29 700 000 px de large.
50
+ * L'onglet figeait en entrant dans la vue Animations.
51
+ *
52
+ * 200 blocs font une demi-heure de montage, largement au-dela de tout usage.
53
+ */
54
+ export const MAX_BLOCS = 200
55
+ export const MAX_CYCLES = 50
56
+
57
+ /** Pas de la molette et du redimensionnement, en secondes. */
58
+ export const STEP = 0.1
59
+
60
+ const DEFAULT_CYCLE_ID = 'defaut'
61
+
62
+ /** Duree minimale d'un bloc : le plancher moteur, ou la mesure de l'etat. */
63
+ export function minDurationOf(state: StateId): number {
64
+ return Math.max(MIN_BLOCK, STATE_BY_ID.get(state)?.minDuration ?? MIN_BLOCK)
65
+ }
66
+
67
+ /** Ramene une duree dans ses bornes et sur le pas, sans trainee de flottants. */
68
+ export function clampDuration(state: StateId, seconds: number): number {
69
+ const snapped = Math.round(seconds / STEP) * STEP
70
+ const bounded = Math.min(MAX_BLOCK, Math.max(minDurationOf(state), snapped))
71
+ return Math.round(bounded * 100) / 100
72
+ }
73
+
74
+ export function makeBlock(state: StateId): Block {
75
+ // la duree de reference est celle relevee sur la video pour cet etat
76
+ return { state, duration: clampDuration(state, STATE_BY_ID.get(state)?.duration ?? 2) }
77
+ }
78
+
79
+ /**
80
+ * Le montage releve sur la video : l'ordre de `SEQUENCE`, chaque etat tenu sa
81
+ * duree mesuree. Il sert d'amorce au premier lancement, puis il appartient a
82
+ * l'utilisateur — il s'edite et se stocke comme les autres. La reference, elle,
83
+ * reste dans le code : vider le stockage la fait revenir.
84
+ */
85
+ export function defaultCycle(): Cycle {
86
+ return {
87
+ /**
88
+ * Nom vide = « jamais nomme par l'utilisateur », donc affiche dans la langue
89
+ * courante. Ecrire ici « Cycle par defaut » l'aurait fige : le nom part au
90
+ * localStorage des la premiere visite et redevient une donnee utilisateur,
91
+ * que changer de langue ne retraduirait plus.
92
+ */
93
+ name: '',
94
+ id: DEFAULT_CYCLE_ID,
95
+ blocks: SEQUENCE.map(makeBlock)
96
+ }
97
+ }
98
+
99
+ export function totalDuration(blocks: Block[]): number {
100
+ return blocks.reduce((sum, b) => sum + b.duration, 0)
101
+ }
102
+
103
+ /** Date de debut d'un bloc dans le montage. */
104
+ export function offsetOf(blocks: Block[], index: number): number {
105
+ let acc = 0
106
+ for (let i = 0; i < index && i < blocks.length; i++) acc += blocks[i]!.duration
107
+ return acc
108
+ }
109
+
110
+ /**
111
+ * Bloc joue a la date `t` et temps ecoule dedans. Au-dela du dernier bloc on
112
+ * retombe au debut : la lecture boucle. L'appelant verifie que le montage n'est
113
+ * pas vide.
114
+ */
115
+ export function blockAt(blocks: Block[], t: number): { index: number; elapsed: number } {
116
+ const total = totalDuration(blocks)
117
+ if (!blocks.length || total <= 0) return { index: 0, elapsed: 0 }
118
+ // le modulo n'est applique que s'il sert : sur une date deja dans le cycle il
119
+ // n'ajouterait qu'une trainee de flottants au temps ecoule
120
+ const wrapped = t >= 0 && t < total ? t : ((t % total) + total) % total
121
+ let acc = 0
122
+ for (let i = 0; i < blocks.length; i++) {
123
+ const end = acc + blocks[i]!.duration
124
+ if (wrapped < end) return { index: i, elapsed: wrapped - acc }
125
+ acc = end
126
+ }
127
+ return { index: blocks.length - 1, elapsed: 0 }
128
+ }
129
+
130
+ /**
131
+ * Ajoute une animation a la fin du montage (palette de droite ou carte « + »).
132
+ *
133
+ * Plafonnee a `MAX_BLOCS`, comme la relecture. Sans ca l'editeur laissait construire un
134
+ * montage plus grand que ce que le stockage rend au rechargement, et le travail
135
+ * disparaissait en silence — une borne de relecture qui n'est pas aussi une borne d'edition
136
+ * est un piege, pas une protection.
137
+ */
138
+ export function blocksWith(blocks: Block[], state: StateId): Block[] {
139
+ if (blocks.length >= MAX_BLOCS) return blocks
140
+ return [...blocks, makeBlock(state)]
141
+ }
142
+
143
+ /** Deplace un bloc, en rendant une nouvelle liste (les etats Vue sont remplaces). */
144
+ export function moveBlock(blocks: Block[], from: number, to: number): Block[] {
145
+ const next = blocks.slice()
146
+ const [moved] = next.splice(from, 1)
147
+ if (!moved) return blocks
148
+ next.splice(Math.min(Math.max(to, 0), next.length), 0, moved)
149
+ return next
150
+ }
151
+
152
+ /** `Mon cycle`, `Mon cycle 2`, `Mon cycle 3`... — jamais deux fois le meme nom. */
153
+ export function uniqueName(base: string, cycles: Cycle[]): string {
154
+ const taken = new Set(cycles.map((c) => c.name))
155
+ if (!taken.has(base)) return base
156
+ let n = 2
157
+ while (taken.has(`${base} ${n}`)) n++
158
+ return `${base} ${n}`
159
+ }
160
+
161
+ /** Identifiant sans collision, y compris avec un localStorage bricole a la main. */
162
+ export function nextCycleId(cycles: Cycle[]): string {
163
+ const taken = new Set(cycles.map((c) => c.id))
164
+ let n = 1
165
+ while (taken.has(`c${n}`)) n++
166
+ return `c${n}`
167
+ }
168
+
169
+ /* ------------------------------------------------------- lecture du stockage */
170
+
171
+ function parseBlock(raw: unknown): Block | null {
172
+ if (typeof raw !== 'object' || raw === null) return null
173
+ const { state, duration } = raw as { state?: unknown; duration?: unknown }
174
+ /*
175
+ * Valide contre SEQUENCE et non contre `STATE_BY_ID` : ce dernier contient `swirl`, qui
176
+ * est deliberement hors du catalogue — c'est la transition d'entree des reglages, un
177
+ * test la verrouille hors de la palette et de la planche. Un montage utilisateur ne se
178
+ * construit qu'a partir de la palette, donc un `swirl` ne peut y arriver que par un
179
+ * stockage bricole a la main, et il n'y a aucune raison de l'y tolerer quand on l'exclut
180
+ * partout ailleurs.
181
+ */
182
+ if (typeof state !== 'string' || !SEQUENCE.includes(state as StateId)) return null
183
+ if (typeof duration !== 'number' || !Number.isFinite(duration)) return null
184
+ return { state: state as StateId, duration: clampDuration(state as StateId, duration) }
185
+ }
186
+
187
+ function parseCycle(raw: unknown, seen: Cycle[]): Cycle | null {
188
+ if (typeof raw !== 'object' || raw === null) return null
189
+ const { id, name, blocks } = raw as { id?: unknown; name?: unknown; blocks?: unknown }
190
+ if (typeof id !== 'string' || !id) return null
191
+ // le nom peut etre vide — c'est le montage d'amorce, qui suit la langue
192
+ if (typeof name !== 'string') return null
193
+ if (!Array.isArray(blocks)) return null
194
+ // on tronque AVANT de relire : valider 150 000 blocs pour n'en garder que 200 serait
195
+ // faire le travail qu'on cherche justement a eviter
196
+ const kept = blocks
197
+ .slice(0, MAX_BLOCS)
198
+ .map(parseBlock)
199
+ .filter((b): b is Block => b !== null)
200
+ if (!kept.length) return null
201
+ if (seen.some((c) => c.id === id)) return null
202
+ return { id, name, blocks: kept }
203
+ }
204
+
205
+ /**
206
+ * Le localStorage est modifiable a la main : on ne lui fait pas confiance, meme
207
+ * regle que pour le hash de l'URL. Tout ce qui ne se relit pas est jete
208
+ * silencieusement plutot que de casser l'application au demarrage.
209
+ */
210
+ export function parseCycles(raw: string | null): Cycle[] {
211
+ if (!raw) return []
212
+ let data: unknown
213
+ try {
214
+ data = JSON.parse(raw)
215
+ } catch {
216
+ return []
217
+ }
218
+ if (!Array.isArray(data)) return []
219
+ const out: Cycle[] = []
220
+ for (const item of data.slice(0, MAX_CYCLES)) {
221
+ const cycle = parseCycle(item, out)
222
+ if (cycle) out.push(cycle)
223
+ }
224
+ return out
225
+ }