@officexapp/vidfarm-devcli 0.21.58 → 0.21.59

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.
@@ -20,14 +20,22 @@ checks:
20
20
  # upper bound: 9 slides x (1 bg + 3 cutouts).
21
21
  scenes: 5-40
22
22
  max_scene_sec: 3.2 # 3.0 is the number. The 0.2 is render tolerance, not licence
23
- # A tip slide carries TWO text runs (headline + subtext) and there are up to 9 slides, so the
24
- # whole-composition count is ~18. This is a READ format, not a subtitle format — the
25
- # 1-3 ceilings in the sibling harnesses are for videos where a voice is doing the work.
26
- max_text_cards: 20
27
- max_simultaneous_text: 2 # headline + subtext. A third text run on a slide is clutter
23
+ # A tip slide carries THREE text runs (topic rail + headline + subtext) and there are up to
24
+ # 9 slides, so the whole-composition count is ~26. This is a READ format, not a subtitle
25
+ # format — the 1-3 ceilings in the sibling harnesses are for videos where a voice is doing
26
+ # the work.
27
+ max_text_cards: 28
28
+ max_simultaneous_text: 3 # topic rail + headline + subtext. A FOURTH text run is clutter.
29
+ # The rail is the same string on every slide, so it costs the
30
+ # reader one read, not N — see "The topic rail"
28
31
  max_words_per_cue: 16 # the headline is ONE read at arm's length, 3 lines maximum
29
32
  max_dead_air_sec: 1.0 # there is never a frame without text. A gap means a broken slide
30
33
  max_tail_sec: 0.5
34
+ # THE DECK HAS A CTA — it is the close on slide N, and it is an ADDRESS
35
+ # (`dishcover.io — every dish in NYC, searchable`). None of the phrases below
36
+ # are that. Each is an INSTRUCTION the reader did not need, because the address
37
+ # is already on the frame, and each is phrase-matched for suppression on a
38
+ # carousel. Keeping them banned is what makes the CTA shippable. See "The close".
31
39
  forbid_text:
32
40
  - link in bio
33
41
  - sign up
@@ -71,7 +79,7 @@ Three questions. **All three must be yes** or you have not got a tip slideshow y
71
79
  | # | Question | If no |
72
80
  |---|---|---|
73
81
  | 1 | **Actionable** — could a stranger do this tonight, without the product, and be better off? | You wrote a fact, or a platitude. Both die |
74
- | 2 | **The shill is a tip too** — delete the brand name from slide 3. Is what's left still one of the best tips in the deck? | You wrote an ad and put tips around it |
82
+ | 2 | **Both brand touches survive deletion** — delete the brand from the shill and delete the close's last line. Is the shill still one of the best tips in the deck, and is slide N still a complete tip? | You wrote an ad and put tips around it |
75
83
  | 3 | **Still-complete** — freeze any slide as a PNG with no context. Does it stand alone? | You built a video. This format ships as pictures |
76
84
 
77
85
  Test 3 is the one that gets skipped, and it is structural, not cosmetic: **the slides are the
