@struq/skill 0.5.1 → 0.5.3

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 (65) hide show
  1. package/README.md +137 -100
  2. package/package.json +5 -3
  3. package/skills/struq/SKILL.md +168 -165
  4. package/skills/struq/blocks/CATALOG.md +49 -46
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +2 -1
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +5 -3
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +6 -2
  8. package/skills/struq/blocks/catalog.json +50 -5
  9. package/skills/struq/blocks/film.json +11 -7
  10. package/skills/struq/blocks/footers/footer-contact.tsx +8 -78
  11. package/skills/struq/blocks/headers/header-bar.tsx +144 -29
  12. package/skills/struq/blocks/headers/menu-curtain.tsx +36 -19
  13. package/skills/struq/blocks/heroes/hero-poster.tsx +36 -10
  14. package/skills/struq/blocks/kit/HeroCanvas.tsx +6 -2
  15. package/skills/struq/blocks/kit/SplitHeadline.tsx +2 -1
  16. package/skills/struq/blocks/kit/accent.ts +29 -3
  17. package/skills/struq/blocks/kit/blocks.css +530 -6
  18. package/skills/struq/blocks/kit/feel.ts +183 -0
  19. package/skills/struq/blocks/kit/gsap.ts +9 -6
  20. package/skills/struq/blocks/kit/middle/PlaceHours.tsx +254 -0
  21. package/skills/struq/blocks/kit/middle/ProcessStrip.tsx +428 -0
  22. package/skills/struq/blocks/kit/middle/ServicesList.tsx +496 -0
  23. package/skills/struq/blocks/kit/middle/entrance.ts +98 -0
  24. package/skills/struq/blocks/kit/middle/hours.ts +154 -0
  25. package/skills/struq/blocks/kit/middle/index.ts +14 -0
  26. package/skills/struq/blocks/kit/motion/HoverLink.tsx +33 -0
  27. package/skills/struq/blocks/kit/motion/ImageCurtain.tsx +79 -0
  28. package/skills/struq/blocks/kit/motion/MaskWipe.tsx +91 -0
  29. package/skills/struq/blocks/kit/motion/Pressable.tsx +58 -0
  30. package/skills/struq/blocks/kit/motion/RevealText.tsx +103 -0
  31. package/skills/struq/blocks/kit/motion/Rise.tsx +77 -0
  32. package/skills/struq/blocks/kit/motion/ScrubLine.tsx +108 -0
  33. package/skills/struq/blocks/kit/motion/index.ts +11 -0
  34. package/skills/struq/blocks/kit/quiet/Close.tsx +178 -52
  35. package/skills/struq/blocks/kit/quiet/Statement.tsx +3 -2
  36. package/skills/struq/blocks/kit/subject/AppScreen.tsx +388 -0
  37. package/skills/struq/blocks/kit/subject/Document.tsx +742 -0
  38. package/skills/struq/blocks/kit/subject/Mark.tsx +44 -0
  39. package/skills/struq/blocks/kit/subject/PatternPaper.tsx +410 -0
  40. package/skills/struq/blocks/kit/subject/SectionDrawing.tsx +349 -0
  41. package/skills/struq/blocks/kit/subject/Ticket.tsx +354 -0
  42. package/skills/struq/blocks/kit/subject/build.ts +66 -0
  43. package/skills/struq/blocks/kit/subject/draw.ts +196 -0
  44. package/skills/struq/blocks/kit/subject/drawings.ts +373 -0
  45. package/skills/struq/blocks/kit/subject/format.ts +122 -0
  46. package/skills/struq/blocks/kit/subject/index.ts +11 -0
  47. package/skills/struq/blocks/kit/subject/patterns.ts +204 -0
  48. package/skills/struq/blocks/preloaders/preloader-stack.tsx +75 -22
  49. package/skills/struq/blocks/showpieces/hero-opener.tsx +55 -7
  50. package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +64 -6
  51. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +56 -3
  52. package/skills/struq/blocks/showpieces/panel-wipe.tsx +93 -11
  53. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +38 -5
  54. package/skills/struq/canon/canon.json +1 -1
  55. package/skills/struq/references/art-direction.md +51 -0
  56. package/skills/struq/scripts/blocks.mjs +56 -20
  57. package/skills/struq/scripts/canon.mjs +36 -7
  58. package/skills/struq/scripts/check.mjs +417 -11
  59. package/skills/struq/scripts/draw.mjs +49 -0
  60. package/skills/struq/scripts/film-check.mjs +28 -4
  61. package/skills/struq/scripts/gate.mjs +13 -3
  62. package/skills/struq/scripts/hand.mjs +222 -0
  63. package/skills/struq/scripts/images.mjs +12 -3
  64. package/skills/struq/scripts/look.mjs +1184 -0
  65. package/skills/struq/scripts/stamp.mjs +47 -0
@@ -14,7 +14,8 @@
14
14
  // on when it cuts (and cuts again on a resize), and it sits inside the line's mask (.struq-line-mask), whose room
15
15
  // above and below the line holds the accent face's tallest and deepest glyph. Its size and leading are the line's
16
16
  // own; only the face and the slant change. A block that fits its type to a width measures with the mark in place, and
