@westopp/windo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/dist/chunk-5RM2VYAM.js +150 -0
  4. package/dist/chunk-5RM2VYAM.js.map +1 -0
  5. package/dist/cli.cjs +303 -0
  6. package/dist/cli.cjs.map +1 -0
  7. package/dist/cli.d.cts +1 -0
  8. package/dist/cli.d.ts +1 -0
  9. package/dist/cli.js +138 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/index.cjs +219 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.cts +374 -0
  14. package/dist/index.d.ts +374 -0
  15. package/dist/index.js +182 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/plugin.cjs +185 -0
  18. package/dist/plugin.cjs.map +1 -0
  19. package/dist/plugin.d.cts +11 -0
  20. package/dist/plugin.d.ts +11 -0
  21. package/dist/plugin.js +11 -0
  22. package/dist/plugin.js.map +1 -0
  23. package/package.json +95 -0
  24. package/src/cli/index.ts +160 -0
  25. package/src/client/App.tsx +310 -0
  26. package/src/client/Canvas.tsx +358 -0
  27. package/src/client/Inspector.tsx +586 -0
  28. package/src/client/Sidebar.tsx +108 -0
  29. package/src/client/bridge.ts +124 -0
  30. package/src/client/chrome.css +1966 -0
  31. package/src/client/icons.tsx +110 -0
  32. package/src/client/index.ts +15 -0
  33. package/src/client/internal-types.ts +147 -0
  34. package/src/client/persist.ts +38 -0
  35. package/src/define-config.ts +54 -0
  36. package/src/descriptor.test.ts +59 -0
  37. package/src/descriptor.ts +185 -0
  38. package/src/globals.d.ts +9 -0
  39. package/src/index.ts +54 -0
  40. package/src/plugin/index.ts +181 -0
  41. package/src/preview/ctx.ts +43 -0
  42. package/src/preview/index.ts +283 -0
  43. package/src/preview/preview.css +81 -0
  44. package/src/preview/registry.ts +159 -0
  45. package/src/preview/render.tsx +90 -0
  46. package/src/preview/virtual.d.ts +8 -0
  47. package/src/protocol.ts +59 -0
  48. package/src/types.ts +319 -0
