@plitzi/sdk-server 0.37.9 → 0.38.1
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.
- package/CHANGELOG.md +615 -0
- package/README.md +17 -0
- package/dist/adapters/cloudAdapters.d.ts +5 -3
- package/dist/adapters/cloudAdapters.js +120 -34
- package/dist/adapters/space/types.d.ts +3 -0
- package/dist/core/configSeam.d.ts +14 -0
- package/dist/core/configSeam.js +114 -0
- package/dist/core/configSeam.test.d.ts +1 -0
- package/dist/core/createServer.js +11 -5
- package/dist/core/createServer.publicData.test.d.ts +1 -0
- package/dist/core/freePort.d.ts +10 -0
- package/dist/core/freePort.js +36 -0
- package/dist/core/freePort.test.d.ts +1 -0
- package/dist/core/http/stages/images.d.ts +6 -0
- package/dist/core/http/stages/images.js +47 -0
- package/dist/core/http/stages/images.test.d.ts +1 -0
- package/dist/core/server/drain.js +9 -4
- package/dist/core/server/fleet/channel.js +1 -1
- package/dist/core/server/fleet/relay.js +1 -1
- package/dist/core/services/action.js +5 -4
- package/dist/core/services/registry.js +2 -0
- package/dist/helpers/cache/keys.d.ts +23 -10
- package/dist/helpers/cache/keys.js +16 -2
- package/dist/helpers/flagOverrides.d.ts +27 -0
- package/dist/helpers/flagOverrides.js +25 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -1
- package/dist/modules/actions/moduleFor.js +5 -3
- package/dist/modules/actions/runtime/flags.d.ts +16 -0
- package/dist/modules/actions/runtime/flags.js +49 -0
- package/dist/modules/actions/runtime/flags.test.d.ts +1 -0
- package/dist/modules/actions/runtime/kvList.js +1 -1
- package/dist/modules/actions/runtime/runAction.js +2 -0
- package/dist/modules/actions/transport/callHandler.js +8 -0
- package/dist/modules/actions/types.d.ts +14 -1
- package/dist/modules/connectors/engine.js +1 -1
- package/dist/modules/functions/capabilities.js +1 -1
- package/dist/modules/functions/load.js +2 -2
- package/dist/modules/functions/manifest.js +1 -1
- package/dist/modules/functions/runner/messages.js +1 -1
- package/dist/modules/images/allowlist.d.ts +5 -0
- package/dist/modules/images/allowlist.js +18 -0
- package/dist/modules/images/imageProxy.d.ts +36 -0
- package/dist/modules/images/imageProxy.js +273 -0
- package/dist/modules/images/imageProxy.test.d.ts +1 -0
- package/dist/modules/images/transform.d.ts +10 -0
- package/dist/modules/images/transform.js +28 -0
- package/dist/modules/images/transform.test.d.ts +1 -0
- package/dist/modules/rsc/connectorRscData.d.ts +12 -7
- package/dist/modules/rsc/connectorRscData.js +12 -8
- package/dist/modules/rsc/handler.js +18 -4
- package/dist/modules/rsc/publicFileResolver.d.ts +13 -0
- package/dist/modules/rsc/publicFileResolver.js +39 -0
- package/dist/modules/rsc/publicFileResolver.test.d.ts +1 -0
- package/dist/modules/rsc/resolveRscData.d.ts +4 -2
- package/dist/modules/rsc/resolveRscData.js +10 -2
- package/dist/modules/runtime/bundle.js +2 -2
- package/dist/modules/ssr/Component.d.ts +5 -1
- package/dist/modules/ssr/Component.js +3 -1
- package/dist/modules/ssr/prepareRender.js +36 -8
- package/package.json +19 -14
- package/skills/plitzi-authoring/CHEATSHEET.md +135 -0
- package/skills/plitzi-authoring/SKILL.md +71 -71
- package/skills/plitzi-authoring/recipes/carousel.ts +114 -0
- package/skills/plitzi-authoring/recipes/detail-page.ts +70 -0
- package/skills/plitzi-authoring/recipes/embed-and-svg.ts +48 -0
- package/skills/plitzi-authoring/recipes/every-few-seconds.ts +119 -0
- package/skills/plitzi-authoring/recipes/feature-flag.ts +24 -0
- package/skills/plitzi-authoring/recipes/filter-a-list.ts +63 -0
- package/skills/plitzi-authoring/recipes/forms-and-modals.ts +64 -0
- package/skills/plitzi-authoring/recipes/from-tailwind.ts +52 -0
- package/skills/plitzi-authoring/recipes/link-to-a-section.ts +35 -0
- package/skills/plitzi-authoring/recipes/link-with-data.ts +40 -0
- package/skills/plitzi-authoring/recipes/marquee.ts +57 -0
- package/skills/plitzi-authoring/recipes/plugin.ts +53 -0
- package/skills/plitzi-authoring/recipes/scroll-a-row.ts +79 -0
- package/skills/plitzi-authoring/recipes/show-data.ts +73 -0
- package/skills/plitzi-authoring/recipes/style-a-page.ts +45 -0
- package/skills/plitzi-authoring/recipes/typed-data.ts +64 -0
- package/skills/plitzi-authoring/recipes/usable-without-sight.ts +39 -0
- package/skills/plitzi-authoring/reference/accessibility.md +2 -2
- package/skills/plitzi-authoring/reference/authoring-errors.md +195 -53
- package/skills/plitzi-authoring/reference/colours-and-motion.md +63 -0
- package/skills/plitzi-authoring/reference/components.md +9 -1
- package/skills/plitzi-authoring/reference/data-and-visibility.md +29 -29
- package/skills/plitzi-authoring/reference/efficiency.md +53 -0
- package/skills/plitzi-authoring/reference/elements-and-styles.md +39 -24
- package/skills/plitzi-authoring/reference/feature-flags.md +78 -0
- package/skills/plitzi-authoring/reference/flows.md +25 -36
- package/skills/plitzi-authoring/reference/kept-state.md +28 -0
- package/skills/plitzi-authoring/reference/layouts.md +20 -3
- package/skills/plitzi-authoring/reference/lists.md +45 -16
- package/skills/plitzi-authoring/reference/plugins.md +15 -0
- package/skills/plitzi-authoring/reference/realtime.md +35 -0
- package/skills/plitzi-authoring/reference/review-checklist.md +8 -3
- package/skills/plitzi-authoring/reference/{templates-and-export.md → snippets-and-export.md} +9 -8
- package/skills/plitzi-authoring/reference/structure.md +29 -20
- package/skills/plitzi-authoring/reference/tailwind.md +19 -0
- package/skills/plitzi-authoring/reference/templates.md +6 -5
- package/skills/plitzi-authoring/reference/testing.md +11 -0
- package/skills/plitzi-authoring/reference/typed-sources.md +20 -0
- package/skills/plitzi-authoring/reference/validation.md +6 -2
|
@@ -7,6 +7,7 @@ One factory per element, named after it. Attributes and the authoring fields go
|
|
|
7
7
|
```ts
|
|
8
8
|
heading({ content: 'Fieldnotes', subType: 'h2', class: title })
|
|
9
9
|
image({ src: '/fox.jpg', alt: 'A fox', css: { 'aspect-ratio': '3/2' } })
|
|
10
|
+
image({ src: 'https://cdn.shop.com/p.jpg', alt: '…', sizes: '360px', width: 1200, height: 800 }) // resized by the page server
|
|
10
11
|
container({ class: card, children: [ … ] })
|
|
11
12
|
text('Wildlife, close up') // a string is the content
|
|
12
13
|
container([hero, grid]) // an array is the children
|
|
@@ -17,7 +18,7 @@ container([hero, grid]) // an array is the children
|
|
|
17
18
|
| `id` | the ONE name this element answers to — its key in the document, a binding's source, a step's target. Unique across the WHOLE space |
|
|
18
19
|
| `class` | a shared class: a `styles()` declaration or a name from the space's `classes`; a list for several. Exclusive with `css` |
|
|
19
20
|
| `css` | rules of this element's own: one set, or one per breakpoint (`{ desktop, tablet, mobile }`) |
|
|
20
|
-
| `states` | `hover`, `focus-visible`, `hidden`… beside the element's own `css` |
|
|
21
|
+
| `states` | `hover`, `focus-visible`, `current` (a link to the page shown), `hidden`… beside the element's own `css` |
|
|
21
22
|
| `variant` | a variant of the class it wears (when the class declares it) or of its type |
|
|
22
23
|
| `slots` | a class for one of the element's OTHER selectors — a form control's `input` |
|
|
23
24
|
| `bind` | where a value comes from |
|
|
@@ -31,15 +32,32 @@ container([hero, grid]) // an array is the children
|
|
|
31
32
|
Anything else in the object is an **attribute** (`authorSpace` warns `unknown-attribute` for one the element never
|
|
32
33
|
reads).
|
|
33
34
|
|
|
34
|
-
**A link
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
**A link says where by its `href`:** a page id (`link({ href: 'reports' })`, folder included), a path within the space
|
|
36
|
+
— with `{{tokens}}` if it needs them, a row's own slug (`'/games/{{ list_games.item.slug }}'`) — or a full URL
|
|
37
|
+
(`https:`, `mailto:`, `tel:`), passed through untouched. `mode` follows from it; write it only to say otherwise. A link
|
|
38
|
+
that opens another tab (`target: 'blank'`) gets `rel="noopener noreferrer"`. A link is an `<a>`: never put a link inside
|
|
37
39
|
another link — make the card the link, or the button, not both.
|
|
38
40
|
|
|
41
|
+
**A link's words are its `content`** and an icon its `icon`, as a button's (`link({ href: 'pricing', content:
|
|
42
|
+
'Pricing', icon: 'fa-solid fa-arrow-right', iconPlacement: 'after' })`, its class on the `icon` slot) — never a
|
|
43
|
+
`text` or `fontAwesome` inside. The
|
|
44
|
+
link to the page being shown is `aria-current="page"`; its class's `current` state styles it, so one header in a
|
|
45
|
+
layout lights the right item everywhere.
|
|
46
|
+
|
|
47
|
+
**An element's `id` is not its HTML id.** It reaches the DOM as `data-id`. For a section a URL can land on, give it
|
|
48
|
+
an `anchor` (`anchor: 'plans'` → `id="plans"`: lowercase, digits, `-`) and link to it with `hash`:
|
|
49
|
+
`link({ href: 'home', hash: 'plans' })` goes to `/#plans` and scrolls there, waiting for a section that renders once
|
|
50
|
+
its data arrives. One anchor per page, layouts included; never inside a list row or a component, which would repeat
|
|
51
|
+
it — `authorSpace` refuses each, and a `hash` no element on that page carries (`anchor-missing`). Under a fixed
|
|
52
|
+
header, give the section `scroll-margin-top` (the header's height) so it does not land beneath it.
|
|
53
|
+
|
|
39
54
|
**Some types hold no children.** A `heading`, `text`, `paragraph`, `image`, `formControl`… renders its own attributes
|
|
40
55
|
and nothing nested in it — `authorSpace` refuses children there. A heading made of parts (a word in another colour, an
|
|
41
56
|
icon, a badge) is a `container` with the heading's tag: `container({ subType: 'h1', class: title, children: [text('Space
|
|
42
57
|
'), text('Gamer', { class: accent })] })` — give the texts `display: inline` in their class.
|
|
58
|
+
A piece that sits in a line of text without breaking it — a dot before a title, a badge — is
|
|
59
|
+
`container({ subType: 'span', … })`: inline by default. It holds words and inline pieces, never a heading or a
|
|
60
|
+
paragraph (`span-holds-block`).
|
|
43
61
|
|
|
44
62
|
**A `button` shows its `content` AND its children.** `content` defaults to "Button": a button whose words are children
|
|
45
63
|
says `content: ''` (`default-content-beside-children` warns otherwise). Its accessible name is everything it shows.
|
|
@@ -69,14 +87,17 @@ node --input-type=module -e "import * as a from '@plitzi/sdk-authoring'; const t
|
|
|
69
87
|
|
|
70
88
|
## CSS
|
|
71
89
|
|
|
72
|
-
Write CSS as you write CSS, shorthands included — they are expanded to the longhands Plitzi's editor reads.
|
|
73
|
-
|
|
90
|
+
Write CSS as you write CSS, shorthands included — they are expanded to the longhands Plitzi's editor reads. Keys in
|
|
91
|
+
kebab-case or camelCase (`paddingTop`, as a React style object), and a bare number on a length is pixels (`gap: 16`;
|
|
92
|
+
`fontWeight: 800`, `opacity: 0.5` stay numbers). A property outside the vocabulary is refused.
|
|
74
93
|
|
|
75
94
|
- `column(gap, extra?)`, `row(gap, extra?)`, `grid(columns, gap, extra?)` for the three layouts every space repeats.
|
|
76
95
|
- **Breakpoints are ranges, not a cascade.** `tablet` is 48–64rem, `mobile` below 48rem, and each inherits only from
|
|
77
96
|
`desktop` — a rule for `tablet` never reaches a phone (`authorSpace` warns `tablet-rule-skips-mobile`). A rule for
|
|
78
97
|
everything narrower than a desktop is `compact`, written to both: `css: { desktop: { … }, compact: { 'grid-template-columns':
|
|
79
98
|
'1fr' }, mobile: { gap: '8px' } }` — what `tablet` or `mobile` say for themselves wins.
|
|
99
|
+
- **One property that changes, in place:** `{ fontSize: { desktop: '24px', mobile: '18px' }, fontWeight: 800 }` — the
|
|
100
|
+
rest of the rule stays desktop-only, without splitting it by breakpoint. Both forms mix.
|
|
80
101
|
- **`backdrop-filter`, `filter` and `transform` make an element the containing block of its `position: fixed`
|
|
81
102
|
children.** A glass header with a fixed phone dock inside puts the dock at the top of the header, not the screen.
|
|
82
103
|
- **Fonts are declared.** A `font-family` loads only if the space lists the face in `fonts`
|
|
@@ -125,6 +146,9 @@ container({ class: card, children: [ … ] });
|
|
|
125
146
|
What is inside follows its container with `ancestors: { [panel.name]: { states: { hidden: { … } } } }` — a
|
|
126
147
|
different `transition-delay` per item staggers them. A container whose children mount only while it shows
|
|
127
148
|
(`loadStrategy: 'visible'`) empties before it can leave: use `'lazy'` on one that animates out.
|
|
149
|
+
- **The navigation of a site goes in a layout, once.** A link to the page being shown marks itself, and its class
|
|
150
|
+
dresses it with `states: { current: { … } }` — never a class picked per page, which forces a copy of the header into
|
|
151
|
+
every page.
|
|
128
152
|
- **`:hover` is part of the selector, not a rule in `customCss`.** `customCss` is for what no class can say:
|
|
129
153
|
keyframes, pseudo-elements, a rule across two unrelated elements, the inside of a third-party widget.
|
|
130
154
|
- Per element TYPE defaults go in `elements: { heading: { base, states, variants, slots } }` — a whole type at once.
|
|
@@ -137,25 +161,16 @@ container({ class: card, children: [ … ] });
|
|
|
137
161
|
|
|
138
162
|
**A variant chosen by the data:** `variantFrom(pill, 'jobs.item.status')`, or with a template when the data does not
|
|
139
163
|
already speak in variant names — `variantFrom(pill, 'runs.item.status', { template: "{{ source == 'completed' ?
|
|
140
|
-
'ok' : 'failed' }}" })`. The binding's key names the CLASS; never write it by hand.
|
|
141
|
-
`
|
|
164
|
+
'ok' : 'failed' }}" })`. The binding's key names the CLASS; never write it by hand. A variant while a condition holds
|
|
165
|
+
— the dot of the slide on screen — is `activeWhen(dot, '{{ list_dots.index == state.slide }}')` (`active`, else
|
|
166
|
+
`idle`); for "the current page", `activeOn` in [layouts.md](layouts.md).
|
|
142
167
|
|
|
143
|
-
##
|
|
168
|
+
## From Tailwind classes
|
|
144
169
|
|
|
145
|
-
|
|
170
|
+
A design written in Tailwind classes becomes classes the builder edits with `styles('pill', tw('…'))` — see
|
|
171
|
+
[tailwind.md](tailwind.md).
|
|
146
172
|
|
|
147
|
-
|
|
148
|
-
variables: {
|
|
149
|
-
color: {
|
|
150
|
-
foreground: { light: '#0c0c14', dark: '#ededf3', default: '#0c0c14' },
|
|
151
|
-
card: { light: '#ffffff', dark: '#101019', default: '#ffffff' },
|
|
152
|
-
primary: { light: '#5b3df5', dark: '#6e52f7', default: '#5b3df5' }
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
```
|
|
173
|
+
## Colours, themes and motion
|
|
156
174
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
the text on them is fixed too. Theme-following text on a fixed background is the bug.
|
|
160
|
-
- A tint of a token is `color-mix(in srgb, var(--primary) 12%, transparent)`, which follows the theme for free.
|
|
161
|
-
- `themeToggle()` is the switch; `theme.resolved` (`light`/`dark`) is a global source for anything else that needs it.
|
|
175
|
+
Every colour is a token with a light and a dark value, read as `var(--name)` (or `tokens(variables)`); keyframes go in
|
|
176
|
+
`customCss` — see [colours-and-motion.md](colours-and-motion.md).
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Feature flags
|
|
2
|
+
|
|
3
|
+
A flag turns a part of the space on or off without editing it: a feature still being built, a version for beta users,
|
|
4
|
+
the old checkout kept until the new one ships. It is **not a visibility**. A hidden element is still rendered — its
|
|
5
|
+
markup is in the page, its subtree mounted. An element gated off by a flag is not rendered at all: not on the server,
|
|
6
|
+
not in the browser, not its children, and its server data is not resolved. Use `visible` for what the page's own state
|
|
7
|
+
shows and hides; use a flag for what a person decides is switched on.
|
|
8
|
+
|
|
9
|
+
**A flag is not a secret.** The space's document — gated elements included — still reaches the browser, as it always
|
|
10
|
+
does, so the other side can appear without a reload when a flag changes. Content a visitor must never receive belongs
|
|
11
|
+
behind a server action or a connector.
|
|
12
|
+
|
|
13
|
+
## Declaring one
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
authorSpace({
|
|
17
|
+
name: 'Shop',
|
|
18
|
+
permanentUrl: 'shop',
|
|
19
|
+
flags: {
|
|
20
|
+
newCheckout: {
|
|
21
|
+
description: 'The one-step checkout',
|
|
22
|
+
value: false, // the answer when no rule matches
|
|
23
|
+
rules: [ // read top to bottom; the first that matches decides
|
|
24
|
+
{ when: { combinator: 'and', rules: [{ field: 'environment', operator: '=', value: 'staging' }] }, value: true },
|
|
25
|
+
{ when: { combinator: 'and', rules: [{ field: 'user.roles', operator: 'contains', value: 'beta' }] }, value: true }
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
pages: [ … ]
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A rule's `when` sees `environment` (`main`, `development`, `staging`, `production`), `hostname`, `routeParams.<name>`,
|
|
34
|
+
`queryParams.<name>` and the visitor: `user.authenticated`, `user.email`, `user.username`, `user.roles` (a list — use
|
|
35
|
+
`contains`). A group with no rules is skipped, never read as "always" (`flag-rule-empty` warns).
|
|
36
|
+
|
|
37
|
+
The name is what a template reads, so it is a template key: letters, digits and `_`, starting with a letter or `_`.
|
|
38
|
+
|
|
39
|
+
## Gating an element or a page
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
container({ id: 'checkout', flag: 'newCheckout', children: [ … ] }) // only while the flag is on
|
|
43
|
+
container({ id: 'legacyCheckout', flag: '!newCheckout', children: [ … ] }) // only while it is off
|
|
44
|
+
{ name: 'Labs', slug: 'labs', flag: 'labs', body: [ … ] } // a page: 404 while the flag is off
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Put the two sides of a rollout next to each other — the new one on `flag`, the old one on `!flag` — so exactly one is
|
|
48
|
+
there whatever the flag says. A gate on a flag the space does not declare is an error (`flag-undeclared`): an
|
|
49
|
+
undeclared flag is off, so the element would never render.
|
|
50
|
+
|
|
51
|
+
## Reading one
|
|
52
|
+
|
|
53
|
+
`flags` is a global source like `state` or `theme`: `{{ flags.newCheckout }}` is `true` or `false` in a binding, a
|
|
54
|
+
step's `when`, a computed value — and in a server action's steps, resolved on the server for the visitor who started
|
|
55
|
+
it. A plugin reads one with `useFlag('newCheckout')` from `@plitzi/plitzi-sdk`. Reading a flag the space does not
|
|
56
|
+
declare is an error (`flag-unknown`).
|
|
57
|
+
|
|
58
|
+
Prefer a gate to `visible: 'flags.x'`: a visibility bound to a flag still renders the hidden half and ships it in the
|
|
59
|
+
HTML. Reach for the source only for what a gate cannot say — a label that changes, a step that branches.
|
|
60
|
+
|
|
61
|
+
## Who decides
|
|
62
|
+
|
|
63
|
+
The space's declaration answers first. Above it, in order, each may override a flag — only one the space declares:
|
|
64
|
+
|
|
65
|
+
1. the server rendering the page — `createServer({ flags })` in a self-hosted deployment;
|
|
66
|
+
2. the SDK embedding the space — `<PlitziSdk flags={{ newCheckout: true }} />` or `render(…, { flags })`;
|
|
67
|
+
3. a tester with the dev tools — the **Flags** tab forces one for that browser, only where debugging is authorized.
|
|
68
|
+
|
|
69
|
+
The draft (`main`) applies its flags as they are. Flags are **not served from a snapshot**: each environment has one set,
|
|
70
|
+
shared by every snapshot it serves. Publishing a snapshot sends the draft's flags with it; the builder's *Publish flags*
|
|
71
|
+
sends only them, with no new snapshot; rolling a snapshot back keeps the environment's flags. Each snapshot keeps a copy
|
|
72
|
+
of the flags it was published with, used only when the environment's own cannot be read (after a Redis copy of them),
|
|
73
|
+
and a self-hosted server keeps the last ones it fetched — so a published site keeps its flags with Plitzi unreachable.
|
|
74
|
+
|
|
75
|
+
## When the feature ships
|
|
76
|
+
|
|
77
|
+
Remove the gate from what stays, delete what it replaced, and remove the flag. A declared flag nothing reads is a
|
|
78
|
+
warning (`flag-unused`): it is a switch nobody can see the effect of.
|
|
@@ -16,7 +16,9 @@ button({
|
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
A flow is a list: a trigger, then steps in order. "Only if" is on the step (`when`, `whenSucceeded`, `whenFailed`),
|
|
19
|
-
never a nested tree.
|
|
19
|
+
never a nested tree. A step that belongs to a feature still behind a [feature flag](feature-flags.md) reads it like any
|
|
20
|
+
source — `when({ field: 'flags.newCheckout', operator: '=', value: true }, …)` — and a server action's steps see the
|
|
21
|
+
same flags, decided on the server. Use the step builders — they fill in where a step runs and what it takes:
|
|
20
22
|
|
|
21
23
|
- **Where it runs.** A global callback registers under its source MODULE (`state`, `auth`, `actions`), an element
|
|
22
24
|
callback under an element's id, a utility under nothing. Naming either half wrong is a control that does nothing,
|
|
@@ -89,6 +91,22 @@ on the page. A press while typing in a field is the field's, unless Ctrl/⌘/Alt
|
|
|
89
91
|
`{{ <step>.key }}` is the key pressed (`shift+f`). A shortcut that cannot fire (`'ctrl+shift'`, `'arrowupp'`) is refused
|
|
90
92
|
where it is written.
|
|
91
93
|
|
|
94
|
+
**Something every few seconds** — an autoplay, a clock — is `onInterval(ms)`, a trigger every element has:
|
|
95
|
+
`[[onInterval(5000), cycleState({ key: 'slide', length: 4 })]]`. It
|
|
96
|
+
ticks while the element is mounted and the tab is in view, never in the builder outside preview, at least every 250 ms
|
|
97
|
+
(`trigger-interval` otherwise). To pause it, give its steps a condition — a state that `on('onMouseEnter')` sets and
|
|
98
|
+
`on('onMouseLeave')` clears. `{{ <step>.count }}` is how many times it has ticked. No plugin needed.
|
|
99
|
+
|
|
100
|
+
**A number that goes round, or stops:** `cycleState({ key: 'slide', length: 4 })` is "next" — after the last the first
|
|
101
|
+
— and `by: -1` "previous"; `stepState({ key: 'shown', by: 40, max: 'apiContainer_site.data.total' })` adds and stops
|
|
102
|
+
at the bound. Each is the `setState` it stands for, with the arithmetic written once.
|
|
103
|
+
|
|
104
|
+
**Scrolling** is a step on the element whose box scrolls (`overflow-x: auto` on a row of cards): `scrollBy('cards',
|
|
105
|
+
{ x: '80%' })` moves it by most of what it shows (`'-80%'` back, `'240'` pixels), `scrollTo('cards', { x: 'end' })`
|
|
106
|
+
to an end or a place, and `scrollIntoView('answer', { block: 'center' })` brings an element into view. `onScroll()`
|
|
107
|
+
fires as the box moves and once on mount, with `{ x, y, atStart, atEnd }` — what an arrow that hides at the end reads
|
|
108
|
+
(recipes/scroll-a-row.ts). A link to a section needs none of this: an `anchor` and a link's `hash`.
|
|
109
|
+
|
|
92
110
|
**A form's flow goes on the `form`**, which hands its submit over with `managedByInteractions: true`
|
|
93
111
|
(`FORM_SUBMIT_UNMANAGED` otherwise — the browser submits it natively and `onSubmit` never fires):
|
|
94
112
|
|
|
@@ -112,8 +130,11 @@ without a word.
|
|
|
112
130
|
## Notifications
|
|
113
131
|
|
|
114
132
|
`addNotification({ content: 'Saved', appearance: 'success' })` — `appearance` is `success`, `danger`, `warning` or
|
|
115
|
-
`info`. They follow the page's theme
|
|
116
|
-
`
|
|
133
|
+
`info`. They follow the page's theme; how they look is the space's `notifications` — colours (`background`, `text`,
|
|
134
|
+
`success`, `danger`, `warning`, `info`) and shape (`radius`, `font`, `fontSize`, `border`, `shadow`, `padding`):
|
|
135
|
+
`notifications: { background: 'var(--card)', text: 'var(--foreground)', success: 'var(--accent)', radius: '12px',
|
|
136
|
+
font: 'var(--font-sans)', border: '1px solid var(--border)', shadow: 'var(--shadow-lg)' }`. The toast carries no class
|
|
137
|
+
of the space's, so this — not a `.Toastify__toast` rule in `customCss` — is where it is dressed.
|
|
117
138
|
|
|
118
139
|
## Modals, dropdowns, tabs
|
|
119
140
|
|
|
@@ -126,39 +147,7 @@ without a word.
|
|
|
126
147
|
|
|
127
148
|
## Realtime channels
|
|
128
149
|
|
|
129
|
-
Pages that see each other — cursors, presence, a shared board —
|
|
130
|
-
then subscribe with a `channel` element; a topic no pattern matches is refused, naming the patterns:
|
|
131
|
-
|
|
132
|
-
```ts
|
|
133
|
-
channels: {
|
|
134
|
-
'board:{id}': { access: { mode: 'public' }, publish: 'server' }, // only `realtime.publish` speaks
|
|
135
|
-
'room:{id}': { access: { mode: 'public' }, presence: true } // pages speak directly
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
channel({ id: 'room', topic: 'room:{{ id }}', keep: 0, bind: { presence: 'computed.me' }, children: [...] })
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
Its descendants bind `room.members`, `room.connected`, `room.last`; flows use `on('onMessage')` (`type`, `data`,
|
|
142
|
-
`from`), `onJoin`/`onLeave` (`from`, `user` and the `state` the member announced — `onJoin` only for who came after
|
|
143
|
-
this page), `publishOn('room', 'reaction', data)` and `announceOn('room', state)`. State everyone must
|
|
144
|
-
agree on goes through a server action whose last step is `realtime.publish` — validated and saved first, announced
|
|
145
|
-
after. `docs/en/realtime.md` is the whole of it.
|
|
146
|
-
|
|
147
|
-
A topic only some visitors may hear — a room behind a password, a customer's order — goes on a channel declared
|
|
148
|
-
`grant: true`. The action that decides they may be there ends with `realtime.grant` and answers the grant; the
|
|
149
|
-
element binds it, or the server refuses the topic every time (`channel-grant`). `realtime.revoke { topic, grant }`
|
|
150
|
-
takes one back — or every grant for the topic, naming none — and lets go whoever is on it with it:
|
|
151
|
-
|
|
152
|
-
```ts
|
|
153
|
-
channels: { 'order:{id}': { access: { mode: 'session' }, grant: true } }
|
|
154
|
-
|
|
155
|
-
// steps: [ …decide…, { id: 'let', task: 'realtime.grant', params: { topic: 'order:{{ input.order }}' } } ]
|
|
156
|
-
channel({ id: 'order', topic: 'order:{{ id }}', bind: { grant: 'state.follow.grant' }, children: [...] })
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
Two people writing the same thing at once: `kv.setIf` writes only if the value is still the one the flow read (empty
|
|
160
|
-
`expected` = only if nothing is there yet — claim a seat, a username), `list.put`/`list.range` keep an ordered list
|
|
161
|
-
(latest first, a leaderboard), and `flow.rateLimit` first in a public action that writes.
|
|
150
|
+
Pages that see each other — cursors, presence, a shared board — talk over a channel: see [realtime](realtime.md).
|
|
162
151
|
|
|
163
152
|
## Lists as state
|
|
164
153
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# State that outlives a reload
|
|
2
|
+
|
|
3
|
+
`render(…, { state })` seeds `runtime.state` when the page starts — the way a host hands a space what it already knows.
|
|
4
|
+
`settings: { keepState: true, stateStorage: 'localStorage' }` keeps `runtime.state` across reloads, filed under
|
|
5
|
+
whoever is signed in (a guest's state is the browser's; another account never sees it). Keep only what a person would
|
|
6
|
+
expect back — favourites, a chosen theme. Everything else goes in `transientState`, which is never written and never
|
|
7
|
+
brought back:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
settings: { keepState: true, transientState: ['filter', 'tourStep', 'panelOpen'] }
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Do not reset kept state from `onPageLoad` instead: what was kept is restored late — after hydration, once auth knows
|
|
14
|
+
who this is — and lands in the middle of that flow, so half of it is undone. A filter restored on the next visit is a
|
|
15
|
+
page that looks broken. Keeping state is the SPACE's setting; a page does not take `keepState`.
|
|
16
|
+
|
|
17
|
+
That lateness shows. Kept state is restored after the server's first paint, so anything kept that changes what is
|
|
18
|
+
DRAWN — the tool a toolbar shows as last picked, a name in an avatar, a panel left off — is drawn with its default and
|
|
19
|
+
then swapped. List those keys in `paintedState`: they are kept in a cookie too, the server renders with them, and the
|
|
20
|
+
page starts from the same values.
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
settings: { keepState: true, paintedState: ['shapesPick', 'name', 'color'], transientState: ['panelOpen'] }
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Only what the first paint shows, and small values: the cookie travels with every request and holds a few kilobytes
|
|
27
|
+
(over that, the page falls back to the defaults and the dev tools say so). Never a secret — a key, a token — and never
|
|
28
|
+
a key that is also in `transientState` (`authorSpace` refuses it).
|
|
@@ -36,6 +36,13 @@ pages: [{ name: 'Quickstart', slug: 'quickstart', folder: 'docs', layout: { id:
|
|
|
36
36
|
sidebar) inside the application shell. The page resolves the chain from the outside in.
|
|
37
37
|
- **A provider in a layout serves every page** — the plan in a sidebar, a star count in a top bar. A server provider
|
|
38
38
|
(`runtime: 'server'`) in a layout is resolved with the page like one in the page.
|
|
39
|
+
- **The layout is a box between the page and its shell.** The page's own class styles the page's root, which holds the
|
|
40
|
+
layout, which holds your shell — so a page that is a column stretching its content to the window's height
|
|
41
|
+
(`min-height: 100vh` with a `flex-grow: 1` child) no longer reaches the shell: the footer lifts off the bottom. Give
|
|
42
|
+
the layout `css: { display: 'contents' }` and the chain is what it was. A slot that only marks where the page goes,
|
|
43
|
+
inside a parent that lays its children out, takes the same.
|
|
44
|
+
- **The slot can be a role, not only a place.** A split screen's form half is the slot itself (`class: formPane`), so
|
|
45
|
+
each page is just its card.
|
|
39
46
|
- **Ids are shared** between a layout and all its pages. Name layout elements after the layout (`docs-topbar`), and
|
|
40
47
|
let pages prefix theirs with the page id.
|
|
41
48
|
|
|
@@ -90,9 +97,19 @@ const navLink = styles('nav-link', {
|
|
|
90
97
|
link({ href: 'spaces', class: navLink, bind: [activeOn(navLink, ['spaces', 'space-record'])] });
|
|
91
98
|
```
|
|
92
99
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
100
|
+
A link to ONE page needs nothing of this: it marks itself — `aria-current="page"`, which a screen reader announces — and
|
|
101
|
+
its class dresses it with the `current` state:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
const navLink = styles('nav-link', { css: { color: 'var(--muted)' }, states: { current: { color: 'var(--foreground)' } } });
|
|
105
|
+
|
|
106
|
+
link({ href: '/pricing', mode: 'internal', class: navLink });
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`activeOn(class, pageIds, { variant?, slot? })` is for an entry lit on SEVERAL pages — a section and the pages under it,
|
|
110
|
+
a journal and its articles: it binds the class's variant to `navigation.currentPageId`, the `active` variant on those
|
|
111
|
+
pages and `idle` everywhere else. Do not write the binding by hand with a `when` rule per page; that is the long form
|
|
112
|
+
this replaces.
|
|
96
113
|
|
|
97
114
|
## Pages from a factory
|
|
98
115
|
|
|
@@ -7,36 +7,44 @@ its children once as a plain `<ul>` and never looks at `items`, so a list with i
|
|
|
7
7
|
list bound to its records (see the provider in data-and-visibility).
|
|
8
8
|
|
|
9
9
|
```ts
|
|
10
|
-
list({
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class: gameGrid,
|
|
14
|
-
bind: { items: 'catalog.data.games' }, // an array from the provider around it
|
|
15
|
-
children: [gameCard()] // rendered once per game
|
|
16
|
-
})
|
|
10
|
+
list({ id: 'games', class: gameGrid, items: 'catalog.data.games', row: 'game-card' }) // a component per game
|
|
11
|
+
list({ id: 'games', items: 'catalog.data.games', row: r => text({ from: `${r.item}.title` }) }) // or a tree of its own
|
|
12
|
+
list({ id: 'plans', items: ['Free', 'Pro'], children: [text({ from: 'plans.item' })] }) // fixed items
|
|
17
13
|
```
|
|
18
14
|
|
|
19
|
-
|
|
15
|
+
`items` makes a list controlled: an array of its own, or the source its rows come from. `row` is what it renders once
|
|
16
|
+
per item — a component's id (placed with the row bound to its `item` prop, or its only prop), or a function handed the
|
|
17
|
+
row's names: `r.item` and `r.index` for a binding or `from`, `r.inTemplate.item` for a template
|
|
18
|
+
(`` `/games/{{ ${r.inTemplate.item}.slug }}` ``). A function needs the list's `id`, which names those sources. With
|
|
19
|
+
`items` a typed source's path (`items: site.data.games`, see typed-sources.md), the row is typed too:
|
|
20
|
+
`row: g => text({ from: g.item.title })`, and a field the sample's games lack is a type error.
|
|
21
|
+
|
|
22
|
+
- Each row publishes **`list_<id>.item`** (the row) and **`list_<id>.index`** (its position, a number from 0 —
|
|
23
|
+
`index + 1` counts, and `==` compares it with text or a number alike). Inside the row,
|
|
20
24
|
bindings read the short form (`'games.item.title'`), templates and attribute tokens the full one
|
|
21
25
|
(`{{ list_games.item.slug }}`).
|
|
22
26
|
- A **nested list** sees the outer row: inside `list_features`, `list_games.item` is still the game.
|
|
23
27
|
- The list is a `<ul>` and its `class` styles that root; each child is rendered straight into it, with no wrapper.
|
|
24
28
|
A card grid needs `{ margin: '0px', padding: '0px', 'list-style-type': 'none' }` in its class.
|
|
25
|
-
- Fixed data is `items: [ … ]` on the list itself — no provider needed.
|
|
29
|
+
- Fixed data is `items: [ … ]` on the list itself — no provider needed. Worth it when the rows ARE data — written from
|
|
30
|
+
an array, or rows that come and go (`repeated-shape` points at sibling copies). Three cards a person rewords on the
|
|
31
|
+
canvas are easier to edit as three cards than as a JSON array.
|
|
26
32
|
- Everything inside a row is `repeated` in the handles: a test addresses one copy with `.first()` / `.nth()`.
|
|
33
|
+
- **A row is its item's by its `id`** when every item has a different one, else by its position: filtered or
|
|
34
|
+
reordered, a row's state (an open detail, a field's text) follows its item. Items named by another field say so —
|
|
35
|
+
`itemKey: 'slug'` (`list-item-key-missing` warns when fixed items lack it). A row whose item changes mounts again,
|
|
36
|
+
so a one-row list showing "the current slide" replays the slide's entrance animation.
|
|
27
37
|
|
|
28
38
|
## Filtering and sorting
|
|
29
39
|
|
|
30
|
-
|
|
40
|
+
Feed the list through a template — `from` with `as` hands a list its template's **value**:
|
|
31
41
|
|
|
32
42
|
```ts
|
|
33
43
|
list({
|
|
34
44
|
id: 'shown',
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
{ returns: 'value' })],
|
|
39
|
-
children: [gameCard()]
|
|
45
|
+
from: 'catalog.data.games',
|
|
46
|
+
as: "{{ source|filter(g => (state.genre ?? '') == '' or g.genre == state.genre)|sort((a, b) => b.score - a.score) }}",
|
|
47
|
+
row: 'game-card'
|
|
40
48
|
})
|
|
41
49
|
```
|
|
42
50
|
|
|
@@ -71,4 +79,25 @@ apiContainer({ id: 'catalog', query: '/data/games.json', cache: true, children:
|
|
|
71
79
|
```
|
|
72
80
|
|
|
73
81
|
A link to it from a card is an attribute token read inside the card's row:
|
|
74
|
-
`link({
|
|
82
|
+
`link({ href: '/games/{{ list_games.item.slug }}' })`.
|
|
83
|
+
|
|
84
|
+
## Carousels
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
carousel({ id: 'hero', label: 'Featured', items: 'site.data.slides', autoplay: 5000, row: 'slide-card', children: [
|
|
88
|
+
button({ content: '›', title: 'Next slide', flows: [[onClick(), carouselNext('hero')]] })
|
|
89
|
+
] })
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
A `carousel` takes `items` and a `row` as a list does, and writes the row into a `carouselTrack`; its other children are
|
|
93
|
+
its controls, which read `carousel_hero.index`, `.count`, `.item` and `.items` (`list({ id: 'dots', items: 'hero.items',
|
|
94
|
+
… })` for dots, `activeWhen(dot, '{{ list_dots.index == carousel_hero.index }}')` for the current one). Steps:
|
|
95
|
+
`carouselNext`, `carouselPrevious`, `carouselGoTo(id, index)`, `carouselPlay`, `carouselPause`; it fires `onChange`.
|
|
96
|
+
|
|
97
|
+
- `mode: 'slide'` (one at a time, `transition` `slide`/`fade`/`none`, back enters from the left), `'marquee'` (`speed` px/s,
|
|
98
|
+
no seam), `'scroll'` (a row that snaps and swipes; next moves a slide). Several slides at once: the carousel's class
|
|
99
|
+
sets `--plitzi-carousel-slide-width` and `--plitzi-carousel-gap`.
|
|
100
|
+
- Autoplay holds still under the pointer (`pauseOnHover`), with keyboard focus inside, in a hidden tab and for reduced
|
|
101
|
+
motion. In the builder it stands on its first slide. `label` names it for a screen reader.
|
|
102
|
+
|
|
103
|
+
See recipes/carousel.ts.
|
|
@@ -92,6 +92,21 @@ Take the topic as a prop (`bindTemplate('roomTopic', 'board.id', 'room:{{ source
|
|
|
92
92
|
channels, and trust a message you act on only when `message.from === 'server'`. The page has one connection and is one
|
|
93
93
|
member per topic, however many elements and plugins listen.
|
|
94
94
|
|
|
95
|
+
## Feature flags
|
|
96
|
+
|
|
97
|
+
A plugin that ships a feature behind one of the space's [feature flags](feature-flags.md) asks with `useFlag` — the
|
|
98
|
+
answer every layer gave (the space's rules, the server, the SDK, a tester forcing it), and it renders again when it
|
|
99
|
+
changes:
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
import { useFlag } from '@plitzi/plitzi-sdk';
|
|
103
|
+
|
|
104
|
+
const newChart = useFlag('newChart'); // false for a flag the space does not declare
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Gating the whole plugin needs no code: give its element `flag: 'newChart'`. `useFlag` is for a plugin that changes
|
|
108
|
+
inside — a new mode, an extra panel.
|
|
109
|
+
|
|
95
110
|
## Registering
|
|
96
111
|
|
|
97
112
|
A project `plitzi create` wrote registers every folder of `src/plugins` by itself, under the folder's name in
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Realtime channels
|
|
2
|
+
|
|
3
|
+
Pages that see each other — cursors, presence, a shared board — need a channel. Declare its topic pattern on the space,
|
|
4
|
+
then subscribe with a `channel` element; a topic no pattern matches is refused, naming the patterns:
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
channels: {
|
|
8
|
+
'board:{id}': { access: { mode: 'public' }, publish: 'server' }, // only `realtime.publish` speaks
|
|
9
|
+
'room:{id}': { access: { mode: 'public' }, presence: true } // pages speak directly
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
channel({ id: 'room', topic: 'room:{{ id }}', keep: 0, bind: { presence: 'computed.me' }, children: [...] })
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Its descendants bind `room.members`, `room.connected`, `room.last`; flows use `on('onMessage')` (`type`, `data`,
|
|
16
|
+
`from`), `onJoin`/`onLeave` (`from`, `user` and the `state` the member announced — `onJoin` only for who came after
|
|
17
|
+
this page), `publishOn('room', 'reaction', data)` and `announceOn('room', state)`. State everyone must
|
|
18
|
+
agree on goes through a server action whose last step is `realtime.publish` — validated and saved first, announced
|
|
19
|
+
after.
|
|
20
|
+
|
|
21
|
+
A topic only some visitors may hear — a room behind a password, a customer's order — goes on a channel declared
|
|
22
|
+
`grant: true`. The action that decides they may be there ends with `realtime.grant` and answers the grant; the
|
|
23
|
+
element binds it, or the server refuses the topic every time (`channel-grant`). `realtime.revoke { topic, grant }`
|
|
24
|
+
takes one back — or every grant for the topic, naming none — and lets go whoever is on it with it:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
channels: { 'order:{id}': { access: { mode: 'session' }, grant: true } }
|
|
28
|
+
|
|
29
|
+
// steps: [ …decide…, { id: 'let', task: 'realtime.grant', params: { topic: 'order:{{ input.order }}' } } ]
|
|
30
|
+
channel({ id: 'order', topic: 'order:{{ id }}', bind: { grant: 'state.follow.grant' }, children: [...] })
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Two people writing the same thing at once: `kv.setIf` writes only if the value is still the one the flow read (empty
|
|
34
|
+
`expected` = only if nothing is there yet — claim a seat, a username), `list.put`/`list.range` keep an ordered list
|
|
35
|
+
(latest first, a leaderboard), and `flow.rateLimit` first in a public action that writes.
|
|
@@ -6,6 +6,8 @@ of it; each line is something that has shipped broken before.
|
|
|
6
6
|
## It renders, and says so
|
|
7
7
|
|
|
8
8
|
- [ ] `authorSpace` returns **zero warnings**. Each one is written code that will not do what it says.
|
|
9
|
+
- [ ] Every **suggestion** is taken, or left for a reason you can say (the copies are about to diverge; three cards a
|
|
10
|
+
person rewords on the canvas). The ones your change opened up first — [efficiency.md](efficiency.md).
|
|
9
11
|
- [ ] No console errors on any page you touched.
|
|
10
12
|
|
|
11
13
|
## Nothing flashes, nothing jumps
|
|
@@ -15,6 +17,8 @@ of it; each line is something that has shipped broken before.
|
|
|
15
17
|
- [ ] Nothing that is on screen by default disappears because a flag has not been set yet (a sidebar until it is
|
|
16
18
|
folded). Checked on a fresh visit, with no state kept from before.
|
|
17
19
|
- [ ] Empty states show only when the answer arrived and is empty — never while loading.
|
|
20
|
+
- [ ] A feature still being built is gated by a [feature flag](feature-flags.md) (`flag: 'x'` / `'!x'`), not hidden
|
|
21
|
+
with `visible` — and checked with the flag on AND off. A flag whose feature shipped is removed, gate and all.
|
|
18
22
|
- [ ] No hidden element leaves a hole: the element itself is hidden, not a wrapper around it.
|
|
19
23
|
- [ ] Nothing shifts when the data lands: a loading area keeps its size, numbers use tabular figures.
|
|
20
24
|
|
|
@@ -23,7 +27,8 @@ of it; each line is something that has shipped broken before.
|
|
|
23
27
|
- [ ] Desktop, tablet and mobile — and nothing scrolls sideways on a phone. A rule for tablets and phones alike is
|
|
24
28
|
written under `compact`.
|
|
25
29
|
- [ ] Light AND dark. Every colour is a token with both values; no theme-following text on a fixed background.
|
|
26
|
-
- [ ] Focus is visible on everything clickable (`focus-visible`)
|
|
30
|
+
- [ ] Focus is visible on everything clickable (`focus-visible`). Animations and transitions stop by themselves for
|
|
31
|
+
`prefers-reduced-motion`; a hover that moves something is turned off there too (`transform: none`).
|
|
27
32
|
|
|
28
33
|
## Usable without sight
|
|
29
34
|
|
|
@@ -44,8 +49,8 @@ Screen readers and browser agents (Claude in Chrome) work a page through its acc
|
|
|
44
49
|
- [ ] Chrome shared by pages is a layout; the current menu entry comes from `activeOn`, not per-page styling.
|
|
45
50
|
- [ ] A look used twice is a class; a tree used twice is a function or a `map` over data; a list of pages, links or
|
|
46
51
|
plans is ONE array everything reads.
|
|
47
|
-
- [ ] No positional ids (`container-45`) in what you wrote; every referenced element is named,
|
|
48
|
-
|
|
52
|
+
- [ ] No positional ids (`container-45`) in what you wrote; every referenced element is named, and a helper that
|
|
53
|
+
runs more than once builds inside `scope()`.
|
|
49
54
|
|
|
50
55
|
## True, and in the right place
|
|
51
56
|
|
package/skills/plitzi-authoring/reference/{templates-and-export.md → snippets-and-export.md}
RENAMED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Snippets, exports, and what authoring refuses
|
|
2
2
|
|
|
3
|
-
## Publishing a
|
|
3
|
+
## Publishing a snippet
|
|
4
4
|
|
|
5
|
-
A
|
|
6
|
-
will never see.
|
|
5
|
+
A snippet is ONE subtree, the style that dresses it and a name, published as a JSON someone drags onto a canvas you
|
|
6
|
+
will never see. What lands is a copy, the page's own from then on — for a block that stays linked to every place it
|
|
7
|
+
renders, write a component instead ([components.md](components.md)).
|
|
7
8
|
|
|
8
9
|
```ts
|
|
9
|
-
import {
|
|
10
|
+
import { authorSnippet } from '@plitzi/sdk-authoring';
|
|
10
11
|
|
|
11
|
-
const {
|
|
12
|
+
const { snippet, warnings } = authorSnippet({
|
|
12
13
|
name: 'Pricing card',
|
|
13
14
|
description: 'A price, a list of features and a call to action.',
|
|
14
15
|
classes: { card: { padding: '24px', 'border-radius': '12px', 'background-color': 'var(--surface)' } },
|
|
@@ -17,8 +18,8 @@ const { template, warnings } = authorTemplate({
|
|
|
17
18
|
```
|
|
18
19
|
|
|
19
20
|
- **Everything it names, it carries.** A class declared in the space it was cut from does not travel: declare every
|
|
20
|
-
rule the subtree names (`
|
|
21
|
-
- **A binding may not point outside the subtree.** Bring the provider into the
|
|
21
|
+
rule the subtree names (`validateSnippet` warns about one it does not carry).
|
|
22
|
+
- **A binding may not point outside the subtree.** Bring the provider into the snippet, or bind to a global.
|
|
22
23
|
|
|
23
24
|
## From an exported JSON
|
|
24
25
|
|