@plitzi/sdk-server 0.37.1 → 0.37.2
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 +146 -0
- package/README.md +26 -0
- package/dist/actions.d.ts +14 -6
- package/dist/actions.js +7 -3
- package/dist/core/server/baseServer.d.ts +4 -1
- package/dist/core/server/baseServer.js +2 -1
- package/dist/core/server/closeOnSignals.d.ts +28 -0
- package/dist/core/server/closeOnSignals.js +46 -0
- package/dist/core/server/pageServer.js +3 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -1
- package/dist/modules/actions/index.d.ts +12 -3
- package/dist/modules/actions/index.js +9 -3
- package/dist/modules/actions/jobs/index.d.ts +32 -0
- package/dist/modules/actions/jobs/index.js +57 -0
- package/dist/modules/actions/jobs/memoryQueue.d.ts +30 -0
- package/dist/modules/actions/jobs/memoryQueue.js +178 -0
- package/dist/modules/actions/jobs/memoryStores.test.d.ts +1 -0
- package/dist/modules/actions/jobs/scheduler.d.ts +62 -0
- package/dist/modules/actions/jobs/scheduler.js +125 -0
- package/dist/modules/actions/jobs/scheduler.test.d.ts +1 -0
- package/dist/modules/actions/jobs/schedules.d.ts +23 -0
- package/dist/modules/actions/jobs/schedules.js +47 -0
- package/dist/modules/actions/jobs/storeClock.d.ts +16 -0
- package/dist/modules/actions/jobs/storeClock.js +32 -0
- package/dist/modules/actions/jobs/testing/jobQueueContract.d.ts +25 -0
- package/dist/modules/actions/jobs/worker.d.ts +62 -0
- package/dist/modules/actions/jobs/worker.js +262 -0
- package/dist/modules/actions/jobs/worker.test.d.ts +1 -0
- package/dist/modules/actions/moduleFor.js +1 -0
- package/dist/modules/actions/runtime/precheck.js +1 -1
- package/dist/modules/actions/runtime/renderResolver.js +2 -2
- package/dist/modules/actions/transport/callHandler.js +1 -1
- package/dist/modules/actions/transport/webhookHandler.js +1 -1
- package/dist/modules/actions/types.d.ts +76 -1
- package/dist/modules/mongo/index.d.ts +4 -0
- package/dist/modules/mongo/jobQueue.d.ts +38 -0
- package/dist/modules/mongo/jobQueue.js +335 -0
- package/dist/modules/mongo/kv.d.ts +15 -0
- package/dist/modules/mongo/kv.js +98 -0
- package/dist/modules/mongo/stores.integration.test.d.ts +1 -0
- package/dist/modules/mysql/jobs/index.d.ts +5 -0
- package/dist/modules/mysql/jobs/jobQueue.d.ts +40 -0
- package/dist/modules/mysql/jobs/jobQueue.js +375 -0
- package/dist/modules/mysql/jobs/kv.d.ts +18 -0
- package/dist/modules/mysql/jobs/kv.js +78 -0
- package/dist/modules/mysql/jobs/schema.d.ts +24 -0
- package/dist/modules/mysql/jobs/schema.js +96 -0
- package/dist/modules/mysql/jobs/stores.integration.test.d.ts +1 -0
- package/dist/modules/ssr/Component.d.ts +2 -5
- package/dist/modules/ssr/Component.js +1 -2
- package/dist/modules/ssr/prepareRender.js +0 -3
- package/dist/mongo.d.ts +25 -0
- package/dist/mongo.js +3 -0
- package/dist/mysql.d.ts +11 -0
- package/dist/mysql.js +4 -1
- package/package.json +16 -6
- package/skills/plitzi-authoring/SKILL.md +73 -267
- package/skills/plitzi-authoring/reference/data-and-visibility.md +114 -0
- package/skills/plitzi-authoring/reference/elements-and-styles.md +107 -0
- package/skills/plitzi-authoring/reference/flows.md +80 -0
- package/skills/plitzi-authoring/reference/layouts.md +140 -0
- package/skills/plitzi-authoring/reference/review-checklist.md +50 -0
- package/skills/plitzi-authoring/reference/structure.md +83 -0
- package/skills/plitzi-authoring/reference/templates-and-export.md +54 -0
- package/skills/plitzi-authoring/reference/templates.md +69 -0
- package/skills/plitzi-authoring/reference/testing.md +52 -0
- package/dist/modules/actions/runtime/schedule.d.ts +0 -35
- package/dist/modules/actions/runtime/schedule.js +0 -76
- /package/dist/{modules/actions/runtime/schedule.test.d.ts → core/server/closeOnSignals.test.d.ts} +0 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Elements and styles
|
|
2
|
+
|
|
3
|
+
## Elements
|
|
4
|
+
|
|
5
|
+
One factory per element, named after it. Attributes and the authoring fields go in ONE flat object:
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
heading({ content: 'Fieldnotes', subType: 'h2', class: title })
|
|
9
|
+
image({ src: '/fox.jpg', alt: 'A fox', css: { 'aspect-ratio': '3/2' } })
|
|
10
|
+
container({ class: card, children: [ … ] })
|
|
11
|
+
text('Wildlife, close up') // a string is the content
|
|
12
|
+
container([hero, grid]) // an array is the children
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
| Authoring field | What it does |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| `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
|
+
| `class` | a shared class: a `styles()` declaration or a name from the space's `classes`; a list for several. Exclusive with `css` |
|
|
19
|
+
| `css` | rules of this element's own: one set, or one per breakpoint (`{ desktop, tablet, mobile }`) |
|
|
20
|
+
| `states` | `hover`, `focus-visible`… beside the element's own `css` |
|
|
21
|
+
| `variant` | a variant of the class it wears (when the class declares it) or of its type |
|
|
22
|
+
| `slots` | a class for one of the element's OTHER selectors — a form control's `input` |
|
|
23
|
+
| `bind` | where a value comes from |
|
|
24
|
+
| `visible` | a condition (`'src'`, `'!src'`), or `false` to start hidden |
|
|
25
|
+
| `flows` | what happens on click, on submit, on load |
|
|
26
|
+
| `runtime` | `'server'` resolves this provider's data on the server |
|
|
27
|
+
| `children` | the tree |
|
|
28
|
+
| `meta` | `meta.label` is the element's name in the builder's tree |
|
|
29
|
+
|
|
30
|
+
Anything else in the object is an **attribute** (`authorSpace` warns `unknown-attribute` for one the element never
|
|
31
|
+
reads).
|
|
32
|
+
|
|
33
|
+
**A link's `href` is a page id.** `link({ href: 'reports' })` finds the page by id, folder included. `mode: 'internal'`
|
|
34
|
+
for a path within the space carrying `{{tokens}}`; `mode: 'external'` for a full URL passed through untouched. A link
|
|
35
|
+
is an `<a>`: never put a link inside another link — make the card the link, or the button, not both.
|
|
36
|
+
|
|
37
|
+
**A `list` is a `<ul>`.** The browser indents it and puts bullets on it; a list of cards needs
|
|
38
|
+
`{ margin: '0px', padding: '0px', 'list-style-type': 'none' }` in its class.
|
|
39
|
+
|
|
40
|
+
**A provider has no box unless you give it a tag.** An `apiContainer` renders no element of its own by default, so its
|
|
41
|
+
children sit in the parent's layout and a `class` on it styles nothing (`STYLE_WITHOUT_TAG`). Put the layout on the
|
|
42
|
+
parent, or give it `attributes: { subType: 'div' }`.
|
|
43
|
+
|
|
44
|
+
A type this SDK does not ship — a plugin — is authored the same way: `defineElement<Props>({ type, … })`,
|
|
45
|
+
`element<Props>('type', props)`, or `elementsFromManifest(manifest)`.
|
|
46
|
+
|
|
47
|
+
## CSS
|
|
48
|
+
|
|
49
|
+
Write CSS as you write CSS, shorthands included — they are expanded to the longhands Plitzi's editor reads. A property
|
|
50
|
+
outside the vocabulary is refused with the key it should have been (`paddingTop` → `padding-top`).
|
|
51
|
+
|
|
52
|
+
- `column(gap, extra?)`, `row(gap, extra?)`, `grid(columns, gap, extra?)` for the three layouts every space repeats.
|
|
53
|
+
- **Breakpoints are ranges, not a cascade.** `tablet` is 48–64rem, `mobile` below 48rem, and each inherits only from
|
|
54
|
+
`desktop` — a rule for `tablet` never reaches a phone. Write it in both; `authorSpace` warns
|
|
55
|
+
`tablet-rule-skips-mobile`.
|
|
56
|
+
- **Fonts are declared.** A `font-family` loads only if the space lists the face in `fonts`
|
|
57
|
+
(`{ source: 'google', family: 'Geist', fallback: 'system-ui, sans-serif', weights: [400, 600], styles: ['normal'] }`).
|
|
58
|
+
|
|
59
|
+
## Classes
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
const card = styles('card', {
|
|
63
|
+
css: { padding: '24px', 'border-radius': '12px', 'background-color': 'var(--card)' },
|
|
64
|
+
states: { hover: { 'border-color': 'var(--primary)' }, 'focus-visible': { outline: '2px solid var(--primary)' } },
|
|
65
|
+
variants: { active: { 'border-color': 'var(--primary)' } }
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
container({ class: card, children: [ … ] });
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- A flat object is the base rules; `{ css, states, variants, ancestors }` when there is more. Each part may be written
|
|
72
|
+
per breakpoint.
|
|
73
|
+
- **Share a rule as a class, never as a spread.** Spreading a `const` into each element's `css` shares the source and
|
|
74
|
+
duplicates the document: one selector per element, so restyling one card in the builder restyles one card.
|
|
75
|
+
- **Build a family from a plain object**, not by layering classes: `styles('button-primary', { ...buttonBase, … })`.
|
|
76
|
+
- One name declared twice with rules that disagree is refused. Declare a class once, in the module that owns it, and
|
|
77
|
+
import it.
|
|
78
|
+
- `ancestors: { [sidebar.name]: { variants: { collapsed: { display: 'none' } } } }` styles an element by the state or
|
|
79
|
+
variant of an ancestor class — the way to react to a parent's hover or collapse without a flow.
|
|
80
|
+
- **`:hover` is part of the selector, not a rule in `customCss`.** `customCss` is for what no class can say:
|
|
81
|
+
keyframes, pseudo-elements, a rule across two unrelated elements, the inside of a third-party widget.
|
|
82
|
+
- Per element TYPE defaults go in `elements: { heading: { base, states, variants, slots } }` — a whole type at once.
|
|
83
|
+
|
|
84
|
+
**A variant chosen by the data:** `variantFrom(pill, 'jobs.item.status')`, or with a template when the data does not
|
|
85
|
+
already speak in variant names — `variantFrom(pill, 'runs.item.status', { template: "{{ source == 'completed' ?
|
|
86
|
+
'ok' : 'failed' }}" })`. The binding's key names the CLASS; never write it by hand. For "the current page", see
|
|
87
|
+
`activeOn` in [layouts.md](layouts.md).
|
|
88
|
+
|
|
89
|
+
## Colours and themes
|
|
90
|
+
|
|
91
|
+
Every colour is a **variable** with both values, and elements say `var(--name)`:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
variables: {
|
|
95
|
+
color: {
|
|
96
|
+
foreground: { light: '#0c0c14', dark: '#ededf3', default: '#0c0c14' },
|
|
97
|
+
card: { light: '#ffffff', dark: '#101019', default: '#ffffff' },
|
|
98
|
+
primary: { light: '#5b3df5', dark: '#6e52f7', default: '#5b3df5' }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- Choose each value against its own background: a colour picked on white is not the same colour on near-black.
|
|
104
|
+
- Fixed colours are only for surfaces that are fixed in both themes (a brand panel that is always dark) — and then
|
|
105
|
+
the text on them is fixed too. Theme-following text on a fixed background is the bug.
|
|
106
|
+
- A tint of a token is `color-mix(in srgb, var(--primary) 12%, transparent)`, which follows the theme for free.
|
|
107
|
+
- `themeToggle()` is the switch; `theme.resolved` (`light`/`dark`) is a global source for anything else that needs it.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Flows
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
button({
|
|
5
|
+
id: 'cta',
|
|
6
|
+
content: 'Get a quote',
|
|
7
|
+
flows: [[
|
|
8
|
+
onClick(),
|
|
9
|
+
named('quote', runServerAction({ actionId: 'shipping-quote', input: { city: 'Berlin' }, mode: 'await' })),
|
|
10
|
+
whenSucceeded('quote', setState({ key: 'quote', type: 'text', value: '{{ quote.output.summary }}' })),
|
|
11
|
+
whenFailed('quote', addNotification({ content: 'Could not reach the courier', appeareance: 'danger' }))
|
|
12
|
+
]]
|
|
13
|
+
})
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
A flow is a list: a trigger, then steps in order. "Only if" is on the step (`when`, `whenSucceeded`, `whenFailed`),
|
|
17
|
+
never a nested tree. Use the step builders — they fill in where a step runs and what it takes:
|
|
18
|
+
|
|
19
|
+
- **Where it runs.** A global callback registers under its source MODULE (`state`, `auth`, `actions`), an element
|
|
20
|
+
callback under an element's id, a utility under nothing. Naming either half wrong is a control that does nothing,
|
|
21
|
+
with no error — which is why the builders exist.
|
|
22
|
+
- **Which `setState`.** `setState({ key })` writes `runtime.state.<key>` and is read as `state.<key>`; never put
|
|
23
|
+
`state.` in the key. `updateElement(…)` changes one element's own attribute or state.
|
|
24
|
+
- **Flip in one step.** `toggleState({ key })` for app state, `toggleElement({ category: 'state', key: 'visibility' },
|
|
25
|
+
'panel')` to show/hide an element. Never two branches under opposite `when` guards — they read the state as it was
|
|
26
|
+
when the flow started, a click behind.
|
|
27
|
+
- **Keys are flat names.** A dotted key (`docsClosed.start`) is split into a path; use `docsClosedStart`.
|
|
28
|
+
|
|
29
|
+
## Reading what came before
|
|
30
|
+
|
|
31
|
+
`named('quote', step)` is how a later step reads an earlier one: `{{ quote.output.total }}`. The trigger too:
|
|
32
|
+
`named('submitted', onSubmit())` → `{{ submitted.values.email }}`. Unnamed steps get ids nothing can refer to.
|
|
33
|
+
|
|
34
|
+
A step's params are templates, evaluated in full (conditions, loops, filters). A source in them is named in full:
|
|
35
|
+
a row's button posts `{ jobId: '{{ list_jobRows.item.id }}' }` — the row that was clicked; the short name is refused.
|
|
36
|
+
|
|
37
|
+
**Pass objects, not JSON text.** `input: { title: '{{ form.values.title }}' }`, never `input: '{"title": …}'` — a
|
|
38
|
+
value with a quotation mark or a newline makes the text unparseable, and unparseable input posts `{}`.
|
|
39
|
+
|
|
40
|
+
## Writes and what they refresh
|
|
41
|
+
|
|
42
|
+
`webHook` (not GET) refreshes every request to its own site when it succeeds; `runServerAction` refreshes
|
|
43
|
+
everything. Say what it should refresh — `invalidateQueries: 'elements', invalidateElements: ['orders']` — or
|
|
44
|
+
`'none'` for a step that only reads, or when a refresh would make the page act on the new answer mid-flow.
|
|
45
|
+
|
|
46
|
+
## Triggers belong to the element that fires them
|
|
47
|
+
|
|
48
|
+
Every element fires `onClick`, `onLoad`, `onHover`, `onMouseEnter`/`onMouseLeave`, `onFocus`/`onBlur` and the ends of a
|
|
49
|
+
server action it started (`onFlowEnd`, `onFlowError`, `onFlowProgress`). A `page` fires `onPageLoad`, a `form`
|
|
50
|
+
`onSubmit`, a `formControl` `onChange`, an `apiContainer` `onApiSuccess`/`onApiError` (each answer, either runtime,
|
|
51
|
+
each refresh), a `modalContainer` `onModalOpen`/`onModalClose`, a `pagination` `onPageChange`. A flow on an element
|
|
52
|
+
that never fires its trigger is refused, naming the type that does.
|
|
53
|
+
|
|
54
|
+
**A form's flow goes on the `form`**, which hands its submit over with `managedByInteractions: true`
|
|
55
|
+
(`FORM_SUBMIT_UNMANAGED` otherwise — the browser submits it natively and `onSubmit` never fires):
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
form({
|
|
59
|
+
id: 'signup',
|
|
60
|
+
managedByInteractions: true,
|
|
61
|
+
flows: [[named('submitted', onSubmit()), setState({ key: 'email', type: 'text', value: '{{ submitted.values.email }}' })]],
|
|
62
|
+
children: [formControl({ id: 'email', name: 'email', label: 'Email', subType: 'email' }), button({ content: 'Sign up', subType: 'submit' })]
|
|
63
|
+
})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
A `formControl` is `required` by default: an optional field says `required: false`, or an empty one stops the submit
|
|
67
|
+
without a word.
|
|
68
|
+
|
|
69
|
+
## Modals, dropdowns, tabs
|
|
70
|
+
|
|
71
|
+
- A modal or dialog is declared `visible: false` and driven with `openModal('credits')` / `closeModal('credits')`
|
|
72
|
+
(`openDialog` / `closeDialog`). `openModal`'s second argument is read inside as `{{ modalContainer_credits.content }}`.
|
|
73
|
+
- A `dropdown`'s label is a child and its `dropdownPopup` sits inside it; a `tabContainer`'s header and body are held
|
|
74
|
+
inside it too. Outside, they are refused.
|
|
75
|
+
|
|
76
|
+
## Lists as state
|
|
77
|
+
|
|
78
|
+
`toggleInState({ key: 'picks', value })` keeps a list; `when({ field: 'state.picks', operator: 'contains', value })`
|
|
79
|
+
asks it. `appendState({ key, value, withId: true })` stores `{ id, value }` — bind `.value`, address by `.id`.
|
|
80
|
+
`whenFailed` matches every outcome that is not `completed` (also `skipped`, `aborted`).
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Layouts, and not writing anything twice
|
|
2
|
+
|
|
3
|
+
A space grows by pages, and most of what a page shows is the same on its neighbours: the top bar, the sidebar, the
|
|
4
|
+
footer, the menu, the "ask for help" card. Written into each page, that chrome is a copy per page — sixteen docs pages
|
|
5
|
+
carried the same sidebar sixteen times, around 900 elements whose only difference was which link was highlighted, and
|
|
6
|
+
the copies disagreed the first time one was edited. The tools below remove every one of those copies.
|
|
7
|
+
|
|
8
|
+
## A layout is the shell, written once
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
export const docsLayout: LayoutSpec = {
|
|
12
|
+
id: 'docs-layout',
|
|
13
|
+
label: 'Docs Layout',
|
|
14
|
+
folder: 'docs', // files it beside its pages in the builder; it routes nothing
|
|
15
|
+
body: [
|
|
16
|
+
container({
|
|
17
|
+
id: 'docs-shell',
|
|
18
|
+
class: shell,
|
|
19
|
+
children: [
|
|
20
|
+
topbar(), // helpers returning ElementSpec keep the layout readable
|
|
21
|
+
container({ id: 'docs-body', class: body, children: [sidebar(), container({ id: 'docs-slot', class: slot })] })
|
|
22
|
+
]
|
|
23
|
+
})
|
|
24
|
+
]
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// in the space
|
|
28
|
+
layouts: [appLayout, docsLayout],
|
|
29
|
+
pages: [{ name: 'Quickstart', slug: 'quickstart', folder: 'docs', layout: { id: 'docs-layout', slot: 'docs-slot' }, body: [ … ] }]
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- **The slot** is the element in the layout where the page's body goes. It must be inside the layout (checked).
|
|
33
|
+
- **Not re-mounted on navigation.** Moving between pages of one layout keeps the shell — its scroll, its open menus,
|
|
34
|
+
its providers, its state — and swaps only the slot. That is also why a layout is cheaper than a copy.
|
|
35
|
+
- **Layouts nest.** `layout: { id: 'app-shell', slot: 'main' }` on a LAYOUT puts a section shell (analytics tabs, docs
|
|
36
|
+
sidebar) inside the application shell. The page resolves the chain from the outside in.
|
|
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
|
+
(`runtime: 'server'`) in a layout is resolved with the page like one in the page.
|
|
39
|
+
- **Ids are shared** between a layout and all its pages. Name layout elements after the layout (`docs-topbar`), and
|
|
40
|
+
let pages prefix theirs with the page id.
|
|
41
|
+
|
|
42
|
+
## The menu is data
|
|
43
|
+
|
|
44
|
+
The pages a menu lists, their titles, their summaries and their order are ONE list, and everything that shows them
|
|
45
|
+
reads it — the sidebar, the page title, the meta description, "previous / next", an index of cards:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// nav.ts — the single source
|
|
49
|
+
export const DOCS_NAV = [
|
|
50
|
+
{
|
|
51
|
+
id: 'start',
|
|
52
|
+
title: 'Getting started',
|
|
53
|
+
icon: 'fas fa-rocket',
|
|
54
|
+
entries: [
|
|
55
|
+
{ id: 'docs-index', slug: '', title: 'Plitzi for developers', lede: 'What a space is…', icon: 'fas fa-book-open' },
|
|
56
|
+
{ id: 'docs-quickstart', slug: 'quickstart', title: 'Quickstart', lede: 'One command…', icon: 'fas fa-bolt' }
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
] as const;
|
|
60
|
+
|
|
61
|
+
// layout.ts — the sidebar, generated
|
|
62
|
+
const navGroup = (group: DocsGroup): ElementSpec =>
|
|
63
|
+
container({
|
|
64
|
+
id: `docs-group-${group.id}`,
|
|
65
|
+
children: group.entries.map(entry =>
|
|
66
|
+
link({
|
|
67
|
+
id: `docs-nav-${entry.id}`,
|
|
68
|
+
href: entry.id,
|
|
69
|
+
class: navLink,
|
|
70
|
+
bind: [activeOn(navLink, entry.id)],
|
|
71
|
+
children: [text(entry.title, { id: `docs-nav-label-${entry.id}` })]
|
|
72
|
+
})
|
|
73
|
+
)
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Adding a page is one entry in the list: the menu, the index and the neighbours' "next" follow.
|
|
78
|
+
|
|
79
|
+
## The current entry: `activeOn`
|
|
80
|
+
|
|
81
|
+
A menu in a layout is the same nodes on every page, so it cannot have the right entry styled by hand. It asks which
|
|
82
|
+
page is showing:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
const navLink = styles('nav-link', {
|
|
86
|
+
css: { color: 'var(--muted)' },
|
|
87
|
+
variants: { active: { color: 'var(--foreground)', 'font-weight': '600' } }
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
link({ href: 'spaces', class: navLink, bind: [activeOn(navLink, ['spaces', 'space-record'])] });
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`activeOn(class, pageIds, { variant?, slot? })` binds the class's variant to `navigation.currentPageId`: the `active`
|
|
94
|
+
variant on those pages, `idle` everywhere else. Several ids for one entry — a section and the pages under it. Do not
|
|
95
|
+
write the binding by hand with a `when` rule per page; that is the long form this replaces.
|
|
96
|
+
|
|
97
|
+
## Pages from a factory
|
|
98
|
+
|
|
99
|
+
When pages share a shape — a heading, a lede, content, a footer — the shape is a function and each page is its data:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
export const docsPage = (id: string, markdownSource: string): PageSpec => {
|
|
103
|
+
const entry = docsEntry(id); // title, slug, lede from nav.ts
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
id,
|
|
107
|
+
name: entry.title,
|
|
108
|
+
slug: entry.slug,
|
|
109
|
+
seoTitle: `${entry.title} — Docs`,
|
|
110
|
+
seoDescription: entry.lede,
|
|
111
|
+
folder: 'docs',
|
|
112
|
+
layout: { id: 'docs-layout', slot: 'docs-slot' },
|
|
113
|
+
body: [
|
|
114
|
+
container({
|
|
115
|
+
id: `${id}-main`, // prefixed: this function runs once per page
|
|
116
|
+
class: main,
|
|
117
|
+
children: [heading(entry.title, { id: `${id}-title`, class: title }), markdown(markdownSource, { id: `${id}-md` }), ...footer(id)]
|
|
118
|
+
})
|
|
119
|
+
]
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const quickstart = docsPage('docs-quickstart', `## One command …`);
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Every page is now its content and nothing else, and every page is guaranteed the same structure.
|
|
127
|
+
|
|
128
|
+
## Styles follow the same rule
|
|
129
|
+
|
|
130
|
+
- A role used on several pages is ONE class (`panelCard`, `pageTitle`), declared in the module that owns the area and
|
|
131
|
+
imported — never re-declared per page, never spread into `css`.
|
|
132
|
+
- Layout classes live with the layout; page classes with their page; shared ones in one `styles.ts` per area.
|
|
133
|
+
- A family of looks is a shared base object and one class per member; a state of one element is a variant.
|
|
134
|
+
- If you find yourself copying a `css` block, it is a class. If you find yourself copying an element tree, it is a
|
|
135
|
+
function, a layout, or a `map` over data.
|
|
136
|
+
|
|
137
|
+
## When NOT to use a layout
|
|
138
|
+
|
|
139
|
+
A block that appears on one page belongs to that page. A block on several pages but in different places is a
|
|
140
|
+
function (`communityLinks('docs-help', 'on-surface')`), not a layout. A layout is for what frames the page.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Before you say it is done
|
|
2
|
+
|
|
3
|
+
The feedback a reviewer gives on almost every change to a space, so you can give it to yourself first. Go through all
|
|
4
|
+
of it; each line is something that has shipped broken before.
|
|
5
|
+
|
|
6
|
+
## It renders, and says so
|
|
7
|
+
|
|
8
|
+
- [ ] `authorSpace` returns **zero warnings**. Each one is written code that will not do what it says.
|
|
9
|
+
- [ ] No console errors on any page you touched.
|
|
10
|
+
|
|
11
|
+
## Nothing flashes, nothing jumps
|
|
12
|
+
|
|
13
|
+
- [ ] Nothing appears only to be taken away while data loads: a condition that REVEALS an element starts hidden and
|
|
14
|
+
answers `'false'` until its source arrives. Checked from the FIRST frame, not a screenshot at the end.
|
|
15
|
+
- [ ] Nothing that is on screen by default disappears because a flag has not been set yet (a sidebar until it is
|
|
16
|
+
folded). Checked on a fresh visit, with no state kept from before.
|
|
17
|
+
- [ ] Empty states show only when the answer arrived and is empty — never while loading.
|
|
18
|
+
- [ ] No hidden element leaves a hole: the element itself is hidden, not a wrapper around it.
|
|
19
|
+
- [ ] Nothing shifts when the data lands: a loading area keeps its size, numbers use tabular figures.
|
|
20
|
+
|
|
21
|
+
## Every screen, every theme
|
|
22
|
+
|
|
23
|
+
- [ ] Desktop, tablet and mobile — and nothing scrolls sideways on a phone. Tablet rules are repeated for mobile when
|
|
24
|
+
phones should keep them.
|
|
25
|
+
- [ ] 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`), and `prefers-reduced-motion` stops what moves.
|
|
27
|
+
|
|
28
|
+
## No copies
|
|
29
|
+
|
|
30
|
+
- [ ] Chrome shared by pages is a layout; the current menu entry comes from `activeOn`, not per-page styling.
|
|
31
|
+
- [ ] 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
|
|
32
|
+
plans is ONE array everything reads.
|
|
33
|
+
- [ ] No positional ids (`container-45`) in what you wrote; every referenced element is named, prefixed when a
|
|
34
|
+
helper runs more than once.
|
|
35
|
+
|
|
36
|
+
## True, and in the right place
|
|
37
|
+
|
|
38
|
+
- [ ] Every number, name and status on screen comes from a source. No placeholder figures, no invented logos or
|
|
39
|
+
testimonials. A panel with nothing true to show is an empty state.
|
|
40
|
+
- [ ] Times are formatted with an explicit zone and say which (`… 06:00 UTC`).
|
|
41
|
+
- [ ] Logic lives in binding templates or on the server, not in an attribute (where a condition is never evaluated).
|
|
42
|
+
- [ ] Writes refresh what they changed — and only that (`invalidateQueries`); a multi-step form does not refresh the
|
|
43
|
+
provider that decides whether to leave.
|
|
44
|
+
- [ ] What only an administrator may read is shown only to administrators — and the check is a real boolean, not
|
|
45
|
+
a template left as text.
|
|
46
|
+
|
|
47
|
+
## Proven
|
|
48
|
+
|
|
49
|
+
- [ ] You looked at it: the visual check ran, on the pages you changed, in both themes.
|
|
50
|
+
- [ ] Both sides of each condition were seen: the empty account and the full one, the admin and the member.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Structuring a space that grows
|
|
2
|
+
|
|
3
|
+
A one-screen space fits in `src/space.ts`. Past a few pages, split it by what changes together — and keep every
|
|
4
|
+
repeated thing in exactly one place.
|
|
5
|
+
|
|
6
|
+
```text
|
|
7
|
+
src/space/
|
|
8
|
+
index.ts the SpaceSpec: name, variables, fonts, layouts, pages — assembly only
|
|
9
|
+
tokens.ts colour, shadow and radius variables, each with light and dark
|
|
10
|
+
styles.ts classes shared across areas (buttons, panels, page titles)
|
|
11
|
+
components.ts functions returning ElementSpec: panel(), statCard(), emptyState()
|
|
12
|
+
layouts/app.ts the application shell (+ its own classes)
|
|
13
|
+
docs/
|
|
14
|
+
nav.ts the section's pages as data: id, slug, title, lede, order
|
|
15
|
+
layout.ts the section shell, generated from nav.ts
|
|
16
|
+
page.ts docsPage(id, content) — the shape every page of the section shares
|
|
17
|
+
pages/
|
|
18
|
+
home.ts one file per page, or per page family
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Components are functions
|
|
22
|
+
|
|
23
|
+
The same block in two places is a function with parameters. Its ids come from a parameter, because ids are one
|
|
24
|
+
namespace for the whole space:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
export const panel = (params: { id: string; title: string; sub?: string; link?: { href: string; label: string }; children: ElementSpec[] }): ElementSpec =>
|
|
28
|
+
container({
|
|
29
|
+
id: params.id,
|
|
30
|
+
class: panelCard,
|
|
31
|
+
children: [
|
|
32
|
+
container({
|
|
33
|
+
id: `${params.id}-head`,
|
|
34
|
+
class: panelHead,
|
|
35
|
+
children: [
|
|
36
|
+
heading(params.title, { id: `${params.id}-title`, subType: 'h3', class: panelTitle }),
|
|
37
|
+
...(params.sub ? [text(params.sub, { id: `${params.id}-sub`, class: panelSub })] : []),
|
|
38
|
+
...(params.link ? [link({ id: `${params.id}-link`, href: params.link.href, class: panelLink, children: [text(params.link.label)] })] : [])
|
|
39
|
+
]
|
|
40
|
+
}),
|
|
41
|
+
...params.children
|
|
42
|
+
]
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
A page then reads as what it shows: `panel({ id: 'hm-traffic', title: 'Traffic', link: { href: 'analytics', label:
|
|
47
|
+
'Open analytics →' }, children: [chart] })`.
|
|
48
|
+
|
|
49
|
+
## Lists are data
|
|
50
|
+
|
|
51
|
+
Four quick links, three pricing plans, a sidebar menu, the steps of an onboarding: an array and a `map`.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
const QUICK = [
|
|
55
|
+
{ id: 'team', icon: 'fas fa-user-plus', title: 'Invite your team', href: 'workspace' },
|
|
56
|
+
{ id: 'docs', icon: 'fas fa-book-open', title: 'Quickstart', href: 'docs-quickstart' }
|
|
57
|
+
] as const;
|
|
58
|
+
|
|
59
|
+
container({ id: 'quick', class: quickGrid, children: QUICK.map(item => quickCard(item)) });
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
When items differ in ONE detail (one link needs a binding), put that detail in the item's data as an optional field
|
|
63
|
+
— never an `if (item.id === 'team')` inside the map.
|
|
64
|
+
|
|
65
|
+
## Names
|
|
66
|
+
|
|
67
|
+
- **Ids:** `area-role` in kebab-case — `hm-traffic-title`, `docs-nav-docs-data`. Never the builder's positional
|
|
68
|
+
`container-45` in code you maintain: rename them when you touch the code.
|
|
69
|
+
- **Classes:** named after the role, not the look — `panel-title`, not `bold-16`.
|
|
70
|
+
- **State keys:** flat and descriptive — `docsClosedStart`, `onboardingDetails`.
|
|
71
|
+
|
|
72
|
+
## Code that came from an export
|
|
73
|
+
|
|
74
|
+
`specFromSpace` writes what the builder saved, faithfully: every element's own `css`, positional ids, `when` rules
|
|
75
|
+
per page. It renders; it is not maintainable. When you work on such a file, leave it better:
|
|
76
|
+
|
|
77
|
+
- repeated `css` blocks → one class;
|
|
78
|
+
- a nav with a hand-styled current entry per page → a layout and `activeOn`;
|
|
79
|
+
- the same tree pasted per item → a function and a `map`;
|
|
80
|
+
- `container-45` → a name.
|
|
81
|
+
|
|
82
|
+
Each of those is safe to do on its own: authoring refuses anything that would not render, and the visual check shows
|
|
83
|
+
the rest.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Templates, exports, and what authoring refuses
|
|
2
|
+
|
|
3
|
+
## Publishing a template
|
|
4
|
+
|
|
5
|
+
A template 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.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { authorTemplate } from '@plitzi/sdk-authoring';
|
|
10
|
+
|
|
11
|
+
const { template, warnings } = authorTemplate({
|
|
12
|
+
name: 'Pricing card',
|
|
13
|
+
description: 'A price, a list of features and a call to action.',
|
|
14
|
+
classes: { card: { padding: '24px', 'border-radius': '12px', 'background-color': 'var(--surface)' } },
|
|
15
|
+
root: container({ class: 'card', children: [heading('$19', { subType: 'h3' }), button({ content: 'Start' })] })
|
|
16
|
+
});
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
- **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 (`validateTemplate` warns about one it does not carry).
|
|
21
|
+
- **A binding may not point outside the subtree.** Bring the provider into the template, or bind to a global.
|
|
22
|
+
|
|
23
|
+
## From an exported JSON
|
|
24
|
+
|
|
25
|
+
Never rewrite an exported space by hand. `specFromSpace({ schema, style })` reads it into the spec that authors it
|
|
26
|
+
(listing each repair in `corrections`); `specToSource(spec, { exportName })` writes that spec as code;
|
|
27
|
+
`compareSpaces(original, authorSpace(spec))` must list nothing but what `corrections` explains. The builder's
|
|
28
|
+
**Export** does all three. Edit the code from then on — and tidy it as you go ([structure.md](structure.md)).
|
|
29
|
+
|
|
30
|
+
For a document you did not author — an export, a JSON edited by hand — run the gate before serving it:
|
|
31
|
+
`validateSpace({ schema, style })`.
|
|
32
|
+
|
|
33
|
+
## What authoring refuses
|
|
34
|
+
|
|
35
|
+
`authorSpace` throws rather than hand back a space that would not render. Each error names the element and the fix:
|
|
36
|
+
|
|
37
|
+
- a CSS property the editor cannot read back; a class or slot the space does not declare (with the likely name)
|
|
38
|
+
- an element with a shared `class` AND rules of its own; one class declared twice with rules that disagree
|
|
39
|
+
- a binding source nothing answers to, or with the wrong prefix
|
|
40
|
+
- a source written by its short name inside a flow's params or a binding's template (`stats.x` for `apiContainer_stats.x`)
|
|
41
|
+
- a name that shadows a global (`variables`, `navigation`, `auth`, `state`, `theme`)
|
|
42
|
+
- two elements with one id — the error says where the first one is
|
|
43
|
+
- a step target that is not there; a broken flow chain, an orphan, a cycle
|
|
44
|
+
- a flow on a trigger its element never fires (`onSubmit` on a button), an element callback sent to a type that
|
|
45
|
+
does not answer it (`openModal` to a container)
|
|
46
|
+
- a `dropdownPopup` outside a `dropdown`, a tab container's parts outside a `tabContainer`
|
|
47
|
+
- a global callback on the wrong module, a utility given one
|
|
48
|
+
|
|
49
|
+
And it WARNS — returned in `warnings`, printed by `npm run author` — for what is written and will not do what it
|
|
50
|
+
says: `unknown-attribute`, `template-never-resolved`, `condition-starts-visible`, `state-key-has-runtime-prefix`,
|
|
51
|
+
`FORM_SUBMIT_UNMANAGED`, `STYLE_WITHOUT_TAG`, `tablet-rule-skips-mobile`. Treat a warning as a bug.
|
|
52
|
+
|
|
53
|
+
Ids are hashes of the path that produced them: re-authoring an unchanged space writes byte-identical documents, so a
|
|
54
|
+
seed can re-run and a diff stays readable.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Templates
|
|
2
|
+
|
|
3
|
+
Plitzi's templates are Twig, run by Plitzi's own interpreter. What a `{{ … }}` does depends on WHERE it is written.
|
|
4
|
+
|
|
5
|
+
## Where a template runs
|
|
6
|
+
|
|
7
|
+
| Written in | What is evaluated | Use it for |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| a binding's `twigTemplate` transformer | the whole template: conditions, filters, `{% set %}`, loops, tests | anything computed — this is where logic goes |
|
|
10
|
+
| a flow step's params | the whole template | values a step sends or stores |
|
|
11
|
+
| an ATTRIBUTE (`href`, `src`, `content`…) | only `{{ name }}` and `{{ name|filter }}` tokens | dropping a value into a string |
|
|
12
|
+
|
|
13
|
+
An attribute holding only a condition — `href: "/x/{{ on ? 'a' : 'b' }}"` — is used **as written** (`authorSpace` warns
|
|
14
|
+
`template-never-resolved`). Move the expression into a binding:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
link({ href: '/', bind: [{ to: 'href', source: 'state.workspace.id',
|
|
18
|
+
transformers: [{ action: 'twigTemplate', params: { template: "{{ source ? '/workspace/' ~ source : '/' }}" } }] }] })
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
In a binding, `source` is the value at the binding's `source`; everything else in scope is a source named in full.
|
|
22
|
+
|
|
23
|
+
## Naming sources inside a template
|
|
24
|
+
|
|
25
|
+
Always the full name — the element's kind, an underscore, its id: `apiContainer_stats`, `list_rows`,
|
|
26
|
+
`modalContainer_credits`. The globals (`state`, `auth`, `navigation`, `variables`, `theme`) are named as themselves. In
|
|
27
|
+
a flow, an earlier step is read by the name given with `named('quote', …)`: `{{ quote.output.total }}`.
|
|
28
|
+
|
|
29
|
+
A list publishes one scope per row: inside a row, `list_rows.item` is THAT row.
|
|
30
|
+
|
|
31
|
+
## The language
|
|
32
|
+
|
|
33
|
+
- Operators: `~` (concat), `+ - * / %`, `== != < > <= >=`, `and or not`, `in`, `a ? b : c`, `a ?? b`.
|
|
34
|
+
- Access: `a.b`, `a.0`, `a[i]`, `(expr).key`, `(expr)[i]`.
|
|
35
|
+
- Blocks: `{% set x = … %}`, `{% if %}…{% elseif %}…{% else %}…{% endif %}`, `{% for item in list %}…{% endfor %}`.
|
|
36
|
+
- Arrow functions in filters: `rows|sort(r => r.at)|first`, `rows|filter(r => r.active)`, `rows|map(r => r.name)`.
|
|
37
|
+
- Tests: `is defined`, `is empty`, `is null`, `is iterable`, `is even`, `is odd`, and their `is not` forms.
|
|
38
|
+
- Functions: `range`, `min`, `max`, `cycle`.
|
|
39
|
+
|
|
40
|
+
Filters: `default upper lower trim capitalize title camelize kebab snake ltrim rtrim pad replace slice split join
|
|
41
|
+
reverse length first last contains startswith endswith number number_format round abs format sort batch chunk map
|
|
42
|
+
reduce merge keys values filter column find pluck unique flatten sum without only index_by group_by url_encode
|
|
43
|
+
striptags spaceless json_encode to_json raw date year month day hours minutes seconds weekday random`.
|
|
44
|
+
|
|
45
|
+
A lookup across two sources — a row joined to the stats around it:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
"{% set s = apiContainer_stats.data.spaces|find('id', list_spaces.item.id) %}{{ s ? s.traffic.views|number_format(0, '.', ',') ~ ' views' : '—' }}"
|
|
49
|
+
// or, in one expression:
|
|
50
|
+
"{{ (apiContainer_stats.data.spaces|find('id', list_spaces.item.id)).traffic.views ?? 0 }}"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Values to be careful with
|
|
54
|
+
|
|
55
|
+
- **An empty list is false**, like an absent one. To tell "arrived and empty" from "not arrived":
|
|
56
|
+
`{{ items is defined and items is empty }}`.
|
|
57
|
+
- **`json_encode` of a string is the string**, unquoted. Do not build JSON by interpolating text a visitor typed —
|
|
58
|
+
pass objects as step params instead.
|
|
59
|
+
- **Numbers**: `number_format(decimals, point, thousands)`; `round(precision)`.
|
|
60
|
+
- **A template that renders a number hands on a number** in step params (`'1'` → `1`).
|
|
61
|
+
|
|
62
|
+
## Dates
|
|
63
|
+
|
|
64
|
+
- `|date(format, zone)` — tokens `Y m n d j H G i s l F M N w U`. **Always pass a zone** (`'UTC'`) and print it: with
|
|
65
|
+
no zone the date is formatted in the local time of whoever renders it — the server's for the HTML, the visitor's
|
|
66
|
+
after hydration — and the two disagree.
|
|
67
|
+
- A timestamp can be seconds or milliseconds, a number or a string of digits — all read.
|
|
68
|
+
- For "3 minutes ago", the `dateConverter` transformer with `{ asAge: true }`; give it `isUnix: false` for an ISO
|
|
69
|
+
string. Its checkbox params accept real booleans (and `'true'`/`'false'`).
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Testing what you authored
|
|
2
|
+
|
|
3
|
+
## Handles
|
|
4
|
+
|
|
5
|
+
`authorSpace` returns `handles`: every element by id, with the selector that finds it in the rendered page
|
|
6
|
+
(`[data-plitzi-el="<id>"]`). `handles.element(id)` throws on a name that does not exist, and `locate(page, handles)`
|
|
7
|
+
turns an id into a Playwright locator. Pages are in `handles.pages` (each with its `path` and `elements`); a layout's
|
|
8
|
+
elements are in `handles.layouts` and render on every page that names the layout.
|
|
9
|
+
|
|
10
|
+
An element marked `conditional` is on screen only under a condition of its own or of an ancestor — skip it in an
|
|
11
|
+
"every named element is visible" check. A `formControl`'s id names its wrapper: type into
|
|
12
|
+
`locate('email').locator('input')`.
|
|
13
|
+
|
|
14
|
+
Select by `data-plitzi-el`, never by a generated class name: authoring derives `<type>-<hash>` for an element's own
|
|
15
|
+
rules, and that name changes when the rules do.
|
|
16
|
+
|
|
17
|
+
## What to check
|
|
18
|
+
|
|
19
|
+
- **Every viewport and both themes.** Desktop, tablet (48–64rem) and mobile (below 48rem); `colorScheme: 'dark'` as well
|
|
20
|
+
as light.
|
|
21
|
+
- **No horizontal scroll on a phone.** A column child grows to its content unless its parent stretches it; a code
|
|
22
|
+
block or a wide table is what finds out.
|
|
23
|
+
- **The loading frame, not only the finished one.** A screenshot after the page settles cannot see an element that
|
|
24
|
+
flashed and went. Watch from the first frame:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
await page.addInitScript(ids => {
|
|
28
|
+
const painted: string[] = [];
|
|
29
|
+
(window as unknown as { __painted: string[] }).__painted = painted;
|
|
30
|
+
const watch = (): void => {
|
|
31
|
+
for (const id of ids) {
|
|
32
|
+
const element = document.querySelector(`[data-plitzi-el="${id}"]`);
|
|
33
|
+
if (element && element.getClientRects().length > 0 && !painted.includes(id)) {
|
|
34
|
+
painted.push(id);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
requestAnimationFrame(watch);
|
|
39
|
+
};
|
|
40
|
+
requestAnimationFrame(watch);
|
|
41
|
+
}, ['empty-state', 'first-steps']);
|
|
42
|
+
|
|
43
|
+
await page.goto('/', { waitUntil: 'load' });
|
|
44
|
+
// … wait for the data to be on screen …
|
|
45
|
+
expect(await page.evaluate(() => (window as unknown as { __painted: string[] }).__painted)).toEqual([]);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- **Both sides of every condition.** An account that has done everything must not see "get started"; a new one must.
|
|
49
|
+
An empty list shows its empty state; a full one does not.
|
|
50
|
+
- **Navigation lands on the new page's content.** After `waitForURL`, wait for an element of the NEW page before typing:
|
|
51
|
+
for a frame the previous page is still mounted, and a form field with the same name there takes the keystrokes.
|
|
52
|
+
- **Console errors are failures.** Collect them and assert the list is empty.
|