@struq/skill 0.4.2 → 0.5.0

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 (50) hide show
  1. package/README.md +167 -59
  2. package/package.json +2 -2
  3. package/skills/struq/SKILL.md +145 -89
  4. package/skills/struq/blocks/CATALOG.md +74 -22
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +34 -12
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +14 -6
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +40 -15
  8. package/skills/struq/blocks/catalog.json +697 -16
  9. package/skills/struq/blocks/film.json +426 -0
  10. package/skills/struq/blocks/footers/footer-contact.tsx +81 -55
  11. package/skills/struq/blocks/footers/footer-reveal.tsx +1 -1
  12. package/skills/struq/blocks/headers/header-bar.tsx +24 -13
  13. package/skills/struq/blocks/headers/menu-curtain.tsx +24 -11
  14. package/skills/struq/blocks/heroes/hero-framed.tsx +33 -20
  15. package/skills/struq/blocks/heroes/hero-index.tsx +32 -11
  16. package/skills/struq/blocks/heroes/hero-poster.tsx +68 -35
  17. package/skills/struq/blocks/heroes/hero-split.tsx +22 -9
  18. package/skills/struq/blocks/heroes/hero-still.tsx +80 -74
  19. package/skills/struq/blocks/kit/accent.ts +82 -0
  20. package/skills/struq/blocks/kit/blocks.css +78 -35
  21. package/skills/struq/blocks/kit/quiet/Close.tsx +73 -0
  22. package/skills/struq/blocks/kit/quiet/PhotoText.tsx +79 -0
  23. package/skills/struq/blocks/kit/quiet/Rows.tsx +81 -0
  24. package/skills/struq/blocks/kit/quiet/Statement.tsx +157 -0
  25. package/skills/struq/blocks/kit/quiet/index.ts +7 -0
  26. package/skills/struq/blocks/kit/quiet/sheet.ts +12 -0
  27. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +87 -36
  28. package/skills/struq/blocks/preloaders/preloader-gate.tsx +116 -46
  29. package/skills/struq/blocks/preloaders/preloader-stack.tsx +10 -7
  30. package/skills/struq/blocks/sections/contact-form.tsx +1 -1
  31. package/skills/struq/blocks/sections/cta-closing.tsx +3 -2
  32. package/skills/struq/blocks/sections/fanned-cards.tsx +14 -10
  33. package/skills/struq/blocks/sections/film-strip.tsx +18 -14
  34. package/skills/struq/blocks/sections/grid-assembly.tsx +16 -11
  35. package/skills/struq/blocks/sections/manifesto.tsx +3 -2
  36. package/skills/struq/blocks/sections/panel-accordion.tsx +3 -2
  37. package/skills/struq/blocks/sections/split-pair.tsx +4 -3
  38. package/skills/struq/blocks/showpieces/hero-opener.tsx +76 -17
  39. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +3 -1
  40. package/skills/struq/blocks/transitions/zoom-through.tsx +9 -10
  41. package/skills/struq/canon/canon.json +1 -1
  42. package/skills/struq/references/craft.md +4 -0
  43. package/skills/struq/references/film.md +158 -0
  44. package/skills/struq/references/triage.md +3 -0
  45. package/skills/struq/scripts/blocks.mjs +89 -7
  46. package/skills/struq/scripts/canon.mjs +8 -1
  47. package/skills/struq/scripts/check.mjs +94 -11
  48. package/skills/struq/scripts/film-check.mjs +997 -0
  49. package/skills/struq/scripts/gate.mjs +146 -0
  50. package/skills/struq/scripts/packs.mjs +73 -12
@@ -9,22 +9,35 @@
9
9
  // <HeroOpener image={images.hero[0]} title="Koken op" accent="vuur." lead="..." cta={{ href: "#reserveren", label: "Reserveer een tafel" }} header={<SiteHeader />} />
10
10
  // <HeroOpener image={images.hero[0]} split={["Vuur", "Noord"]} lead="..." cta={...} />
11
11
  //
12
- // The line is the display face at poster size, with at most one italic accent word. The flat scrim
12
+ // <HeroOpener image={images.hero[0]} title="Koken op vuur." accent="vuur" lead="..." cta={...} />
13
+ //
14
+ // The line is the display face at poster size, with at most one italic accent word: the site's one accent word, marked
15
+ // in place when the title holds it (the same mark as the Statement and the Close, kept whole by SplitText's line cut),
16
+ // or set after the title when it does not. The flat scrim
13
17
  // keeps the copy at 4.5:1; never a gradient. Reduced motion and phones: the composed still, no pin.
14
18
  // The section after the opener slides over it: globals.css gives it a ground and a higher layer.
19
+ //
20
+ // The entrance (the line rising through its masks, the split words, the lead and the CTA) waits for
21
+ // any preloader or page transition to start leaving (whenUncovered, from the helper the frames share),
22
+ // and plays at once when nothing covers the page.
15
23
  import { useRef } from "react";
24
+ import { findAccent, withAccent } from "../kit/accent";
16
25
  import { HeroCanvas } from "../kit/HeroCanvas";
17
26
  import { MagneticButton } from "../kit/MagneticButton";
18
- import { SplitHeadline } from "../kit/SplitHeadline";
19
- import { gsap, useGSAP } from "../kit/gsap";
27
+ import { SplitText, gsap, useGSAP } from "../kit/gsap";
20
28
  import type { StruqImage } from "../kit/images";
29
+ import { whenUncovered } from "../preloaders/preloader-slices-uncovered";
21
30
 
