@weft-js/cli 0.1.1 → 0.1.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.
Files changed (171) hide show
  1. package/README.md +38 -14
  2. package/bin/weft.mjs +8 -1
  3. package/docs/async.md +16 -6
  4. package/docs/browser.md +22 -8
  5. package/docs/config.md +39 -9
  6. package/docs/core.md +30 -0
  7. package/docs/deploy.md +33 -8
  8. package/docs/design.md +12 -3
  9. package/docs/entrypoints.md +79 -0
  10. package/docs/fonts.md +12 -2
  11. package/docs/icons.md +13 -7
  12. package/docs/lists.md +3 -1
  13. package/docs/meta.md +13 -3
  14. package/docs/plugins.md +32 -9
  15. package/docs/props.md +13 -2
  16. package/docs/reactivity.md +26 -0
  17. package/docs/rules.md +19 -12
  18. package/docs/rust.md +47 -17
  19. package/docs/start-short.md +3 -2
  20. package/docs/start.md +226 -52
  21. package/docs/state.md +33 -7
  22. package/docs/style.md +31 -8
  23. package/docs/surface.md +61 -15
  24. package/docs/testing.md +15 -5
  25. package/docs/theme.md +182 -0
  26. package/docs/ui-state.md +19 -6
  27. package/docs/verify.md +13 -0
  28. package/package.json +13 -4
  29. package/src/add/config.mjs +93 -0
  30. package/src/add/errors.mjs +2 -0
  31. package/src/add/setup.mjs +88 -0
  32. package/src/add-rust.mjs +82 -27
  33. package/src/add.mjs +209 -181
  34. package/src/agents-md.mjs +144 -43
  35. package/src/alias.mjs +16 -5
  36. package/src/audit/page.mjs +313 -0
  37. package/src/audit/report.mjs +69 -0
  38. package/src/audit/utils.mjs +5 -0
  39. package/src/audit.mjs +159 -206
  40. package/src/browser/cli.mjs +24 -5
  41. package/src/browser/client.mjs +189 -25
  42. package/src/browser/commands.mjs +166 -51
  43. package/src/catalog/audit.mjs +129 -0
  44. package/src/catalog/auth.mjs +40 -0
  45. package/src/catalog/compiler.mjs +337 -0
  46. package/src/catalog/contracts.mjs +18 -0
  47. package/src/catalog/database.mjs +52 -0
  48. package/src/catalog/deploy.mjs +117 -0
  49. package/src/catalog/design.mjs +159 -0
  50. package/src/catalog/forms.mjs +25 -0
  51. package/src/catalog/hydration.mjs +29 -0
  52. package/src/catalog/icons.mjs +24 -0
  53. package/src/catalog/media.mjs +163 -0
  54. package/src/catalog/metadata.mjs +116 -0
  55. package/src/catalog/plugins.mjs +228 -0
  56. package/src/catalog/realtime.mjs +195 -0
  57. package/src/catalog/rust.mjs +75 -0
  58. package/src/catalog/server-functions.mjs +92 -0
  59. package/src/catalog/state.mjs +39 -0
  60. package/src/catalog/style-groups.mjs +54 -0
  61. package/src/catalog/tests.mjs +127 -0
  62. package/src/catalog/text.mjs +200 -0
  63. package/src/catalog/ui-state.mjs +42 -0
  64. package/src/catalog.mjs +67 -460
  65. package/src/check/runtime.mjs +280 -0
  66. package/src/check.mjs +198 -208
  67. package/src/cli/authoring.mjs +213 -0
  68. package/src/cli/project.mjs +302 -0
  69. package/src/cli.mjs +222 -250
  70. package/src/config.mjs +1 -1
  71. package/src/contributions.mjs +75 -25
  72. package/src/db-layer.mjs +13 -5
  73. package/src/deploy/cdn.mjs +94 -16
  74. package/src/deploy/files.mjs +100 -21
  75. package/src/deploy/fit.mjs +127 -23
  76. package/src/deploy/hosts.mjs +90 -20
  77. package/src/deploy/index/cli.mjs +20 -0
  78. package/src/deploy/index/environment.mjs +69 -0
  79. package/src/deploy/index/steps.mjs +87 -0
  80. package/src/deploy/index.mjs +228 -114
  81. package/src/deploy/needs.mjs +88 -31
  82. package/src/design-checks/accessible-names.mjs +62 -0
  83. package/src/design-checks/contrast.mjs +43 -0
  84. package/src/design-checks/csp.mjs +60 -0
  85. package/src/design-checks/files.mjs +59 -0
  86. package/src/design-checks/heading-links.mjs +61 -0
  87. package/src/design-checks/interaction.mjs +65 -0
  88. package/src/design-checks/jsx.mjs +67 -0
  89. package/src/design-checks/layout-values.mjs +68 -0
  90. package/src/design-checks/native-controls.mjs +48 -0
  91. package/src/design-checks/outline.mjs +39 -0
  92. package/src/design-checks/reporter.mjs +37 -0
  93. package/src/design-checks/rules.mjs +274 -0
  94. package/src/design-checks/values.mjs +262 -0
  95. package/src/design-checks.mjs +10 -400
  96. package/src/diag.mjs +32 -8
  97. package/src/docs.mjs +138 -43
  98. package/src/doctor.mjs +20 -8
  99. package/src/files.mjs +18 -5
  100. package/src/fix.mjs +10 -2
  101. package/src/gate/after.mjs +40 -0
  102. package/src/gate/before.mjs +54 -0
  103. package/src/gate/classify.mjs +72 -0
  104. package/src/gate/mutation.mjs +209 -0
  105. package/src/gate/resolve-cases.mjs +22 -0
  106. package/src/gate/result.mjs +63 -0
  107. package/src/gate/run.mjs +75 -0
  108. package/src/gate.mjs +104 -202
  109. package/src/infer/box-props.cjs +140 -0
  110. package/src/infer/core-boxes.cjs +46 -0
  111. package/src/infer/derived-writes.cjs +95 -0
  112. package/src/infer/engine/checks.cjs +193 -0
  113. package/src/infer/engine/flow.cjs +293 -0
  114. package/src/infer/engine/report.cjs +158 -0
  115. package/src/infer/engine/utils.cjs +15 -0
  116. package/src/infer/engine.cjs +108 -368
  117. package/src/infer/facts/sites.cjs +114 -0
  118. package/src/infer/facts.cjs +210 -117
  119. package/src/infer/hosts.cjs +69 -22
  120. package/src/infer/lsp.mjs +149 -40
  121. package/src/infer/session.cjs +73 -17
  122. package/src/infer/shadow.cjs +13 -5
  123. package/src/infer/worker.cjs +57 -9
  124. package/src/jsx-scan.mjs +63 -21
  125. package/src/load-config/schema.mjs +281 -0
  126. package/src/load-config.mjs +137 -138
  127. package/src/map.mjs +93 -26
  128. package/src/mcp/browser-tools.mjs +155 -0
  129. package/src/mcp.mjs +147 -69
  130. package/src/meta-table.mjs +43 -8
  131. package/src/mutate.mjs +120 -36
  132. package/src/plugin-checks.mjs +52 -23
  133. package/src/plugins.mjs +125 -39
  134. package/src/project.mjs +170 -79
  135. package/src/props-checks.mjs +24 -6
  136. package/src/props-listener.mjs +27 -9
  137. package/src/react-checks.mjs +35 -4
  138. package/src/server-state-checks.mjs +34 -0
  139. package/src/share-checks.mjs +121 -37
  140. package/src/site.mjs +33 -6
  141. package/src/slots.mjs +104 -19
  142. package/src/start/build.mjs +116 -0
  143. package/src/start/routes.mjs +38 -0
  144. package/src/start.mjs +193 -95
  145. package/src/state-checks/core-state.mjs +223 -0
  146. package/src/state-checks/modules.mjs +17 -0
  147. package/src/state-checks/scan.mjs +232 -0
  148. package/src/state-checks/server-graph.mjs +65 -0
  149. package/src/state-checks.mjs +109 -133
  150. package/src/style/find.mjs +121 -0
  151. package/src/style.mjs +133 -127
  152. package/src/surface-checks.mjs +212 -46
  153. package/src/tailwind.mjs +5 -4
  154. package/src/tasks.mjs +189 -27
  155. package/src/test-rules/context.mjs +109 -0
  156. package/src/test-rules/file-rules.mjs +279 -0
  157. package/src/test-rules/rule-helpers.mjs +244 -0
  158. package/src/test-rules/syntax.mjs +223 -0
  159. package/src/test-rules/test-rules.mjs +127 -0
  160. package/src/testdiff.mjs +94 -32
  161. package/src/tests.mjs +31 -483
  162. package/src/text-checks.mjs +229 -69
  163. package/src/tokens.mjs +12 -4
  164. package/src/trust/scan.mjs +95 -0
  165. package/src/trust.mjs +214 -102
  166. package/src/weft.mjs +41 -9
  167. package/src/workspace.mjs +96 -19
  168. package/ts-plugin/context.js +57 -0
  169. package/ts-plugin/index.js +4 -124
  170. package/ts-plugin/mapping.js +65 -0
  171. package/ts-plugin/proxy.js +144 -0