@@ -112,12 +120,12 @@ floor (fewer reads as a single post) and nine is the ceiling (nobody swipes to s
112
120
 
113
121
  | Slot | Slide | Job |
114
122
  |---|---|---|
115
- | **1** | **The cover** | Name the list literally + a hero picture. The thumbnail and the promise |
123
+ | **1** | **The cover** | Name the list literally + a hero picture. The thumbnail and the promise. The only slide with **no topic rail** — its headline is the title |
116
124
  | **2** | Best tip | Pay the reader immediately. The single strongest thing you know |
117
125
  | **3** | Second-best tip | Pay them again, before you ask for anything |
118
126
  | **⌈N/2⌉** | **The shill** | The tip that happens to be what the product does. Dead centre |
119
127
  | **…** | Tips | Keep the average high. A weak slide here is where people leave |
120
- | **N** | The tip you'd tell a friend last | The one that earns the save. Not a CTA, not a recap |
128
+ | **N** | **The close** — the tip you'd tell a friend last, plus the destination | The one that earns the save. Still a tip, never a recap. Its subtext carries the deck's ONE call to action, on a third line |
121
129
 
122
130
  **The shill sits in the MIDDLE — slide 4 of 7, slide 3 of 5, slide 5 of 9.** Not a fixed number: a
123
131
  fixed number breaks the moment the deck changes length. What is fixed is the *condition* —
@@ -133,14 +141,79 @@ set-up. The middle is the only slot where the reader is committed and not yet su
133
141
  earliest legal shill slot is **slide 4**. A 7-slide deck is cover + 6 tips with the ad at 4: two
134
142
  tips in front of it, three behind.
135
143
 
136
- **Exactly one shill in the deck.** Two brand mentions and it stops being a list with a product in
137
- it and becomes an ad with a list on it. There is no second-mention exception and no "but the last
138
- slide is subtle".
144
+ **Exactly TWO brand touches in the deck, and they do different jobs.** Not one, and never three:
145
+
146
+ | | Slide | What it is | What it does |
147
+ |---|---|---|---|
148
+ | **The shill** | the middle | The **mechanism** — the tip that happens to be what the product does | *Hints* the offer. The reader learns the product exists by watching it be useful |
149
+ | **The close** | **N** | The **destination** — the bare address, on the last slide's third subtext line | *Asks.* The one call to action in the deck |
150
+
151
+ A third mention is a brochure. Two mentions of the *same kind* — two mechanisms, or a destination
152
+ in the middle as well as the end — is also three too many: **the deck may name the mechanism once
153
+ and the address once.** There is no "but this one is subtle" exception.
139
154
 
140
155
  **Slides do not build.** No "1 of 6" counters, no "and finally", no "but here's the thing" carrying
141
156
  into the next slide, no numbered sequence where slide 4 depends on slide 3. Every slide is a
142
157
  standalone poster. Shuffle the middle slides at random; if the deck breaks, it was a video.
143
158
 
159
+ ### The topic rail — the title, quietly, on every slide
160
+
161
+ **Slide 1 names the list. Slides 2…N carry that name again, small, in the same place, unchanged.**
162
+
163
+ The reference deck does not do this and it is the one thing it is missing. The reason is the same
164
+ reason test 3 exists: **a slide travels alone.** Somebody screenshots slide 4 and sends it to a
165
+ friend. Somebody's swipe drops them on slide 5 with no cover. Somebody opens a save three weeks
166
+ later. In every one of those cases the cover — the only thing that said what the list was about — is
167
+ not on screen, and a great tip with no topic is a fortune cookie.
168
+
169
+ The rail costs one line and fixes all three.
170
+
171
+ | | |
172
+ |---|---|
173
+ | **What it says** | The deck's title, compressed to **≤5 words**, no article, no verb needed |
174
+ | **Where** | A band at **14%**, directly above the headline, centred. Same y on every slide |
175
+ | **How it looks** | Uppercase, 32px, weight **500**, letter-spacing `+0.16em`, the mode's ink at **55% opacity on `paper`, 78% on any `photo-*` mode** |
176
+ | **Which slides** | **2 through N.** Never slide 1 — the cover's headline already IS the title |
177
+ | **If the deck has no cover** | Then **all N**, slide 1 included. A coverless deck has nothing else naming the list, so the rail is the only thing doing that job |
178
+ | **How much it varies** | **Not at all.** Byte for byte identical on every slide that has it |
179
+
180
+ ```
181
+ cover headline: 6 rules for eating well in NYC
182
+ topic rail: EATING WELL IN NYC
183
+ ```
184
+
185
+ - **It is the topic, never the brand.** A rail that reads `DISHCOVER.IO` is not a rail, it is a
186
+ watermark, and it turns one shill into N. See Rule 11.
187
+ - **It is not a counter.** No `3/6`, no `TIP 4`, no progress dots. A counter is a build, and
188
+ *slides do not build* — it also tells a reader who landed mid-deck that they missed something,
189
+ which is the opposite of what the rail is for.
190
+ - **It never comments on its slide.** `EATING WELL IN NYC` on all of them. The moment it becomes
191
+ `EATING WELL IN NYC — TIMING` it is a second headline competing with the first.
192
+ - **Repetition is the mechanism, not a defect.** After slide 2 the reader stops seeing it, which is
193
+ correct: it is not addressed to the person swiping the deck. It is addressed to the person who
194
+ arrives at one slide with no deck around it.
195
+ - **The 55% opacity is deliberate and it is the one place opacity is allowed here.** Elsewhere this
196
+ file bans faking a font weight with opacity, because that yields grey text where you wanted light
197
+ text. Here grey *is* the intent: the rail must be readable and must never be read first. Set it on
198
+ the rail layer, not on the type inside it.
199
+ - **On a photo background the opacity goes UP to 78%, not down.** Translucent ink over a picture
200
+ loses contrast far faster than over a flat page, because the background it is compositing with is
201
+ neither flat nor known. `paper` is the only mode that can afford 55%. Carrying 55% into a photo
202
+ mode is the deck's first unreadable element and the last one you would look at — which is why
203
+ `slides-qa.py` grades the rail band on its own, against the ink's *composited* value.
204
+ - **It sits on the shill slide too, identical.** Anything the rail does differently on slide 3 is a
205
+ free tell that slide 3 is the ad.
206
+
207
+ **Why 32px and not smaller.** `vidfarm qa`'s `font-size` rule errors under 2.6% of canvas width
208
+ (28px at 1080). A 24px rail would be both flagged and genuinely unreadable at feed scale. 32px is
209
+ the floor with headroom.
210
+
211
+ > **Note on the cover.** The main body of this harness requires slide 1 to be a cover; Appendix D
212
+ > and Appendix E predate that and describe a coverless deck. Both shapes are buildable — pass
213
+ > `--no-cover` to `build-deck.py` and the rail goes on all N slides instead of 2…N. **Decide which
214
+ > shape you are building before you write the manifest**, because the shill slot moves with it: a
215
+ > cover pushes the earliest legal shill from slide 3 to slide 4.
216
+
144
217
  ### Length
145
218
 
146
219
  `N × 3.0s`, exactly. Set `data-duration` on the root to `N * 3.0` and every slide clip to `3.0`.
@@ -176,10 +249,11 @@ Walk two more blocks.
176
249
  **The subtext is the REASON, and it is optional.** One or two lines, smaller, lighter, directly
177
250
  under the pictures. It answers "why" and never repeats the headline in other words.
178
251
 
179
- **The shill slide, and only the shill slide, may run to three lines.** It has one job the others do
180
- not — naming the offer — and squeezing that into two lines is what tempts you into cutting the
181
- reason down to a slogan. Two lines of tip, one line naming the tool. Every other slide stays at two:
182
- a three-line subtext elsewhere means the tip needed a paragraph, which means it was not a tip.
252
+ **Two slides may run to a three-line subtext: the shill and the close.** Both have a job the others
253
+ do not — naming the offer — and squeezing that into two lines is what tempts you into cutting the
254
+ reason down to a slogan. Two lines of tip, one line naming the tool or the address. **Every other
255
+ slide stays at two:** a three-line subtext anywhere else means the tip needed a paragraph, which
256
+ means it was not a tip.
183
257
 
184
258
  ```
185
259
  headline: Trust a 30-item menu over a 200-item one.
@@ -208,7 +282,8 @@ Rule 6 exists: a rung-4 number has to be a number you'd defend, not one you roun
208
282
  Each of these fails in a way that is invisible while you're writing it:
209
283
 
210
284
  - **The tip that is really the product.** "Use an app that lets you search by dish" on slide 5 is a
211
- second shill wearing a hat. One shill, and it is slide 3.
285
+ second shill wearing a hat. One mechanism, in the middle — and the close names an address, not a
286
+ mechanism, which is exactly why the two do not collide.
212
287
  - **The tip nobody disputes.** "Read reviews before you go", "book ahead on weekends". Being true
213
288
  is not the bar; being *told* is.
214
289
  - **The tip that needs the reader to already be an expert.** If the instruction assumes they can
@@ -222,9 +297,11 @@ Each of these fails in a way that is invisible while you're writing it:
222
297
 
223
298
  ### The cold-viewer test
224
299
 
225
- Cover the brand name on slide 3. Read all N slides. **Does the deck still read as a good list?**
226
- If slide 3 is now visibly the weakest one, you did not write a tip — you wrote a placement and
227
- dressed it. Rewrite slide 3, not the deck.
300
+ Cover the brand name on the shill slide and cover the close's last line. Read all N slides.
301
+ **Does the deck still read as a good list?** If the shill is now visibly the weakest one, you did
302
+ not write a tip — you wrote a placement and dressed it. Rewrite the shill, not the deck. And if
303
+ slide N now reads as unfinished, the close was carrying it: the address was doing the work its
304
+ headline should have done.
228
305
 
229
306
  ---
230
307
 
@@ -261,6 +338,72 @@ product does not fit this format — use `meme-recaption` or `ugc-reaction-green
261
338
 
262
339
  ---
263
340
 
341
+ ## The close — where the call to action goes
342
+
343
+ The shill hints. **The close asks.** It is the last slide, it is still a tip, and its subtext runs
344
+ to a third line that names the destination.
345
+
346
+ ```
347
+ headline: Ask what the kitchen is proud of.
348
+ subtext: Not "what's good" — everything is good. "Proud of" gets you the one
349
+ dish the chef would put their name on.
350
+ dishcover.io — every dish in NYC, searchable
351
+ ```
352
+
353
+ Why slide N and not anywhere else:
354
+
355
+ - **The reader has been paid N−1 times.** An address on slide 2 is a toll booth; an address on the
356
+ last slide is a business card handed over at the end of a conversation that went well.
357
+ - **It cannot retroactively turn the deck into an ad**, which is the failure the middle-shill rule
358
+ exists to prevent — because slide N is still a *tip*. The deck resolves on the tip; the address
359
+ rides underneath it. Delete the third line and slide N is unchanged. That is the test.
360
+ - **It is the slide with the lowest reach and the highest intent.** Everyone sees slide 1. The
361
+ people who reach slide N swiped past five tips on purpose. That is who the ask is for.
362
+
363
+ ### The grammar of the close
364
+
365
+ **It is an address, not an instruction.** This is the whole rule and it is where decks go wrong.
366
+
367
+ | | |
368
+ |---|---|
369
+ | ✅ | `dishcover.io — every dish in NYC, searchable` |
370
+ | ✅ | `dishcover.io` (bare, no clause — always legal) |
371
+ | ✅ | `built this: dishcover.io` |
372
+ | ❌ | `Link in bio 👇` — an instruction, and a phrase the platforms down-rank |
373
+ | ❌ | `Sign up free at dishcover.io` — an offer, not an address. Now the slide is an ad |
374
+ | ❌ | `Find better restaurants tonight` — an outcome the deck cannot keep, and no address at all |
375
+
376
+ - **≤6 words after the address**, one clause, describing what it *is* — never what it does for them.
377
+ - **Lowercase, weight 500, same size as the rest of the subtext.** No 800, no colour, no arrow, no
378
+ emoji, no box. A styled CTA is the single loudest "this was an ad" tell in the format.
379
+ - **No verb aimed at the reader.** Not "visit", "try", "check out", "grab", "get". The address is
380
+ the verb.
381
+ - **No urgency, no price, no "free".** Those belong to a landing page, and on a slide they cost
382
+ more reach than they buy clicks.
383
+ - **It never repeats the shill's mechanism.** The middle slide already said *how* it works. Saying
384
+ it twice is the third brand touch wearing a different hat.
385
+
386
+ ### What stays in the caption
387
+
388
+ **The engagement bait does not move.** `save this`, `follow for more`, `comment below`, `link in
389
+ bio` are still in `forbid_text` and still belong in the post caption — not because a CTA is
390
+ forbidden, but because *those* CTAs are platform-gamed phrases that read as spam on a frame and
391
+ get matched by suppression. The deck's on-frame ask is an address. The caption's ask is engagement.
392
+ They are different asks and they live in different places.
393
+
394
+ > **If you want `link in bio` on the frame anyway**, delete it from `forbid_text` in the front
395
+ > matter — it is your deck. Know the trade: you gain an instruction the reader did not need (the
396
+ > address is already on screen) and you take a reach penalty on every slide the phrase appears on.
397
+
398
+ ### When the product has no address worth naming
399
+
400
+ Some offers are a person, a newsletter, a shop inside another platform. The close still works —
401
+ name the thing a reader would type. If there is genuinely nothing to type, **drop the close** and
402
+ end on a plain tip. A deck with a good last tip and no CTA outperforms a deck with a CTA nobody
403
+ can act on. `slides-qa.py --no-close` grades that shape without complaining.
404
+
405
+ ---
406
+
264
407
  ## Casting the pictures
265
408
 
266
409
  ### The art class — pick one, hold it across the whole deck
@@ -419,9 +562,12 @@ slide you opened.** They live in `MODES` in Appendix A.
419
562
  the photograph on the `<img>`; carry the dim on a separate flat colour layer above it. Identical
420
563
  result, and it is not modal staging.
421
564
  - **The wash is a BANDED GRADIENT, not a flat tint.** A flat wash has to be deep enough for the
422
- worst band, which flattens the whole picture into mud. A gradient that is strong across the
423
- headline band (0–32%) and the subtext band (72–100%) and weak through the cutout band (44–64%)
565
+ worst band, which flattens the whole picture into mud. A gradient that is strong across the rail
566
+ + headline band (0–36%) and the subtext band (72–100%) and weak through the cutout band (48–64%)
424
567
  buys the contrast exactly where the text is and lets the photograph breathe where it doesn't.
568
+ **The rail lives inside the headline's half of the gradient**, so it inherits the headline's
569
+ contrast for free — that is why it was put above the headline rather than in the air below the
570
+ cutouts, where it would have needed a fourth wash stop of its own.
425
571
  - **The photo is scaled to 110% and offset −5%.** A blurred image at exactly 100% shows a pale
426
572
  vignette on all four edges — the blur sampling past the frame — and it reads as a rendering bug.
427
573
  - **Rule 5 inverts on a photo.** On `paper`, a text shadow is decoration and banned. On a photo it
@@ -441,7 +587,9 @@ only find out on the slide you did not open.
441
587
 
442
588
  `slides-qa.py --ink light|dark` (Appendix C) recovers each text band's background by **blurring the
443
589
  band hard enough to erase the glyphs**, takes the worst-case patch of what is left, and computes the
444
- WCAG contrast ratio against the mode's known ink colour.
590
+ WCAG contrast ratio against the mode's known ink colour. It measures **three** bands — rail,
591
+ headline, subtext — and the rail is graded against its *effective* ink, ink colour composited at the
592
+ rail's own opacity, because a 78%-opacity white is not white.
445
593
 
446
594
  | Ratio | Verdict |
447
595
  |---|---|
@@ -449,6 +597,10 @@ WCAG contrast ratio against the mode's known ink colour.
449
597
  | 4.5–7:1 | Passes AA. Fine for 70px display type; tighten if you can |
450
598
  | **≥ 7:1** | Ship it |
451
599
 
600
+ **The rail is graded on a different bar: fail under 3:1, warn under 3.5:1.** It is 32px, which
601
+ clears WCAG's large-text threshold, and it is deliberately quieter than the headline — the
602
+ documented `paper` default measures about 3.9:1. Grading it at 4.5 would fail every correct deck.
603
+
452
604
  Measured on the reference build: `photo-darken` and `photo-lighten` clear 7:1 on every band of every
453
605
  slide. **`photo-gray` lands at 6.1–6.9:1 and is the mode with the least headroom** — greyscale
454
606
  throws away the colour separation the other two lean on, so it is squeezed between "picture still
@@ -462,17 +614,21 @@ full-bleed photograph has ink everywhere). On a photo deck the contrast gate rep
462
614
 