22
31
  type Props = {
23
32
  /** The beeldbank or client photo. Without one (imageFit "none"), the opener is typographic on the inverse ground over the dots scene. */
24
33
  image?: StruqImage;
25
34
  /** The poster line ("across"). */
26
35
  title?: string;
27
- /** One word in the display face's italic, after the title. */
36
+ /**
37
+ * The site's one accent word, in the pairing's italic accent face. A word the title holds is marked where it first
38
+ * stands (whole word, any case, punctuation outside), as the Statement and the Close mark it; a word the title does
39
+ * not hold is set after the title, as before.
40
+ */
28
41
  accent?: string;
29
42
  /** Two words for the split wordmark; replaces title. */
30
43
  split?: [string, string];
@@ -46,6 +59,7 @@ export function HeroOpener({ image, title, accent, split, lead, cta, header, scr
46
59
  // The lead goes on the photo's calm side; the CTA takes the other. Over a busy frame (no calm
47
60
  // side known) the lead gets a flat backing at a fixed opacity and is set a step larger.
48
61
  const side = calm ?? (image?.negativeSpace === "left" || image?.negativeSpace === "right" ? image.negativeSpace : image ? "none" : "left");
62
+ const inTitle = Boolean(title && findAccent(title, accent));
49
63
  const stage = useRef<HTMLElement>(null);
50
64
 
51
65
  useGSAP(
@@ -53,12 +67,48 @@ export function HeroOpener({ image, title, accent, split, lead, cta, header, scr
53
67
  const el = stage.current;
54
68
  if (!el) return;
55
69
  const q = gsap.utils.selector(el);
70
+ const title = q("[data-opener-title]")[0] as HTMLElement | undefined;
56
71
  const mm = gsap.matchMedia();
72
+ mm.add("(prefers-reduced-motion: reduce)", () => {
73
+ if (!title) return;
74
+ title.dataset.ready = "1";
75
+ gsap.from(title, { autoAlpha: 0, duration: 0.6 });
76
+ });
57
77
  mm.add("(prefers-reduced-motion: no-preference)", () => {
78
+ // Every part is set in its start state now and the whole entrance plays once nothing covers the page.
79
+ let uncovered = false;
80
+ const intro = gsap.timeline({ paused: true });
58
81
  if (split) {
59
- gsap.from(q("[data-split-word]"), { yPercent: 110, duration: 1.4, ease: "expo.out", stagger: 0.12, delay: 0.25 });
82
+ intro.from(q("[data-split-word]"), { yPercent: 110, duration: 1.4, ease: "expo.out", stagger: 0.12 }, 0.25);
60
83
  }
61
- gsap.from(q("[data-opener-copy] > *"), { y: 32, autoAlpha: 0, duration: 1, ease: "expo.out", stagger: 0.1, delay: 0.7 });
84
+ intro.from(q("[data-opener-copy] > *"), { y: 32, autoAlpha: 0, duration: 1, ease: "expo.out", stagger: 0.1 }, 0.7);
85
+ // The poster line rises line by line through its masks, cut again when the width or the face changes (a cut
86
+ // after the entrance carries its progress over, so it never replays).
87
+ let lines: gsap.core.Timeline | null = null;
88
+ const cut = title
89
+ ? SplitText.create(title, {
90
+ type: "lines",
91
+ mask: "lines",
92
+ linesClass: "struq-line",
93
+ autoSplit: true,
94
+ onSplit(self) {
95
+ lines = gsap
96
+ .timeline({ paused: !uncovered })
97
+ .from(self.lines, { yPercent: 115, duration: 1.25, ease: "expo.out", stagger: 0.09 }, 0.15);
98
+ return lines;
99
+ },
100
+ })
101
+ : null;
102
+ if (title) title.dataset.ready = "1";
103
+ const stop = whenUncovered(() => {
104
+ uncovered = true;
105
+ intro.play();
106
+ lines?.play();
107
+ });
108
+ return () => {
109
+ stop();
110
+ cut?.revert();
111
+ };
62
112
  });
63
113
  mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
64
114
  // The handover: the opener holds, then scales down and darkens under the next section.
@@ -75,7 +125,9 @@ export function HeroOpener({ image, title, accent, split, lead, cta, header, scr
75
125
 
76
126
  // At poster size the words need air of their own: tight tracking pulls the letters together, and
77
127
  // word spacing in em keeps the gap in step with the size for any face.
78
- const poster = "font-display block text-[19vw] leading-[0.92] tracking-[-0.045em] [word-spacing:0.09em] md:text-[16vw]";
128
+ // The size sits on each word's mask too: the shared mask's room for accents and descenders is in its own em.
129
+ const size = "text-[19vw] md:text-[16vw]";
130
+ const poster = `font-display block ${size} leading-[0.92] tracking-[-0.045em] [word-spacing:0.09em]`;
79
131
  return (
80
132
  <section ref={stage} data-showpiece="hero-opener" className={`relative h-svh overflow-hidden ${image ? "bg-black text-on-photo" : "scene-inverse"} ${className}`}>
81
133
  <div data-opener-inner="" className="absolute inset-0 origin-top">
@@ -84,9 +136,9 @@ export function HeroOpener({ image, title, accent, split, lead, cta, header, scr
84
136
  <div data-opener-dim="" aria-hidden className="absolute inset-0 bg-black opacity-0" />
85
137
  {header ? <div className="absolute inset-x-0 top-0 z-20">{header}</div> : null}
86
138
  {split ? (
87
- <h1 aria-label={split.join(" ")} className="absolute inset-x-0 top-1/2 z-10 flex -translate-y-1/2 justify-between px-6 md:px-[3vw]">
139
+ <h1 aria-label={split.join(" ")} className="absolute inset-x-0 top-1/2 z-10 flex -translate-y-1/2 justify-between px-sheet-margin">
88
140
  {split.map((word) => (
89
- <span key={word} className="block overflow-hidden pb-[0.06em]">
141
+ <span key={word} className={`struq-line-mask block ${size}`}>
90
142
  <span data-split-word="" data-quiet="" className={poster}>
91
143
  {word}
92
144
  </span>
@@ -94,7 +146,7 @@ export function HeroOpener({ image, title, accent, split, lead, cta, header, scr
94
146
  ))}
95
147
  </h1>
96
148
  ) : null}
97
- <div className="absolute inset-x-0 bottom-0 z-10 flex flex-col gap-[3vh] px-6 pb-[3vh] md:px-[3vw]">
149
+ <div className="absolute inset-x-0 bottom-0 z-10 flex flex-col gap-[3vh] px-sheet-margin pb-[3vh]">
98
150
  <div data-opener-copy="" className={`flex flex-col gap-6 md:flex-row md:items-end md:justify-between ${side === "right" ? "md:flex-row-reverse md:text-right" : ""}`}>
99
151
  {side === "none" ? (
100
152
  <p data-quiet="" className="relative isolate max-w-[30ch] text-h3">
@@ -111,15 +163,22 @@ export function HeroOpener({ image, title, accent, split, lead, cta, header, scr
111
163
  </div>
112
164
  </div>
113
165
  {!split && title ? (
114
- <SplitHeadline as="h1" className={poster}>
115
- {title}
116
- {accent ? (
166
+ <h1 data-opener-title="" data-split="" data-quiet="" className={poster}>
167
+ {/* An accent the title holds is marked in place; any other accent is the trailing word, as it always was. */}
168
+ {inTitle ? (
169
+ withAccent(title, accent)
170
+ ) : (
117
171
  <>
118
- {" "}
119
- <em className="font-accent font-light italic tracking-[-0.02em] [word-spacing:0.09em]">{accent}</em>
172
+ {title}
173
+ {accent ? (
174
+ <>
175
+ {" "}
176
+ <em className="font-accent font-light italic tracking-[-0.02em] [word-spacing:0.09em]">{accent}</em>
177
+ </>
178
+ ) : null}
120
179
  </>
121
- ) : null}
122
- </SplitHeadline>
180
+ )}
181
+ </h1>
123
182
  ) : null}
124
183
  </div>
125
184
  </div>
@@ -69,6 +69,8 @@ export function WordmarkFinale({ name, line, place, timeZone, children, classNam
69
69
  const split = SplitText.create(el, {
70
70
  type: "chars",
71
71
  mask: "chars",
72
+ // The shared mask (blocks.css): room for accents and descenders in every face.
73
+ charsClass: "struq-char",
72
74
  onSplit(self) {
73
75
  return gsap.from(self.chars, {
74
76
  yPercent: 105,
@@ -87,7 +89,7 @@ export function WordmarkFinale({ name, line, place, timeZone, children, classNam
87
89
  );
88
90
 
89
91
  return (
90
- <footer ref={root} data-showpiece="wordmark-finale" className={`overflow-hidden bg-surface-0 px-6 pb-6 pt-24 md:px-[3vw] ${className}`}>
92
+ <footer ref={root} data-showpiece="wordmark-finale" className={`overflow-hidden bg-surface-0 px-sheet-margin pb-6 pt-24 ${className}`}>
91
93
  <div className="flex flex-col gap-6 border-t border-line pt-6 md:flex-row md:items-end md:justify-between">
92
94
  {line ? <p className="font-display text-h3 max-w-[24ch]">{line}</p> : <span />}
93
95
  {children}
@@ -465,15 +465,14 @@ export function ZoomThrough({
465
465
  >
466
466
  <h2 className={`font-display ${size} leading-[0.88] tracking-[-0.04em] [word-spacing:0.08em]`}>
467
467
  {heading.map((h) => (
468
- <span
469
- key={h}
470
- className={`-mx-6 flex px-6 md:-mx-[5vw] md:px-[5vw] ${headRight ? "justify-end" : "justify-start"} ${
471
- motion ? "overflow-hidden pb-[0.1em] -mb-[0.1em] group-data-[place=ground]/zt:pb-0 group-data-[place=ground]/zt:mb-0" : ""
472
- }`}
473
- >
468
+ <span key={h} className={`-mx-6 flex px-6 md:-mx-[5vw] md:px-[5vw] ${headRight ? "justify-end" : "justify-start"}`}>
469
+ {/* The ground band sits outside the shared line mask, so the mask's child paints no plate and a band
470
+ never grows into the line above it. */}
474
471
  <span className={`block group-data-[place=ground]/zt:bg-surface-0 group-data-[place=ground]/zt:pb-[0.12em] group-data-[place=ground]/zt:pt-[0.08em] ${band}`}>
475
- <span data-rise={motion ? "" : undefined} data-hline="" className="block whitespace-nowrap">
476
- {h}
472
+ <span className={`block ${motion ? "struq-line-mask" : ""}`}>
473
+ <span data-rise={motion ? "" : undefined} data-hline="" className="block whitespace-nowrap">
474
+ {h}
475
+ </span>
477
476
  </span>
478
477
  </span>
479
478
  </span>
@@ -488,14 +487,14 @@ export function ZoomThrough({
488
487
  }`}
489
488
  >
490
489
  {line ? (
491
- <span className={`block max-w-[38ch] ${motion ? "overflow-hidden" : ""}`}>
490
+ <span className={`block max-w-[38ch] ${motion ? "struq-line-mask" : ""}`}>
492
491
  <span data-after={motion ? "" : undefined} className="block text-balance text-lead text-ink">
493
492
  {line}
494
493
  </span>
495
494
  </span>
496
495
  ) : null}
497
496
  {caption ? (
498
- <span className={`block ${motion ? "overflow-hidden" : ""}`}>
497
+ <span className={`block ${motion ? "struq-line-mask" : ""}`}>
499
498
  <span data-after={motion ? "" : undefined} className="block text-small text-ink-muted">
500
499
  {caption}
501
500
  </span>
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "Struq lib/canon",
4
4
  "ref": "origin/main",
5
- "commit": "a2b7d56"
5
+ "commit": "95e54a2"
6
6
  },
7
7
  "palettes": [
8
8
  {
@@ -56,6 +56,10 @@ catalogue does not have: your code has to hold the same bar or it shows as the s
56
56
  `document.fonts.ready` with the loaded face, and refitted on `loadingdone`. Never fit with the fallback face, and
57
57
  never ask Google for a weight the family does not ship: `canon.mjs type` requests each face's real range.
58
58
  - **Word spacing at poster size:** 0.06 to 0.1em on display lines, so tight tracking never runs two words into one.
59
+ - **Descenders are never cut.** A mask that hides a rising line must leave room below the baseline for g, j, p and y
60
+ and above the cap line for accents: use `SplitHeadline` or the `.struq-line-mask` class, never `overflow-hidden`
61
+ on a heading. At poster size keep the leading at 1 or more in a serif and 0.95 or more in a sans; look at a line
62
+ with a g and a j in it before you call the type done.
59
63
  - One italic accent face at most, on one word of the page.
60
64
  - One fluid root-rem knob (`html { font-size: clamp(...) }` in globals.css): author in rem; breakpoints change the
61
65
  layout, never the size.
@@ -0,0 +1,158 @@
1
+ # The film: treatments, worked
2
+
3
+ SKILL.md holds the rules: the treatment template, the three languages, their arcs and the budgets. This file shows
4
+ them filled in. Read it once before you write your first treatment.
5
+
6
+ ## The words
7
+
8
+ **Treatment.** The head of STRUQ.md: the idea, the language, the motif, the lexicon, the centerpiece and the
9
+ storyboard. It is the script of the site. Every block you install answers to a line of it; a block no line asks for
10
+ does not go on the page.
11
+
12
+ **Idea.** A point of view, not a description. "Duin is a small hotel behind the dunes" describes the client and
13
+ gives the site nothing to do. "Hier hoor je de zee voordat je haar ziet" is an idea: it tells you what the opening
14
+ withholds and what the centerpiece gives. Test it: if the sentence could open the client's Wikipedia page, it is a
15
+ description.
16
+
17
+ **Language.** How the whole site moves: the preloader, the page transition and the menu come from one family, so
18
+ every cut between scenes is made the same way. weight is slow and photographic, cut is hard and short, curtain parts
19
+ and holds. Pick it by the client's world, not by the block you like best.
20
+
21
+ **Motif.** One visual device from a closed set (line, frame, aperture, cut, word, field) that stands for something in
22
+ the client's world: the window frame of a canal house, the seam of a knitted garment, the horizon over the sea at
23
+ night. It shows at four places (arrival, opening, centerpiece, close) and changes a little each time, so the visitor
24
+ feels the page is about one thing.
25
+
26
+ **Lexicon.** Three to five working words of the client's trade: a material, a part, a tool, a unit. Not the brief
27
+ repeated: "twaalf kamers, Bergen aan Zee" are facts for the copy, while "helmgras, zout, vloed, sleutel" is a
28
+ lexicon for a hotel behind the dunes. Every label, counter, caption and margin note on the site is written with
29
+ them. A caption names the client's material, never "Scene 01". Trade words are not facts, so they need no source;
30
+ numbers still come from the brief only.
31
+
32
+ The three treatments below are examples of the form. Their palettes, pairings, packs and centerpieces belong to
33
+ those clients: pick yours from the canon and the arc by your own brief, and pick the language by the client's
34
+ world (a fashion label is cut, not weight, unless the brief says slow).
35
+
36
+ **Arc.** The order of scenes for a language, with the loud and quiet beats already placed. You cast the brief into
37
+ it: each scene gets this client's content. You may swap one scene for another with the same tag; the loud scenes are
38
+ the opening, the centerpiece and at most one gentle one, never side by side.
39
+
40
+ ## How a motif travels
41
+
42
+ | Motif | Arrival | Opening | Centerpiece | Close |
43
+ |---|---|---|---|---|
44
+ | line | the cover opens on a hairline | the headline stands on a horizon or seam | a rule or slab is drawn through the words | the rule draws under the closing link |
45
+ | frame | the small print of the preloader | the print on the wall | the grid of prints, one opening into a room | an empty hairline frame round the last line |
46
+ | aperture | the cover opens through a slit | a window cut in the type | the window the camera pushes through | the footer uncovered through a growing slit |
47
+ | cut | the slices drop away | a slit through the poster type | a hard seam between two photos | the page ends on a cut, no fade |
48
+ | word | the name set across the screen | one word at poster scale | a word that fills, splits or shears | the name again, edge to edge |
49
+ | field | the cover in the field colour | a live field behind the type | the one field scene | the field returning under the footer |
50
+
51
+ ## Quiet scenes, from the kit
52
+
53
+ The quiet scenes come from `@/components/struq/kit/quiet`, already on the page grid and the sheet's margin, each
54
+ entering once and then holding still: no pin, no scrub, no parallax, no hover, no counter.
55
+
56
+ - `Statement` sets the one sentence at moment size, one `accent` word in the italic face, a `label` in the margin
57
+ ("Werkplaats Zuid, sinds 1998") and a small upright `image` hanging off the last line.
58
+ - `PhotoText` puts a photo across seven columns and a short heading with one or two paragraphs across four, the
59
+ caption under the photo in the lexicon ("Trap, schaal 1:20").
60
+ - `Rows` lists three to six terms with one sentence each between hairlines, a column photo beside them, no numbers.
61
+ - `Close` ends on one line and one link; `frame` draws the hairline frame when the motif is the frame.
62
+
63
+ Quiet scenes are what make the centerpiece read as the moment. Write an own scene only when none of the four fits,
64
+ and put `data-scene="<name>"` on its root.
65
+
66
+ ## weight: Atelier Sloot, a ceramics studio
67
+
68
+ ```
69
+ # STRUQ.md: Atelier Sloot
70
+ Brief: keramiekatelier in Deventer dat servies in kleine series draait, voor restaurants en voor thuis
71
+ Idea: Een kom is pas af als hij gebruikt wordt.
72
+ Language: weight
73
+ Motif: frame: one object alone on its plank; a small upright print at arrival, the print on the wall at the opening, the object grown to the whole screen at the centerpiece, an empty frame round the last line
74
+ Lexicon: klei, glazuur, oven, serie
75
+ Palette: <yours, from canon.mjs list by the brief's mood and sector>
76
+ Type: <yours, by the client's temperament>
77
+ Images: <the client's photos, else the pack images.mjs picks for the brief>
78
+ Centerpiece: image-to-fullbleed: one bowl grows from a print on the wall to the size of the screen; you see it the way you would hold it, which is the idea
79
+ Tone arc: paper, paper, paper, paper, paper, ink, paper, paper
80
+ Storyboard:
81
+ 1. Arrival (frame) preloader-stack, "Atelier Sloot" under the frame | lands on: the hero print
82
+ 2. Opening (loud) hero-framed:wand | on screen: one bowl as a small print, its label "Kom, mat glazuur", the h1 "Gemaakt om te gebruiken." | enters: the line rises beside the print | hands over: the first scroll walks up to the print until it fills the screen
83
+ 3. Statement (quiet) Statement, accent "hand" | on screen: "Eerst de hand, dan de oven." at moment size on paper, the label "Klei, nog nat" in the margin, a small print of a bowl drying off the last line | enters: lines rise once | hands over: plain scroll
84
+ 4. About (loud) about-studio:wandeling | on screen: who makes it and where, as the brief says, beside two columns of objects | enters: the columns drift with the scroll | hands over: plain scroll
85
+ 5. Approach (quiet) Rows | on screen: "Hoe een kom ontstaat.", then draaien, drogen, glazuren, stoken, one sentence each, no numbers, a column photo of the oven beside them | enters: each row once | hands over: plain scroll
86
+ 6. Centerpiece (loud) image-to-fullbleed | on screen: one bowl on stone, from print to full screen, with "Op ware grootte." | enters: pinned 2.2 screens | hands over: the photo lets go and scrolls away whole
87
+ 7. Release (quiet) PhotoText | on screen: a photo of the drying rack, "Een serie voor je zaak." and two sentences on ordering one, the caption "Droogrek, één serie" | enters: once | hands over: plain scroll
88
+ 8. Close (quiet) Close, frame | echoes the opening: an empty hairline frame round "Vraag een serie aan." and the mail link | footer: wordmark-finale
89
+ ```
90
+
91
+ Budgets: pins 1.5 + 2.2 = 3.7 screens; full-bleed reveals two, built the same way (a print growing to the screen);
92
+ clocks none, so no `timeZone` on the finale; four of the seven scenes after the arrival quiet, and the one gentle
93
+ loud scene (the walk past the objects) has a quiet scene on each side. `approach:draad` was left out: it draws a
94
+ line, and this site's motif is a frame.
95
+
96
+ ## cut: Vaar, a knitwear label
97
+
98
+ ```
99
+ # STRUQ.md: Vaar
100
+ Brief: kleine oplagen merinotruien uit een Amsterdams atelier, voor mensen die liever één goede trui hebben dan vijf
101
+ Idea: Eén naad, en die zit waar je hem ziet.
102
+ Language: cut
103
+ Motif: cut: the seam of a knitted garment; the slices at arrival, a slit through the poster type at the opening, the hard seam between two photos at the centerpiece, the page ending on a cut
104
+ Lexicon: naad, steek, merino, oplage
105
+ Palette: <yours, from canon.mjs list by the brief's mood and sector>
106
+ Type: <yours, by the client's temperament>
107
+ Images: <the client's photos, else the pack images.mjs picks for the brief>
108
+ Centerpiece: split-pair: two garments meet on one seam and the word "Naad" shears along it; the idea made physical
109
+ Tone arc: paper, paper, paper, paper, ink, paper, paper
110
+ Storyboard:
111
+ 1. Arrival (frame) preloader-slices, name "Vaar", caption "Merino, kleine oplage, Amsterdam" | lands on: the poster
112
+ 2. Opening (loud) hero-poster:column | on screen: "Langzaam / gebreid" fitted to the width, a slit of folded wool through it | enters: the slices uncover it | hands over: the slit opens sideways into the full photo
113
+ 3. Work (quiet) hover-list | on screen: the pieces of this oplage, name and colour | enters: rows rise once | hands over: plain scroll
114
+ 4. Statement (quiet) Statement, accent "naad" | on screen: "Elke trui heeft één naad." at moment size, the label "Merino, vier draden" in the margin, a small photo of the seam off the last line | enters: once | hands over: plain scroll
115
+ 5. Centerpiece (loud) split-pair | on screen: the word "Naad", two photos meeting on it | enters: pinned 3 screens | hands over: the halves leave at their own speeds
116
+ 6. Release (quiet) fill-rows | on screen: the three yarns, a detail on hover | enters: once | hands over: a hard cut to paper
117
+ 7. Close (quiet) Close | echoes the opening: "Uit deze oplage, zolang hij er is." at moment size, a link to the shop at the far end of the line under it | footer: footer-contact:address
118
+ ```
119
+
120
+ Budgets: pins 1.5 + 3 = 4.5; one full-bleed reveal; no clock, so the footer goes without `clock`; no marquee, so
121
+ the close is type only.
122
+
123
+ ## curtain: Nachtwerk, a night festival
124
+
125
+ ```
126
+ # STRUQ.md: Nachtwerk
127
+ Brief: driedaags kunstfestival op de boulevard van Vlissingen, alleen na zonsondergang, voor wie de stad bij nacht wil zien
128
+ Idea: De stad begint pas als het donker is.
129
+ Language: curtain
130
+ Motif: line: the horizon over the water at night; the name parts on it at arrival, a low band of light on it at the opening, a slab that becomes the rising line at the centerpiece, the rule under the closing link
131
+ Lexicon: eb, vloed, de pier, drie nachten
132
+ Palette: <yours, from canon.mjs list by the brief's mood and sector>
133
+ Type: <yours, by the client's temperament>
134
+ Images: <the client's photos, else the pack images.mjs picks for the brief>
135
+ Centerpiece: scrub-connector: "Eb" and "Vloed" far apart, joined by one slab that seats as a rising line; the tide as the festival's clock
136
+ Tone arc: field, field, paper, paper, paper, ink, paper, paper
137
+ Storyboard:
138
+ 1. Arrival (frame) preloader-wordmark:split, "Nachtwerk" | lands on: the field, the halves parting on its middle line
139
+ 2. Opening (loud) background-aurora:horizon | on screen: "Drie nachten aan zee." at 16vw on the calm side, a low band of light along the foot | enters: lines rise as the band brightens | hands over: the band rises as the hero scrolls away
140
+ 3. Statement (quiet) Statement, accent "donker" | on screen: one sentence on why the festival starts at dusk, the label "De pier, na zonsondergang" in the margin, a small photo of an empty stage off the last line | enters: once | hands over: plain scroll
141
+ 4. Details (loud) sticky-chapters | on screen: the three nights as chapters, with the places the brief names | enters: one photo holds while the nights scroll past, each night's photo rising over the last | hands over: plain scroll
142
+ 5. Build (quiet) PhotoText | on screen: who makes it, as the brief says, a photo of the pier, the caption "De pier bij vloed" | enters: once | hands over: plain scroll
143
+ 6. Centerpiece (loud) scrub-connector | on screen: "Eb" and "Vloed" | enters: pinned 3 screens | hands over: the joined pair scrolls away whole
144
+ 7. Release (quiet) PhotoText, side right | on screen: one photo of the boulevard, the dates and place from the brief | enters: once | hands over: plain scroll
145
+ 8. Close (quiet) cta-closing:pointer | echoes the opening: the line drawn under "Kom kijken als het donker wordt." | footer: footer-reveal:lift
146
+ ```
147
+
148
+ Budgets: pins 3 screens; no full-bleed reveal; no clock; two contrast scenes (the field and the ink); the chapters
149
+ are the one gentle loud scene, a quiet scene on each side. `preloader-gate`, with its neutral hairline ring, fits
150
+ the language as well; its film leader (`dial="leader"`, counting 3, 2, 1) stays out, because this client is not a
151
+ film.
152
+
153
+ ## Before the stop
154
+
155
+ Read the storyboard once as a visitor would. If two scenes in a row move with the scroll, one becomes quiet. If a
156
+ scene carries a metaphor from outside the client's world (a film leader, a slate, a typewriter, a clock nobody asked
157
+ for), it goes or its words come from the lexicon. If the centerpiece could sit on any other client's site, the idea
158
+ is not in it yet.
@@ -9,6 +9,9 @@ The principle: **fix the execution, preserve the identity.** Type, spacing, surf
9
9
  motion, scaling, and icon misuse are execution, so they are fair game. Brand color, words,
10
10
  and the component foundation are identity, so leave them unless explicitly asked.
11
11
 
12
+ A redesign writes a treatment too, with the kept sections as own scenes, and stops once for the user's approval as a
13
+ build does: no block and no canon goes in before the reply is logged.
14
+
12
15
  ---
13
16
 
14
17
  ## Preserve by default: do NOT touch unless the user asks
@@ -4,21 +4,34 @@
4
4
  //
5
5
  // node <skill-dir>/scripts/blocks.mjs list [kind] the catalogue, one line per block with its variants
6
6
  // node <skill-dir>/scripts/blocks.mjs init the kit, the app shell (on a starter) and the base packages
7
- // node <skill-dir>/scripts/blocks.mjs add <slug> [<slug>] a block and every file it imports, plus its packages
7
+ // node <skill-dir>/scripts/blocks.mjs add <slug>[:<variant>] [...] a block and every file it imports, plus its packages
8
+ // node <skill-dir>/scripts/blocks.mjs arc [language] a language's storyboard skeleton (film.json); none: the three
8
9
  // flags: --dir <project>, --force (replace files you already have), --no-install
9
10
  //
11
+ // add refuses, installing nothing, until STRUQ.md passes the plan check and STRUQ-LOG.md holds the user's reply to the
12
+ // treatment, and then takes only blocks the storyboard names, in the site's language (gate.mjs). list, init and arc
13
+ // never ask. The kit's quiet scenes (Statement, PhotoText, Rows, Close) come with init, so add only says how to import
14
+ // one.
15
+ //
10
16
  // Everything lands under components/struq/ in the layout of Struq's blocks/ (kit/, heroes/, sections/ ...), so the
11
17
  // relative imports between them hold. A layout or stylesheet you wrote yourself is never overwritten.
12
18
  import { spawnSync } from "node:child_process";
13
19
  import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
14
20
  import { dirname, join, relative, resolve } from "node:path";
15
21
  import { fileURLToPath } from "node:url";
22
+ import { addRefusal, refuse } from "./gate.mjs";
16
23
  import { appendLog } from "./log.mjs";
17
24
 
18
25
  const here = dirname(fileURLToPath(import.meta.url));
19
26
  const BLOCKS = join(here, "..", "blocks");
20
27
  const RECIPES = join(here, "..", "recipes");
21
28
  const CATALOG = JSON.parse(readFileSync(join(BLOCKS, "catalog.json"), "utf8")).blocks;
29
+ const FILM = join(BLOCKS, "film.json");
30
+ /** The kit's composed quiet scenes, one file each in kit/quiet/: a storyboard names them where a block would stand. */
31
+ const KIT_SCENES = readdirSync(join(BLOCKS, "kit", "quiet"))
32
+ .filter((n) => /^[A-Z]\w*\.tsx$/.test(n))
33
+ .map((n) => n.replace(/\.tsx$/, ""));
34
+ const kitScene = (name) => KIT_SCENES.find((k) => k.toLowerCase() === name.split(":")[0].toLowerCase()) ?? null;
22
35
 
23
36
  const args = process.argv.slice(2);
24
37
  const dirAt = args.indexOf("--dir");
@@ -83,15 +96,47 @@ function report(step, what) {
83
96
  appendLog(root, step, [what, ...(notes.length ? [`Notes: ${notes.join(" ")}`] : [])]);
84
97
  }
85
98
 
99
+ /**
100
+ * A language's arc as STRUQ.md lines: the Tone arc, then one numbered Storyboard line per scene in the treatment's
101
+ * format, the default pick filled in (the own scene first where the arc prefers it, as its kit quiet scene when it has
102
+ * one, else the first block) and what else fits after `or:`. The defaults pass check.mjs --plan as they stand.
103
+ */
104
+ function arcLines(arc) {
105
+ const pick = (scene) => {
106
+ const own = scene.kit ?? "own scene";
107
+ const ownFirst = scene.own && (scene.ownFirst || !scene.blocks.length);
108
+ const options = ownFirst ? [own, ...scene.blocks] : [...scene.blocks, ...(scene.own ? [own] : [])];
109
+ if (scene.kit && scene.own && !options.includes("own scene")) options.push("own scene");
110
+ return options;
111
+ };
112
+ const width = Math.max(...arc.scenes.map((s) => s.name.length));
113
+ const last = arc.scenes.length - 1;
114
+ const lines = [`Tone arc: ${arc.scenes.map((s) => s.tone[0]).join(", ")}`, "Storyboard:"];
115
+ arc.scenes.forEach((scene, at) => {
116
+ const [first, ...others] = pick(scene);
117
+ const head = `${`${at + 1}.`.padEnd(at + 1 >= 10 ? 4 : 3)}${scene.name.padEnd(width)} ${`(${scene.tag})`.padEnd(8)} ${first}`;
118
+ const parts = [head];
119
+ if (scene.tag === "frame") parts.push("lands on: <what>");
120
+ else if (at === last) parts.push("echoes the opening: <how>");
121
+ else parts.push("on screen: <...>", "enters: <...>", "hands over: <...>");
122
+ if (others.length) parts.push(`or: ${others.join(", ")}`);
123
+ if (scene.footer?.length) parts.push(`footer: ${scene.footer[0]}`);
124
+ lines.push(parts.join(" | "));
125
+ });
126
+ return lines;
127
+ }
128
+
86
129
  const commands = {
87
130
  list() {
88
131
  const kind = rest[0];
89
132
  const rows = CATALOG.filter((b) => !kind || b.kind === kind);
90
133
  if (!rows.length) throw new Error(`No blocks of kind "${kind}". Kinds: ${[...new Set(CATALOG.map((b) => b.kind))].join(", ")}`);
134
+ // The film facts of the default variant, so a storyboard can be cast from this list alone.
135
+ const film = (b) => (b.role ? ` film: ${b.role} · energy ${b.energy ?? 0} · pins ${b.pins ? b.scroll : 0}` : "");
91
136
  for (const b of rows) {
92
- console.log(`${b.kind.padEnd(16)} ${b.slug.padEnd(24)} ${b.title}${b.variants.length ? ` [${b.variants.map((v) => v.id).join(", ")}]` : ""}`);
137
+ console.log(`${b.kind.padEnd(16)} ${b.slug.padEnd(24)} ${b.title}${b.variants.length ? ` [${b.variants.map((v) => v.id).join(", ")}]` : ""}${film(b)}`);
93
138
  }
94
- console.log(`\n${rows.length} block(s). What each does and its variants: blocks/CATALOG.md.`);
139
+ console.log(`\n${rows.length} block(s). What each does, its variants and their film lines: blocks/CATALOG.md.`);
95
140
  },
96
141
 
97
142
  init() {
@@ -120,14 +165,51 @@ const commands = {
120
165
  console.log("OK init ready");
121
166
  },
122
167
 
168
+ arc() {
169
+ const film = JSON.parse(readFileSync(FILM, "utf8"));
170
+ const languages = film.FILM_LANGUAGES;
171
+ const id = rest[0];
172
+ if (!id) {
173
+ const width = Math.max(...languages.map((l) => l.id.length)) + 2;
174
+ for (const l of languages) console.log(`${l.id.padEnd(width)}${l.for.join(", ")}`);
175
+ console.log(`\nPrint one: blocks.mjs arc <${languages.map((l) => l.id).join(" | ")}>`);
176
+ return;
177
+ }
178
+ const language = languages.find((l) => l.id === id);
179
+ const arc = film.FILM_ARCS.find((a) => a.language === id);
180
+ if (!language || !arc) throw new Error(`No language "${id}". The languages: ${languages.map((l) => l.id).join(", ")}.`);
181
+ for (const line of arcLines(arc)) console.log(line);
182
+ const f = language.frames;
183
+ console.log(`\nFrames of ${id}: preloader ${f.preloader}, page transition ${f.pageTransition} (only with a second route), menu ${f.menu}. Header: ${film.FILM_HEADER.join(" or ")}.`);
184
+ console.log(`Use: paste the Tone arc and the Storyboard into STRUQ.md under your treatment head, fill every <...>, keep one pick per line (or: lists what else fits), then run check.mjs --plan.`);
185
+ },
186
+
123
187
  add() {
124
- if (!rest.length) throw new Error("Usage: blocks.mjs add <slug> [<slug> ...]. The slugs: blocks.mjs list");
188
+ if (!rest.length) throw new Error("Usage: blocks.mjs add <slug>[:<variant>] [...]. The slugs: blocks.mjs list");
189
+ // A kit quiet scene is no block: init already copied it, so say how to import it and install nothing for it.
190
+ for (const arg of rest.filter(kitScene)) {
191
+ const name = kitScene(arg);
192
+ console.log(`${name} is a kit quiet scene, already in components/struq/kit/quiet/ (blocks.mjs init): import { ${name} } from "@/components/struq/kit/quiet";`);
193
+ }
194
+ rest.splice(0, rest.length, ...rest.filter((arg) => !kitScene(arg)));
195
+ if (!rest.length) return;
196
+ const requests = rest.map((arg) => {
197
+ const [slug, variant = null] = arg.split(":");
198
+ const b = CATALOG.find((x) => x.slug === slug);
199
+ if (!b) throw new Error(`Unknown block "${slug}". Run: blocks.mjs list`);
200
+ if (variant && !b.variants.some((v) => v.id === variant)) {
201
+ throw new Error(`${slug} has no variant "${variant}". Its variants: ${b.variants.map((v) => v.id).join(", ") || "none"}.`);
202
+ }
203
+ return { slug, variant };
204
+ });
125
205
  const deps = project();
206
+ // The one stop: a passing plan, the user's logged reply, and only blocks the storyboard names (gate.mjs).
207
+ const refusal = addRefusal(root, requests);
208
+ if (refusal) return refuse("blocks.mjs add", refusal);
126
209
  const wanted = new Set();
127
210
  const added = [];
128
- for (const slug of rest) {
211
+ for (const { slug } of requests) {
129
212
  const b = CATALOG.find((x) => x.slug === slug);
130
- if (!b) throw new Error(`Unknown block "${slug}". Run: blocks.mjs list`);
131
213
  for (const f of b.files) place(join(BLOCKS, f), target(f));
132
214
  for (const p of b.packages) wanted.add(p);
133
215
  added.push(b);
@@ -145,7 +227,7 @@ const commands = {
145
227
 
146
228
  const run = commands[cmd];
147
229
  if (!run) {
148
- console.error("Usage: blocks.mjs list [kind] | init | add <slug> [<slug> ...] [--dir <project>] [--force] [--no-install]");
230
+ console.error("Usage: blocks.mjs list [kind] | init | add <slug>[:<variant>] [...] | arc [language] [--dir <project>] [--force] [--no-install]");
149
231
  process.exit(2);
150
232
  }
151
233
  try {
@@ -9,10 +9,14 @@
9
9
  // node <skill-dir>/scripts/canon.mjs print <palette> <pairing> prints the block and the font URLs, writes nothing
10
10
  // add --dir <project> to run from elsewhere
11
11
  //
12
+ // palette and type refuse, writing nothing, until STRUQ.md passes the plan check and STRUQ-LOG.md holds the user's
13
+ // reply to the treatment (gate.mjs). list and print never ask.
14
+ //
12
15
  // Node 18 or later, no network, no dependencies.
13
16
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
14
17
  import { dirname, join, resolve } from "node:path";
15
18
  import { fileURLToPath } from "node:url";
19
+ import { refuse, stopRefusal } from "./gate.mjs";
16
20
  import { appendLog } from "./log.mjs";
17
21
  import { fontHrefs, headFamily, lightness, paletteTokens } from "./tokens.mjs";
18
22
 
@@ -126,7 +130,10 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
126
130
  process.exit(2);
127
131
  }
128
132
  try {
129
- run();
133
+ // palette and type write into the project, so they wait for the one stop: a passing plan and the logged reply.
134
+ const refusal = cmd === "palette" || cmd === "type" ? stopRefusal(root) : null;
135
+ if (refusal) refuse(`canon.mjs ${cmd}, nothing written`, refusal);
136
+ else run();
130
137
  } catch (e) {
131
138
  console.error(`FAIL ${e.message}`);
132
139
  process.exitCode = 1;