@plitzi/sdk-server 0.37.9 → 0.37.10

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 (101) hide show
  1. package/CHANGELOG.md +589 -0
  2. package/README.md +17 -0
  3. package/dist/adapters/cloudAdapters.d.ts +5 -3
  4. package/dist/adapters/cloudAdapters.js +120 -34
  5. package/dist/adapters/space/types.d.ts +3 -0
  6. package/dist/core/configSeam.d.ts +14 -0
  7. package/dist/core/configSeam.js +114 -0
  8. package/dist/core/configSeam.test.d.ts +1 -0
  9. package/dist/core/createServer.js +11 -5
  10. package/dist/core/createServer.publicData.test.d.ts +1 -0
  11. package/dist/core/freePort.d.ts +10 -0
  12. package/dist/core/freePort.js +36 -0
  13. package/dist/core/freePort.test.d.ts +1 -0
  14. package/dist/core/http/stages/images.d.ts +6 -0
  15. package/dist/core/http/stages/images.js +47 -0
  16. package/dist/core/http/stages/images.test.d.ts +1 -0
  17. package/dist/core/server/fleet/channel.js +1 -1
  18. package/dist/core/server/fleet/relay.js +1 -1
  19. package/dist/core/services/action.js +5 -4
  20. package/dist/core/services/registry.js +2 -0
  21. package/dist/helpers/cache/keys.d.ts +23 -10
  22. package/dist/helpers/cache/keys.js +16 -2
  23. package/dist/helpers/flagOverrides.d.ts +27 -0
  24. package/dist/helpers/flagOverrides.js +25 -0
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +2 -1
  27. package/dist/modules/actions/moduleFor.js +5 -3
  28. package/dist/modules/actions/runtime/flags.d.ts +16 -0
  29. package/dist/modules/actions/runtime/flags.js +49 -0
  30. package/dist/modules/actions/runtime/flags.test.d.ts +1 -0
  31. package/dist/modules/actions/runtime/kvList.js +1 -1
  32. package/dist/modules/actions/runtime/runAction.js +2 -0
  33. package/dist/modules/actions/transport/callHandler.js +8 -0
  34. package/dist/modules/actions/types.d.ts +14 -1
  35. package/dist/modules/connectors/engine.js +1 -1
  36. package/dist/modules/functions/capabilities.js +1 -1
  37. package/dist/modules/functions/load.js +2 -2
  38. package/dist/modules/functions/manifest.js +1 -1
  39. package/dist/modules/functions/runner/messages.js +1 -1
  40. package/dist/modules/images/allowlist.d.ts +5 -0
  41. package/dist/modules/images/allowlist.js +18 -0
  42. package/dist/modules/images/imageProxy.d.ts +36 -0
  43. package/dist/modules/images/imageProxy.js +273 -0
  44. package/dist/modules/images/imageProxy.test.d.ts +1 -0
  45. package/dist/modules/images/transform.d.ts +10 -0
  46. package/dist/modules/images/transform.js +28 -0
  47. package/dist/modules/images/transform.test.d.ts +1 -0
  48. package/dist/modules/rsc/connectorRscData.d.ts +12 -7
  49. package/dist/modules/rsc/connectorRscData.js +12 -8
  50. package/dist/modules/rsc/handler.js +18 -4
  51. package/dist/modules/rsc/publicFileResolver.d.ts +13 -0
  52. package/dist/modules/rsc/publicFileResolver.js +39 -0
  53. package/dist/modules/rsc/publicFileResolver.test.d.ts +1 -0
  54. package/dist/modules/rsc/resolveRscData.d.ts +4 -2
  55. package/dist/modules/rsc/resolveRscData.js +10 -2
  56. package/dist/modules/runtime/bundle.js +2 -2
  57. package/dist/modules/ssr/Component.d.ts +5 -1
  58. package/dist/modules/ssr/Component.js +3 -1
  59. package/dist/modules/ssr/prepareRender.js +36 -8
  60. package/package.json +9 -4
  61. package/skills/plitzi-authoring/CHEATSHEET.md +135 -0
  62. package/skills/plitzi-authoring/SKILL.md +71 -71
  63. package/skills/plitzi-authoring/recipes/carousel.ts +114 -0
  64. package/skills/plitzi-authoring/recipes/detail-page.ts +70 -0
  65. package/skills/plitzi-authoring/recipes/embed-and-svg.ts +48 -0
  66. package/skills/plitzi-authoring/recipes/every-few-seconds.ts +119 -0
  67. package/skills/plitzi-authoring/recipes/feature-flag.ts +24 -0
  68. package/skills/plitzi-authoring/recipes/filter-a-list.ts +63 -0
  69. package/skills/plitzi-authoring/recipes/forms-and-modals.ts +64 -0
  70. package/skills/plitzi-authoring/recipes/from-tailwind.ts +52 -0
  71. package/skills/plitzi-authoring/recipes/link-to-a-section.ts +35 -0
  72. package/skills/plitzi-authoring/recipes/link-with-data.ts +40 -0
  73. package/skills/plitzi-authoring/recipes/marquee.ts +57 -0
  74. package/skills/plitzi-authoring/recipes/plugin.ts +53 -0
  75. package/skills/plitzi-authoring/recipes/scroll-a-row.ts +79 -0
  76. package/skills/plitzi-authoring/recipes/show-data.ts +73 -0
  77. package/skills/plitzi-authoring/recipes/style-a-page.ts +45 -0
  78. package/skills/plitzi-authoring/recipes/typed-data.ts +64 -0
  79. package/skills/plitzi-authoring/recipes/usable-without-sight.ts +39 -0
  80. package/skills/plitzi-authoring/reference/accessibility.md +2 -2
  81. package/skills/plitzi-authoring/reference/authoring-errors.md +195 -53
  82. package/skills/plitzi-authoring/reference/colours-and-motion.md +63 -0
  83. package/skills/plitzi-authoring/reference/components.md +9 -1
  84. package/skills/plitzi-authoring/reference/data-and-visibility.md +29 -29
  85. package/skills/plitzi-authoring/reference/efficiency.md +53 -0
  86. package/skills/plitzi-authoring/reference/elements-and-styles.md +39 -24
  87. package/skills/plitzi-authoring/reference/feature-flags.md +78 -0
  88. package/skills/plitzi-authoring/reference/flows.md +25 -36
  89. package/skills/plitzi-authoring/reference/kept-state.md +28 -0
  90. package/skills/plitzi-authoring/reference/layouts.md +20 -3
  91. package/skills/plitzi-authoring/reference/lists.md +45 -16
  92. package/skills/plitzi-authoring/reference/plugins.md +15 -0
  93. package/skills/plitzi-authoring/reference/realtime.md +35 -0
  94. package/skills/plitzi-authoring/reference/review-checklist.md +8 -3
  95. package/skills/plitzi-authoring/reference/{templates-and-export.md → snippets-and-export.md} +9 -8
  96. package/skills/plitzi-authoring/reference/structure.md +29 -20
  97. package/skills/plitzi-authoring/reference/tailwind.md +19 -0
  98. package/skills/plitzi-authoring/reference/templates.md +6 -5
  99. package/skills/plitzi-authoring/reference/testing.md +11 -0
  100. package/skills/plitzi-authoring/reference/typed-sources.md +20 -0
  101. package/skills/plitzi-authoring/reference/validation.md +6 -2