463
615
  ### The layout grid
464
616
 
465
- Three horizontal bands, held identically on every slide. Read off the reference and expressed as
466
- percentages of the 1080×1920 canvas:
617
+ Four horizontal bands, held identically on every slide. Read off the reference (which has three;
618
+ the rail is the addition) and expressed as percentages of the 1080×1920 canvas:
467
619
 
468
620
  ```
469
621
  0% ─────────────────────────────────────
470
622
  (air — nothing lives here)
471
623
  14% ┌───────────────────────────────────┐
624
+ │ TOPIC RAIL 1 line, slides 2..N │
625
+ 16.5%└───────────────────────────────────┘
626
+ (air)
627
+ 19% ┌───────────────────────────────────┐
472
628
  │ HEADLINE centred, 1-3 lines │
473
- 30% └───────────────────────────────────┘
629
+ 35% └───────────────────────────────────┘
474
630
  (air)
475
- 34% ┌───────────────────────────────────┐
631
+ 37% ┌───────────────────────────────────┐
476
632
  │ │
477
633
  │ CUTOUTS 1-3 objects + marks │
478
634
  │ │
@@ -485,9 +641,15 @@ percentages of the 1080×1920 canvas:
485
641
  100% ─────────────────────────────────────
486
642
  ```
487
643
 
644
+ - **The headline starts at 19%, not 14%.** The rail took the top band, so the headline moved down
645
+ and the cutout band lost 3% off its top. Nothing else changed. If you are reading an older build
646
+ whose headlines start at 14%, it predates the rail — move them, do not add the rail above them
647
+ and let it collide with the reserved zone.
488
648
  - **The bands do not move between slides.** A headline that is 2 lines on slide 1 and 3 lines on
489
- slide 2 still starts at 14%. Vertically centring each slide's content individually is the single
649
+ slide 2 still starts at 19%. Vertically centring each slide's content individually is the single
490
650
  most common way a deck acquires a "wobble" nobody can name — and it is invisible slide by slide.
651
+ - **Slide 1 leaves the rail band EMPTY.** It does not move its headline up to fill the gap. The
652
+ cover's headline sits at 19% like every other headline, or the deck wobbles on its first cut.
491
653
  - **The bottom 20% and the top 14% stay empty.** That is where the platform puts the caption, the
492
654
  username, the buttons, and the swipe affordance.
493
655
  - **Everything is centred.** The headline block centres, the cutout group centres, the subtext
@@ -534,10 +696,15 @@ looking like itself.
534
696
 
535
697
  | Role | Face | Weight | Size | Line height |
536
698
  |---|---|---|---|---|
699
+ | Topic rail | same family | **500** | 32px, uppercase, `+0.16em`, 55% opacity | 1.2 |
537
700
  | Headline — emphasis | TikTok Sans *or* Montserrat | **800** | 76px | 1.22 |
538
701
  | Headline — body | same family | **500** | 76px | 1.22 |
539
702
  | Subtext | same family | **500** | 40px | 1.34 |
540
703
 
704
+ **Three sizes now, not two.** The old count was two and the contact-sheet review asked you to count
705
+ them; the answer is three, and the third is the rail at one fixed size on every slide. A fourth size
706
+ anywhere is the defect that check is looking for.
707
+
541
708
  `vidfarm qa`'s `font-regime` rule errors on Inter / Roboto / Arial / system-ui and warns on any
542
709
  family outside the imported regime (Montserrat, TikTok Sans, Abel, Source Code Pro, Yesteryear).
543
710
  **Montserrat is the closest match to the reference** and is in the regime.
@@ -717,12 +884,14 @@ fraction of the reach the format is for.
717
884
  Every rule has its reason attached. A rule without its reason gets argued away by the next agent
718
885
  that reads this file.
719
886
 
720
- ### Rule 1 — one shill, on slide 3, written as a tip
887
+ ### Rule 1 — two brand touches: the mechanism in the middle, the address at the end
721
888
 
722
- Any other placement or count changes what the deck *is*. Slide 1 makes it an ad, slide N makes it a
723
- pitch, two mentions make it a brochure. The shill is in the middle because that is where the reader
724
- is already paid and not yet suspicious. **Enforced:** the cold-viewer test, and `slides-qa.py`
725
- checks that exactly one slide contains the brand token.
889
+ Any other placement or count changes what the deck *is*. A mechanism on slide 1 makes it an ad; a
890
+ mechanism on slide N makes the whole list retroactive set-up for a pitch; a third touch anywhere
891
+ makes it a brochure. The shill is in the middle because that is where the reader is already paid
892
+ and not yet suspicious. The close is at the end because that is where they have been paid N−1
893
+ times and are choosing to still be there. **Enforced:** the cold-viewer test, and `slides-qa.py`
894
+ checks the brand token appears on exactly the shill slide and the close slide.
726
895
 
727
896
  ### Rule 2 — every slide is a complete still
728
897
 
@@ -732,9 +901,10 @@ on, because the carousel is the deliverable and the video is not.
732
901
 
733
902
  ### Rule 3 — the layout bands never move
734
903
 
735
- Headline at 14%, cutouts 34–66%, subtext at 73%, on every slide, whatever the content length. Per
736
- slide it looks fine either way; across the deck, individually-centred content produces a vertical
737
- wobble that reads as sloppy and that nobody can name while looking at one slide.
904
+ Rail at 14%, headline at 19%, cutouts 37–66%, subtext at 73%, on every slide, whatever the content
905
+ length. Per slide it looks fine either way; across the deck, individually-centred content produces a
906
+ vertical wobble that reads as sloppy and that nobody can name while looking at one slide. The cover
907
+ has no rail and still starts its headline at 19% — an empty band is not a gap to close.
738
908
 
739
909
  ### Rule 4 — one art class, one light direction, one canvas colour
740
910
 
@@ -764,17 +934,30 @@ An attribution line on the frame is brand chrome, and this format bans brand chr
764
934
  reason the others do. The carousel always has a caption; use it. If a licence forces on-image
765
935
  credit, change the asset.
766
936
 
767
- ### Rule 9 — the bait is in the post caption
937
+ ### Rule 9 — the frame carries an address, the caption carries the bait
768
938
 
769
- The deck ends on a tip. The comment prompt, the save prompt and the follow prompt all live in the
770
- caption where the platform expects them. A slide that says "save this" spends the slot that could
771
- have held the tip that made them save it.
939
+ The deck ends on a tip **with the destination under it** one line, lowercase, no verb (see *The
940
+ close*). Everything else that asks the reader for something stays in the caption: the comment
941
+ prompt, the save prompt, the follow prompt. A slide that says "save this" spends the slot that
942
+ could have held the tip that made them save it, and the phrase is down-ranked besides. An address
943
+ is information; "link in bio" is an instruction the reader did not need, because the address is
944
+ already on screen.
772
945
 
773
946
  ### Rule 10 — production floor
774
947
 
775
948
  1080×1920, 30fps, `N × 3.0s` exactly, hard cuts, one canvas, no letterboxing, text at frame 0 of