17
- // waits for the accent face with accentFaceReady before it trusts a measure.
17
+ // waits for the accent face with accentFaceReady before it trusts a measure. Punctuation that touches the word ("it.",
18
+ // "(licht)") is set with the mark inside one no-wrap span, so it never wraps onto a line of its own.
18
19
  import { createElement, Fragment, type ReactNode } from "react";
19
20
 
20
21
  /** The accent's classes: the pairing's accent face (blocks.css --font-accent), italic. Nothing else changes. */
@@ -41,8 +42,33 @@ export function findAccent(text: string, accent?: string): AccentParts | null {
41
42
  };
42
43
  }
43
44
 
44
- function marked({ before, word, after }: AccentParts): ReactNode {
45
- return createElement(Fragment, null, before, createElement("em", { className: ACCENT_CLASS, "data-accent": "" }, word), after);
45
+ export type GluedParts = { before: string; open: string; word: string; close: string; after: string };
46
+
47
+ /**
48
+ * The parts with the punctuation that touches the word on either side (an opening quote or bracket before it, a full
49
+ * stop, comma or closing quote after it) taken out of the text around it, to be set with the word as one unit.
50
+ */
51
+ export function glueParts({ before, word, after }: AccentParts): GluedParts {
52
+ const open = /\S*$/.exec(before)?.[0] ?? "";
53
+ const close = /^\S*/.exec(after)?.[0] ?? "";
54
+ return {
55
+ before: before.slice(0, before.length - open.length),
56
+ open,
57
+ word,
58
+ close,
59
+ after: after.slice(close.length),
60
+ };
61
+ }
62
+
63
+ // The punctuation that touches the word is set inside one no-wrap span with the mark. Without it the full stop after
64
+ // the mark is a text node of its own, and SplitText's line cut wraps each run in an inline block, so on a narrow screen
65
+ // "it." could break into "it" and a "." alone on the next line. The span keeps them on one line in the browser's own
66
+ // wrap and in every cut; a word without touching punctuation gets no span.
67
+ function marked(parts: AccentParts): ReactNode {
68
+ const { before, open, word, close, after } = glueParts(parts);
69
+ const em = createElement("em", { className: ACCENT_CLASS, "data-accent": "" }, word);
70
+ const unit = open || close ? createElement("span", { "data-accent-glue": "", style: { whiteSpace: "nowrap" } }, open, em, close) : em;
71
+ return createElement(Fragment, null, before, unit, after);
46
72
  }
47
73
 
48
74
  /** The text with its accent word (the first time it occurs) in the accent face; the text itself when it is absent. */
@@ -206,8 +206,9 @@
206
206
  initial-value: 0px;
207
207
  }
208
208
  [data-blocks] :is(.struq-line-mask, .struq-char-mask) {
209
+ /* The locked room (LOCKED.mask in ./feel.ts): the kit's measured 0.3em above, the site's safer 0.42em below. */
209
210
  --struq-mask-top: 0.3em;
210
- --struq-mask-bottom: 0.35em;
211
+ --struq-mask-bottom: 0.42em;
211
212
  padding-block: var(--struq-mask-top) var(--struq-mask-bottom);
212
213
  margin-block: calc(var(--struq-mask-top) * -1) calc(var(--struq-mask-bottom) * -1);
213
214
  overflow: hidden !important;
@@ -405,6 +406,18 @@
405
406
  }
406
407
 
407
408
  /* --- hero-poster --- */
409
+ /* The poster's top line keeps clear of the site's bar on every width: the sheet's top room is the bar's height (the
410
+ header bar's --hb-top or the menu curtain's --menu-bar-h, shared on the nearest [data-blocks]) plus a breath, and
411
+ never less than the poster's own margin when there is no bar. The fit reads the room as laid out. */
412
+ [data-blocks] [data-hero-poster] {
413
+ --hp-bar: var(--hb-top, var(--menu-bar-h, 0px));
414
+ --hp-pt: max(2rem, calc(var(--hp-bar) + 1.5rem));
415
+ }
416
+ @media (min-width: 768px) {
417
+ [data-blocks] [data-hero-poster] {
418
+ --hp-pt: max(4svh, calc(var(--hp-bar) + 3svh));
419
+ }
420
+ }
408
421
  /* With scripts and motion the poster's first paint is the moment before its entrance: the lines wait below their
409
422
  masks and the window is a hairline. The block's script takes over (data-live) and plays the entrance; if it never
410
423
  arrives everything opens after 3s. Without scripts or under reduced motion the composed poster shows at once. */
@@ -938,18 +951,53 @@
938
951
  /* --- menu-curtain --- */
939
952
  /* The menu's ground is the gate's deep one (a step darker than the page in every palette, so the menu reads as the
940
953
  room the page is shown in); the slices take their own, as in page-transition-slices. Browsers without relative
941
- colour take the inverse. The bar's height is shared with the page, which keeps its hero type below it. */
954
+ colour take the inverse. The bar's height is shared with the page through the nearest [data-blocks], which keeps its
955
+ hero type below it (hero-poster reads it). */
956
+ [data-blocks]:has([data-menu-curtain]),
942
957
  [data-blocks] [data-menu-curtain] {
943
958
  --menu-bar-h: 4rem;
959
+ }
960
+ [data-blocks] [data-menu-curtain] {
944
961
  --menu: var(--inverse);
945
962
  --on-menu: var(--on-inverse);
946
963
  --on-menu-muted: color-mix(in oklch, var(--on-menu) 70%, var(--menu));
947
964
  }