package/docs/surface.md CHANGED
@@ -11,19 +11,32 @@ import { z } from 'zod'
11
11
  export const Bars = defineSurface({
12
12
  name: 'bars',
13
13
  kind: 'canvas2d', // or 'webgl2'
14
- props: z.object({ data: z.array(z.number()) }), // or defineSurface<{ data: number[] }, 'canvas2d'>({ ... })
15
- setup(s, props) { // runs once; `props.data` is live, so never destructure it
16
- s.draw(() => { // repaints when a box it read changes; the canvas is cleared first
17
- const { width, height } = s.size.value // CSS px, live
18
- s.ctx.fillStyle = s.tokens.accent.css // live theme colours: `.css`, or `.linear` for shaders
19
- props.data.forEach((v, i) => s.ctx.fillRect(i * 12, height - v, 10, v))
14
+ // Or defineSurface<{ data: number[] }, 'canvas2d'>({ ... })
15
+ props: z.object({ data: z.array(z.number()) }),
16
+ // Runs once; props.data is live, so never destructure it.
17
+ setup(s, props) {
18
+ // Repaints on box changes; the canvas is cleared first.
19
+ s.draw(() => {
20
+ const { width, height } = s.size.value // CSS px, live
21
+ // Live theme colours: .css, or .linear for shaders.
22
+ s.ctx.fillStyle = s.tokens.accent.css
23
+ props.data.forEach((v, i) => {
24
+ s.ctx.fillRect(i * 12, height - v, 10, v)
25
+ })
20
26
  })
21
27
  },
22
28
  })
23
29
 
24
30
  export function Page() {
25
31
  const rows = state([3, 7, 5])
26
- return <Surface use={Bars} data={rows.value} aspect="16/9" label="Sales by month" />
32
+ return (
33
+ <Surface
34
+ use={Bars}
35
+ data={rows.value}
36
+ aspect="16/9"
37
+ label="Sales by month"
38
+ />
39
+ )
27
40
  }
28
41
  ```
29
42
 
@@ -33,21 +46,43 @@ A control beside the surface needs only a box and a live prop. Nothing re-render
33
46
  import { state } from '@weft-js/core'
34
47
  import { defineSurface, Surface } from '@weft-js/core/surface'
35
48
 
36
- export const Stars = defineSurface<{ density: number }, 'canvas2d'>({ // name the kind too, so `s.ctx` is typed
49
+ // Name the kind too, so s.ctx is typed.
50
+ export const Stars = defineSurface<{ density: number }, 'canvas2d'>({
37
51
  name: 'stars',
38
52
  kind: 'canvas2d',
39
53
  setup(s, props) {
40
- s.frame(({ seconds }) => { // every tick; reading `props.density` here keeps it live
41
- for (let i = 0; i < props.density; i++) s.ctx.fillRect((i * 37 + seconds * 20) % s.size.value.width, (i * 91) % s.size.value.height, 1, 1)
54
+ // Every tick; reading props.density here keeps it live.
55
+ s.frame(({ seconds }) => {
56
+ for (let i = 0; i < props.density; i++) {
57
+ const { width, height } = s.size.value
58
+ const x = (i * 37 + seconds * 20) % width
59
+ const y = (i * 91) % height
60
+ s.ctx.fillRect(x, y, 1, 1)
61
+ }
42
62
  })
43
63
  },
44
64
  })
45
65
 
46
66
  export function Sky() {
47
67
  const density = state(100)
68
+ const setDensity = (e: Event & {
69
+ currentTarget: HTMLInputElement
70
+ }) => (density.value = e.currentTarget.valueAsNumber)
48
71
  return <>
49
- <input type="range" min="10" max="500" value={density.value} onInput={e => (density.value = e.currentTarget.valueAsNumber)} aria-label="Density" />
50
- <Surface use={Stars} density={density.value} aspect="16/9" decorative />
72
+ <input
73
+ type="range"
74
+ min="10"
75
+ max="500"
76
+ value={density.value}
77
+ onInput={setDensity}
78
+ aria-label="Density"
79
+ />
80
+ <Surface
81
+ use={Stars}
82
+ density={density.value}
83
+ aspect="16/9"
84
+ decorative
85
+ />
51
86
  </>
52
87
  }
53
88
  ```
@@ -78,7 +113,10 @@ Never `fillText` (X010). `s.text()` keeps a real element over the text, so selec
78
113
  forced colours and print all work:
79
114
 
80
115
  ```tsx
81
- const score = s.text(() => `Score ${props.score}`, { x: 8, y: 8, width: 200, font: '600 16px Inter', lineHeight: 22 })
116
+ const score = s.text(() => `Score ${props.score}`, {
117
+ x: 8, y: 8, width: 200,
118
+ font: '600 16px Inter', lineHeight: 22,
119
+ })
82
120
  ```
83
121
 
84
122
  - Options may be a function, for live positions: `() => ({ x: s.size.value.width / 2, ... })`.
@@ -95,7 +133,13 @@ jsdom has no canvas, so `@weft-js/core/test` gives it a recording one:
95
133
 
96
134
  <!-- example: skip imports the Page above from its own file -->
97
135
  ```tsx
98
- import { canvasCalls, clock, fakeMeasure, flush, render } from '@weft-js/core/test'
136
+ import {
137
+ canvasCalls,
138
+ clock,
139
+ fakeMeasure,
140
+ flush,
141
+ render,
142
+ } from '@weft-js/core/test'
99
143
  import { expect, test } from 'vitest'
100
144
  import { Page } from './Page' // the Bars page above
101
145
 
@@ -104,7 +148,9 @@ test('draws a bar per value', async () => {
104
148
  render(Page, {}, { slots: { 'text.measure': fakeMeasure() } })
105
149
  await flush()
106
150
  await clock.advance(16) // a frame
107
- expect(canvasCalls(document.querySelector('canvas')!).filter(c => c.name === 'fillRect')).toHaveLength(3)
151
+ const canvas = document.querySelector('canvas')!
152
+ const bars = canvasCalls(canvas).filter(c => c.name === 'fillRect')
153
+ expect(bars).toHaveLength(3)
108
154
  })
109
155
  ```
110
156
 
package/docs/testing.md CHANGED
@@ -3,7 +3,8 @@
3
3
  A new app has no test runner. Add one:
4
4
 
5
5
  ```bash
6
- npm install -D vitest jsdom @testing-library/dom @testing-library/user-event
6
+ npm install -D vitest jsdom @testing-library/dom \
7
+ @testing-library/user-event
7
8
  ```
8
9
 
9
10
  <!-- example: skip @weft-js/core/vite has no type declarations yet (TS7016 under strict) -->
@@ -12,7 +13,10 @@ npm install -D vitest jsdom @testing-library/dom @testing-library/user-event
12
13
  import weft from '@weft-js/core/vite'
13
14
  import { defineConfig } from 'vitest/config'
14
15
 
15
- export default defineConfig({ plugins: [weft()], test: { environment: 'jsdom' } })
16
+ export default defineConfig({
17
+ plugins: [weft()],
18
+ test: { environment: 'jsdom' },
19
+ })
16
20
  ```
17
21
 
18
22
  Then `npx vitest run`. A test:
@@ -24,14 +28,20 @@ import { expect, test } from 'vitest'
24
28
 
25
29
  function Cart() {
26
30
  const count = state(0)
27
- return <button type="button" onClick={() => count.value++}>Cart ({count.value})</button>
31
+ return (
32
+ <button type="button" onClick={() => count.value++}>
33
+ Cart ({count.value})
34
+ </button>
35
+ )
28
36
  }
29
37
 
30
38
  test('adding to the cart', async () => {
31
39
  render(Cart)
32
40
  await user.click(screen.getByRole('button', { name: 'Cart (0)' }))
33
- expect(screen.getByRole('button', { name: 'Cart (1)' })).toBeTruthy()
34
- await expect(() => user.click(screen.getByRole('button'))).toTouchAtMost(1, { runs: 2 })
41
+ expect(screen.getByRole('button', { name: 'Cart (1)' }))
42
+ .toBeTruthy()
43
+ const click = () => user.click(screen.getByRole('button'))
44
+ await expect(click).toTouchAtMost(1, { runs: 2 })
35
45
  })
36
46
  ```
37
47
 
package/docs/theme.md ADDED
@@ -0,0 +1,182 @@
1
+ # Theme: changing warp's look
2
+
3
+ Three levels, in order. Stop at the first that gets you there; each one keeps the contrast check.
4
+
5
+ 1. `warp({ seed, dials, fields, radius, fonts, tokens })` in the weft config. Checked by D010 at the stops `dials` leave.
6
+ For your own names, per-element looks and named themes: `colors`, `system`, `parts`, `themes` (below).
7
+ 2. `src/theme.css`, unlayered: `[data-scope]` and `[data-part]` rules beat warp with no `!important`.
8
+ 3. Your own component, with warp's tokens.
9
+
10
+ The Theme studio on the docs site (`/components/theme-studio`) edits level 1 with live components and contrast
11
+ ratios, and copies the result as `weft.config.ts`.
12
+
13
+ ## warp() options
14
+
15
+ | Option | Does |
16
+ |---|---|
17
+ | `seed.accent` | The hue family: `#rrggbb`, a name from `colors`, or `{ hue, chroma }`. Each theme solves its own lightness, and `weft build` prints where it landed |
18
+ | `seed.neutral` | The tint of pages, surfaces and text, `#rrggbb` or `{ hue, chroma }` |
19
+ | `dials` | Default `expression` (quiet balanced vivid), `density`, `flair`, `theme`. With `theme: 'light'`, dark isn't checked |
20
+ | `fields` | `floating` (the default: the label floats in a notch) or `classic` (the label above the box) |
21
+ | `radius` | `sharp` (2px), `soft`, `round`. Below 2px, set the radius tokens |
22
+ | `fonts` | `body`, `display`, `mono`: a family or a whole stack |
23
+ | `tokens` | Any `--warp-*`, set on every scope. Colours are `#rrggbb`, `oklch()`, `color-mix()` or `light-dark()` |
24
+
25
+ App-wide values go in `warp()`. A `--warp-color-*` set in CSS on `:root` skips the check and is undone inside any scope that
26
+ sets a dial, such as a Table's density.
27
+
28
+ ## colors, system, parts, themes
29
+
30
+ | Option | Does |
31
+ |---|---|
32
+ | `colors` | `{ brand: '#e8590c' }`: `--warp-color-brand`, a tone (`tone="brand"`), and `bg-brand` via `.weft/tokens/warp.tailwind.css`. Lowercase names, `#rrggbb` |
33
+ | `system` | The scales: `radius`, `space`, `pad`, `control`, `font`, `shadow`, `duration`, `ease`, `border`, `icon`, `text.<role>` |
34
+ | `parts` | One element, at rest and per state (`hover focus press active open disabled`). Numbers are px (ms for `duration`); names are system steps or `colors` |
35
+ | `themes` | `{ ocean: { seed, colors, system, parts } }` under `[data-palette="ocean"]`. `dials.palette` sets the default; `setPalette()` switches |
36
+
37
+ Every part takes `bg text border borderWidth radius padding paddingX paddingY gap margin height minHeight width size
38
+ shadow opacity scale duration easing font fontSize weight leading tracking case`. A part's own keys add to those or
39
+ replace one of the same name with its own meaning (an underline's `width` is its thickness):
40
+
41
+ | Part | For | Own keys | States |
42
+ |---|---|---|---|
43
+ | `button` | Button and ButtonLink | `bg` `text` `border` `radius` | hover focus press active open disabled |
44
+ | `button.soft` | | `bg` `text` `border` | hover focus press active open disabled |
45
+ | `button.outline` | | `bg` `text` `border` | hover focus press active open disabled |
46
+ | `button.ghost` | | `bg` `text` `border` | hover focus press active open disabled |
47
+ | `button.frame` | | `bg` `text` `border` | hover focus press active open disabled |
48
+ | `button.link` | | `bg` `text` `border` | hover focus press active open disabled |
49
+ | `iconButton` | A button that holds only an icon (copy, close, menu): square by default | `radius` `icon` | hover focus press active open disabled |
50
+ | `copy` | CopyButton | `radius` | hover focus press active open disabled |
51
+ | `close` | The close button of a dialog, sheet, drawer, alert or side nav | — | hover focus press active open disabled |
52
+ | `link` | Link: the text link with the drawn underline | — | hover focus press active open disabled |
53
+ | `link.underline` | | `color` `width` `offset` | hover focus |
54
+ | `nav` | Nav: the row or column of links with the sliding bar | — | — |
55
+ | `nav.item` | | — | hover focus press active open disabled |
56
+ | `nav.underline` | | `color` `width` `duration` | — |
57
+ | `tabs` | Tabs | — | — |
58
+ | `tabs.tab` | | `radius` | hover focus press active open disabled |
59
+ | `tabs.underline` | | `color` `width` | — |
60
+ | `pill` | Pill and PillLink | `radius` | hover focus press active open disabled |
61
+ | `badge` | Badge | — | hover focus press active open disabled |
62
+ | `chip` | Chip | `radius` | hover focus press active open disabled |
63
+ | `card` | Card and Surface | — | hover focus press active open disabled |
64
+ | `field` | Text fields, selects and pickers | `bg` `radius` | hover focus press active open disabled |
65
+ | `field.label` | | `text` | hover focus |
66
+ | `sweep` | The sweep: the stroke that draws round a control on hover and focus, and the text while it runs | `stroke` `base` `text` `width` `duration` | — |
67
+ | `dialog` | Modal and Dialog | — | — |
68
+ | `sheet` | Sheet and Drawer | — | — |
69
+ | `popover` | Popover, Menu and Tooltip panels | — | — |
70
+ | `toast` | Toast | — | — |
71
+
72
+ A solid's `bg` and `text` move its tone channels, so hover, pull-out and sweep follow. `hover.bg` is the hover fill. Text
73
+ set on a fill set in the same part is checked like a seed: below 4.5:1 is D010. Each named theme's seed is fitted the
74
+ same way as the main one.
75
+
76
+ ```ts
77
+ warp({
78
+ colors: { brand: '#e8590c', ink: '#1b1b1f' },
79
+ parts: {
80
+ button: {
81
+ bg: 'brand',
82
+ text: 'ink',
83
+ radius: 'pill',
84
+ hover: { bg: 'ink', text: 'white' },
85
+ },
86
+ sweep: { stroke: 'brand', width: 2 },
87
+ nav: {
88
+ item: { hover: { text: 'brand' } },
89
+ underline: { color: 'brand', width: 3 },
90
+ },
91
+ },
92
+ themes: {
93
+ ocean: {
94
+ seed: { accent: '#0b7285' },
95
+ colors: { brand: '#0b7285' },
96
+ },
97
+ },
98
+ })
99
+ ```
100
+
101
+ ## Brutalist
102
+
103
+ ```ts
104
+ warp({
105
+ dials: { expression: 'quiet', flair: 'none' },
106
+ fonts: {
107
+ display: 'ui-monospace, monospace',
108
+ body: 'ui-monospace, monospace',
109
+ },
110
+ tokens: {
111
+ '--warp-radius-mark': '0px',
112
+ '--warp-radius-control': '0px',
113
+ '--warp-radius-surface': '0px',
114
+ },
115
+ })
116
+ ```
117
+
118
+ ```css
119
+ /* src/theme.css */
120
+ [data-scope="button"] {
121
+ --warp-tone-edge: var(--warp-color-text);
122
+ border-width: 2px;
123
+ box-shadow: 4px 4px 0 var(--warp-color-text);
124
+ }
125
+ [data-scope="surface"] {
126
+ border: 2px solid var(--warp-color-text);
127
+ box-shadow: none;
128
+ }
129
+ ```
130
+
131
+ Raw colours and shadows in `src/theme.css` or on a `[data-scope]` rule are D001 and D003 warnings, not errors.
132
+
133
+ ## Warm editorial
134
+
135
+ ```ts
136
+ warp({
137
+ seed: { accent: '#9a3412', neutral: { hue: 70, chroma: 0.03 } },
138
+ dials: { expression: 'vivid' },
139
+ fonts: { display: '"Iowan Old Style", Georgia, serif' },
140
+ })
141
+ ```
142
+
143
+ ## An exact brand
144
+
145
+ A seed gives the hue, not the colour. To pin it, set the fill and its hover for both themes. One alone never re-derives the other.
146
+
147
+ ```ts
148
+ warp({
149
+ seed: { accent: '#c8102e' },
150
+ tokens: {
151
+ '--warp-color-accent-solid': 'light-dark(#c8102e, #e5484d)',
152
+ '--warp-color-accent-solid-hover': 'light-dark(#a50d26, #f2666a)',
153
+ },
154
+ })
155
+ ```
156
+
157
+ ## Button tone channels
158
+
159
+ A rule that sets a button's `background` leaves its hover layer on the accent. Set the channels and fill, ink, edge and hover
160
+ stay together, at every emphasis:
161
+
162
+ | Channel | Paints |
163
+ |---|---|
164
+ | `--warp-tone-fill` | The background |
165
+ | `--warp-tone-ink` | The text |
166
+ | `--warp-tone-edge` | The border |
167
+ | `--warp-tone-layer` | The hover layer that wipes across |
168
+
169
+ ```css
170
+ [data-scope="button"][data-emphasis="solid"] {
171
+ --warp-tone-fill: #111111;
172
+ --warp-tone-ink: #ffffff;
173
+ --warp-tone-layer: #333333;
174
+ }
175
+ ```
176
+
177
+ The channels are on Button only. Fields read `--warp-field-*`, tables `--warp-table-*`.
178
+
179
+ ## D010
180
+
181
+ Each failing pair is named with its stop. Change that token or the seed, or limit the stops with `dials`. `weft build` stops on
182
+ it, because a refused config would ship warp's default look.
package/docs/ui-state.md CHANGED
@@ -11,8 +11,13 @@ type Todo = { id: string; title: string }
11
11
 
12
12
  export function TodoRow({ todo }: { todo: Todo }) {
13
13
  const row = interaction({ share: () => `todo:${todo.id}` })
14
- const seen = derived(() => (row.remote.value.hover ? 'someone is here' : ''))
15
- watch(() => row.hover.value, on => { if (on) void fetch(`/api/todos/${todo.id}`) }) // only ever your own hover
14
+ const seen = derived(() =>
15
+ row.remote.value.hover ? 'someone is here' : '',
16
+ )
17
+ // Only your own hover triggers the request.
18
+ watch(() => row.hover.value, on => {
19
+ if (on) void fetch(`/api/todos/${todo.id}`)
20
+ })
16
21
  return (
17
22
  <li>
18
23
  <Checkbox ui={row}>{todo.title}</Checkbox>
@@ -53,7 +58,10 @@ Sharing with other clients is off until you say who may, in `src/realtime.server
53
58
  ```ts
54
59
  import { realtime } from '@weft-js/start'
55
60
 
56
- export default realtime({ share: ({ ctx, key }) => ctx.user !== null && key.startsWith('todo:') })
61
+ export default realtime({
62
+ share: ({ ctx, key }) =>
63
+ ctx.user !== null && key.startsWith('todo:'),
64
+ })
57
65
  ```
58
66
 
59
67
  That rule runs once per key. If it reads a database, a 200-row list means 200 queries, so answer them together instead. You get up to 400 keys in one call and give back the ones allowed:
@@ -61,11 +69,16 @@ That rule runs once per key. If it reads a database, a 200-row list means 200 qu
61
69
  ```ts
62
70
  import { realtime } from '@weft-js/start'
63
71
 
64
- // visibleKeys is yours: one query that returns the keys this user may see.
65
- declare function visibleKeys(userId: string, keys: string[]): Promise<string[]>
72
+ // One query returns the keys this user may see.
73
+ declare function visibleKeys(
74
+ userId: string, keys: string[],
75
+ ): Promise<string[]>
66
76
 
67
77
  export default realtime({
68
- share: { keys: async ({ ctx, keys }) => (ctx.user ? visibleKeys(ctx.user.id, keys) : []) },
78
+ share: {
79
+ keys: async ({ ctx, keys }) =>
80
+ ctx.user ? visibleKeys(ctx.user.id, keys) : [],
81
+ },
69
82
  })
70
83
  ```
71
84
 
package/docs/verify.md CHANGED
@@ -46,3 +46,16 @@ touched.
46
46
 
47
47
  Tests added for code that didn't change have nothing to fail on: fail-before is skipped, and the mutants come from
48
48
  the source files the tests import. `--refactor` promises no behavior change: then no test may change.
49
+
50
+
51
+ ## Indexing intent
52
+
53
+ For a site intended for public search, declare
54
+ `build: { audit: { indexing: 'public' } }`. L024 then fails the build or audit if
55
+ robots metadata or an HTTP X-Robots-Tag header says noindex. `private` or the
56
+ backward-compatible `auto` leaves this a warning; neither removes a directive.
57
+ After deployment, run `weft audit --url https://your-site.example --indexing public`:
58
+ a host or proxy may add a header that a local build cannot see. Preserve intentional
59
+ preview/private exclusions. A noindex finding is an indexing policy issue, not
60
+ chat-agent traffic; use a clean profile before attributing other audit failures
61
+ to application or framework code.
package/package.json CHANGED
@@ -1,8 +1,17 @@
1
1
  {
2
2
  "name": "@weft-js/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "The weft command: dev, build, check, the agent layer and the MCP server",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Ravonus/weft.git",
9
+ "directory": "packages/cli"
10
+ },
11
+ "homepage": "https://github.com/Ravonus/weft/tree/main/packages/cli#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/Ravonus/weft/issues"
14
+ },
6
15
  "type": "module",
7
16
  "bin": {
8
17
  "weft": "./bin/weft.mjs"
@@ -15,14 +24,14 @@
15
24
  "node": ">=26"
16
25
  },
17
26
  "dependencies": {
18
- "@weft-js/create": "0.1.1",
27
+ "@weft-js/create": "0.1.2",
19
28
  "axe-core": "4.14.0",
20
29
  "typescript": "6.0.3",
21
30
  "yaml": "2.9.1",
22
31
  "zod": "4.6.7"
23
32
  },
24
33
  "peerDependencies": {
25
- "@weft-js/core": "^0.1.1"
34
+ "@weft-js/core": "^0.1.2"
26
35
  },
27
36
  "publishConfig": {
28
37
  "access": "public"
@@ -38,4 +47,4 @@
38
47
  "README.md",
39
48
  "LICENSE"
40
49
  ]
41
- }
50
+ }
@@ -0,0 +1,93 @@
1
+ import { createRequire } from 'node:module'
2
+ import { join } from 'node:path'
3
+ import ts from 'typescript'
4
+ import { pluginFactory, pluginName } from '../load-config.mjs'
5
+ import { refused } from './errors.mjs'
6
+
7
+ /** The import to write: `<pkg>/plugin` when the package has one, and however it exports its factory. */
8
+ function pluginImport(root, pkg) {
9
+ const require = createRequire(join(root, 'package.json'))
10
+ const name = pluginName(pkg)
11
+ for (const spec of [`${pkg}/plugin`, pkg]) {
12
+ let path
13
+ try {
14
+ path = require.resolve(spec)
15
+ } catch {
16
+ continue
17
+ }
18
+ const factory = pluginFactory(createRequire(import.meta.url)(path), pkg)
19
+ if (!factory)
20
+ throw refused(`${spec} exports no plugin factory to call, so nothing was changed.`)
21
+ return {
22
+ name: factory.name,
23
+ line: `import ${factory.named ? `{ ${factory.name} }` : factory.name} from '${spec}'`,
24
+ }
25
+ }
26
+ return { name, line: `import ${name} from '${pkg}'` } // not installed yet: only a dry run gets this far
27
+ }
28
+
29
+ /** The object literal `export default` gives, directly or through `defineConfig(…)`. */
30
+ function configObject(sf) {
31
+ for (const st of sf.statements) {
32
+ if (!ts.isExportAssignment(st)) continue
33
+ const value = ts.isCallExpression(st.expression) ? st.expression.arguments[0] : st.expression
34
+ if (value && ts.isObjectLiteralExpression(value)) return value
35
+ }
36
+ return null
37
+ }
38
+
39
+ export function addToTs(root, file, text, pkg) {
40
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true)
41
+ const object = configObject(sf)
42
+ if (!object)
43
+ throw refused(
44
+ `${file} doesn't export a plain object, so \`weft add\` can't edit it. Add \`${pkg}\` to \`plugins\` by hand.`,
45
+ )
46
+ const { name, line } = pluginImport(root, pkg)
47
+ const prop = object.properties.find((p) => p.name?.getText(sf) === 'plugins')
48
+ if (prop && !(ts.isPropertyAssignment(prop) && ts.isArrayLiteralExpression(prop.initializer)))
49
+ throw refused(
50
+ `\`plugins\` in ${file} isn't a plain array, so \`weft add\` can't edit it. Add \`${pkg}\` by hand.`,
51
+ )
52
+ const edits = []
53
+ const imports = sf.statements.filter(ts.isImportDeclaration)
54
+ const after = imports.at(-1)?.end ?? 0
55
+ edits.push([after, `${imports.length ? '\n' : ''}${line}${imports.length ? '' : '\n'}`])
56
+ if (prop) {
57
+ const list = prop.initializer
58
+ const last = list.elements.at(-1)
59
+ edits.push(last ? [last.end, `, ${name}()`] : [list.getStart(sf) + 1, `${name}()`])
60
+ } else {
61
+ const last = object.properties.at(-1)
62
+ edits.push(
63
+ last
64
+ ? [
65
+ object.properties.end,
66
+ `${object.properties.hasTrailingComma ? '' : ','}\n plugins: [${name}()],`,
67
+ ]
68
+ : [object.getStart(sf) + 1, `\n plugins: [${name}()],\n`],
69
+ )
70
+ }
71
+ return edits
72
+ .toSorted((a, b) => b[0] - a[0])
73
+ .reduce((t, [at, insert]) => t.slice(0, at) + insert + t.slice(at), text)
74
+ }
75
+
76
+ export function addToData(file, text, pkg) {
77
+ if (file.endsWith('.json')) {
78
+ const data = JSON.parse(text)
79
+ data.plugins = [...(data.plugins ?? []), pkg]
80
+ return `${JSON.stringify(data, null, 2)}\n`
81
+ }
82
+ const { parseDocument } = createRequire(import.meta.url)('yaml')
83
+ const doc = parseDocument(text)
84
+ if (doc.has('plugins')) doc.addIn(['plugins'], pkg)
85
+ else doc.set('plugins', [pkg])
86
+ return doc.toString()
87
+ }
88
+
89
+ export const listed = (config, pkg) => (config.plugins ?? []).some((p) => p.name === pkg)
90
+
91
+ /** The config names the package already (imported or listed), which a read can tell without loading it. */
92
+ export const names = (text, pkg) =>
93
+ new RegExp(`['"]${pkg.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}(/[\\w-]+)?['"]`).test(text)
@@ -0,0 +1,2 @@
1
+ export const usageError = (message) => Object.assign(new Error(message), { code: 'E_USAGE' })
2
+ export const refused = (message) => Object.assign(new Error(message), { code: 'E_ADD' })
@@ -0,0 +1,88 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { dirname, join } from 'node:path'
4
+ import { pathToFileURL } from 'node:url'
5
+ import { database } from '../map.mjs'
6
+ import { dbLayer } from '../db-layer.mjs'
7
+ import { refused } from './errors.mjs'
8
+
9
+ /** The plugin's `setup`, once the package is installed: a plain import, since the package can't run anything by being read. */
10
+ export async function setupOf(root, pkg) {
11
+ let path
12
+ try {
13
+ path = createRequire(join(root, 'package.json')).resolve(`${pkg}/plugin`)
14
+ } catch {
15
+ return undefined // no plugin entry, or a dry run before the install
16
+ }
17
+ const mod = await import(pathToFileURL(path).href)
18
+ const make = mod.default ?? Object.values(mod).find((v) => typeof v === 'function')
19
+ return make?.().contribute({ command: 'check', mode: 'development', root }).setup
20
+ }
21
+
22
+ /** What `setup` wants, checked in full before anything is written, so a refusal changes nothing. */
23
+ export async function planSetup(root, setup) {
24
+ const layer = await dbLayer(root)
25
+ if (setup.needs?.includes('db') && !layer)
26
+ throw refused('This needs a database: run `weft add db` first. Nothing was changed.')
27
+ if (setup.needs?.includes('auth') && !existsSync(join(root, 'src/auth.server.ts')))
28
+ throw refused('This needs users: run `weft add auth` first. Nothing was changed.')
29
+ const read = (rel) =>
30
+ existsSync(join(root, rel)) ? readFileSync(join(root, rel), 'utf8') : undefined
31
+ let plan
32
+ try {
33
+ plan = setup.run({
34
+ engine: layer ? database(root, layer).engine : null,
35
+ layer: layer?.layer ?? null,
36
+ read,
37
+ })
38
+ } catch (error) {
39
+ throw refused(`${error.message}. Nothing was changed.`)
40
+ }
41
+ const taken = Object.keys(plan.files ?? {}).filter((f) => existsSync(join(root, f)))
42
+ if (taken.length) throw refused(`${taken.join(', ')} already exist. Nothing was changed.`)
43
+ return plan
44
+ }
45
+
46
+ export function applySetup(root, plan) {
47
+ for (const [file, text] of Object.entries({ ...plan.files, ...plan.edits })) {
48
+ mkdirSync(dirname(join(root, file)), { recursive: true })
49
+ writeFileSync(join(root, file), text)
50
+ }
51
+ for (const name of ['.env', '.env.example']) {
52
+ const file = join(root, name)
53
+ const had = existsSync(file) ? readFileSync(file, 'utf8') : ''
54
+ const lines = Object.entries(plan.env ?? {})
55
+ .filter(([k]) => !had.includes(`${k}=`))
56
+ .map(([k, v]) => `${k}=${v}\n`)
57
+ if (lines.length) writeFileSync(file, `${had && `${had.trimEnd()}\n`}${lines.join('')}`)
58
+ }
59
+ }
60
+
61
+ export const setupActions = (plan) => [
62
+ ...Object.keys({ ...plan.files, ...plan.edits }).map((f) => `write ${f}`),
63
+ ...(plan.env ? [`add ${Object.keys(plan.env).join(', ')} to .env and .env.example`] : []),
64
+ ...(plan.next ?? []).map((n) => `next: ${n}`),
65
+ ]
66
+
67
+ export function authEdits({ root, files, context, tables, envText, layer }) {
68
+ const edits = { ...files, 'src/context.server.ts': context }
69
+ if (!envText.includes('BETTER_AUTH_SECRET'))
70
+ edits['src/env.server.ts'] = envText.replace(
71
+ 'server: {\n',
72
+ 'server: {\n BETTER_AUTH_SECRET: z.string().min(32),\n',
73
+ )
74
+ if (layer.layer === 'drizzle') {
75
+ edits['src/db/auth.schema.ts'] = tables
76
+ edits['src/db/schema.ts'] =
77
+ `${readFileSync(join(root, 'src/db/schema.ts'), 'utf8').trimEnd()}\n\nexport * from './auth.schema'\n`
78
+ } else if (layer.layer === 'prisma') {
79
+ edits['prisma/schema.prisma'] =
80
+ `${readFileSync(join(root, 'prisma/schema.prisma'), 'utf8').trimEnd()}\n${tables}`
81
+ } else {
82
+ const next = existsSync(join(root, 'migrations'))
83
+ ? readdirSync(join(root, 'migrations')).filter((f) => f.endsWith('.sql')).length
84
+ : 0
85
+ edits[`migrations/${String(next).padStart(4, '0')}_auth.sql`] = tables
86
+ }
87
+ return edits
88
+ }