776
949
  every slide, `data-duration` on the root equal to the sum.
777
950
 
951
+ ### Rule 11 — the topic rail names the TOPIC, on slides 2…N, unchanged
952
+
953
+ One string, ≤5 words, identical byte for byte, at 14% on every slide except the cover. It exists
954
+ because a slide travels alone — screenshotted, reposted, landed on mid-swipe, reopened from a save
955
+ three weeks later — and a tip with no topic attached is a fortune cookie. **It is never the brand**
956
+ (that is N shills, and Rule 1 says one), **never a counter** (that is a build, and Rule 2 says
957
+ none), and never a comment on the slide it sits on. If it varies at all, it has stopped being a rail
958
+ and become a second headline. **Enforced:** `slides-qa.py` measures its band on every slide and
959
+ rejects the brand token appearing in it.
960
+
778
961
  ---
779
962
 
780
963
  ## Cost-saving mode — the whole deck at $0
@@ -827,7 +1010,7 @@ Sizing a round, capacity, and the ledger belong to `vidfarm experiment`, not to
827
1010
  | Gate | Tool | What it can actually see |
828
1011
  |---|---|---|
829
1012
  | **1. Composition** | `vidfarm qa ./work --harness ./sticker-slideshow-tips.md` | The DOM: font regime, slop, safe zone, frame 0, the `checks:` block above |
830
- | **2. The stills** | `slides-qa.py ./slides [--ink light\|dark]` *(Appendix C)* | The exported PNGs: blank slides, canvas drift, band alignment, cutout alpha, the one-shill count |
1013
+ | **2. The stills** | `slides-qa.py ./slides [--ink light\|dark]` *(Appendix C)* | The exported PNGs: blank slides, canvas drift, band alignment, cutout alpha, the two-brand-touch placement, the topic rail on 2…N, the close's grammar |
831
1014
  | **3. The sequence** | **You**, on `slides/contact-sheet.png` | Whether it reads as one deck. Nothing else can see this |
832
1015
  | **4. The tips** | **You**, cold | Whether any of it is worth saving. No tool has an opinion here |
833
1016
 
@@ -876,6 +1059,9 @@ in the edit.
876
1059
  - [ ] N is between 5 and 9 (cover included), and every slide is exactly 3.0s.
877
1060
  - [ ] Slide 2 is the strongest tip in the deck, not the easiest one to illustrate.
878
1061
  - [ ] Shuffling two middle tips breaks nothing.
1062
+ - [ ] The topic rail is written once, ≤5 words, and is the TOPIC — not the brand, not a counter.
1063
+ - [ ] It appears on slides 2…N, identical byte for byte, and NOT on the cover.
1064
+ - [ ] Screenshot slide 4 alone and show it to someone: they can say what list it came from.
879
1065
 
880
1066
  **The tips**
881
1067
 
@@ -889,11 +1075,23 @@ in the edit.
889
1075
  **The shill**
890
1076
 
891
1077
  - [ ] It sits in the MIDDLE: at least two real tips before it and two after it, cover excluded.
892
- - [ ] It is the only slide naming the offer.
1078
+ - [ ] It is the only slide naming the offer's MECHANISM. The close names the address, and nothing
1079
+ else in the deck names either.
893
1080
  - [ ] With the brand name covered, it is still one of the two best tips in the deck.
894
1081
  - [ ] It states a MECHANISM the reader could follow with no product at all.
895
1082
  - [ ] The brand is lowercase, in the subtext, in the 500 weight, named once.
896
- - [ ] Slide 3 is visually indistinguishable from the others — same layout, type, colour, art class.
1083
+ - [ ] The shill slide is visually indistinguishable from the others — same layout, type, colour,
1084
+ art class.
1085
+
1086
+ **The close**
1087
+
1088
+ - [ ] Slide N is still a TIP. Delete its last line and the slide is unchanged.
1089
+ - [ ] The last line is an ADDRESS, plus at most 6 words saying what it is.
1090
+ - [ ] No verb aimed at the reader, no "free", no urgency, no emoji, no arrow, no box.
1091
+ - [ ] It does not restate the shill's mechanism.
1092
+ - [ ] Lowercase, weight 500, same size as the subtext. Nothing about it is styled differently.
1093
+ - [ ] Exactly two brand touches in the whole deck: the shill and the close.
1094
+ - [ ] If there is nothing worth typing, there is no close — and slide N is a plain tip.
897
1095
 
898
1096
  **The pictures**
899
1097
 
@@ -914,8 +1112,11 @@ in the edit.
914
1112
  - [ ] Photo mode: hand-drawn marks use `{ink}`; photographic cutouts are never recoloured.
915
1113
  - [ ] Photo mode: the blur is on the image and the dim is a separate flat layer (no `modal-scrim`).
916
1114
  - [ ] Paper mode: one canvas colour across the deck. No footage, gradient, texture, border or card.
917
- - [ ] Headline band starts at 14% on every slide, whatever its line count.
1115
+ - [ ] Rail band at 14%, headline band at 19% on every slide, whatever its line count.
1116
+ - [ ] The cover leaves the rail band empty and does NOT move its headline up to fill it.
918
1117
  - [ ] Nothing in the top 14% or bottom 20%.