@@ -0,0 +1,181 @@
1
+ // Vite plugin (node side). Pairs `@vitejs/plugin-react` with the windo plugin:
2
+ // serves the chrome + iframe HTML documents ourselves (appType custom) and
3
+ // exposes a `virtual:windo-registry` module that re-exports the user config and
4
+ // glob-imports every `*.windo.tsx` file.
5
+
6
+ import { existsSync } from 'node:fs'
7
+ import { isAbsolute, resolve as resolvePath } from 'node:path'
8
+ import reactPlugin from '@vitejs/plugin-react'
9
+ import type { Plugin, PluginOption, UserConfig, ViteDevServer } from 'vite'
10
+
11
+ export interface WindoPluginOptions {
12
+ include?: string | string[]
13
+ config?: string
14
+ root?: string
15
+ }
16
+
17
+ const VIRTUAL_ID = 'virtual:windo-registry'
18
+ const RESOLVED_VIRTUAL_ID = `\0${VIRTUAL_ID}`
19
+
20
+ const CONFIG_CANDIDATES = ['windo.config.ts', 'windo.config.tsx', 'windo.config.mts', 'windo.config.js', 'windo.config.mjs'] as const
21
+
22
+ function findConfig(root: string, configOpt?: string): string | null {
23
+ if (configOpt) return isAbsolute(configOpt) ? configOpt : resolvePath(root, configOpt)
24
+ for (const candidate of CONFIG_CANDIDATES) {
25
+ const full = resolvePath(root, candidate)
26
+ if (existsSync(full)) return full
27
+ }
28
+ return null
29
+ }
30
+
31
+ function toGlob(include?: string | string[]): string {
32
+ const first = Array.isArray(include) ? include[0] : include
33
+ const pattern = first ?? '**/*.windo.tsx'
34
+ return pattern.startsWith('/') ? pattern : `/${pattern}`
35
+ }
36
+
37
+ const CHROME_HTML = `<!doctype html>
38
+ <html data-theme="light">
39
+ <head>
40
+ <meta charset="utf-8" />
41
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
42
+ <title>windo</title>
43
+ </head>
44
+ <body>
45
+ <div id="root"></div>
46
+ <script type="module">
47
+ import('@westopp/windo/client')
48
+ </script>
49
+ </body>
50
+ </html>
51
+ `
52
+
53
+ const IFRAME_HTML = `<!doctype html>
54
+ <html>
55
+ <head>
56
+ <meta charset="utf-8" />
57
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
58
+ </head>
59
+ <body style="background: transparent">
60
+ <div id="root"></div>
61
+ <script type="module">
62
+ import('@westopp/windo/preview')
63
+ </script>
64
+ </body>
65
+ </html>
66
+ `
67
+
68
+ // Absolute ids for the two HTML entry documents, relative to the project root.
69
+ // They are virtual — the files need not exist on disk; the plugin's `load`
70
+ // returns their contents. Keeping them rooted at `<root>/index.html` and
71
+ // `<root>/__windo/iframe.html` makes `vite build` emit them at the matching
72
+ // paths in the output directory (Vite derives an HTML asset's output path from
73
+ // the input id's path relative to root).
74
+ function htmlIds(root: string) {
75
+ return {
76
+ index: resolvePath(root, 'index.html'),
77
+ iframe: resolvePath(root, '__windo/iframe.html'),
78
+ }
79
+ }
80
+
81
+ export function windo(options: WindoPluginOptions = {}): PluginOption[] {
82
+ let resolvedRoot = process.cwd()
83
+ let ids = htmlIds(resolvedRoot)
84
+
85
+ const registryModule = (): string => {
86
+ const root = options.root ?? resolvedRoot ?? process.cwd()
87
+ const configPath = findConfig(root, options.config)
88
+ const glob = toGlob(options.include)
89
+ const importPath = configPath ? JSON.stringify(configPath) : 'null'
90
+ const globLiteral = JSON.stringify(glob)
91
+ return [
92
+ `import * as __cfg from ${importPath}`,
93
+ 'const config = __cfg.config ?? (__cfg.default && __cfg.default.config) ?? __cfg.default',
94
+ 'export { config }',
95
+ `export const modules = import.meta.glob(${globLiteral})`,
96
+ '',
97
+ ].join('\n')
98
+ }
99
+
100
+ const plugin: Plugin = {
101
+ name: 'windo',
102
+ // `pre` so our resolveId/load claim the virtual registry and the two HTML
103
+ // entries before Vite's filesystem fallback tries (and fails) to read them.
104
+ enforce: 'pre',
105
+
106
+ config(userConfig: UserConfig, { command, isPreview }) {
107
+ const root = resolvePath(options.root ?? userConfig.root ?? '.')
108
+ ids = htmlIds(root)
109
+ const base: UserConfig = {
110
+ // Dev serves the chrome + iframe HTML from `configureServer`, so it runs
111
+ // headless (`custom`). Build and preview want the normal SPA behaviour so
112
+ // `/` resolves to the emitted index.html.
113
+ appType: command === 'serve' && !isPreview ? 'custom' : 'spa',
114
+ define: {
115
+ __WINDO_IFRAME_SRC__: JSON.stringify(command === 'build' ? './__windo/iframe.html' : './__windo/iframe'),
116
+ },
117
+ resolve: {
118
+ dedupe: ['react', 'react-dom'],
119
+ },
120
+ optimizeDeps: {
121
+ // Pre-bundle every React entrypoint in one pass so React's identity is
122
+ // fixed up front. If `react-dom/client` (the preview renderer) is
123
+ // discovered late, Vite re-optimizes and can split React across two
124
+ // instances — fine until a component calls a hook, then "invalid hook
125
+ // call". Keeping them all here prevents that re-optimize.
126
+ include: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
127
+ },
128
+ }
129
+
130
+ // Wire the chrome + iframe as build inputs unless the user supplied their
131
+ // own. Both are emitted as static HTML the canvas can be hosted from.
132
+ if (command === 'build' && !userConfig.build?.rollupOptions?.input) {
133
+ base.build = { rollupOptions: { input: { index: ids.index, iframe: ids.iframe } } }
134
+ }
135
+ return base
136
+ },
137
+
138
+ configResolved(resolved) {
139
+ resolvedRoot = resolved.root
140
+ ids = htmlIds(resolvedRoot)
141
+ },
142
+
143
+ resolveId(id) {
144
+ if (id === VIRTUAL_ID) return RESOLVED_VIRTUAL_ID
145
+ if (id === ids.index || id === ids.iframe) return id
146
+ return null
147
+ },
148
+
149
+ load(id) {
150
+ if (id === RESOLVED_VIRTUAL_ID) return registryModule()
151
+ if (id === ids.index) return CHROME_HTML
152
+ if (id === ids.iframe) return IFRAME_HTML
153
+ return null
154
+ },
155
+
156
+ configureServer(server: ViteDevServer) {
157
+ server.middlewares.use(async (req, res, next) => {
158
+ if (req.method !== 'GET') return next()
159
+ const url = (req.url ?? '').split('?')[0]
160
+ let html: string | null = null
161
+ if (url === '/' || url === '/index.html') html = CHROME_HTML
162
+ else if (url.startsWith('/__windo/iframe')) html = IFRAME_HTML
163
+ if (html === null) return next()
164
+ try {
165
+ const transformed = await server.transformIndexHtml(url, html)
166
+ res.statusCode = 200
167
+ res.setHeader('Content-Type', 'text/html')
168
+ res.end(transformed)
169
+ } catch (err) {
170
+ next(err)
171
+ }
172
+ })
173
+ },
174
+ }
175
+
176
+ return [reactPlugin(), plugin]
177
+ }
178
+
179
+ export const windoPlugin = windo
180
+
181
+ export default windo
@@ -0,0 +1,43 @@
1
+ // Build the live `WindoRenderContext` handed to every render-time function
2
+ // inside the iframe. `ctx.logger.log` posts WindoLogEntry events up to the
3
+ // chrome; `ctx.state`/`ctx.setState` are the component-local state surface;
4
+ // opted-in contexts are resolved against the current env values.
5
+
6
+ import type { WindoContextMap, WindoEnvState, WindoLogEntry, WindoRenderContext } from '../types'
7
+
8
+ export function buildRenderContext<State>(
9
+ env: WindoEnvState,
10
+ contexts: WindoContextMap,
11
+ postLog: (entry: WindoLogEntry) => void,
12
+ state: State,
13
+ setState: (patch: Partial<State>) => void
14
+ ): WindoRenderContext<State> {
15
+ const logger = {
16
+ log: (...args: unknown[]) => postLog({ ts: Date.now(), args }),
17
+ }
18
+
19
+ const ctx: WindoRenderContext<State> = {
20
+ colorScheme: env.colorScheme,
21
+ viewport: env.viewport,
22
+ reducedMotion: env.reducedMotion,
23
+ direction: env.direction,
24
+ locale: env.locale,
25
+ logger,
26
+ state,
27
+ setState,
28
+ contexts: {},
29
+ }
30
+
31
+ for (const name of Object.keys(contexts)) {
32
+ const def = contexts[name]
33
+ const defaults: Record<string, unknown> = {}
34
+ const controlMap = def.controls ?? {}
35
+ for (const key of Object.keys(controlMap)) {
36
+ defaults[key] = controlMap[key].default
37
+ }
38
+ const values = { ...defaults, ...(env.contexts[name] ?? {}) }
39
+ ctx.contexts[name] = def.resolve ? def.resolve(values, ctx) : values
40
+ }
41
+
42
+ return ctx
43
+ }
@@ -0,0 +1,283 @@
1
+ // The iframe preview entry. Resolves the virtual registry into a manifest,
2
+ // owns the runtime state (selected windo, parsed values, env), renders the
3
+ // selected component, and runs the postMessage conversation with the chrome.
4
+
5
+ import { config, modules } from 'virtual:windo-registry'
6
+ import { createElement } from 'react'
7
+ import type { Root } from 'react-dom/client'
8
+ import { createRoot } from 'react-dom/client'
9
+ import type { z } from 'zod'
10
+ import { describeSchema } from '../descriptor'
11
+ import type { WindoHostMessage, WindoPreviewMessage } from '../protocol'
12
+ import { isHostMessage, isWindoMessage, WINDO_MSG } from '../protocol'
13
+ import type { WindoActionMeta, WindoContextMap, WindoDefinition, WindoEnvState, WindoFactoryArg, WindoGroup, WindoLogEntry, WindoManifestEntry, WindoRenderContext, WindoVariantMeta } from '../types'
14
+ import { buildRenderContext } from './ctx'
15
+ import { buildContextMeta, flattenZodError, idFromPath, sortEntries, summarizeLogArg, toCloneable } from './registry'
16
+ import { PreviewRoot } from './render'
17
+ import './preview.css'
18
+
19
+ const contexts = config.contexts ?? {}
20
+
21
+ const w: WindoFactoryArg<readonly WindoGroup[], WindoContextMap> = {
22
+ groups: Object.fromEntries(config.groups.map(g => [g.slug, g])) as Record<string, WindoGroup>,
23
+ contexts,
24
+ }
25
+
26
+ interface ResolvedWindo {
27
+ id: string
28
+ def: WindoDefinition
29
+ }
30
+
31
+ const DEFAULT_ENV: WindoEnvState = {
32
+ colorScheme: 'light',
33
+ viewport: { width: 1160, height: 720, name: 'desktop' },
34
+ reducedMotion: false,
35
+ direction: 'ltr',
36
+ locale: 'en-US',
37
+ contexts: {},
38
+ }
39
+
40
+ let resolved: ResolvedWindo[] = []
41
+ let entries: WindoManifestEntry[] = []
42
+ let currentId: string | null = null
43
+ let currentDef: WindoDefinition | null = null
44
+ let currentSchema: z.ZodType | undefined
45
+ let currentValues: unknown = {}
46
+ let env: WindoEnvState = DEFAULT_ENV
47
+ let currentState: Record<string, unknown> = {}
48
+ let root: Root | null = null
49
+
50
+ function postToParent(msg: WindoPreviewMessage) {
51
+ window.parent.postMessage(msg, '*')
52
+ }
53
+
54
+ function postLog(entry: WindoLogEntry) {
55
+ // Summarize args (events/DOM nodes → compact markers) so the console stays
56
+ // readable and a SyntheticEvent doesn't balloon the postMessage payload.
57
+ postToParent({ source: WINDO_MSG, dir: 'preview', type: 'log', entry: { ...entry, args: entry.args.map(summarizeLogArg) } })
58
+ }
59
+
60
+ /** The live ctx, rebuilt on demand so render-time, toolbar, and stage events all share the current state. */
61
+ function makeCtx(): WindoRenderContext {
62
+ return buildRenderContext(env, contexts, postLog, currentState, setState)
63
+ }
64
+
65
+ function setState(patch: Partial<Record<string, unknown>>) {
66
+ currentState = { ...currentState, ...patch }
67
+ render()
68
+ postState()
69
+ }
70
+
71
+ /** Post the current state snapshot + each action's live `disabled` flag up to the chrome. */
72
+ function postState() {
73
+ if (!currentId || !currentDef) return
74
+ // `disabled` predicates are pure reads of the same state — one ctx covers them all.
75
+ const ctx = makeCtx()
76
+ const actions = (currentDef.actions ?? []).map((a, i) => ({
77
+ id: String(i),
78
+ disabled: a.disabled ? !!a.disabled(ctx) : false,
79
+ }))
80
+ // Preserve declared keys (incl. explicit `undefined`) so the read-only strip mirrors state faithfully.
81
+ const state: Record<string, unknown> = {}
82
+ for (const key of Object.keys(currentState)) state[key] = toCloneable(currentState[key])
83
+ postToParent({ source: WINDO_MSG, dir: 'preview', type: 'state', id: currentId, state, actions })
84
+ }
85
+
86
+ /** Run a toolbar (`click`) action by its index. */
87
+ function invokeAction(actionId: string) {
88
+ const action = currentDef?.actions?.[Number(actionId)]
89
+ if (!action) return
90
+ action.run(makeCtx(), true)
91
+ }
92
+
93
+ /** Dispatch pointer-bound actions as the pointer crosses the stage. */
94
+ function dispatchStage(phase: 'enter' | 'leave') {
95
+ if (!currentDef?.actions) return
96
+ for (const action of currentDef.actions) {
97
+ const on = action.on ?? 'click'
98
+ if (on === 'click') continue
99
+ // Rebuild ctx per action so a later action sees an earlier one's setState.
100
+ const ctx = makeCtx()
101
+ if (on === 'hover') action.run(ctx, phase === 'enter')
102
+ else if (on === 'enter' && phase === 'enter') action.run(ctx, true)
103
+ else if (on === 'exit' && phase === 'leave') action.run(ctx, true)
104
+ }
105
+ }
106
+
107
+ function manifestEntry(id: string, def: WindoDefinition): WindoManifestEntry {
108
+ const actions: WindoActionMeta[] = (def.actions ?? []).map((a, i) => ({ id: String(i), label: a.label, on: a.on ?? 'click' }))
109
+ const entry: WindoManifestEntry = {
110
+ id,
111
+ title: def.title,
112
+ group: def.group,
113
+ status: def.status ?? 'stable',
114
+ placement: def.placement ?? 'center',
115
+ uses: def.uses ?? [],
116
+ hasVariants: (def.variants?.length ?? 0) > 0,
117
+ actions,
118
+ hasState: !!def.state && Object.keys(def.state as object).length > 0,
119
+ }
120
+ if (def.description !== undefined) entry.description = def.description
121
+ if (def.deprecation !== undefined) entry.deprecation = def.deprecation
122
+ return entry
123
+ }
124
+
125
+ function computeDefaults(schema: z.ZodType | undefined): unknown {
126
+ if (!schema) return {}
127
+ const result = schema.safeParse({})
128
+ return result.success ? result.data : {}
129
+ }
130
+
131
+ function safeCode(def: WindoDefinition, defaults: unknown): string | null {
132
+ if (!def.code) return null
133
+ try {
134
+ return def.code(defaults as never)
135
+ } catch {
136
+ return null
137
+ }
138
+ }
139
+
140
+ function postManifest() {
141
+ postToParent({
142
+ source: WINDO_MSG,
143
+ dir: 'preview',
144
+ type: 'manifest',
145
+ title: config.title ?? 'windo',
146
+ entries,
147
+ groups: [...config.groups],
148
+ contexts: buildContextMeta(contexts),
149
+ })
150
+ }
151
+
152
+ function postDescribe(id: string, def: WindoDefinition, schema: z.ZodType | undefined) {
153
+ const defaults = computeDefaults(schema)
154
+ // variant patches and defaults may carry ReactNodes/functions that postMessage
155
+ // cannot clone — reduce them to JSON-safe forms before they cross the boundary.
156
+ const variants: WindoVariantMeta[] = (def.variants ?? []).map(v => ({ label: v.label, props: toCloneable(v.props) as Record<string, unknown> }))
157
+ postToParent({
158
+ source: WINDO_MSG,
159
+ dir: 'preview',
160
+ type: 'describe',
161
+ id,
162
+ descriptor: describeSchema(schema),
163
+ props: def.props ?? [],
164
+ variants,
165
+ defaults: toCloneable(defaults),
166
+ code: safeCode(def, defaults),
167
+ })
168
+ }
169
+
170
+ function render() {
171
+ if (!currentDef || !root) return
172
+ const ctx = makeCtx()
173
+ const id = currentId ?? ''
174
+ root.render(
175
+ createElement(PreviewRoot, {
176
+ def: currentDef,
177
+ ctx,
178
+ values: currentValues,
179
+ contexts,
180
+ onStage: dispatchStage,
181
+ onError: (message: string, stack?: string) => {
182
+ postToParent({ source: WINDO_MSG, dir: 'preview', type: 'render-error', id, message, ...(stack === undefined ? {} : { stack }) })
183
+ },
184
+ })
185
+ )
186
+ }
187
+
188
+ function handleSelect(id: string) {
189
+ const match = resolved.find(r => r.id === id)
190
+ if (!match) return
191
+ currentId = id
192
+ currentDef = match.def
193
+ currentSchema = match.def.configurableProps
194
+ currentValues = computeDefaults(currentSchema)
195
+ // Reset component-local state to the windo's declared initial state.
196
+ currentState = { ...((match.def.state as Record<string, unknown>) ?? {}) }
197
+ render()
198
+ postState()
199
+ postDescribe(id, currentDef, currentSchema)
200
+ }
201
+
202
+ function handleSetProps(id: string, json: string) {
203
+ let parsed: unknown
204
+ try {
205
+ parsed = JSON.parse(json)
206
+ } catch (err) {
207
+ postToParent({
208
+ source: WINDO_MSG,
209
+ dir: 'preview',
210
+ type: 'parse-error',
211
+ id,
212
+ errors: [{ path: '', message: err instanceof Error ? err.message : 'Invalid JSON' }],
213
+ })
214
+ return
215
+ }
216
+
217
+ if (currentSchema) {
218
+ const result = currentSchema.safeParse(parsed)
219
+ if (result.success) {
220
+ currentValues = result.data
221
+ postToParent({ source: WINDO_MSG, dir: 'preview', type: 'parse-ok', id })
222
+ render()
223
+ } else {
224
+ postToParent({ source: WINDO_MSG, dir: 'preview', type: 'parse-error', id, errors: flattenZodError(result.error) })
225
+ }
226
+ return
227
+ }
228
+
229
+ currentValues = parsed
230
+ postToParent({ source: WINDO_MSG, dir: 'preview', type: 'parse-ok', id })
231
+ render()
232
+ }
233
+
234
+ function handleMessage(msg: WindoHostMessage) {
235
+ switch (msg.type) {
236
+ case 'request-manifest':
237
+ postManifest()
238
+ break
239
+ case 'select':
240
+ handleSelect(msg.id)
241
+ break
242
+ case 'set-props':
243
+ handleSetProps(msg.id, msg.json)
244
+ break
245
+ case 'set-env':
246
+ env = msg.env
247
+ render()
248
+ break
249
+ case 'invoke-action':
250
+ invokeAction(msg.actionId)
251
+ break
252
+ }
253
+ }
254
+
255
+ async function init() {
256
+ const rootEl = document.getElementById('root')
257
+ if (!rootEl) return
258
+ root = createRoot(rootEl)
259
+
260
+ const paths = Object.keys(modules)
261
+ const loaded = await Promise.all(
262
+ paths.map(async path => {
263
+ const mod = await modules[path]()
264
+ return { path, def: mod.default.resolve(w) }
265
+ })
266
+ )
267
+
268
+ resolved = loaded.map(({ path, def }) => ({ id: idFromPath(path), def }))
269
+ entries = sortEntries(
270
+ resolved.map(r => manifestEntry(r.id, r.def)),
271
+ config.groups.map(g => g.slug)
272
+ )
273
+
274
+ window.addEventListener('message', event => {
275
+ if (!isWindoMessage(event.data)) return
276
+ if (!isHostMessage(event.data)) return
277
+ handleMessage(event.data)
278
+ })
279
+
280
+ postToParent({ source: WINDO_MSG, dir: 'preview', type: 'ready' })
281
+ }
282
+
283
+ void init()
@@ -0,0 +1,81 @@
1
+ /* The iframe document is transparent — the chrome paints the canvas grid
2
+ behind it. The root fills the frame; placement classes position the
3
+ rendered component within it. */
4
+
5
+ html,
6
+ body {
7
+ margin: 0;
8
+ background: transparent;
9
+ }
10
+
11
+ #root {
12
+ min-height: 100vh;
13
+ display: flex;
14
+ }
15
+
16
+ .windo-placement {
17
+ flex: 1;
18
+ display: flex;
19
+ box-sizing: border-box;
20
+ }
21
+
22
+ /* Opt-in padding via the `-padding` placement suffix. */
23
+ .windo-placement.padded {
24
+ padding: 24px;
25
+ }
26
+
27
+ .windo-placement.center {
28
+ align-items: center;
29
+ justify-content: center;
30
+ }
31
+
32
+ .windo-placement.top {
33
+ justify-content: center;
34
+ align-items: flex-start;
35
+ }
36
+
37
+ .windo-placement.bottom {
38
+ justify-content: center;
39
+ align-items: flex-end;
40
+ }
41
+
42
+ .windo-placement.left {
43
+ justify-content: flex-start;
44
+ align-items: center;
45
+ }
46
+
47
+ .windo-placement.right {
48
+ justify-content: flex-end;
49
+ align-items: center;
50
+ }
51
+
52
+ .windo-placement.top-left {
53
+ justify-content: flex-start;
54
+ align-items: flex-start;
55
+ }
56
+
57
+ .windo-placement.top-right {
58
+ justify-content: flex-end;
59
+ align-items: flex-start;
60
+ }
61
+
62
+ .windo-placement.bottom-left {
63
+ justify-content: flex-start;
64
+ align-items: flex-end;
65
+ }
66
+
67
+ .windo-placement.bottom-right {
68
+ justify-content: flex-end;
69
+ align-items: flex-end;
70
+ }
71
+
72
+ .windo-placement.fill {
73
+ align-items: stretch;
74
+ justify-content: stretch;
75
+ }
76
+
77
+ .windo-placement.fill > * {
78
+ flex: 1;
79
+ width: 100%;
80
+ height: 100%;
81
+ }