@@ -1,3 +1,4 @@
1
+ import { requestFlagOverrides } from "../../helpers/flagOverrides.js";
1
2
  import { authorizesDebugging } from "../../helpers/debugAuthorization.js";
2
3
  import { realtimeModuleFor } from "../realtime/index.js";
3
4
  import { matchRscPage } from "../rsc/matchRscPage.js";
@@ -11,10 +12,12 @@ import { createOfflineDataLoader } from "../../helpers/offlineDataLoader.js";
11
12
  import { registerExternalPlugins } from "./registerExternalPlugins.js";
12
13
  import { reportMissingPlugins } from "./reportMissingPlugins.js";
13
14
  import { resolvePageSeo } from "./resolvePageSeo.js";
15
+ import { imagesPathOf } from "../../core/http/stages/images.js";
14
16
  import { sdkAssetVersion } from "../../core/sdkAssets.js";
15
17
  import { buildServerInfo } from "../../helpers/buildServerInfo.js";
16
18
  import { hydrationPayload } from "../../helpers/hydrationPayload.js";
17
19
  import { resolveDebugMode } from "../../helpers/resolveDebugMode.js";
20
+ import { flagUserFromSSR, flagValues, resolveFlags } from "@plitzi/sdk-shared/flags";
18
21
  import { hasServerElements } from "@plitzi/sdk-shared/schema/serverElements";
19
22
  import { debugCookieName } from "@plitzi/sdk-shared/devTools";
20
23
  import { themeFromCookies } from "@plitzi/sdk-shared/theme";
@@ -35,7 +38,7 @@ var declaredTheme = (style) => {
35
38
  };