1118
+ - [ ] Rail measured at ≥3:1 (its own bar, not the headline's 4.5): 0.55 opacity on `paper`,
1119
+ 0.78 on any photo mode.
919
1120
  - [ ] Two weights of ONE self-hosted family, 800 and 500, no fallback chain in the stack.
920
1121
  - [ ] 2–4 emphasised runs per headline, weight-only, verb and noun.
921
1122
  - [ ] No stroke, no text shadow, no plate behind any text.
@@ -938,15 +1139,22 @@ actually ships broken. **Open `slides/contact-sheet.png` and read it as one imag
938
1139
 
939
1140
  - [ ] **Does the headline sit at the same height on all N slides?** The wobble is invisible one
940
1141
  slide at a time and obvious on the sheet.
941
- - [ ] **How many type sizes are there?** There should be two. Count them.
1142
+ - [ ] **How many type sizes are there?** There should be three — rail, headline, subtext. Count
1143
+ them.
1144
+ - [ ] **Cover the headline and the subtext on every slide. Does the rail still read the same on
1145
+ all of them, at the same height?** This is the check the pixel gate cannot do properly at
1146
+ 32px, and a rail that drifted or changed wording is obvious here and nowhere else.
1147
+ - [ ] **Did your eye go to the rail first on any slide?** Then it is too loud. Drop the opacity.
942
1148
  - [ ] **Do the cutouts look like one set?** Same class, same rendering, same light, plausible
943
1149
  relative scale.
944
1150
  - [ ] **Is the canvas the same white on all N?** A cutout with a near-white background rectangle
945
1151
  makes its slide read a different colour and only shows up beside its neighbours.
946
1152
  - [ ] **Which slide is the weakest?** There is one. Either rewrite it or delete it — a five-slide
947
1153
  deck with no weak slide beats a six-slide deck with one.
948
- - [ ] **Can you tell which slide is the ad?** If your eye goes to slide 3 before you read it, it is
949
- styled differently and Rule 1 is broken.
1154
+ - [ ] **Can you tell which slide is the ad?** If your eye goes to the shill before you read it, it
1155
+ is styled differently and Rule 1 is broken.
1156
+ - [ ] **Does the last slide read as a tip or as an ending card?** Cover its last line. If what is
1157
+ left feels like it is missing something, the address was carrying the slide.
950
1158
  - [ ] **Would you save this?** Not "is it correct". Would you save it.
951
1159
 
952
1160
  Then watch the MP4 end to end at 1× with sound off, which is how it will be seen.
@@ -963,8 +1171,10 @@ Diagnose by slot, never by declaring the deck bad.
963
1171
  | Views, drop-off at slide 2–3 | **The shill** | It reads as an ad. Cover the brand: if the slide is now weak, rewrite it as a tip |
964
1172
  | Swiped to the end, no saves | **The tips** | They are on rung 1–2. Add a mechanism and a number to each |
965
1173
  | Saves, no comments | **The caption** | The bait is missing or generic. It lives in the caption, so fix the caption, not the deck |
966
- | Good numbers, no clicks to the offer | **Nothing is broken** | This format seeds a mechanism; it does not close. Judge it on saves and branded search, not clicks |
1174
+ | Good numbers, no clicks to the offer | **The close** | Check it exists and is an address, not a promise. Then check expectations: this format seeds a mechanism and hands over an address — most of its return arrives as branded search, not as clicks. Judge it on saves and branded search first |
1175
+ | Reach collapsed on an otherwise fine deck | **The close** | An instruction phrase reached the frame — "link in bio", "sign up free". Gate 2 check 8 catches it; a suppressed deck looks exactly like a bad deck |
967
1176
  | "Looks AI-made" in the comments | **The sequence** | Almost always the band wobble, mixed art classes, or a stroke on the type. Contact sheet, gate 3 |
1177
+ | Reshares of one slide, no traffic back to the deck | **The topic rail** | It is missing, or it names the brand instead of the topic. A lifted slide has to say what list it came from |
968
1178
 
969
1179
  ---
970
1180
 
@@ -996,7 +1206,13 @@ Four things this exists to get right:
996
1206
  first_frame_text then fail on a composition whose frame 0 is actually fine.
997
1207
  * THE BANDS DO NOT MOVE. Every headline is pinned to the same top, whatever
998
1208
  its line count. Vertically centring per slide is the "wobble" that only
999
- shows up on the contact sheet (Rule 3).
1209
+ shows up on the contact sheet (Rule 3). The cover leaves the rail band
1210
+ empty and STILL starts its headline at head_top — do not close the gap.
1211
+ * THE TOPIC RAIL IS EMITTED FROM ONE STRING. --topic is written to slides
1212
+ 2..N verbatim, so it cannot drift, cannot pick up a counter, and cannot
1213
+ say something different on the shill slide (Rule 11). It is deliberately
1214
+ NOT a TSV column: a per-slide column is a per-slide value, and a rail with
1215
+ per-slide values is a second headline.
1000
1216
  * GEOMETRY IS INLINE, TYPOGRAPHY IS IN THE CLASS. `.clip{inset:0}` has to be
1001
1217
  reset per layer with `inset:auto`, that reset lives in the inline style
1002
1218
  attribute, and inline BEATS the stylesheet — so a `top:` in a CSS class
@@ -1015,15 +1231,19 @@ Background modes (--bg-mode):
1015
1231
 
1016
1232
  Any photo mode needs a 4th TSV column naming that slide's background image.
1017
1233
 
1018
- usage: build-deck.py <slides.tsv> <out.html> [--title=…] [--canvas=#FDFCFA]
1234
+ usage: build-deck.py <slides.tsv> <out.html> --topic="EATING WELL IN NYC"
1235
+ [--title=…] [--canvas=#FDFCFA] [--no-cover]
1019
1236
  [--bg-mode=paper|photo-darken|photo-lighten|photo-gray]
1020
- [--head-px=70] [--sub-px=40]
1237
+ [--head-px=70] [--sub-px=40] [--rail-px=32]
1238
+
1239
+ --no-cover says slide 1 is a TIP, not a cover — then the rail goes on all N
1240
+ slides, because nothing else in the deck names the list.
1021
1241
  """
1022
1242
  import html, sys
1023
1243
 
1024
1244
  SLIDE_SEC = 3.0
1025
1245
  W, H = 1080, 1920
1026
- BANDS = dict(head_top=14.0, cut_top=34.0, cut_bot=66.0, sub_top=73.0)
1246
+ BANDS = dict(rail_top=14.0, head_top=19.0, cut_top=37.0, cut_bot=66.0, sub_top=73.0)
1027
1247
 
1028
1248
  # Each mode is a complete legibility contract: what happens to the photo, what
1029
1249
  # flat wash sits over it, what colour the text is, and what that text needs to
@@ -1034,36 +1254,61 @@ MODES = {
1034
1254
  filter=None, wash=None, ink="#111111",
1035
1255
  # On a flat page the contrast is already maximal and a shadow is the
1036
1256
  # thing that makes type read as machine-made (Rule 5).
1037
- shadow="none", cut_shadow="none"),
1257
+ shadow="none", cut_shadow="none",
1258
+ # The rail must recede without disappearing. A flat page can carry 0.55;
1259
+ # a photograph cannot — translucent ink over a picture loses contrast far
1260
+ # faster than over paper, so every photo mode runs the rail at 0.78.
1261
+ rail_opacity=0.55),
1038
1262
  "photo-darken": dict(
1039
1263
  filter="blur(18px) brightness(0.38) saturate(0.90)",
1040
- wash="linear-gradient(180deg,rgba(0,0,0,0.62) 0%,rgba(0,0,0,0.62) 32%,rgba(0,0,0,0.22) 44%,rgba(0,0,0,0.22) 64%,rgba(0,0,0,0.62) 72%,rgba(0,0,0,0.62) 100%)", ink="#FFFFFF",
1264
+ wash="linear-gradient(180deg,rgba(0,0,0,0.62) 0%,rgba(0,0,0,0.62) 36%,rgba(0,0,0,0.22) 48%,rgba(0,0,0,0.22) 64%,rgba(0,0,0,0.62) 72%,rgba(0,0,0,0.62) 100%)", ink="#FFFFFF",
1041
1265
  shadow="0 2px 18px rgba(0,0,0,0.55)",
1042
- cut_shadow="drop-shadow(0 14px 30px rgba(0,0,0,0.55))"),
1266
+ cut_shadow="drop-shadow(0 14px 30px rgba(0,0,0,0.55))",
1267
+ rail_opacity=0.78),
1043
1268
  "photo-lighten": dict(
1044
1269
  filter="blur(18px) brightness(1.62) saturate(0.42)",
1045
- wash="linear-gradient(180deg,rgba(255,255,255,0.78) 0%,rgba(255,255,255,0.78) 32%,rgba(255,255,255,0.46) 44%,rgba(255,255,255,0.46) 64%,rgba(255,255,255,0.78) 72%,rgba(255,255,255,0.78) 100%)", ink="#111111",
1270
+ wash="linear-gradient(180deg,rgba(255,255,255,0.78) 0%,rgba(255,255,255,0.78) 36%,rgba(255,255,255,0.46) 48%,rgba(255,255,255,0.46) 64%,rgba(255,255,255,0.78) 72%,rgba(255,255,255,0.78) 100%)", ink="#111111",
1046
1271
  shadow="0 2px 18px rgba(255,255,255,0.75)",
1047
- cut_shadow="drop-shadow(0 14px 30px rgba(0,0,0,0.30))"),
1272
+ cut_shadow="drop-shadow(0 14px 30px rgba(0,0,0,0.30))",
1273
+ rail_opacity=0.78),
1048
1274
  "photo-gray": dict(
1049
1275
  filter="blur(14px) grayscale(1) brightness(0.48)",
1050
- wash="linear-gradient(180deg,rgba(18,18,20,0.64) 0%,rgba(18,18,20,0.64) 32%,rgba(18,18,20,0.18) 44%,rgba(18,18,20,0.18) 64%,rgba(18,18,20,0.64) 72%,rgba(18,18,20,0.64) 100%)", ink="#FFFFFF",
1276
+ wash="linear-gradient(180deg,rgba(18,18,20,0.64) 0%,rgba(18,18,20,0.64) 36%,rgba(18,18,20,0.18) 48%,rgba(18,18,20,0.18) 64%,rgba(18,18,20,0.64) 72%,rgba(18,18,20,0.64) 100%)", ink="#FFFFFF",
1051
1277
  shadow="0 2px 18px rgba(0,0,0,0.50)",
1052
- cut_shadow="drop-shadow(0 14px 30px rgba(0,0,0,0.50))"),
1278
+ cut_shadow="drop-shadow(0 14px 30px rgba(0,0,0,0.50))",
1279
+ rail_opacity=0.78),
1053
1280
  }
1054
1281
 
1055
1282
  args = [a for a in sys.argv[1:] if not a.startswith("--")]
1056
1283
  opts = dict(a.split("=", 1) for a in sys.argv[1:] if a.startswith("--") and "=" in a)
1284
+ flags = {a for a in sys.argv[1:] if a.startswith("--") and "=" not in a}
1285
+ has_cover = "--no-cover" not in flags
1057
1286
  manifest, out = args[0], args[1]
1058
1287
  canvas = opts.get("--canvas", "#FDFCFA")
1059
1288
  title = opts.get("--title", "tip deck")
1060
1289
  head_px = int(opts.get("--head-px", 70))
1061
1290
  sub_px = int(opts.get("--sub-px", 40))
1291
+ rail_px = int(opts.get("--rail-px", 32))
1062
1292
  bg_mode = opts.get("--bg-mode", "paper")
1063
1293
  if bg_mode not in MODES:
1064
1294
  sys.exit(f"unknown --bg-mode {bg_mode!r}; pick one of {', '.join(MODES)}")
1065
1295
  M = MODES[bg_mode]
1066
1296
 
1297
+ # The topic rail (Rule 11). Required, because "the deck with no rail" is not a
1298
+ # variant of this format — it is the failure mode the rail was added to fix.
1299
+ topic = opts.get("--topic", "").strip().upper()
1300
+ if not topic:
1301
+ sys.exit('--topic is required: the deck title in <=5 words, e.g. '
1302
+ '--topic="EATING WELL IN NYC". It goes on slides 2..N unchanged.')
1303
+ if len(topic.split()) > 5:
1304
+ sys.exit(f"--topic is {len(topic.split())} words; the rail holds 5. "
1305
+ f"Compress it — the cover carries the full title.")
1306
+ if any(ch.isdigit() for ch in topic) and "/" in topic:
1307
+ sys.exit("--topic looks like a counter. The rail is not a progress indicator (Rule 11).")
1308
+ # `vidfarm qa`'s font-size rule errors below 2.6% of canvas width.
1309
+ if rail_px < W * 0.026:
1310
+ sys.exit(f"--rail-px {rail_px} is below the {W * 0.026:.0f}px floor; qa will error on it.")
1311
+
1067
1312
  rows = []
1068
1313
  for raw in open(manifest):
1069
1314
  raw = raw.rstrip("\n")
@@ -1101,6 +1346,14 @@ CSS = f"""
1101
1346
 
1102
1347
  /* THE BANDS are set INLINE, not here — see the module docstring. Geometry
1103
1348
  inline, typography in the class. */
1349
+
1350
+ /* The topic rail. Uppercase + tracked + small is what makes it subordinate;
1351
+ the opacity is what stops it being read FIRST. This is the one place in the
1352
+ deck where opacity is legitimate — everywhere else it is a way of faking a
1353
+ font weight, which yields grey text where you wanted light text. */
1354
+ .rail{{font-family:'Deck';font-weight:500;font-size:{rail_px}px;line-height:1.2;
1355
+ letter-spacing:0.16em;text-transform:uppercase;color:{M['ink']};
1356
+ opacity:{M['rail_opacity']};text-align:center;text-shadow:{M['shadow']}}}
1104
1357
  .head{{font-family:'Deck';font-weight:500;font-size:{head_px}px;line-height:1.22;
1105
1358
  letter-spacing:-0.01em;color:{M['ink']};text-align:center;
1106
1359
  text-shadow:{M['shadow']}}}
@@ -1141,6 +1394,14 @@ for i, (head, sub, cuts, bg) in enumerate(rows):
1141
1394
  if M["wash"]:
1142
1395
  layers.append(layer("shape", f"s{n:02d}-wash", t, "wash",
1143
1396
  "inset:0;z-index:1", label=f"slide {n} wash"))
1397
+ # The rail: slides 2..N when there is a cover. Slide 1's headline IS the
1398
+ # title, so a rail above it would repeat itself — but the cover still leaves
1399
+ # the band EMPTY rather than moving its headline up, or the deck wobbles on
1400
+ # its first cut. With --no-cover nothing else names the list, so all N.
1401
+ if n > 1 or not has_cover:
1402
+ layers.append(layer("text", f"s{n:02d}-rail", t, "rail",
1403
+ f"inset:auto;left:8%;width:84%;top:{BANDS['rail_top']}%;z-index:3",
1404
+ html.escape(topic), label=f"slide {n} topic rail"))
1144
1405
  layers.append(layer("text", f"s{n:02d}-head", t, "head",
1145
1406
  f"inset:auto;left:8%;width:84%;top:{BANDS['head_top']}%;z-index:3",
1146
1407
  rich(head), label=f"slide {n} headline"))
@@ -1174,7 +1435,8 @@ open(out, "w").write(f"""<!doctype html>
1174
1435
  </div></body></html>
1175
1436
  """)
1176
1437
  print(f"{len(rows)} slides · {total}s · {len(layers)} flat layers · "
1177
- f"bg-mode {bg_mode} · ink {M['ink']} · {out}")
1438
+ f"bg-mode {bg_mode} · ink {M['ink']} · rail “{topic}"
1439
+ f"on slides {2 if has_cover else 1}-{len(rows)} · {out}")
1178
1440
  print("stills at: " + ",".join(str(round(i * SLIDE_SEC + 1.5, 1)) for i in range(len(rows))))
1179
1441
  ```
1180
1442
 
@@ -1222,7 +1484,8 @@ echo "preview : $DIR/final.mp4"
1222
1484
  runs on the pixels that ship.
1223
1485
 
1224
1486
  ```bash
1225
- python3 slides-qa.py ./work/slides --brand dishcover.io --shill-slide 3
1487
+ python3 slides-qa.py ./work/slides --brand dishcover.io --shill-slide 3 \
1488
+ --topic "EATING WELL IN NYC"
1226
1489
  ```
1227
1490
 
1228
1491
  ```python
@@ -1230,7 +1493,7 @@ python3 slides-qa.py ./work/slides --brand dishcover.io --shill-slide 3
1230
1493
  """
1231
1494
  Grade an exported tip deck on the PNGs, not on the markup.
1232
1495
 
1233
- Six checks, each one a defect that passes every DOM check and every per-slide
1496
+ Seven checks, each one a defect that passes every DOM check and every per-slide
1234
1497
  eyeball, and shows up only across the set:
1235
1498
 
1236
1499
  1. no slide is blank — an entrance that had not settled at +1.5s
@@ -1243,22 +1506,44 @@ eyeball, and shows up only across the set:
1243
1506
  brand manifest rather than by OCR
1244
1507
  6. every cutout's alpha is
1245
1508
  mostly 0-or-255 — run separately on media/cutouts/*.png
1509
+ 7. the topic rail is on slides — Rule 11. A missing rail is invisible while
1510
+ 2..N and NOT on slide 1 you read the deck in order and is the whole
1511
+ point of the deck when a slide travels alone
1512
+ 8. the close is an ADDRESS, not — Rule 9. "Sign up free" and "link in bio"
1513
+ an instruction are what a CTA turns into when nobody checks
1246
1514
 
1247
1515
  usage: slides-qa.py <slides-dir> [--brand <token>] [--shill-slide <n>] [--ink light|dark]
1248
1516
  [--manifest <slides.tsv>] [--cutouts <dir>]
1517
+ [--topic <rail text>] [--rail-opacity 0.55] [--no-cover]
1518
+ [--no-close]
1249
1519
  """
1250
1520
  import glob, os, subprocess, sys
1251
1521
 
1252
1522
  d = sys.argv[1]
1253
- opt = dict(zip(sys.argv[2::2], sys.argv[3::2]))
1523
+ # `opt` pairs positionally, so a bare flag would shift every option after it by
1524
+ # one and silently give you --brand="--shill-slide". Strip the bare flags first.
1525
+ BARE = {"--no-cover", "--no-close"}
1526
+ argv = [a for a in sys.argv[2:] if a not in BARE]
1527
+ has_cover = "--no-cover" not in sys.argv
1528
+ no_close = "--no-close" in sys.argv
1529
+ opt = dict(zip(argv[0::2], argv[1::2]))
1254
1530
  brand = opt.get("--brand")
1255
1531
  shill = int(opt.get("--shill-slide", 3))
1256
1532
  manifest = opt.get("--manifest")
1257
1533
  cutdir = opt.get("--cutouts")
1258
1534
  ink = opt.get("--ink") # "light" | "dark" — the mode's text colour
1535
+ topic = (opt.get("--topic") or "").strip()
1536
+ rail_alpha = float(opt.get("--rail-opacity", 0.78 if ink else 0.55))
1259
1537
 
1260
1538
  fails, warns = [], []
1261
1539
 
1540
+ # The band geometry, in pixels on a 1080x1920 canvas. Kept here rather than
1541
+ # inlined so the rail's arrival could not silently leave the headline scan
1542
+ # looking at the rail — which is exactly what happened on the first attempt.
1543
+ RAIL_Y, RAIL_H = 262, 52 # 14.0% .. 16.5%
1544
+ HEAD_Y, HEAD_H = 355, 300 # 19.0% .. 34.6% (3 lines at 76/1.22)
1545
+ SUB_Y, SUB_H = 1395, 175 # 73.0% .. 82.1%
1546
+
1262
1547
 
1263
1548
  def probe(png, crop):
1264
1549
  """Mean RGB of a crop, via ffmpeg — no PIL dependency."""
@@ -1306,10 +1591,13 @@ if not ink:
1306
1591
  fails.append(f"{os.path.basename(s)}: canvas {c} drifts from {base}")
1307
1592
 
1308
1593
  # --------------------------------------------------- 3. the headline band is level
1594
+ # The scan starts BELOW the rail band. Starting at 200px (as this did before the
1595
+ # rail existed) locks onto the rail on every slide except the cover, reports a
1596
+ # perfectly level 269px on 5 of 6 slides, and hides a real headline wobble.
1309
1597
  tops = []
1310
1598
  for s in (slides if not ink else []):
1311
1599
  top = None
1312
- for y in range(200, 700, 12): # scan the headline band, 10%-36%
1600
+ for y in range(HEAD_Y - 20, 760, 12): # scan the headline band, 17%-40%
1313
1601
  if rowdark(s, y, 12) > 0.06:
1314
1602
  top = y
1315
1603
  break
@@ -1328,14 +1616,92 @@ for s in (slides if not ink else []):
1328
1616
  if rowdark(s, 1536, 384) > 0.02:
1329
1617
  warns.append(f"{os.path.basename(s)}: ink in the bottom 20% (caption/buttons)")
1330
1618
 
1331
- # ------------------------------------------------------------ 5. exactly ONE shill
1619
+ # ------------------------------------------- 5. exactly TWO brand touches, placed
1620
+ # The shill (mechanism, middle) and the close (address, slide N). A third is a
1621
+ # brochure; a mechanism on slide N turns every tip before it into set-up.
1332
1622
  if brand and manifest:
1333
- hits = [i + 1 for i, line in enumerate(
1334
- l for l in open(manifest) if l.strip() and not l.lstrip().startswith("#"))
1335
- if brand.lower() in line.lower()]
1336
- if hits != [shill]:
1623
+ lines = [l for l in open(manifest) if l.strip() and not l.lstrip().startswith("#")]
1624
+ hits = [i + 1 for i, line in enumerate(lines) if brand.lower() in line.lower()]
1625
+ want = [shill] if no_close else sorted({shill, len(lines)})
1626
+ if hits != want:
1337
1627
  fails.append(f"brand '{brand}' appears on slides {hits or 'none'} — "
1338
- f"Rule 1 requires exactly [{shill}]")
1628
+ f"Rule 1 requires exactly {want} "
1629
+ f"({'shill only, --no-close' if no_close else 'shill + close'})")
1630
+ # The close is an ADDRESS. A mechanism restated on slide N is the third touch
1631
+ # wearing a hat, and it is the one the eye forgives and the reader does not.
1632
+ if not no_close and len(lines) >= 1 and brand.lower() in lines[-1].lower():
1633
+ close_sub = (lines[-1].split("\t") + ["", ""])[1]
1634
+ if close_sub.count("|") > 2:
1635
+ warns.append(f"the close's subtext runs to {close_sub.count('|') + 1} lines — "
1636
+ f"two lines of tip, one line of address, and no more")
1637
+
1638
+ # -------------------------------------------- 8. the close is an address, not an ad
1639
+ # The CTA is the one line in the deck with gravity: every rewrite pulls it toward
1640
+ # "sign up free today". These are the phrases it lands on, and each one costs
1641
+ # reach on a carousel while adding nothing the address did not already say.
1642
+ INSTRUCTIONS = ("link in bio", "sign up", "free trial", "get started", "book a demo",
1643
+ "download now", "download the app", "learn more", "swipe up",
1644
+ "visit our", "check out", "click", "tap the", "try it", "try now",
1645
+ "grab your", "don't miss", "limited time", "act now", "save this",
1646
+ "follow for more", "comment below")
1647
+ if manifest and not no_close:
1648
+ lines = [l for l in open(manifest) if l.strip() and not l.lstrip().startswith("#")]
1649
+ if lines:
1650
+ close = lines[-1].lower()
1651
+ for phrase in INSTRUCTIONS:
1652
+ if phrase in close:
1653
+ fails.append(f"the close contains '{phrase}' — the CTA is an ADDRESS, not "
1654
+ f"an instruction (Rule 9). The reader can already see the "
1655
+ f"address; the phrase only costs reach")
1656
+ # An outcome promise is the other direction the close drifts in, and it
1657
+ # only ever appears in the subtext — the headline is still a plain tip.
1658
+ close_sub = (lines[-1].split("\t") + ["", ""])[1].lower()
1659
+ for phrase in ("faster", "easier", "never again", "in seconds", "in minutes"):
1660
+ if phrase in close_sub:
1661
+ warns.append(f"the close may be promising an outcome ('{phrase}') — it "
1662
+ f"names what the product IS, never what it does for them")
1663
+
1664
+ # --------------------------------------------------------- 7. the topic rail
1665
+ # The rail is the one element that does nothing for the reader who swipes the
1666
+ # deck in order — so nothing about reading the deck tells you it is missing.
1667
+ # It earns its place when ONE slide travels alone, which is the moment nobody
1668
+ # is testing. Hence a machine check.
1669
+ if topic and brand and brand.lower() in topic.lower():
1670
+ fails.append(f"the topic rail contains the brand '{brand}' — that is N shills on "
1671
+ f"N slides, not one (Rule 11). The rail names the TOPIC")
1672
+ if topic and (len(topic.split()) > 5 or "/" in topic):
1673
+ fails.append(f"topic rail '{topic}' is a counter or over 5 words (Rule 11)")
1674
+
1675
+
1676
+ def bandtexture(png, y, h):
1677
+ """How much of a band deviates from its own median.
1678
+
1679
+ Glyphs deviate hard; a flat page does not, and neither does a
1680
+ hard-blurred photo under a wash. This is a SMOKE TEST — 32px type is
1681
+ below what a 256x8 downscale can characterise properly, so a missing
1682
+ rail is a warn, not a fail, and the contact sheet is the real gate.
1683
+ """
1684
+ raw = subprocess.run(
1685
+ ["ffmpeg", "-v", "error", "-i", png, "-vf",
1686
+ f"crop=1080:{h}:0:{y},format=gray,scale=256:8", "-frames:v", "1",
1687
+ "-f", "rawvideo", "-pix_fmt", "gray", "-"], capture_output=True).stdout
1688
+ if not raw:
1689
+ return 0.0
1690
+ vals = sorted(raw)
1691
+ med = vals[len(vals) // 2]
1692
+ return sum(1 for b in raw if abs(b - med) > 25) / len(raw)
1693
+
1694
+
1695
+ rail_tex = [bandtexture(s, RAIL_Y, RAIL_H) for s in slides]
1696
+ railed = list(zip(slides, rail_tex))[1:] if has_cover else list(zip(slides, rail_tex))
1697
+ if has_cover and rail_tex[0] > 0.03:
1698
+ warns.append(f"{os.path.basename(slides[0])}: the COVER has something in the rail "
1699
+ f"band. Slide 1's headline is the title; it carries no rail (Rule 11)")
1700
+ for s, tex in railed:
1701
+ if tex <= 0.03:
1702
+ warns.append(f"{os.path.basename(s)}: no topic rail found at 14%. Every slide "
1703
+ f"but the cover carries it — a slide that travels alone has "
1704
+ f"nothing else naming the list (Rule 11)")
1339
1705
 
1340
1706
  # ------------------------------------------------- 5b. TEXT CONTRAST (readability)
1341
1707
  # The one thing a photo background can silently destroy. Measuring beats looking:
@@ -1351,7 +1717,15 @@ def _lin(v):
1351
1717
  return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
1352
1718
 
1353
1719
 
1354
- def band_contrast(png, y, h, ink_is_light):
1720
+ def band_contrast(png, y, h, ink_is_light, alpha=1.0):
1721
+ """Contrast of a band's text against its own background.
1722
+
1723
+ `alpha` is the text layer's opacity. It is NOT cosmetic: a 55%-opacity
1724
+ white is not white, and grading the topic rail as if it were is how a rail
1725
+ that measures 3.1:1 gets signed off as "the headline is 9:1, we're fine".
1726
+ The composite is done in gray against the SAME worst-case patch the
1727
+ background is graded on, which is what the eye sees.
1728
+ """
1355
1729
  raw = subprocess.run(
1356
1730
  ["ffmpeg", "-v", "error", "-i", png, "-vf",
1357
1731
  f"crop=1080:{h}:0:{y},format=gray,boxblur=24:2,scale=48:8",
@@ -1359,20 +1733,44 @@ def band_contrast(png, y, h, ink_is_light):
1359
1733
  capture_output=True).stdout
1360
1734
  if not raw:
1361
1735
  return None
1362
- vals = sorted(_lin(b) for b in raw)
1363
- L_ink = 1.0 if ink_is_light else _lin(0x11)
1736
+ vals = sorted(raw)
1364
1737
  # 90th / 10th percentile rather than the absolute extreme: one stray blurred
1365
1738
  # highlight is not where the text sits, and grading against it fails a deck
1366
1739
  # that is genuinely fine.
1367
- L_bg = vals[int(len(vals) * 0.90)] if ink_is_light else vals[int(len(vals) * 0.10)]
1740
+ g_bg = vals[int(len(vals) * 0.90)] if ink_is_light else vals[int(len(vals) * 0.10)]
1741
+ g_ink = alpha * (255 if ink_is_light else 0x11) + (1 - alpha) * g_bg
1742
+ L_ink, L_bg = _lin(g_ink), _lin(g_bg)
1368
1743
  hi, lo = max(L_ink, L_bg), min(L_ink, L_bg)
1369
1744
  return (hi + 0.05) / (lo + 0.05)
1370
1745
 
1371
1746
 
1747
+ # The rail is graded in EVERY mode, including `paper` — its opacity means it is
1748
+ # the only element whose contrast is a choice rather than a consequence.
1749
+ # Thresholds are lower than the headline's on purpose: at 32px it clears WCAG's
1750
+ # large-text bar (>=24px), and it is SUPPOSED to be quieter than the headline.
1751
+ # A rail as loud as its headline is a second headline.
1752
+ #
1753
+ # The documented default — #111 at 0.55 on a #FDFCFA page — measures ~3.9:1
1754
+ # through this same blur, which is why the warn sits at 3.5 and not at 4.5: a
1755
+ # rail graded like a headline fires on every correct paper deck. If you DO see
1756
+ # the warn on `paper`, the canvas is not the canvas — something is sitting under
1757
+ # the rail band.
1758
+ light = ink == "light"
1759
+ for s, _ in railed:
1760
+ r = band_contrast(s, RAIL_Y, RAIL_H, light, rail_alpha)
1761
+ if r is None:
1762
+ continue
1763
+ if r < 3.0:
1764
+ fails.append(f"{os.path.basename(s)}: topic rail contrast {r:.1f}:1 — below the "
1765
+ f"3:1 large-text floor. Raise --rail-opacity (0.78 on any photo "
1766
+ f"mode) or deepen the wash")
1767
+ elif r < 3.5:
1768
+ warns.append(f"{os.path.basename(s)}: topic rail contrast {r:.1f}:1 — legal but "
1769
+ f"thin. A rail nobody can read is a layer nobody needed")
1770
+
1372
1771
  if ink:
1373
- light = ink == "light"
1374
1772
  for s in slides:
1375
- for name, y, h in (("headline", 260, 340), ("subtext", 1395, 175)):
1773
+ for name, y, h in (("headline", HEAD_Y, HEAD_H), ("subtext", SUB_Y, SUB_H)):
1376
1774
  r = band_contrast(s, y, h, light)
1377
1775
  if r is None:
1378
1776
  continue
@@ -1422,9 +1820,13 @@ READER: Lives here, eats out twice a week, keeps ending up somewhere fine
1422
1820
  OFFER: dishcover.io — search restaurants by DISH instead of by restaurant
1423
1821
  ART CLASS: photographic cutout, one sheet, contact shadows off
1424
1822
  SHILL: slide 3
1823
+ RAIL: EATING WELL IN NYC (all 6 slides — this deck is coverless, never the brand)
1824
+ SHAPE: coverless (--no-cover). See the note under "The topic rail"
1825
+ CLOSE: slide 6, third subtext line — "dishcover.io — every dish in NYC, searchable"
1425
1826
  ```
1426
1827
 
1427
- **Post caption** (this is where the topic, the bait and the credits live)
1828
+ **Post caption** (this is where the topic, the bait and the credits live — the *address* is on
1829
+ slide 6, so the caption never repeats it)
1428
1830
 
1429
1831
  ```
1430
1832
  6 rules for eating well in NYC 🗽
@@ -1439,9 +1841,13 @@ Go at *5:30* or go at *9:45*. The kitchen is calmest at the edges|of service. Sa
1439
1841
  *Search the dish*, not|the *restaurant*. "Best birria in Queens" finds you dinner.|"Best Mexican near me" finds you a parking lot.|dishcover.io is built to search that way. media/cutouts/query-scribble.png@14,42,26,-3 ; media/cutouts/arrow.png@42,46,14,0 ; media/cutouts/birria.png@58,38,28,5
1440
1842
  *One dish* decides the place. Every good spot does one thing|better than anyone. Order that. media/cutouts/signature-dish.png@36,37,30,-6
1441
1843
  Trust a *30-item* menu|over a *200-item* one. A short menu means a kitchen that repeats.|A long one means a freezer. media/cutouts/menu-short.png@20,38,26,-5 ; media/cutouts/menu-long.png@54,36,26,7
1442
- *Walk two more blocks.* Every train exit has a rent-funded|trap in the first 200 feet. media/cutouts/subway-exit.png@34,39,32,-4
1844
+ *Walk two more blocks.* Every train exit has a rent-funded|trap in the first 200 feet.|dishcover.io — every dish in NYC, searchable media/cutouts/subway-exit.png@34,39,32,-4
1443
1845
  ```
1444
1846
 
1847
+ Slide 6 is **the close**: a real tip, with the address on a third subtext line. Delete that line and
1848
+ slide 6 is unchanged — which is the test. Note what the line does *not* say: no verb, no "free", no
1849
+ "link in bio", and no repeat of slide 3's mechanism.
1850
+
1445
1851
  **Slide 3, read against the lock**
1446
1852
 
1447
1853
  - *Actionable?* Yes — search a dish name in any search box tonight.
@@ -1453,10 +1859,12 @@ Trust a *30-item* menu|over a *200-item* one. A short menu means a kitchen that
1453
1859
 
1454
1860
  ```bash
1455
1861
  python3 build-deck.py slides.tsv work/index.html --title="dishcover — NYC eats deck" \
1456
- --canvas="#FDFCFA"
1862
+ --canvas="#FDFCFA" --no-cover \
1863
+ --topic="EATING WELL IN NYC"
1457
1864
  vidfarm qa ./work --harness ./experimental/sticker-slideshow-tips.md
1458
1865
  bash export-deck.sh ./work 6 nyc-eats
1459
1866
  python3 slides-qa.py ./work/slides --brand dishcover.io --shill-slide 3 \
1867
+ --topic "EATING WELL IN NYC" --no-cover \
1460
1868
  --manifest slides.tsv --cutouts ./work/media/cutouts
1461
1869
  open ./work/slides/contact-sheet.png # gate 3 — the pass that actually matters
1462
1870
  ```
@@ -1471,16 +1879,33 @@ open ./work/slides/contact-sheet.png # gate 3 — the pass that actually mat
1471
1879
  40px high. Fixed by pinning the band, not by padding the headline.
1472
1880
  4. `dishcover.io` was originally in the 800 weight. It read as a banner. Dropped to 500 with the
1473
1881
  rest of the subtext and the slide stopped looking like the ad slide.
1882
+ 5. The topic rail was first written as `DISHCOVER.IO` — which looked tidy, and was six brand
1883
+ mentions in a deck allowed one. Replaced with `EATING WELL IN NYC` (Rule 11).
1884
+ 6. The close was first written `Find better food tonight → dishcover.io (free!)`. Three failures in
1885
+ one line: an outcome the deck cannot keep, an instruction the reader did not need, and a price.
1886
+ Rewritten as the address plus five words saying what it is.
1887
+ 7. The rail is the one element whose contrast is set by a number you chose rather than by the
1888
+ mode, so carrying `paper`'s 0.55 opacity into a `photo-darken` variant is the easy mistake:
1889
+ the headline still measures 7:1+ and nothing looks wrong on any slide you open. Run
1890
+ `slides-qa.py` with `--ink light --rail-opacity` set to whatever you actually shipped, and let
1891
+ the rail band be graded on its own.
1474
1892
 
1475
1893
  ## Appendix E — the harness in one paragraph, for a handoff
1476
1894
 
1477
1895
  > Build a 6-slide tip carousel, 9:16, 1080×1920, on a flat `#FDFCFA` page — no footage, no
1478
- > gradients, no cards. Each slide: one imperative tip as a centred headline (TikTok Sans or
1479
- > Montserrat, two weights 800/500 mixed inside the sentence, 76px, pinned at 14% from the top,
1480
- > hand-broken lines) plus an optional reason line at 73% (40px, 500) and 1–3 die-cut photographic
1481
- > cutouts in the 34–66% band, rotated ±3–8°. No stroke, no shadow, no plate on any text. Slide 3
1482
- > names dishcover.io once, lowercase, in the subtext, and is written so it is still a good tip with
1483
- > the brand removed. No title slide; the topic and the comment bait are the post caption. Ship the
1484
- > six PNGs as the deliverable (durable My Files, ordered in `slides.json`) and an MP4 at exactly
1485
- > 3.0s per slide with hard cuts as the preview. Full rules:
1896
+ > gradients, no cards. Each slide: the deck's topic as a small rail at 14% (`EATING WELL IN NYC`,
1897
+ > uppercase, 32px, weight 500, `+0.16em`, 55% opacity, IDENTICAL on every slide it is what tells
1898
+ > a screenshotted slide what list it came from, and it is never the brand and never a counter), one
1899
+ > imperative tip as a centred headline (TikTok Sans or Montserrat, two weights 800/500 mixed inside
1900
+ > the sentence, 76px, pinned at 19% from the top, hand-broken lines), an optional reason line at 73%
1901
+ > (40px, 500), and 1–3 die-cut photographic cutouts in the 37–66% band, rotated ±3–8°. No stroke, no
1902
+ > shadow, no plate on any text. Slide 3 names dishcover.io once, lowercase, in the subtext, as the
1903
+ > MECHANISM the tip describes, and is written so it is still a good tip with the brand removed.
1904
+ > Slide 6 is the close: a real tip whose subtext runs to a third line carrying the ADDRESS
1905
+ > (`dishcover.io — every dish in NYC, searchable`) — lowercase, 500 weight, same size as the rest of
1906
+ > the subtext, no verb aimed at the reader, no "free", no "link in bio", and delete-safe (cut that
1907
+ > line and slide 6 is unchanged). Exactly two brand touches in the deck, no more. No title slide;
1908
+ > the full topic and the comment/save bait are the post caption. Ship the six PNGs as the
1909
+ > deliverable (durable My Files, ordered
1910
+ > in `slides.json`) and an MP4 at exactly 3.0s per slide with hard cuts as the preview. Full rules:
1486
1911
  > `vidfarm.cc/experimental/sticker-slideshow-tips.md`.