948
965
  @media (min-width: 768px) {
966
+ [data-blocks]:has([data-menu-curtain]),
949
967
  [data-blocks] [data-menu-curtain] {
950
968
  --menu-bar-h: 4.5rem;
951
969
  }
952
970
  }
971
+ /* The bar's ink before the script cuts it, and without scripts: the first section's data-bar-tone (the element right
972
+ after the curtain, or that element's first child) picks the copy that shows; the script then cuts every copy to the
973
+ sections under it. */
974
+ [data-blocks] [data-menu-curtain]:not([data-ready]) [data-tone-face]:not([data-tone-face="ground"]),
975
+ [data-blocks]
976
+ [data-menu-curtain]:not([data-ready]):has(
977
+ + [data-bar-tone="photo"],
978
+ + [data-bar-tone="inverse"],
979
+ + :not([data-bar-tone]) > [data-bar-tone="photo"]:first-child,
980
+ + :not([data-bar-tone]) > [data-bar-tone="inverse"]:first-child
981
+ )
982
+ [data-tone-face="ground"] {
983
+ clip-path: inset(0 0 100% 0);
984
+ }
985
+ [data-blocks]
986
+ [data-menu-curtain]:not([data-ready]):has(+ [data-bar-tone="photo"], + :not([data-bar-tone]) > [data-bar-tone="photo"]:first-child)
987
+ [data-tone-face="photo"],
988
+ [data-blocks]
989
+ [data-menu-curtain]:not([data-ready]):has(+ [data-bar-tone="inverse"], + :not([data-bar-tone]) > [data-bar-tone="inverse"]:first-child)
990
+ [data-tone-face="inverse"] {
991
+ clip-path: none;
992
+ }
993
+ /* Once the page scrolls each copy stands on its solid ground, with a faint hairline, scrubbed by --mc-k over the bar's
994
+ own height of scroll. */
995
+ [data-blocks] [data-menu-curtain] [data-mc-ground] {
996
+ opacity: var(--mc-k, 0);
997
+ }
998
+ [data-blocks] [data-menu-curtain] [data-mc-line] {
999
+ opacity: calc(var(--mc-k, 0) * 0.16);
1000
+ }
953
1001
  @supports (color: oklch(from red l c h)) {
954
1002
  [data-blocks] [data-menu-curtain] {
955
1003
  --menu: oklch(from var(--surface-0) min(calc(l * 0.55), 0.2) c h);
@@ -1440,6 +1488,23 @@ html:not(.sq-js) [data-blocks] [data-header-bar] [data-hb-move] {
1440
1488
  [data-blocks] [data-header-bar]:not([data-ready]) [data-hb-copy] {
1441
1489
  visibility: hidden;
1442
1490
  }
1491
+ /* The plain bar's ink before the copies show, and without scripts: the start tone, read from the page. The script
1492
+ writes it (data-start, the section under the bar); before it runs, the first section's data-bar-tone decides: the
1493
+ element right after the bar, or that element's first child (a <main> around the sections). */
1494
+ [data-blocks] [data-header-bar]:not([data-ready]):is(
1495
+ [data-start="photo"],
1496
+ :not([data-start]):has(+ [data-bar-tone="photo"], + :not([data-bar-tone]) > [data-bar-tone="photo"]:first-child)
1497
+ )
1498
+ [data-hb-real] {
1499
+ color: var(--on-photo);
1500
+ }
1501
+ [data-blocks] [data-header-bar]:not([data-ready]):is(
1502
+ [data-start="inverse"],
1503
+ :not([data-start]):has(+ [data-bar-tone="inverse"], + :not([data-bar-tone]) > [data-bar-tone="inverse"]:first-child)
1504
+ )
1505
+ [data-hb-real] {
1506
+ color: var(--on-inverse);
1507
+ }
1443
1508
  [data-blocks] [data-header-bar][data-ready] [data-hb-real] {
1444
1509
  color: transparent;
1445
1510
  }
@@ -1490,9 +1555,9 @@ html:not(.sq-js) [data-blocks] [data-header-bar] [data-hb-move] {
1490
1555
  outline-offset: 6px;
1491
1556
  border-radius: 1px;
1492
1557
  }
1493
- /* Once the page has scrolled the bar takes the ground under it (over a photo the palette's dark over a blur) and quiet
1494
- its hairline, all scrubbed by --hb-k over the bar's own height of scroll. */
1495
- [data-blocks] [data-header-bar] :is([data-hb-ground], [data-hb-blur]) {
1558
+ /* Once the page has scrolled the bar takes the solid ground under it (over a photo the palette's dark) and quiet its
1559
+ hairline, all scrubbed by --hb-k over the bar's own height of scroll. */
1560
+ [data-blocks] [data-header-bar] [data-hb-ground] {
1496
1561
  opacity: var(--hb-k, 0);
1497
1562
  }
1498
1563
  [data-blocks] [data-header-bar] [data-hb-line] {
@@ -1613,7 +1678,7 @@ html:not(.sq-js) [data-blocks] [data-header-bar] [data-hb-time] {
1613
1678
  text-decoration-thickness: 1px;
1614
1679
  text-underline-offset: 0.2em;
1615
1680
  }
1616
- [data-blocks] [data-header-bar] :is([data-hb-ground], [data-hb-blur], [data-hb-line]) {
1681
+ [data-blocks] [data-header-bar] :is([data-hb-ground], [data-hb-line]) {
1617
1682
  transition: opacity 0.2s linear;
1618
1683
  }
1619
1684
  }
@@ -2143,3 +2208,462 @@ html:not(.sq-js) [data-blocks] [data-header-bar] [data-hb-time] {
2143
2208
  animation: struq-fade 0s 3s forwards;
2144
2209
  }
2145
2210
  }
2211
+
2212
+ /* --- subject --- */
2213
+ /* The subject pieces (kit/subject: Document, AppScreen, Ticket, PatternPaper, Section): what the client makes, drawn
2214
+ from the page's tokens. Figures are tabular and never break. Printed matter ("print" paper) stands in the palette's
2215
+ own light colour with its own dark ink in every scene, a step brighter than a light ground; the tokens turn inside
2216
+ it, so text-ink, border-line and bg-accent mean the sheet's ink there. */
2217
+ [data-blocks] [data-subject] {
2218
+ --subject-accent: var(--accent);
2219
+ }
2220
+ [data-blocks] [data-subject] .subject-figures {
2221
+ white-space: nowrap;
2222
+ font-variant-numeric: tabular-nums lining-nums;
2223
+ }
2224
+ [data-blocks] [data-subject] .subject-paper {
2225
+ --paper: var(--surface-1);
2226
+ background-color: var(--paper);
2227
+ color: var(--ink);
2228
+ }
2229
+ [data-blocks] [data-subject] .subject-paper[data-paper="print"] {
2230
+ --paper: var(--on-photo);
2231
+ --ink: var(--photo-ground, var(--inverse));
2232
+ --ink-muted: color-mix(in oklab, var(--ink) 68%, var(--paper));
2233
+ --line: color-mix(in oklab, var(--ink) 16%, transparent);
2234
+ --surface-0: var(--paper);
2235
+ --surface-1: color-mix(in oklab, var(--paper), var(--ink) 5%);
2236
+ --surface-2: color-mix(in oklab, var(--paper), var(--ink) 10%);
2237
+ --surface-3: color-mix(in oklab, var(--paper), var(--ink) 20%);
2238
+ --accent-ink: var(--paper);
2239
+ /* bg-accent reads --color-accent, which [data-blocks] resolved once; point it at the sheet's own accent. */
2240
+ --color-accent: var(--accent);
2241
+ }
2242
+ @supports (color: oklch(from red l c h)) {
2243
+ [data-blocks] [data-subject] .subject-paper[data-paper="print"] {
2244
+ --paper: oklch(from var(--on-photo) min(calc(l + 0.025), 0.985) calc(c * 0.55) h);
2245
+ --ink: var(--photo-ground, oklch(from var(--on-photo) 0.22 calc(c * 0.6) h));
2246
+ --accent: oklch(from var(--subject-accent) min(l, 0.52) c h);
2247
+ }
2248
+ }
2249
+ [data-blocks] [data-subject] .subject-dashed {
2250
+ border-top: 1px dashed color-mix(in oklab, var(--ink) 34%, transparent);
2251
+ }
2252
+ [data-blocks] [data-subject] .subject-slip {
2253
+ margin-inline: calc(var(--pad, 1.5rem) * -1);
2254
+ padding-inline: var(--pad, 1.5rem);
2255
+ border-top: 2px dotted color-mix(in oklab, var(--ink) 40%, transparent);
2256
+ }
2257
+ [data-blocks] [data-subject] .subject-fold {
2258
+ position: absolute;
2259
+ inset-inline: 0;
2260
+ top: 33.333%;
2261
+ height: 2px;
2262
+ border-top: 1px solid color-mix(in oklab, var(--ink) 6%, transparent);
2263
+ border-bottom: 1px solid color-mix(in oklab, var(--paper) 30%, white);
2264
+ pointer-events: none;
2265
+ }
2266
+ [data-blocks] [data-subject] .subject-paper[data-edge="torn"] {
2267
+ border-bottom-width: 0;
2268
+ margin-bottom: 0.5rem;
2269
+ }
2270
+ [data-blocks] [data-subject] .subject-torn polygon {
2271
+ fill: var(--paper);
2272
+ }
2273
+ /* AppScreen: the changed row warms for a moment (the build tweens --app-glow), over its own ground. */
2274
+ [data-blocks] [data-subject] .subject-app-row {
2275
+ box-shadow: inset 0 0 0 100vmax color-mix(in oklab, var(--accent) var(--app-glow, 0%), transparent);
2276
+ }
2277
+ @media (prefers-reduced-motion: no-preference) {
2278
+ [data-blocks] [data-subject] .subject-caret {
2279
+ animation: subject-caret 1.1s steps(1) infinite;
2280
+ }
2281
+ }
2282
+ @keyframes subject-caret { 50% { opacity: 0; } }
2283
+ /* Ticket: two notches cut where the stub tears off (across on a phone, down the side on a wide ticket), and the
2284
+ perforation between them. The badge's lanyard slot shows the ground it hangs over. */
2285
+ [data-blocks] [data-subject] .subject-ticket {
2286
+ --notch: 0.75rem;
2287
+ --tear: calc(100% - 7rem);
2288
+ -webkit-mask:
2289
+ radial-gradient(circle at 0 var(--tear), #0000 var(--notch), #000 calc(var(--notch) + 0.5px)) left / 51% 100% no-repeat,
2290
+ radial-gradient(circle at 100% var(--tear), #0000 var(--notch), #000 calc(var(--notch) + 0.5px)) right / 51% 100% no-repeat;
2291
+ mask:
2292
+ radial-gradient(circle at 0 var(--tear), #0000 var(--notch), #000 calc(var(--notch) + 0.5px)) left / 51% 100% no-repeat,
2293
+ radial-gradient(circle at 100% var(--tear), #0000 var(--notch), #000 calc(var(--notch) + 0.5px)) right / 51% 100% no-repeat;
2294
+ }
2295
+ [data-blocks] [data-subject] .subject-perforation {
2296
+ border-top: 2px dotted color-mix(in oklab, var(--ink) 45%, transparent);
2297
+ }
2298
+ @container (min-width: 36rem) {
2299
+ [data-blocks] [data-subject] .subject-ticket {
2300
+ -webkit-mask:
2301
+ radial-gradient(circle at 72% 0, #0000 var(--notch), #000 calc(var(--notch) + 0.5px)) top / 100% 51% no-repeat,
2302
+ radial-gradient(circle at 72% 100%, #0000 var(--notch), #000 calc(var(--notch) + 0.5px)) bottom / 100% 51% no-repeat;
2303
+ mask:
2304
+ radial-gradient(circle at 72% 0, #0000 var(--notch), #000 calc(var(--notch) + 0.5px)) top / 100% 51% no-repeat,
2305
+ radial-gradient(circle at 72% 100%, #0000 var(--notch), #000 calc(var(--notch) + 0.5px)) bottom / 100% 51% no-repeat;
2306
+ }
2307
+ [data-blocks] [data-subject] .subject-perforation {
2308
+ border-top: 0;
2309
+ border-left: 2px dotted color-mix(in oklab, var(--ink) 45%, transparent);
2310
+ }
2311
+ }
2312
+ [data-blocks] [data-subject] {
2313
+ --subject-ground: var(--surface-0);
2314
+ }
2315
+ [data-blocks] [data-subject] .subject-slot {
2316
+ background-color: var(--subject-ground);
2317
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--ink) 30%, transparent);
2318
+ }
2319
+
2320
+ /* --- motion --- */
2321
+ /* The feel tokens (kit/feel.ts) as custom properties, and the CSS of the motion primitives (kit/motion: RevealText,
2322
+ Rise, MaskWipe, ImageCurtain, HoverLink, Pressable, ScrubLine). A site sets data-temperament="crisp|weighted|slow" on
2323
+ <body>; weighted holds without it. These values are feelVars() in kit/feel.ts, and a unit test keeps them equal.
2324
+ The locked values live elsewhere and once: the mask room on .struq-line-mask, the focus ring on :focus-visible. */
2325
+ :root,
2326
+ [data-temperament="weighted"] {
2327
+ --ease-enter: cubic-bezier(0.16, 1, 0.3, 1);
2328
+ --ease-exit: cubic-bezier(0.32, 0, 0.67, 0);
2329
+ --ease-swap: cubic-bezier(0.76, 0, 0.24, 1);
2330
+ --ease-curtain: cubic-bezier(0.83, 0, 0.17, 1);
2331
+ --dur-enter: 0.9s;
2332
+ --dur-line: 1.25s;
2333
+ --dur-exit: 0.35s;
2334
+ --dur-swap: 1s;
2335
+ --dur-curtain: 1.4s;
2336
+ --dur-hover-line: 360ms;
2337
+ --dur-hover-color: 280ms;
2338
+ --dur-hover-fill: 500ms;
2339
+ --dur-press-in: 120ms;
2340
+ --dur-press-out: 600ms;
2341
+ --press-sink: 1px;
2342
+ --press-lift: 2px;
2343
+ --travel-block: 48px;
2344
+ --stagger-word: 0.045s;
2345
+ }
2346
+ [data-temperament="crisp"] {
2347
+ --ease-enter: cubic-bezier(0.22, 1, 0.36, 1);
2348
+ --ease-exit: cubic-bezier(0.5, 0, 0.75, 0);
2349
+ --ease-swap: cubic-bezier(0.83, 0, 0.17, 1);
2350
+ --ease-curtain: cubic-bezier(0.83, 0, 0.17, 1);
2351
+ --dur-enter: 0.6s;
2352
+ --dur-line: 0.85s;
2353
+ --dur-exit: 0.25s;
2354
+ --dur-swap: 0.7s;
2355
+ --dur-curtain: 0.9s;
2356
+ --dur-hover-line: 240ms;
2357
+ --dur-hover-color: 180ms;
2358
+ --dur-hover-fill: 320ms;
2359
+ --dur-press-in: 90ms;
2360
+ --dur-press-out: 360ms;
2361
+ --press-sink: 1px;
2362
+ --press-lift: 1px;
2363
+ --travel-block: 24px;
2364
+ --stagger-word: 0.03s;
2365
+ }
2366
+ [data-temperament="slow"] {
2367
+ --ease-enter: cubic-bezier(0.16, 1, 0.3, 1);
2368
+ --ease-exit: cubic-bezier(0.12, 0, 0.39, 0);
2369
+ --ease-swap: cubic-bezier(0.65, 0, 0.35, 1);
2370
+ --ease-curtain: cubic-bezier(0.65, 0, 0.35, 1);
2371
+ --dur-enter: 1.3s;
2372
+ --dur-line: 1.7s;
2373
+ --dur-exit: 0.5s;
2374
+ --dur-swap: 1.4s;
2375
+ --dur-curtain: 1.9s;
2376
+ --dur-hover-line: 520ms;
2377
+ --dur-hover-color: 400ms;
2378
+ --dur-hover-fill: 750ms;
2379
+ --dur-press-in: 160ms;
2380
+ --dur-press-out: 900ms;
2381
+ --press-sink: 1px;
2382
+ --press-lift: 3px;
2383
+ --travel-block: 72px;
2384
+ --stagger-word: 0.07s;
2385
+ }
2386
+
2387
+ /* RevealText by words: each word is a shared mask, set top-aligned, because a clipping inline-block sits on its bottom
2388
+ edge instead of its text's baseline; the rising word carries the mask's room, as every mask child does. */
2389
+ [data-blocks] [data-reveal-text] [data-word] {
2390
+ display: inline-block;
2391
+ vertical-align: top;
2392
+ }
2393
+ [data-blocks] [data-reveal-text] [data-word-rise] {
2394
+ display: inline-block;
2395
+ }
2396
+
2397
+ /* MaskWipe and ImageCurtain: the panel rests off the photo, so without scripts the photo stands uncovered. */
2398
+ [data-blocks] [data-curtain="up"] [data-curtain-panel] {
2399
+ transform: translateY(-100%);
2400
+ }
2401
+ [data-blocks] [data-curtain="down"] [data-curtain-panel] {
2402
+ transform: translateY(100%);
2403
+ }
2404
+ [data-blocks] [data-curtain="left"] [data-curtain-panel] {
2405
+ transform: translateX(-100%);
2406
+ }
2407
+ [data-blocks] [data-curtain="right"] [data-curtain-panel] {
2408
+ transform: translateX(100%);
2409
+ }
2410
+
2411
+ /* HoverLink: one rule under the text (draw, slide) or a colour turn (color). Focus shows what hover shows. */
2412
+ [data-blocks] [data-hover-link] {
2413
+ position: relative;
2414
+ color: inherit;
2415
+ text-decoration: none;
2416
+ transition: color var(--dur-hover-color) var(--ease-enter);
2417
+ }
2418
+ [data-blocks] :is([data-hover-link="draw"], [data-hover-link="slide"])::after,
2419
+ [data-blocks] [data-hover-link="slide"]::before {
2420
+ content: "";
2421
+ position: absolute;
2422
+ left: 0;
2423
+ right: 0;
2424
+ bottom: -0.08em;
2425
+ height: 1px;
2426
+ background: currentColor;
2427
+ pointer-events: none;
2428
+ }
2429
+ [data-blocks] [data-hover-link="draw"]::after {
2430
+ transform: scaleX(0);
2431
+ transform-origin: left center;
2432
+ transition: transform var(--dur-hover-line) var(--ease-enter);
2433
+ }
2434
+ [data-blocks] [data-hover-link="draw"]:is(:hover, :focus-visible)::after {
2435
+ transform: scaleX(1);
2436
+ }
2437
+ [data-blocks] [data-hover-link="slide"]::before {
2438
+ clip-path: inset(0 0 0 0);
2439
+ transition: clip-path var(--dur-hover-line) var(--ease-enter) calc(var(--dur-hover-line) * 0.35);
2440
+ }
2441
+ [data-blocks] [data-hover-link="slide"]::after {
2442
+ clip-path: inset(0 100% 0 0);
2443
+ transition: clip-path var(--dur-hover-line) var(--ease-enter);
2444
+ }
2445
+ [data-blocks] [data-hover-link="slide"]:is(:hover, :focus-visible)::before {
2446
+ clip-path: inset(0 0 0 100%);
2447
+ transition-delay: 0s;
2448
+ }
2449
+ [data-blocks] [data-hover-link="slide"]:is(:hover, :focus-visible)::after {
2450
+ clip-path: inset(0 0 0 0);
2451
+ transition-delay: calc(var(--dur-hover-line) * 0.35);
2452
+ }
2453
+ [data-blocks] [data-hover-link="color"]:is(:hover, :focus-visible) {
2454
+ color: var(--accent);
2455
+ }
2456
+
2457
+ /* Pressable: lift on hover and focus, give way on press. `translate`, so a magnet's GSAP transform never meets it. */
2458
+ [data-blocks] [data-pressable] {
2459
+ -webkit-tap-highlight-color: transparent;
2460
+ transition: translate var(--dur-press-out) var(--ease-enter);
2461
+ }
2462
+ @media (hover: hover) {
2463
+ [data-blocks] [data-pressable][data-lift]:not(:disabled):hover {
2464
+ translate: 0 calc(var(--press-lift) * -1);
2465
+ }
2466
+ }
2467
+ [data-blocks] [data-pressable][data-lift]:focus-visible {
2468
+ translate: 0 calc(var(--press-lift) * -1);
2469
+ }
2470
+ [data-blocks] [data-pressable="sink"]:not(:disabled):active {
2471
+ translate: 0 var(--press-sink);
2472
+ transition-duration: var(--dur-press-in);
2473
+ }
2474
+ [data-blocks] [data-pressable="fill"] {
2475
+ overflow: hidden;
2476
+ isolation: isolate;
2477
+ }
2478
+ [data-blocks] [data-pressable] [data-press-fill] {
2479
+ position: absolute;
2480
+ inset: 0;
2481
+ z-index: -1;
2482
+ background: var(--press-fill, var(--ink));
2483
+ transform: scaleY(0);
2484
+ transform-origin: bottom center;
2485
+ transition: transform var(--dur-press-out) var(--ease-enter);
2486
+ }
2487
+ [data-blocks] [data-pressable] [data-press-label] {
2488
+ position: relative;
2489
+ transition: color var(--dur-press-out) var(--ease-enter);
2490
+ }
2491
+ [data-blocks] [data-pressable="fill"]:not(:disabled):active [data-press-fill] {
2492
+ transform: scaleY(1);
2493
+ transition-duration: var(--dur-press-in);
2494
+ }
2495
+ [data-blocks] [data-pressable="fill"]:not(:disabled):active [data-press-label] {
2496
+ color: var(--surface-0);
2497
+ transition-duration: var(--dur-press-in);
2498
+ }
2499
+
2500
+ /* ScrubLine: a rule grows from its start edge. */
2501
+ [data-blocks] [data-scrub-line="x"] {
2502
+ transform-origin: left center;
2503
+ }
2504
+ [data-blocks] [data-scrub-line="y"] {
2505
+ transform-origin: center top;
2506
+ }
2507
+
2508
+ /* The motion gate: with scripts on and motion allowed, each primitive waits in its start state until it is wired
2509
+ (data-ready), and shows its final state after 3s if that never comes. */
2510
+ @media (prefers-reduced-motion: no-preference) {
2511
+ :is(.js, .sq-js) [data-blocks] [data-reveal-text]:not([data-ready]) {
2512
+ visibility: hidden;
2513
+ animation: struq-show 0s 3s forwards;
2514
+ }
2515
+ :is(.js, .sq-js) [data-blocks] [data-wipe]:not([data-ready]) {
2516
+ animation: struq-open 0s 3s forwards;
2517
+ }
2518
+ :is(.js, .sq-js) [data-blocks] [data-wipe="bottom"]:not([data-ready]) {
2519
+ clip-path: inset(100% 0 0 0);
2520
+ }
2521
+ :is(.js, .sq-js) [data-blocks] [data-wipe="top"]:not([data-ready]) {
2522
+ clip-path: inset(0 0 100% 0);
2523
+ }
2524
+ :is(.js, .sq-js) [data-blocks] [data-wipe="left"]:not([data-ready]) {
2525
+ clip-path: inset(0 100% 0 0);
2526
+ }
2527
+ :is(.js, .sq-js) [data-blocks] [data-wipe="right"]:not([data-ready]) {
2528
+ clip-path: inset(0 0 0 100%);
2529
+ }
2530
+ :is(.js, .sq-js) [data-blocks] [data-curtain]:not([data-ready]) [data-curtain-panel] {
2531
+ transform: none;
2532
+ animation: struq-unveil 0s 3s forwards;
2533
+ }
2534
+ :is(.js, .sq-js) [data-blocks] [data-scrub-line="x"]:not([data-ready]) {
2535
+ transform: scaleX(0);
2536
+ }
2537
+ :is(.js, .sq-js) [data-blocks] [data-scrub-line="y"]:not([data-ready]) {
2538
+ transform: scaleY(0);
2539
+ }
2540
+ :is(.js, .sq-js) [data-blocks] [data-scrub-line="path"]:not([data-ready]) {
2541
+ visibility: hidden;
2542
+ animation: struq-show 0s 3s forwards;
2543
+ }
2544
+ :is(.js, .sq-js) [data-blocks] [data-rise]:not([data-ready]) {
2545
+ opacity: 0;
2546
+ animation: struq-fade 0s 3s forwards;
2547
+ }
2548
+ }
2549
+
2550
+ /* Reduced motion: the same states, cut in, nothing travels. */
2551
+ @media (prefers-reduced-motion: reduce) {
2552
+ [data-blocks] [data-hover-link],
2553
+ [data-blocks] [data-hover-link]::before,
2554
+ [data-blocks] [data-hover-link]::after,
2555
+ [data-blocks] [data-pressable],
2556
+ [data-blocks] [data-pressable] :is([data-press-fill], [data-press-label]) {
2557
+ transition: none;
2558
+ }
2559
+ [data-blocks] [data-pressable] {
2560
+ translate: none !important;
2561
+ }
2562
+ }
2563
+
2564
+ /* --- middle --- */
2565
+ /* The middle scenes (kit/middle: ServicesList, ProcessStrip, PlaceHours). Their hover and state changes move on the
2566
+ feel's custom properties above, so a temperament changes their character and nothing else. With scripts on and
2567
+ motion allowed, each scene's parts wait in their start state until its one timeline is built (data-ready on the
2568
+ data-middle root), and stand composed after 3s if that never comes. */
2569
+
2570
+ /* ServicesList rows: the accent floods up from the row's foot on hover and on focus; the name turns to the accent's
2571
+ ink and nudges in; the sentence, always shown, turns a quarter of the fill later. */
2572
+ [data-blocks] [data-flood] {
2573
+ transform: scaleY(0);
2574
+ transition: transform var(--dur-hover-fill) var(--ease-enter);
2575
+ }
2576
+ [data-blocks] [data-flood-name] {
2577
+ transition:
2578
+ color var(--dur-hover-fill) var(--ease-enter),
2579
+ translate var(--dur-hover-fill) var(--ease-enter);
2580
+ }
2581
+ [data-blocks] [data-flood-text] {
2582
+ transition:
2583
+ color var(--dur-hover-fill) var(--ease-enter) calc(var(--dur-hover-fill) / 4),
2584
+ opacity var(--dur-hover-fill) var(--ease-enter) calc(var(--dur-hover-fill) / 4),
2585
+ translate var(--dur-hover-fill) var(--ease-enter) calc(var(--dur-hover-fill) / 4);
2586
+ }
2587
+ [data-blocks] [data-flood-row]:is(:hover, :focus-visible) [data-flood] {
2588
+ transform: scaleY(1);
2589
+ }
2590
+ [data-blocks] [data-flood-row]:is(:hover, :focus-visible) :is([data-flood-name], [data-flood-text]) {
2591
+ color: var(--accent-ink);
2592
+ }
2593
+ [data-blocks] [data-flood-row]:is(:hover, :focus-visible) [data-flood-name] {
2594
+ translate: 1.5vw 0;
2595
+ }
2596
+ /* The sentence always shows: a services list that hides what each service is until hover reads as names alone
2597
+ (lab check, 2026-11-15). On wide screens hover only lifts it a little as the flood arrives. */
2598
+ @media (min-width: 768px) {
2599
+ [data-blocks] [data-flood-row]:is(:hover, :focus-visible) [data-flood-text] {
2600
+ translate: 0 -0.25rem;
2601
+ }
2602
+ }
2603
+
2604
+ /* ProcessStrip chapters: the stages not in front step back. */
2605
+ [data-blocks] [data-chapter] [data-chapter-text] {
2606
+ transition:
2607
+ opacity var(--dur-enter) var(--ease-enter),
2608
+ translate var(--dur-enter) var(--ease-enter);
2609
+ }
2610
+ [data-blocks] [data-chapter][data-back] [data-chapter-text] {
2611
+ opacity: 0.24;
2612
+ translate: -0.8vw 0;
2613
+ }
2614
+
2615
+ /* PlaceHours: the open marker, a small ring filled while open. */
2616
+ [data-blocks] [data-open-dot] {
2617
+ display: inline-block;
2618
+ flex: none;
2619
+ width: 0.5em;
2620
+ height: 0.5em;
2621
+ border: 1px solid currentColor;
2622
+ border-radius: 50%;
2623
+ }
2624
+ [data-blocks] [data-open-dot][data-open] {
2625
+ background: currentColor;
2626
+ }
2627
+
2628
+ @media (prefers-reduced-motion: no-preference) {
2629
+ :is(.js, .sq-js) [data-blocks] [data-middle]:not([data-ready]) [data-part="heading"] {
2630
+ visibility: hidden;
2631
+ animation: struq-show 0s 3s forwards;
2632
+ }
2633
+ :is(.js, .sq-js) [data-blocks] [data-middle]:not([data-ready]) [data-part="detail"] {
2634
+ opacity: 0;
2635
+ animation: struq-fade 0s 3s forwards;
2636
+ }
2637
+ :is(.js, .sq-js) [data-blocks] [data-middle]:not([data-ready]) [data-part="media"] {
2638
+ clip-path: inset(100% 0 0 0);
2639
+ animation: struq-open 0s 3s forwards;
2640
+ }
2641
+ :is(.js, .sq-js) [data-blocks] [data-middle]:not([data-ready]) [data-part="media"][data-open="margins"] {
2642
+ clip-path: inset(0 6% 0 6%);
2643
+ }
2644
+ :is(.js, .sq-js) [data-blocks] [data-scene="services"][data-variant="panels"]:not([data-motion]) [data-panel] {
2645
+ clip-path: inset(100% 0 0 0);
2646
+ animation: struq-open 0s 3s forwards;
2647
+ }
2648
+ :is(.js, .sq-js) [data-blocks] [data-scene="process"] [data-stage]:not([data-ready]) [data-tick] {
2649
+ clip-path: inset(0 100% 0 0);
2650
+ animation: struq-open 0s 3s forwards;
2651
+ }
2652
+ :is(.js, .sq-js) [data-blocks] [data-scene="process"] [data-stage]:not([data-ready]) [data-term] {
2653
+ visibility: hidden;
2654
+ animation: struq-show 0s 3s forwards;
2655
+ }
2656
+ :is(.js, .sq-js) [data-blocks] [data-scene="process"] [data-stage]:not([data-ready]) [data-text] {
2657
+ opacity: 0;
2658
+ animation: struq-fade 0s 3s forwards;
2659
+ }
2660
+ }
2661
+
2662
+ @media (prefers-reduced-motion: reduce) {
2663
+ [data-blocks] :is([data-flood], [data-flood-name], [data-flood-text], [data-chapter-text]) {
2664
+ transition: none;
2665
+ }
2666
+ [data-blocks] [data-flood-row]:is(:hover, :focus-visible) [data-flood-name] {
2667
+ translate: none;
2668
+ }
2669
+ }