36
39
  var prepareRender = async (req, config, spaceId, environment, revision, pluginManager, offlineDataCache, metrics, offlineDataOverride) => {
37
40
  const m = (name, fn) => metrics ? metrics.measure(name, fn) : Promise.resolve(fn());
38
- const offlineCacheKey = environment !== "main" ? buildOfflineDataCacheKey(spaceId, environment, revision) : void 0;
41
+ const offlineCacheKey = environment !== "main" ? buildOfflineDataCacheKey(spaceId, environment, revision, req.ctx.spaceDeployment?.flagsVersion) : void 0;
39
42
  const cachedOfflineStr = offlineDataOverride === void 0 && offlineCacheKey ? offlineDataCache?.get(offlineCacheKey) : void 0;
40
43
  const loadOfflineData = createOfflineDataLoader(() => {
41
44
  if (offlineDataOverride !== void 0) return Promise.resolve(offlineDataOverride);
@@ -46,13 +49,27 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
46
49
  const rscPath = resolveRscEndpoint(config);
47
50
  const schema = offlineData?.schema;
48
51
  const pageMatch = schema !== void 0 ? matchRscPage(schema, req.path, req.ctx.user) : void 0;
49
- const rscData = rscPath !== void 0 && schema !== void 0 && schema.rsc?.enabled !== false && pageMatch !== void 0 && hasServerElements(schema, pageMatch.pageId) && config.adapters.getRscData ? await m("rsc", () => config.adapters.getRscData?.({
52
+ const rscEnabled = rscPath !== void 0 && schema !== void 0 && schema.rsc?.enabled !== false;
53
+ const debugAuthorized = !Boolean(req.query["__pt"]) && authorizesDebugging(config, schema?.settings);
54
+ const flagOverrides = await requestFlagOverrides(config, req, {
55
+ spaceId: req.ctx.spaceDeployment?.spaceId ?? spaceId,
56
+ environment: req.ctx.spaceDeployment?.environment ?? environment
57
+ }, debugAuthorized);
58
+ const pageFlags = schema !== void 0 && pageMatch !== void 0 ? flagValues(resolveFlags(schema.flags, {
59
+ environment: req.ctx.spaceDeployment?.environment ?? environment,
60
+ hostname: req.hostname,
61
+ routeParams: pageMatch.routeParams,
62
+ queryParams: req.query,
63
+ user: flagUserFromSSR(req.ctx.user)
64
+ }, flagOverrides)) : void 0;
65
+ const rscData = rscEnabled && pageMatch !== void 0 && hasServerElements(schema, pageMatch.pageId, pageFlags) && config.adapters.getRscData ? await m("rsc", () => config.adapters.getRscData?.({
50
66
  req,
51
67
  spaceId: req.ctx.spaceDeployment?.spaceId ?? spaceId,
52
68
  environment: req.ctx.spaceDeployment?.environment ?? environment,
53
69
  revision: req.ctx.spaceDeployment?.revision ?? revision,
54
70
  user: req.ctx.user,
55
- loadOfflineData
71
+ loadOfflineData,
72
+ flagOverrides
56
73
  })) : rscPath ? { serverData: {} } : void 0;
57
74
  const pageSeo = resolvePageSeo(schema, pageMatch?.pageId);
58
75
  const server = buildServerInfo(req, config, {
@@ -60,7 +77,8 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
60
77
  rscData,
61
78
  actionPath: resolveActionEndpoint(config),
62
79
  realtimePath: realtimeModuleFor(config)?.path,
63
- realtimeTransport: realtimeModuleFor(config)?.transport
80
+ realtimeTransport: realtimeModuleFor(config)?.transport,
81
+ imagePath: imagesPathOf(config)
64
82
  });
65
83
  if (offlineDataOverride === void 0 && !cachedOfflineStr && offlineCacheKey && offlineData !== void 0) offlineDataCache?.set(offlineCacheKey, JSON.stringify(offlineData));
66
84
  const version = config.assetVersion ?? sdkAssetVersion();
@@ -81,7 +99,6 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
81
99
  * so does the space itself (`settings.debugMode`) — an owner inspecting their own published site. The space is read
82
100
  * from what this server loaded, never from the request, so a visitor has no say in it.
83
101
  */
84
- const debugAuthorized = !Boolean(req.query["__pt"]) && authorizesDebugging(config, schema?.settings);
85
102
  const debugRendered = resolveDebugMode(debugAuthorized, readCookie(req.headers.cookie, debugCookieName(req.headers.host)));
86
103
  /**
87
104
  * The runs this render's server elements started, handed to a page that may debug them — and to no other.
@@ -123,6 +140,13 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
123
140
  * would hydrate other markup. Only the keys the space declares, and only with `keepState` on.
124
141
  */
125
142
  const paintedState = schema?.settings.keepState ? paintedStateFor(req.headers.cookie, ssrPaintedCookieName(req.headers.host), paintedKeys(schema.settings)) : void 0;
143
+ /**
144
+ * The two layers of the space's flags this server answers for. Its own (`config.flags`), asked per space when it
145
+ * serves several; and the ones a tester forced from the dev tools, read from their cookie — only for a page allowed
146
+ * to debug, or any visitor could switch on a feature still behind a flag. Both travel to the browser beside the
147
+ * page, so it hydrates with the flags it was drawn with.
148
+ */
149
+ const { server: serverFlags, qa: forcedFlags } = flagOverrides;
126
150
  const offlineDataStr = hydrationPayload(offlineData, {
127
151
  offlineMode: true,
128
152
  environment,
@@ -133,7 +157,9 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
133
157
  ...paintedState ? { state: paintedState } : {},
134
158
  ...clientAnalytics ? { analytics: clientAnalytics } : {},
135
159
  ...overQuota ? { overQuota } : {},
136
- ...actionRuns ? { actionRuns } : {}
160
+ ...actionRuns ? { actionRuns } : {},
161
+ ...serverFlags ? { serverFlags } : {},
162
+ ...forcedFlags ? { forcedFlags } : {}
137
163
  });
138
164
  const pluginNames = req.ctx.spaceDeployment?.pluginNames ?? [];
139
165
  const pluginSources = req.ctx.spaceDeployment?.pluginSources;
@@ -167,7 +193,9 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
167
193
  sdkDevToolsStylePath,
168
194
  overQuota,
169
195
  theme,
170
- ...paintedState ? { state: paintedState } : {}
196
+ ...paintedState ? { state: paintedState } : {},
197
+ ...serverFlags ? { serverFlags } : {},
198
+ ...forcedFlags ? { forcedFlags } : {}
171
199
  },
172
200
  entries,
173
201
  templateParams: {
@@ -202,7 +230,7 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
202
230
  ssrOnly: config.ssrOnly === true,
203
231
  offlineData: offlineDataStr
204
232
  },
205
- cacheable: actionRuns === void 0
233
+ cacheable: actionRuns === void 0 && forcedFlags === void 0
206
234
  };
207
235
  };
208
236
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plitzi/sdk-server",
3
- "version": "0.37.9",
3
+ "version": "0.37.10",
4
4
  "license": "AGPL-3.0",
5
5
  "files": [
6
6
  "dist",
@@ -79,8 +79,8 @@
79
79
  "type": "module",
80
80
  "sideEffects": false,
81
81
  "dependencies": {
82
- "@plitzi/plitzi-sdk": "0.37.9",
83
- "@plitzi/sdk-shared": "0.37.9",
82
+ "@plitzi/plitzi-sdk": "0.37.10",
83
+ "@plitzi/sdk-shared": "0.37.10",
84
84
  "ejs": "^6.0.1",
85
85
  "esbuild": "^0.28.2",
86
86
  "jsonwebtoken": "^9.0.3",
@@ -94,7 +94,8 @@
94
94
  "mongodb": "^6 || ^7",
95
95
  "mysql2": "^3",
96
96
  "react": "^19",
97
- "react-dom": "^19"
97
+ "react-dom": "^19",
98
+ "sharp": "^0.34.0"
98
99
  },
99
100
  "peerDependenciesMeta": {
100
101
  "core-js": {
@@ -108,6 +109,9 @@
108
109
  },
109
110
  "mysql2": {
110
111
  "optional": true
112
+ },
113
+ "sharp": {
114
+ "optional": true
111
115
  }
112
116
  },
113
117
  "devDependencies": {
@@ -129,6 +133,7 @@
129
133
  "mysql2": "^3.24.4",
130
134
  "react": "^19.3.0",
131
135
  "react-dom": "^19.3.0",
136
+ "sharp": "^0.34.5",
132
137
  "tsx": "^4.23.15",
133
138
  "typescript": "^6.0.3",
134
139
  "vite": "^8.3.1",
@@ -0,0 +1,135 @@
1
+ # Plitzi authoring — cheatsheet
2
+
3
+ What most changes need, on one page. Everything is imported from `@plitzi/sdk-authoring`. For the rest, ask by name —
4
+ `npx @plitzi/cli explain navigate` (an element, a step, a trigger, a problem's code), `--list steps` for every one of a
5
+ kind — rather than reading its `.d.ts`.
6
+
7
+ ## The space
8
+
9
+ ```ts
10
+ const space: SpaceSpec = {
11
+ name, permanentUrl, // permanentUrl: lowercase, digits, `-`
12
+ variables: { color: { card: { light, dark, default } } }, // every colour a token, both themes; tokens(variables).card
13
+ classes: { card }, // styles() declarations, by their own name; styles('card', tw('p-4 rounded-xl'))
14
+ computed: { total: '{{ … }}' }, // values read anywhere as computed.total
15
+ flags: { beta: { description, value: false, rules: [] } },
16
+ customCss: '@keyframes …', // keyframes and what no property says
17
+ layouts: [{ id: 'shell', body }], // chrome shared by pages
18
+ components: [{ id, props, slots, root }], // a block placed many times with different content
19
+ pages: [{ id, name, slug: '', layout: { id: 'shell', slot: 'main' }, body: [] }] // slot: where the body goes
20
+ }; // slug '' is home; 'products/:slug' takes a param
21
+ const { schema, style, warnings } = authorSpace(space, { plugins: [declaration] });
22
+ ```
23
+
24
+ ## Elements
25
+
26
+ Every factory takes the element's attributes and these fields: `id`, `class`, `css`, `states`, `slots`, `bind`,
27
+ `visible`, `flows`, `anchor`, `flag`, `runtime`, `loadStrategy`, `children`.
28
+
29
+ ```ts
30
+ text('Words', { class }) // also text({ content }); a <div> — `display: inline` to flow
31
+ heading('Title', { subType: 'h2' }) // h1–h6; holds no children
32
+ paragraph('A paragraph.')
33
+ container([children], { subType: 'section' }) // div, section, nav, header, footer, main, article, li, h1–h6, span…
34
+ link({ href: 'about', children }) // a page id, a '/path', or a URL — the mode follows from the href
35
+ link({ href: 'home', hash: 'plans' }) // → /#plans, onto the element with anchor: 'plans'
36
+ button({ content: 'Save', flows }) // holds children too: then content: ''
37
+ image({ src, alt }) // or decorative: true
38
+ embed({ src, title }) // a map, a player: another page in a frame
39
+ svg('<svg …>…</svg>', { label }) // checked, sanitised; currentColor follows the class
40
+ fontAwesome({ icon: 'fa-solid fa-xmark' })
41
+ list({ id: 'rows', items: 'p.data.rows', row: 'row-card' }) // a <ul>; a component per row (its `item` prop)
42
+ list({ id: 'rows', items: 'p.data.rows', row: r => text({ from: `${r.item}.title` }) }) // or a tree per row
43
+ carousel({ id: 'hero', items: 'p.data.slides', autoplay: 5000, row: 'slide-card', children: [/* carouselNext('hero') arrows */] })
44
+ // rows follow their item's `id`, or `itemKey: 'slug'`
45
+ apiContainer({ id: 'p', query: '/data/x.json', cache: true, children }) // publishes p.data; no box of its own
46
+ // loadingSlot: 'p-skeleton' — a child shown until it answers
47
+ form({ id, managedByInteractions: true, flows, children })
48
+ formControl({ name: 'email', label: 'Email', subType: 'email' }) // select: options: [{ label, value }]
49
+ modalContainer({ id, visible: false, title, children })
50
+ component('card', { props: { title }, children: { body: [ … ] } })
51
+ custom({ renderType: 'myPlugin' }); defineElement<Props>(declaration) // a plugin
52
+ ```
53
+
54
+ ## Styles
55
+
56
+ ```ts
57
+ const card = styles('card', { padding: 24, borderRadius: 16 }); // shorthands, camelCase, numbers as px
58
+ styles('title', { fontSize: { desktop: '48px', compact: '32px' }, fontWeight: 700 }); // one property per breakpoint
59
+ styles('card', { css: { desktop: { … }, compact: { … } }, states: { hover: { … } }, variants: { active: { … } } });
60
+ container({ class: card }); // a shared class…
61
+ container({ id: 'hero', class: [card, { opacity: '0.5' }] }); // …plus one thing (needs an id)
62
+ container({ css: { padding: '8px' } }); // rules of its own — never with `class`
63
+ ```
64
+
65
+ Breakpoints: `desktop` (base), `tablet` (48–64rem), `mobile` (<48rem), `compact` (tablet AND mobile). Each inherits
66
+ from `desktop` alone. States: `hover`, `focus`, `focus-visible`, `active`, `disabled`…. An ancestor's state:
67
+ `ancestors: { [card.name]: { states: { hover: { … } } } }`.
68
+
69
+ ## Data, bindings, visibility
70
+
71
+ ```ts
72
+ from: 'p.data.title' // the main attribute: content, src, href, items
73
+ from: 'rows.item.price', as: 'price' // shown as a format (space `formats`) or a template
74
+ from: site.data.title // const site = source('p', sampleJson): typed, checked
75
+ bind: { alt: 'p.data.title' } // other attributes; short source names
76
+ bind: [bindTemplate('content', 'p.data.items', '{{ source|length }} items')]
77
+ bind: [bindTemplate('items', 'p.data.rows', '{{ source|filter(r => r.on) }}', { returns: 'value' })]
78
+ bind: [variantFrom(pill, 'rows.item.status')] // a variant chosen by the data
79
+ bind: [activeOn(navLink, 'about')] // the current page's link
80
+ bind: [activeWhen(dot, '{{ list_dots.index == state.slide }}')] // a variant while a condition holds
81
+ visible: 'p.data.signedIn' // shown while true; '!p.data.signedIn' inverts
82
+ visible: { source: 'p.data.rows', template: '{{ source is defined and source|length == 0 }}' }
83
+ visible: false // hidden until a step shows it
84
+ ```
85
+
86
+ Inside a list row: `rows.item` in a binding, `{{ list_rows.item.x }}` in a template or an attribute,
87
+ `list_rows.index` is a number from 0. A provider's source in a template is `apiContainer_p`… —
88
+ spelled in full, as the element publishes it. Globals: `state`, `navigation` (`routeParams`, `queryParams`),
89
+ `auth`, `variables`, `computed`, `flags`, `theme`, `host`.
90
+
91
+ ## Flows
92
+
93
+ A flow is `[trigger, …steps]`, in an element's `flows`.
94
+
95
+ ```ts
96
+ [onClick(), setState({ key: 'saved', type: 'boolean', value: true })] // types: boolean, number, text, json
97
+ [named('sent', onSubmit()), setState({ key: 'email', type: 'text', value: '{{ sent.values.email }}' })]
98
+ [on('onChange'), …]; [on('onMouseEnter'), …]; [onPageLoad(), …]; [onKey('mod+k, escape'), …]
99
+ [onInterval(5000), …] // every 5 s, while the tab is in view
100
+ [onClick(), scrollBy('cards', { x: '80%' })]; scrollTo('cards', { x: 'end' }); scrollIntoView('answer')
101
+ [named('row', onScroll()), …] // { x, y, atStart, atEnd } of its own box
102
+ [onClick(), toggleState({ key: 'menuOpen' })]
103
+ [onClick(), cycleState({ key: 'slide', length: 3, by: -1 })] // goes round; stepState({ key, by, min, max }) stops
104
+ [onClick(), openModal('details')]; closeModal('details')
105
+ [onClick(), navigate({ urlType: 'page', url: 'about' })]
106
+ [onClick(), runServerAction({ actionId: 'checkout', input: { id: '{{ list_rows.item.id }}' } })]
107
+ [onClick(), when({ field: 'state.step', operator: '=', value: '2' }, setState({ … }))]
108
+ [whileRunning('queue', onClick()), delay(1000), …]
109
+ [named('picked', declaredTrigger(declaration, 'onPick')), …]; declaredCallback(declaration, 'reset', { on: 'seats' })
110
+ ```
111
+
112
+ ## The problems met most
113
+
114
+ Each comes with its code in brackets; reference/authoring-errors.md has a row for every one — search it by code.
115
+
116
+ | Code | Write instead |
117
+ | --- | --- |
118
+ | `class-and-css` | `class: [card, { … }]` with an `id`, or the rules in the class |
119
+ | `id-taken` | ids are one namespace for the whole space, pages included: build a repeated helper inside `scope()` |
120
+ | `unknown-attribute` | the attribute it lists; an event is a flow, not an attribute |
121
+ | `children-in-leaf` | a `container` — a heading made of parts is `container({ subType: 'h1' })` |
122
+ | `template-short-source` | the source in full in templates: `list_rows`, `apiContainer_p` |
123
+ | `template-unknown-name` | a source, a variable, `navigation.queryParams.x` or `source` |
124
+ | `template-text-into-value` | `{ returns: 'value' }` on a template feeding `items` |
125
+ | `list-items-ignored` | `source: 'controlled'` |
126
+ | `condition-starts-visible` | `visible: { source, template }`, which starts hidden |
127
+ | `tablet-rule-skips-mobile` | the rule under `compact` |
128
+ | `colour-without-dark` | `{ light, dark, default }` |
129
+ | `click-on-static-element` | the flow on a `button` (it holds children) or a `link` |
130
+ | `image-without-alt` / `control-without-name` | `alt` or `decorative: true`; `title` on an icon button |
131
+ | `anchor-missing` | the `anchor` on the section the `hash` names |
132
+
133
+ ## Recipes
134
+
135
+ Whole, authoring examples by intent — copy one, then change the names: see the Recipes table in SKILL.md.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: plitzi-authoring
3
3
  description: >-
4
- Write or change a Plitzi space or template in TypeScript with @plitzi/sdk-authoring — pages, layouts, components,
4
+ Write or change a Plitzi space or snippet in TypeScript with @plitzi/sdk-authoring — pages, layouts, components,
5
5
  elements, CSS classes, data bindings, providers and flows — instead of hand-writing schema JSON. Use whenever the task is to
6
6
  create, extend, restyle or fix a space: adding a page or a section, sharing a header across pages, binding an
7
7
  element to data, showing or hiding something, wiring what happens on click, or turning an exported JSON into code.
@@ -27,10 +27,14 @@ export const space = {
27
27
  const { schema, style, warnings } = authorSpace(space);
28
28
  ```
29
29
 
30
- The published `.d.ts` is the reference for every factory, field and step builder
31
- (`node_modules/@plitzi/sdk-authoring/dist/index.d.ts`) — read it instead of guessing a name or a param. For one element
32
- type, the exported catalogues answer faster (`elementDefaultAttributes`, `elementTriggers`, `elementSlots`… — see
33
- [elements and styles](reference/elements-and-styles.md)).
30
+ **Start with [the cheatsheet](CHEATSHEET.md)** — the factories, fields, steps and the problems met most, on one page —
31
+ and open a reference below only when the task names its subject. What an element takes, a step's params, what a
32
+ trigger hands its flow: `npx @plitzi/cli explain <name>` answers in a few lines (over MCP, `plitzi://explain/<name>`).
33
+ The published `.d.ts` documents the rest — search it by name, never read it whole.
34
+
35
+ This skill writes a space that lives in code. One that lives on Plitzi — edited in the builder, published from there —
36
+ is edited through the Plitzi MCP server instead, never both on one space; `plitzi create --from <space>` turns the
37
+ second into the first. If the MCP asks for a sign-in nobody can give, a project in code needs no account.
34
38
 
35
39
  ## How to work
36
40
 
@@ -41,8 +45,13 @@ type, the exported catalogues answer faster (`elementDefaultAttributes`, `elemen
41
45
  do exactly that. Never work around a check, cast past it, or move the logic into a plugin to escape it. The first
42
46
  refusals come one at a time; the linter's come as ONE list — fix every line of it before running again. How it
43
47
  checks, a one-file author script for any project, and what it cannot see: [validation](reference/validation.md).
44
- 3. **Read every warning.** Each one names something written that will not do what it says. Zero warnings is the bar.
45
- 4. **Look at it.** `npm run shot -- /about --width 390 --scheme dark` saves a picture of one page; `npm run visual`
48
+ 3. **Read every warning, then every suggestion.** A warning names something written that will not do what it says —
49
+ zero warnings is the bar. A suggestion (`[suggest]`, `suggestions` in `authorSpace`'s result) names a shorter way to
50
+ the same page: a layout for chrome on every page, a component or a `list` for copies, a button's own `content`. Take
51
+ them, the ones that save the most first. See [efficiency](reference/efficiency.md).
52
+ 4. **Look at it.** `npm run check -- /about --width 1440,390` says in text whether the page is whole — every element on
53
+ screen, nothing overflowing, a clean console; `npm run shot -- /about --width 390 --scheme dark` is the picture, for
54
+ when it says something is wrong (`--frames 4` shows what moves); `npm run visual`
46
55
  runs the checks. Look at desktop, tablet and mobile, light and dark, and the page while its data is still loading.
47
56
  5. **Go through the [review checklist](reference/review-checklist.md) before saying it is done.** It is the feedback a
48
57
  reviewer gives on every change, written down so you do not need to hear it.
@@ -50,22 +59,28 @@ type, the exported catalogues answer faster (`elementDefaultAttributes`, `elemen
50
59
  ## The rules that matter most
51
60
 
52
61
  1. **Name what is referred to.** Give an `id` to every element a binding, a flow or a test addresses. Ids are ONE
53
- namespace for the whole space — layouts and every page share it — so an element built by a helper that runs more
54
- than once gets its id prefixed by what it is for: `` `${pageId}-foot` ``.
62
+ namespace for the whole space — layouts and every page share it — so a helper that runs more than once builds inside
63
+ `scope('promos', ref => …)`: every id in it is prefixed (`promos-panel`), and `ref('slides')` names one in full. An `id` is the element's name in the
64
+ space (`data-id` in the page); the DOM id a URL's `#fragment` lands on is its `anchor`.
55
65
  2. **Share with classes, layouts and components, never with copies.** A look used twice is a `styles()` class.
56
66
  Chrome shown on several pages — a header, a sidebar, a footer — is a **layout** the pages name, written once. A block
57
67
  placed many times with different content — a product card, a testimonial — is a **component** placed with
58
- `component(id, { props })`. A menu, a card grid, a list of steps is DATA mapped to elements
59
- (`entries.map(entry => link(…))`), not a block pasted per item. See [layouts and duplication](reference/layouts.md)
60
- and [components](reference/components.md).
68
+ `component(id, { props })`. A menu, a card grid, a list of steps is DATA, never a block pasted per item: a short
69
+ menu mapped in code (`entries.map(entry => link({ href, content: entry.title }))`) is fine; rows — cards, dishes,
70
+ tiles — are one `list` over them (`items: [ … ]`) whose row is written once. A link or a button says its words as
71
+ its own `content`, not through a `text` inside it. See [layouts and duplication](reference/layouts.md),
72
+ [components](reference/components.md) and [efficiency](reference/efficiency.md).
61
73
  3. **One element, one base selector.** An element takes a shared `class` OR its own `css`, never both — authoring
62
- refuses the pair. A look that never changes is its own class.
74
+ refuses the pair. "This class plus one thing" is the class with rules on top, last in the list:
75
+ `class: [cover, { opacity: '0.25' }]` (needs the element's `id`; it becomes the class `<id>--own`). A look that
76
+ never changes is its own class.
63
77
  4. **Visible by default; decide which way the logic flips.** An element is on screen unless something hides it. When
64
78
  the logic REVEALS it (hidden → shown: an empty state, a "get started" card, an admin-only panel), it starts hidden —
65
79
  `visible: 'source'`, or `visible: false` plus a binding for a computed condition — so it never flashes while data
66
80
  loads (`condition-starts-visible` warns). When a flag HIDES it (shown → hidden: a sidebar label until the sidebar
67
81
  is folded), it keeps the default and an absent flag must leave it shown. See
68
- [data and visibility](reference/data-and-visibility.md).
82
+ [data and visibility](reference/data-and-visibility.md). What a PERSON switches on — a feature in beta, a rollout —
83
+ is a feature flag, not a visibility: gated off, it is not rendered at all. See [feature flags](reference/feature-flags.md).
69
84
  5. **Know where a template is evaluated.** A binding's template (`bindTemplate`) and a step's params evaluate full
70
85
  Twig. An ATTRIBUTE resolves a name with filters (`{{ list_games.item.slug }}`, `{{ post.slug|url_encode }}`) against
71
86
  the sources around the element; a condition there is used as written. Inside any template a source is spelled in
@@ -95,82 +110,67 @@ type, the exported catalogues answer faster (`elementDefaultAttributes`, `elemen
95
110
  (`hideLabel: true` hides it and keeps the name), every image an `alt` or `decorative: true`. Clicks go on a
96
111
  `button` — it holds children, so a whole card can be one — or a `link`, never on a container. See
97
112
  [accessibility](reference/accessibility.md).
113
+ 14. **Motion that stays smooth.** Animate `opacity` and `transform` only; no animated blur, shadow or size. See
114
+ [motion](reference/colours-and-motion.md).
98
115
 
99
116
  ## Recipes
100
117
 
101
- Copy the one that fits, then change the names. Each is the shape `authorSpace` accepts on the first try.
118
+ Each is a whole file that authors with no warning — CI holds it to that. Copy the one that fits, then change the names.
102
119
 
103
- ```ts
104
- // Data with no backend: a JSON file the project serves (public/data/games.json), read by a provider.
105
- apiContainer({ id: 'catalog', query: '/data/games.json', cache: true, children: [ /* reads catalog.data.… */ ] })
106
-
107
- // A list of rows from that data. Inside a row: short form in bindings, full name in templates and attributes.
108
- list({ id: 'games', source: 'controlled', bind: { items: 'catalog.data.games' }, children: [
109
- link({ mode: 'internal', href: '/games/{{ list_games.item.slug }}', children: [
110
- text('', { bind: { content: 'games.item.title' } })
111
- ] })
112
- ] })
113
-
114
- // A filtered list: a template that hands over its VALUE.
115
- list({ id: 'shown', source: 'controlled',
116
- bind: [bindTemplate('items', 'catalog.data.games', "{{ source|filter(g => g.genre == state.genre) }}", { returns: 'value' })],
117
- children: [ … ] })
118
-
119
- // Text computed from data.
120
- text('', { bind: [bindTemplate('content', 'catalog.data.games', '{{ source|length }} games')] })
121
-
122
- // Shown only when a computed condition says so (starts hidden, no flash).
123
- text('Nothing here yet', { visible: { source: 'catalog.data.games', template: '{{ source is defined and source|length == 0 }}' } })
124
-
125
- // A value used in many places: declare it once in the space, read it anywhere as computed.xp.
126
- computed: { xp: '{{ (state.favourites|length) * 10 }}' }
127
-
128
- // A button that does something: a flow is [trigger, steps…].
129
- button({ content: 'Save', flows: [[onClick(), setState({ key: 'saved', type: 'boolean', value: true })]] })
130
-
131
- // A form: the flow goes on the form, which manages its own submit.
132
- form({ id: 'signup', managedByInteractions: true,
133
- flows: [[named('sent', onSubmit()), setState({ key: 'email', type: 'text', value: '{{ sent.values.email }}' })]],
134
- children: [formControl({ name: 'email', label: 'Email', subType: 'email' }), button({ content: 'Sign up', subType: 'submit' })] })
135
-
136
- // A modal: hidden until a flow opens it.
137
- modalContainer({ id: 'details', visible: false, title: 'Details', children: [ … ] })
138
- button({ content: 'Open', flows: [[onClick(), openModal('details')]] })
139
-
140
- // A plugin: author it from its own declaration, flow on its events, call its actions — all checked.
141
- const seats = defineElement<SeatPickerAttributes>(declaration);
142
- seats({ id: 'seats', flows: [[named('picked', declaredTrigger(declaration, 'onPick')), setState({ key: 'seat', type: 'text', value: '{{ picked.seat }}' })]] })
143
- button({ content: 'Clear', flows: [[onClick(), declaredCallback(declaration, 'reset', { on: 'seats' })]] })
144
- authorSpace(space, { plugins: [declaration] })
145
-
146
- // Usable without sight: an icon-only button says what it does; a clickable card IS a button; a field keeps its label
147
- // out of sight rather than dropping it; a picture says what it shows, or that it only decorates.
148
- button({ content: '', title: 'Close', children: [fontAwesome({ icon: 'fa-solid fa-xmark' })], flows: [[onClick(), closeModal('details')]] })
149
- button({ content: '', class: card, children: [text('Pro'), text('12 € a month')], flows: [[onClick(), setState({ key: 'plan', type: 'text', value: 'pro' })]] })
150
- formControl({ name: 'q', label: 'Search the docs', hideLabel: true, placeholder: 'Search…' })
151
- image({ src: '/team.jpg', alt: 'The team at the 2026 offsite' }); image({ src: '/grain.png', decorative: true })
152
-
153
- // A link: to a page by its id, to a path with mode 'internal', to anything else with mode 'external'.
154
- link({ href: 'about' }); link({ href: '/games/nebula', mode: 'internal' }); link({ href: 'mailto:hi@x.com', mode: 'external' })
155
- ```
120
+ | To… | Open |
121
+ | --- | --- |
122
+ | Show data with no backend: a JSON file, a card per row, a count, an empty state, a computed value | [recipes/show-data.ts](recipes/show-data.ts) |
123
+ | Data typed by a sample of it: completed and checked paths, typed rows | [recipes/typed-data.ts](recipes/typed-data.ts) |
124
+ | Filter and sort a list from a select | [recipes/filter-a-list.ts](recipes/filter-a-list.ts) |
125
+ | A page per record (`/products/:slug`), and "not found" | [recipes/detail-page.ts](recipes/detail-page.ts) |
126
+ | Link to a section of a page (`/#plans`) | [recipes/link-to-a-section.ts](recipes/link-to-a-section.ts) |
127
+ | A carousel: slides with arrows, dots and autoplay; a marquee; a row that swipes | [recipes/carousel.ts](recipes/carousel.ts) |
128
+ | Do something every few seconds — a ticker, a poll, a slideshow by hand | [recipes/every-few-seconds.ts](recipes/every-few-seconds.ts) |
129
+ | A marquee that scrolls for ever and stops under the pointer | [recipes/marquee.ts](recipes/marquee.ts) |
130
+ | A row that scrolls sideways, with arrows that hide at its ends | [recipes/scroll-a-row.ts](recipes/scroll-a-row.ts) |
131
+ | A link built from a row: WhatsApp with the product's name, `mailto:` | [recipes/link-with-data.ts](recipes/link-with-data.ts) |
132
+ | React to a click or a submit; a modal; a switch | [recipes/forms-and-modals.ts](recipes/forms-and-modals.ts) |
133
+ | A feature behind a flag | [recipes/feature-flag.ts](recipes/feature-flag.ts) |
134
+ | An element of your own (a plugin), checked | [recipes/plugin.ts](recipes/plugin.ts) |
135
+ | Controls a screen reader and a browser agent can use | [recipes/usable-without-sight.ts](recipes/usable-without-sight.ts) |
136
+ | Tokens, a shared class, "the class plus one thing", tablet and phone at once | [recipes/style-a-page.ts](recipes/style-a-page.ts) |
137
+ | A design in Tailwind classes, kept as classes the builder edits | [recipes/from-tailwind.ts](recipes/from-tailwind.ts) |
138
+ | A page from elsewhere in a frame (`embed`: a map, a player) and a drawing of your own (`svg`) | [recipes/embed-and-svg.ts](recipes/embed-and-svg.ts) |
156
139
 
157
140
  ## References
158
141
 
159
142
  | Read | When |
160
143
  | --- | --- |
161
144
  | [elements-and-styles.md](reference/elements-and-styles.md) | Any element or CSS: factories, fields, classes, states, variants, tokens, fonts, lists, links |
145
+ | [colours-and-motion.md](reference/colours-and-motion.md) | Colours for both themes, tokens, keyframes, and motion that stays smooth (good practices) |
146
+ | [tailwind.md](reference/tailwind.md) | A design written in Tailwind classes: `tw()`, its breakpoints and states, what it refuses |
162
147
  | [layouts.md](reference/layouts.md) | Anything shown on more than one page; menus; reducing duplication of elements and styles |
148
+ | [efficiency.md](reference/efficiency.md) | The same page with fewer elements: the suggestions `authorSpace` makes, and the short way for each long one |
163
149
  | [components.md](reference/components.md) | One block placed many times — a card, a tier, a testimonial: props, slots, binding a row into one, why it is closed |
164
150
  | [data-and-visibility.md](reference/data-and-visibility.md) | Bindings, providers, offline data, loading/empty/error states, live data, caching, showing and hiding, kept state |
165
- | [lists.md](reference/lists.md) | Rendering rows, filtering and sorting them, a detail page for one record |
151
+ | [kept-state.md](reference/kept-state.md) | State that outlives a reload: `keepState`, transient and painted keys |
152
+ | [feature-flags.md](reference/feature-flags.md) | Switching a part of the space on or off — a beta, a rollout, the old version kept until the new one ships |
153
+ | [lists.md](reference/lists.md) | Rendering rows, filtering and sorting them, a detail page for one record, carousels |
154
+ | [typed-sources.md](reference/typed-sources.md) | Data typed by a sample of it: `source()`, typed rows, `twig` for templates |
166
155
  | [validation.md](reference/validation.md) | How `authorSpace` checks, the loop that wastes no attempts, and what it cannot see |
167
156
  | [authoring-errors.md](reference/authoring-errors.md) | What `authorSpace` refuses or warns about, and what to write instead |
168
157
  | [templates.md](reference/templates.md) | Any `{{ … }}` or `{% … %}`: where it runs, naming sources, filters, tests, dates |
169
- | [flows.md](reference/flows.md) | Clicks, submits, page loads, server actions, realtime channels, modals, state |
158
+ | [flows.md](reference/flows.md) | Clicks, submits, page loads, every few seconds, server actions, modals, state |
159
+ | [realtime.md](reference/realtime.md) | Pages that see each other: channels, presence, cursors, a shared board, who may hear a topic |
170
160
  | [plugins.md](reference/plugins.md) | A component of your own: props, binding them, writing state, channels, registering, behaving in the builder |
171
161
  | [structure.md](reference/structure.md) | A space bigger than one screen: files, helpers, naming, keeping it short |
172
162
  | [testing.md](reference/testing.md) | Any test: `inspectPage` (one call, every problem), handles, fixtures, catching a flash from the first frame, shortcuts, counting renders |
173
163
  | [performance.md](reference/performance.md) | A page with many elements, a busy flow, something that feels slow: what renders, what it costs, how to measure it |
174
- | [templates-and-export.md](reference/templates-and-export.md) | Publishing a template; turning an exported JSON — or a server action — into code |
164
+ | [snippets-and-export.md](reference/snippets-and-export.md) | Publishing a snippet; turning an exported JSON — or a server action — into code |
175
165
  | [accessibility.md](reference/accessibility.md) | Icon buttons, fields, images, clickable cards, toggles, headings, landmarks, a canvas — anything a screen reader or a browser agent has to use |
176
166
  | [review-checklist.md](reference/review-checklist.md) | Before you say it is done |
167
+
168
+ **What to read for a kind of task** — the cheatsheet, then only these:
169
+
170
+ - A site — landing, catalogue, blog: elements-and-styles, layouts, components, lists.
171
+ - An app with state and actions: data-and-visibility, templates, flows — realtime when pages see each other.
172
+ - A component of your own: plugins.
173
+ - A refusal or a warning: search authoring-errors.md for its code — never read it whole.
174
+ - Tests: testing.
175
+
176
+ Nothing else needs reading up front.
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Three carousels from one element: slides that change on their own with arrows and dots, a marquee of logos that
3
+ * never ends, and a row of cards a visitor swipes. The slides, the dots and anything else inside read the carousel's
4
+ * source — `carousel_<id>.index`, `.count`, `.item`, `.items` — and the arrows and dots are steps on it.
5
+ */
6
+ import {
7
+ activeWhen,
8
+ bindTemplate,
9
+ button,
10
+ carousel,
11
+ carouselGoTo,
12
+ carouselNext,
13
+ carouselPrevious,
14
+ container,
15
+ heading,
16
+ list,
17
+ onClick,
18
+ styles,
19
+ text
20
+ } from '@plitzi/sdk-authoring';
21
+
22
+ import type { SpaceSpec } from '@plitzi/sdk-authoring';
23
+
24
+ const SLIDES = [
25
+ { id: 'one', title: 'Lamps', lede: 'Warm light for long evenings' },
26
+ { id: 'two', title: 'Paper', lede: 'Notebooks that lie flat' },
27
+ { id: 'three', title: 'Pens', lede: 'Six colours, quick to dry' }
28
+ ];
29
+
30
+ const slide = styles('hero-slide', { padding: '64px 32px', backgroundColor: 'var(--card)', borderRadius: '16px' });
31
+
32
+ const dot = styles('hero-dot', {
33
+ css: { width: 10, height: 10, padding: '0px', borderRadius: '999px', backgroundColor: 'var(--muted)' },
34
+ variants: { active: { backgroundColor: 'var(--primary)' } }
35
+ });
36
+
37
+ const dots = styles('hero-dots', { display: 'flex', gap: 8, margin: '0px', padding: '0px', listStyleType: 'none' });
38
+
39
+ // Several slides at once: the carousel's class says how wide each is and the gap between them.
40
+ const cards = styles('picks-row', { '--plitzi-carousel-slide-width': '240px', '--plitzi-carousel-gap': '16px' });
41
+
42
+ export const recipe: SpaceSpec = {
43
+ name: 'Carousels',
44
+ permanentUrl: 'carousels',
45
+ variables: {
46
+ color: {
47
+ card: { light: '#f4f4f8', dark: '#1c1c22', default: '#f4f4f8' },
48
+ muted: { light: '#c4c4cc', dark: '#3a3a44', default: '#c4c4cc' },
49
+ primary: { light: '#4f46e5', dark: '#818cf8', default: '#4f46e5' }
50
+ }
51
+ },
52
+ pages: [
53
+ {
54
+ id: 'home',
55
+ name: 'Home',
56
+ slug: '',
57
+ body: [
58
+ // One slide at a time, on its own every 5 s — held while the pointer or the keyboard is on it.
59
+ carousel({
60
+ id: 'hero',
61
+ label: 'Featured',
62
+ items: SLIDES,
63
+ autoplay: 5000,
64
+ row: r =>
65
+ container({
66
+ class: slide,
67
+ children: [heading({ subType: 'h2', from: `${r.item}.title` }), text({ from: `${r.item}.lede` })]
68
+ }),
69
+ children: [
70
+ button({ content: '‹', title: 'Previous slide', flows: [[onClick(), carouselPrevious('hero')]] }),
71
+ button({ content: '›', title: 'Next slide', flows: [[onClick(), carouselNext('hero')]] }),
72
+ list({
73
+ id: 'hero-dots',
74
+ class: dots,
75
+ items: 'hero.items',
76
+ row: r =>
77
+ button({
78
+ content: '',
79
+ class: dot,
80
+ bind: [
81
+ bindTemplate('title', r.index, 'Slide {{ source + 1 }}'),
82
+ activeWhen(dot, `{{ ${r.inTemplate.index} == carousel_hero.index }}`)
83
+ ],
84
+ flows: [[onClick(), carouselGoTo('hero', `{{ ${r.inTemplate.index} }}`)]]
85
+ })
86
+ })
87
+ ]
88
+ }),
89
+ // Scrolling past for ever at 40 px a second, still under the pointer.
90
+ carousel({
91
+ id: 'logos',
92
+ label: 'Brands we carry',
93
+ mode: 'marquee',
94
+ speed: 40,
95
+ items: ['Acme', 'Globex', 'Initech', 'Umbrella', 'Hooli'],
96
+ row: r => text({ from: r.item, css: { padding: '0px 24px', fontWeight: 600 } })
97
+ }),
98
+ // A row that swipes on a phone and snaps to each card; the arrows move it a card at a time.
99
+ carousel({
100
+ id: 'picks',
101
+ label: 'Picks',
102
+ mode: 'scroll',
103
+ class: cards,
104
+ items: SLIDES,
105
+ row: r => container({ class: slide, children: [text({ from: `${r.item}.title` })] }),
106
+ children: [
107
+ button({ content: '›', title: 'More picks', flows: [[onClick(), carouselNext('picks')]] }),
108
+ text({ from: 'picks.index', as: '{{ source + 1 }} of {{ carousel_picks.count }}' })
109
+ ]
110
+ })
111
+ ]
112
+ }
113
+ ]
114
+ };