@zvndev/darkmown 2.6.1-next.11.g82bb8d7 → 2.7.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 (73) hide show
  1. package/AGENTS.md +392 -77
  2. package/CHANGELOG.md +47 -0
  3. package/README.md +316 -49
  4. package/docs/cli.md +51 -17
  5. package/docs/spec-alignment.md +25 -4
  6. package/grammar/wd-directives.gbnf +13 -5
  7. package/package.json +7 -3
  8. package/site/_/base.skin +1 -1
  9. package/site/_/footer.wd +1 -1
  10. package/site/pages/api-errors/index.wd +81 -0
  11. package/site/pages/app.wd +1 -1
  12. package/site/pages/attr-binding/index.wd +84 -0
  13. package/site/pages/blog/darkmown-2-7-0.md +176 -0
  14. package/site/pages/blog/darkmown-2-7-0.skin +127 -0
  15. package/site/pages/blog/no-unsafe-eval.md +66 -0
  16. package/site/pages/blog/no-unsafe-eval.skin +127 -0
  17. package/site/pages/blog/zero-js-by-default.md +1 -1
  18. package/site/pages/branch-state/index.wd +68 -0
  19. package/site/pages/docs/index.wd +320 -19
  20. package/site/pages/index.wd +3 -1
  21. package/site/pages/playground/index.js +63 -56
  22. package/site/pages/pulse/index.wd +1 -1
  23. package/site/pages/settings/index.wd +97 -0
  24. package/site/pages/showcase/index.wd +37 -1
  25. package/site/pages/upload/index.wd +63 -0
  26. package/site/pages/url-state/index.wd +70 -0
  27. package/src/builder.js +222 -34
  28. package/src/catalog.js +217 -21
  29. package/src/cli.js +6 -3
  30. package/src/compiler/actions.js +80 -12
  31. package/src/compiler/body.js +104 -28
  32. package/src/compiler/collections.js +29 -13
  33. package/src/compiler/context.js +9 -2
  34. package/src/compiler/expr-ast.js +14 -0
  35. package/src/compiler/fetch.js +8 -4
  36. package/src/compiler/forms.js +133 -14
  37. package/src/compiler/frontmatter.js +28 -7
  38. package/src/compiler/includes.js +46 -6
  39. package/src/compiler/interpolation.js +56 -1
  40. package/src/compiler/loops.js +489 -35
  41. package/src/compiler/markdown.js +457 -44
  42. package/src/compiler/page.js +8 -3
  43. package/src/compiler/predicates.js +84 -14
  44. package/src/compiler/state.js +167 -18
  45. package/src/compiler/structure.js +159 -29
  46. package/src/errors.js +111 -3
  47. package/src/feeds.js +140 -7
  48. package/src/grammar.js +53 -10
  49. package/src/runtime.js +509 -130
  50. package/src/runtime.min.js +2 -0
  51. package/src/runtime.min.js.map +1 -0
  52. package/src/statics.js +46 -2
  53. package/src/templates/blog/README.md +2 -0
  54. package/src/templates/dashboard/README.md +2 -0
  55. package/src/templates/landing/README.md +2 -0
  56. package/src/templates/starter/README.md +1 -1
  57. package/src/templates/store/README.md +2 -0
  58. package/src/tools/validate.js +14 -4
  59. package/types/builder.d.ts +6 -2
  60. package/types/catalog.d.ts +12 -0
  61. package/types/compiler/actions.d.ts +1 -1
  62. package/types/compiler/body.d.ts +0 -3
  63. package/types/compiler/forms.d.ts +12 -1
  64. package/types/compiler/frontmatter.d.ts +8 -1
  65. package/types/compiler/includes.d.ts +16 -2
  66. package/types/compiler/interpolation.d.ts +23 -0
  67. package/types/compiler/loops.d.ts +47 -0
  68. package/types/compiler/markdown.d.ts +63 -1
  69. package/types/compiler/predicates.d.ts +7 -1
  70. package/types/compiler/state.d.ts +8 -2
  71. package/types/compiler/structure.d.ts +32 -1
  72. package/types/feeds.d.ts +68 -2
  73. package/types/statics.d.ts +25 -0
@@ -0,0 +1,176 @@
1
+ ---
2
+ title: "Darkmown 2.7.0: no more silent failures"
3
+ description: A release built around one question asked of every code path, when this goes wrong does the author find out. Plus a minified runtime that paid for five new features.
4
+ date: 2026-09-04
5
+ excerpt: A brace in a link destination used to stay literal text with no warning. A line that was just ":state" rendered as prose. Both are fixed, and the runtime got small enough to afford five new features.
6
+ schema: BlogPosting
7
+ tags: [release, compiler, runtime]
8
+ ---
9
+
10
+ # Darkmown 2.7.0: no more silent failures
11
+
12
+ Most framework bugs announce themselves. You get a stack trace, a red screen,
13
+ a test that goes from green to red. The bugs worth being afraid of are the
14
+ other kind: the page compiles, the build says `Built 38 routes`, and something
15
+ on it is quietly wrong.
16
+
17
+ This release came out of an audit that asked one question of every path in the
18
+ compiler, the builder and the runtime. **When this goes wrong, does the author
19
+ find out?** Too often the honest answer was no.
20
+
21
+ Four examples, all of which shipped in 2.6.0 and none of which printed a word:
22
+
23
+ - `[Read more](/docs/{ post.slug }/)` left the literal text `/docs/{ post.slug }/`
24
+ in the `href`. The brace resolved only when it filled the entire destination.
25
+ - A line that was exactly `:state`, with no name and no value, rendered as the
26
+ text `:state` on the page.
27
+ - A static `@loop` over `| … |` table rows produced a header-only table
28
+ followed by three paragraphs reading `| Ash | 3 | 12 |`.
29
+ - A `:every 5s -> n++` inside a reactive `@loop` registered one interval per
30
+ row, and a row removed from the list kept ticking forever.
31
+
32
+ Every one of those is now either compiled correctly or refused with a coded
33
+ error that names the fix. That is the whole shape of the release.
34
+
35
+ ## Braces resolve, and then they bind
36
+
37
+ The destination case turned out to be worth more than a bug fix. Once every
38
+ brace in a destination resolves, the obvious next question is what happens when
39
+ the value is reactive. It binds:
40
+
41
+ ```wd
42
+ :state region = "eu"
43
+
44
+ :button "EU" -> region = "eu"
45
+ :button "US" -> region = "us"
46
+
47
+ [Open the docs for this region](/docs/{ region }/)
48
+ ```
49
+
50
+ Click a button and the `href` changes. The compiler emits the destination as a
51
+ small template (the literal parts plus a reader) and the runtime rebuilds the
52
+ whole value on every render. The build-time paint is still the seed, so the
53
+ link works before the runtime loads and a crawler following it lands somewhere
54
+ real.
55
+
56
+ Two guards came with it. A value in URL position is percent-encoded, so a `)`
57
+ arriving from your data cannot close the link and hand the rest back to the
58
+ Markdown parser. And the runtime re-checks the scheme of the value it
59
+ assembled, refusing `javascript:`, `data:` and `vbscript:` with an empty
60
+ attribute rather than a half-applied one, because the compiler cannot see a
61
+ value that only exists at runtime.
62
+
63
+ Raw HTML attributes still do not bind. `<a href="{ url }">` is painted once,
64
+ and now says so in a warning that names the position that does bind.
65
+
66
+ ## The URL is state
67
+
68
+ Here is the feature I have wanted longest. A filter nobody can link to is half
69
+ a feature:
70
+
71
+ ```wd
72
+ :state q = "" from-url
73
+
74
+ :bind q placeholder="Search products"
75
+
76
+ :state products = [{"name": "Aurora Lamp"}, {"name": "Briza Fan"}]
77
+
78
+ @loop products into p where p.name contains q
79
+ - **{ p.name }**
80
+ @empty
81
+ Nothing matches that search.
82
+ @endloop
83
+ ```
84
+
85
+ Type in the box and the address bar becomes `?q=aurora`. Reload and the search
86
+ comes back. Send the URL to somebody and they open on the same view.
87
+
88
+ `from-url` is a third word in the persistence vocabulary, next to `persist` and
89
+ `ephemeral`, and it is on `:state` only. Writes go through
90
+ `history.replaceState`, so filtering never fills the back button with one entry
91
+ per keystroke. A value back at its declared seed drops its parameter, so the
92
+ default page keeps a clean URL. Combine it with `persist` and the precedence is
93
+ URL, then storage, then seed: a link somebody sent you beats what this browser
94
+ remembers.
95
+
96
+ Putting it on a `:store` is a compile error rather than a guess, and the error
97
+ says why. A `:store` is shared by every page and every tab, while a query
98
+ parameter belongs to one page's address. Those cannot mean the same thing.
99
+
100
+ ## What the API actually said
101
+
102
+ A failed `:fetch` used to set `name_error` to `Error: HTTP 422`, no matter what
103
+ the server had gone to the trouble of explaining. Now, when the response body
104
+ is JSON, `name_error` is the body's own `error` or `message` field, and the
105
+ whole parsed body lands in a new `name_error_body`:
106
+
107
+ ```wd
108
+ :fetch signup from "/api/signup" method=POST
109
+
110
+ :if signup_error
111
+ **{ signup_error }**
112
+ :if signup_error_body
113
+ Photo: { signup_error_body.fields.photo }
114
+ :endif
115
+ :endif
116
+ ```
117
+
118
+ Given a 422 whose body is
119
+ `{"error": "Pick a file first.", "fields": {"photo": "No file was attached."}}`,
120
+ the page renders *Pick a file first.* and the per-field message underneath it.
121
+ No JavaScript of your own.
122
+
123
+ ## Smaller runtime, more features
124
+
125
+ None of the above would have fit. The runtime shipped as comment-stripped
126
+ readable source, and the audit fixes alone had taken it to 8036 bytes gzipped
127
+ out of a budget of 8192. Adding the five features below would have put the same
128
+ file at 8886, well over the line.
129
+
130
+ It now ships minified: a committed esbuild artifact, with an external sourcemap
131
+ served next to it so DevTools still shows the readable source with real names
132
+ and real comments. That bought about 2 KB back, and this release spent it:
133
+ attribute binding, `from-url`, bound `:select`/`:radio`/`:checkbox` outside a
134
+ form, server error bodies, and real multipart file upload. Total after all five:
135
+ **6584 bytes gzipped**, against the same 8192 byte budget.
136
+
137
+ The budget did not move. That is the point of having one.
138
+
139
+ ## Things that now fail loudly
140
+
141
+ Some of the audit findings could not be fixed by making them work, because
142
+ there was nothing correct to do. Those became errors:
143
+
144
+ - **`WD191`**, a reactive `@loop` over Markdown table rows. A reactive row is
145
+ cloned into a `<div>`, which is not a legal child of `<table>`. The hint names
146
+ both ways out: a static source, or containers styled as table rows.
147
+ - **`WD315`**, `:every` or `:effect` inside a reactive loop body. Neither has a
148
+ per-row meaning to salvage.
149
+ - **A bare directive keyword**, which now throws that directive's own error with
150
+ that directive's own `Use:` hint. To show a directive name as text, escape it
151
+ with a backslash: `\:fetch`.
152
+ - **`WD251`**, `:state b = a` where `a` is declared state. It used to seed the
153
+ literal string `"a"` and track nothing. Derive it with `:computed` instead.
154
+
155
+ There are thirteen new codes in all, each with a cause, a fix and a compilable
156
+ example in `darkmown catalog --llms-full`.
157
+
158
+ ## The rest
159
+
160
+ `:::` containers and `:button` now take `role`, `aria-*` and `title`, which was
161
+ the one place the no-attribute-syntax rule genuinely blocked a correct page.
162
+ A `:state` declared inside a closed `:if` branch is finally hydrated when the
163
+ branch opens. A static `@loop` fills a Markdown table. Renders settle, so a row
164
+ cloned mid-render gets its own `:if` state in the same pass. Rows keep their
165
+ place, so a re-render no longer steals focus from an input.
166
+
167
+ The [changelog](https://github.com/ZVN-DEV/darkmown/blob/master/CHANGELOG.md)
168
+ has all of it, and six new demo pages have it running:
169
+ [reactive links](/attr-binding/), [URL state](/url-state/),
170
+ [bound controls](/settings/), [server errors](/api-errors/),
171
+ [file upload](/upload/), and [closed-branch state](/branch-state/).
172
+
173
+ Upgrade with `npm install -D @zvndev/darkmown@latest`. Read the behavior
174
+ changes in the changelog first: interpolation now resolves in positions where
175
+ it used to stay literal, so a page that showed braces in an `href` will start
176
+ showing values.
@@ -0,0 +1,127 @@
1
+ /* Colocated post styling — a .skin next to a .md attaches automatically, so
2
+ these prose pages stay static (zero JS) yet still read on-brand. A .md can't
3
+ @include /base.wd, so this reading skin carries the site's tokens itself.
4
+ Kept in sync with site/_/base.skin; copy it beside each new post. */
5
+
6
+ tokens
7
+ ink #1c1814
8
+ ink-soft #4b4239
9
+ muted #756a5d
10
+ paper #faf8f3
11
+ panel #ffffff
12
+ sunk #efe9dc
13
+ line #e7e1d4
14
+ accent #0f6b5e
15
+ accent-deep #0a4f45
16
+ accent-soft #e3f0ec
17
+ code-bg #1b2420
18
+ code-ink #e9efe7
19
+ code-line #2c3b34
20
+ radius 12px
21
+
22
+ tokens dark
23
+ ink #ece6db
24
+ ink-soft #c3bbab
25
+ muted #9a9081
26
+ paper #14110d
27
+ panel #1e1913
28
+ sunk #221d17
29
+ line #2f281f
30
+ accent #5ccab4
31
+ accent-deep #8fe0cf
32
+ accent-soft #16302a
33
+ code-bg #100d0a
34
+ code-ink #e4ebe2
35
+ code-line #2a231b
36
+
37
+ html
38
+ bg $paper
39
+
40
+ page
41
+ max-width 720px
42
+ margin 0 auto
43
+ padding 3rem 1.5rem 5rem
44
+ font-family ui-serif, "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, Cambria, serif
45
+ font-size 18px
46
+ line-height 1.7
47
+ color $ink
48
+ bg $paper
49
+ -webkit-font-smoothing antialiased
50
+ text-rendering optimizeLegibility
51
+
52
+ h1
53
+ font-size clamp(2rem, 5vw, 2.8rem)
54
+ line-height 1.12
55
+ letter-spacing -.022em
56
+ margin 1rem 0 1.25rem
57
+ color $ink
58
+
59
+ h2
60
+ font-size 1.5rem
61
+ line-height 1.2
62
+ letter-spacing -.012em
63
+ margin 2.4rem 0 .6rem
64
+ color $ink
65
+
66
+ h3
67
+ font-size 1.2rem
68
+ margin 1.8rem 0 .4rem
69
+ color $ink
70
+
71
+ p
72
+ margin 1.1rem 0
73
+
74
+ a
75
+ color $accent
76
+ text-underline-offset 2px
77
+
78
+ a:hover
79
+ color $accent-deep
80
+
81
+ strong
82
+ font-weight 650
83
+ color $ink
84
+
85
+ hr
86
+ border 0
87
+ border-top 1px solid $line
88
+ margin 2.5rem 0
89
+
90
+ ul, ol
91
+ margin 1.1rem 0
92
+ padding-left 1.4rem
93
+
94
+ li
95
+ margin .35rem 0
96
+
97
+ code
98
+ font-family ui-monospace, "SF Mono", Menlo, Consolas, monospace
99
+ font-size .88em
100
+ bg $sunk
101
+ color $accent-deep
102
+ padding .12rem .36rem
103
+ radius 6px
104
+
105
+ pre
106
+ bg $code-bg
107
+ color $code-ink
108
+ padding 1.2rem 1.35rem
109
+ margin 1.5rem 0
110
+ radius $radius
111
+ overflow-x auto
112
+ line-height 1.62
113
+ font-size .87rem
114
+ box-shadow inset 0 0 0 1px $code-line
115
+
116
+ pre code
117
+ bg transparent
118
+ color inherit
119
+ padding 0
120
+ font-size 1em
121
+
122
+ blockquote
123
+ margin 1.6rem 0
124
+ padding .6rem 1.3rem
125
+ border-left 3px solid $accent
126
+ color $ink-soft
127
+ font-style italic
@@ -0,0 +1,66 @@
1
+ ---
2
+ title: Reactive pages with no unsafe-eval
3
+ description: Since 2.1.0 the runtime interprets a validated expression AST instead of building a function, so reactive and static pages ship the same strict Content-Security-Policy.
4
+ date: 2026-07-11
5
+ excerpt: A reactive page used to force 'unsafe-eval' into your CSP. It no longer does, because the runtime stopped compiling expressions to JavaScript and started interpreting them.
6
+ schema: BlogPosting
7
+ tags: [security, runtime]
8
+ ---
9
+
10
+ # Reactive pages with no unsafe-eval
11
+
12
+ Every reactive framework has to answer one question honestly: how does the
13
+ browser evaluate `p.price < 50`?
14
+
15
+ Through Darkmown 2.0.x the answer was `new Function`. A `:computed`, a
16
+ `@loop … where`, a `.class when`, and a `:if` comparison were all validated at
17
+ compile time against a closed whitelist, then emitted as a small JavaScript
18
+ fragment that the runtime turned back into a callable. That is safe from the
19
+ injection angle, because nothing an author writes reaches the fragment unless it
20
+ matched the grammar first. It is not safe from the *deployment* angle, because
21
+ `new Function` needs `script-src 'unsafe-eval'`, and a security-minded adopter
22
+ who reads your CSP does not care how carefully you validated the input. They
23
+ care that the policy has a hole in it.
24
+
25
+ ## What changed
26
+
27
+ The compiler still validates the expression exactly as before. What it emits is
28
+ different: instead of a JavaScript fragment, it serializes the already-validated
29
+ expression into a compact AST and puts it in the `data-wd-*` attribute. The
30
+ runtime walks that AST with a closed evaluator. Readers, unary operators, and a
31
+ fixed arithmetic, comparison, and logical set. An unknown op tag is a hard
32
+ error, never a fallthrough.
33
+
34
+ No `new Function`. No `eval`. Nothing on the page turns text into code.
35
+
36
+ ## What it buys
37
+
38
+ The shipped policy is now byte-identical for static and reactive pages, across
39
+ `dist/_headers`, `vercel.json`, and the local `serve` layer:
40
+
41
+ ```
42
+ script-src 'self'
43
+ ```
44
+
45
+ No `'unsafe-inline'`, no `'unsafe-eval'`, on any page the framework emits. The
46
+ one inline script Darkmown ships (the `transitions: true` speculation-rules
47
+ block) is authorized by a build-time `'sha256-…'` hash, and the inline state
48
+ seed is a JSON data block that CSP does not gate at all.
49
+
50
+ ## What it cost
51
+
52
+ 136 bytes. The runtime went from 7,518 to 7,654 bytes gzipped, still under the
53
+ 8 KB budget that CI enforces on every commit.
54
+
55
+ Semantics did not move. The interpreter applies the same JavaScript operators
56
+ the generated fragments used to, and a parity battery compares the two
57
+ evaluators across a wide matrix of expressions, states, and loop items, asserting
58
+ identical results. The fuzz suite still proves that hostile text survives only as
59
+ inert literal data.
60
+
61
+ ## The rule underneath
62
+
63
+ Compile-time validation is the security layer, and it always was. Dropping
64
+ `eval` did not make the language safer against a malicious author, because the
65
+ whitelist already did that. It made the language **deployable** somewhere a
66
+ strict CSP is not negotiable, which is most places worth deploying.
@@ -0,0 +1,127 @@
1
+ /* Colocated post styling — a .skin next to a .md attaches automatically, so
2
+ these prose pages stay static (zero JS) yet still read on-brand. A .md can't
3
+ @include /base.wd, so this reading skin carries the site's tokens itself.
4
+ Kept in sync with site/_/base.skin; copy it beside each new post. */
5
+
6
+ tokens
7
+ ink #1c1814
8
+ ink-soft #4b4239
9
+ muted #756a5d
10
+ paper #faf8f3
11
+ panel #ffffff
12
+ sunk #efe9dc
13
+ line #e7e1d4
14
+ accent #0f6b5e
15
+ accent-deep #0a4f45
16
+ accent-soft #e3f0ec
17
+ code-bg #1b2420
18
+ code-ink #e9efe7
19
+ code-line #2c3b34
20
+ radius 12px
21
+
22
+ tokens dark
23
+ ink #ece6db
24
+ ink-soft #c3bbab
25
+ muted #9a9081
26
+ paper #14110d
27
+ panel #1e1913
28
+ sunk #221d17
29
+ line #2f281f
30
+ accent #5ccab4
31
+ accent-deep #8fe0cf
32
+ accent-soft #16302a
33
+ code-bg #100d0a
34
+ code-ink #e4ebe2
35
+ code-line #2a231b
36
+
37
+ html
38
+ bg $paper
39
+
40
+ page
41
+ max-width 720px
42
+ margin 0 auto
43
+ padding 3rem 1.5rem 5rem
44
+ font-family ui-serif, "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, Cambria, serif
45
+ font-size 18px
46
+ line-height 1.7
47
+ color $ink
48
+ bg $paper
49
+ -webkit-font-smoothing antialiased
50
+ text-rendering optimizeLegibility
51
+
52
+ h1
53
+ font-size clamp(2rem, 5vw, 2.8rem)
54
+ line-height 1.12
55
+ letter-spacing -.022em
56
+ margin 1rem 0 1.25rem
57
+ color $ink
58
+
59
+ h2
60
+ font-size 1.5rem
61
+ line-height 1.2
62
+ letter-spacing -.012em
63
+ margin 2.4rem 0 .6rem
64
+ color $ink
65
+
66
+ h3
67
+ font-size 1.2rem
68
+ margin 1.8rem 0 .4rem
69
+ color $ink
70
+
71
+ p
72
+ margin 1.1rem 0
73
+
74
+ a
75
+ color $accent
76
+ text-underline-offset 2px
77
+
78
+ a:hover
79
+ color $accent-deep
80
+
81
+ strong
82
+ font-weight 650
83
+ color $ink
84
+
85
+ hr
86
+ border 0
87
+ border-top 1px solid $line
88
+ margin 2.5rem 0
89
+
90
+ ul, ol
91
+ margin 1.1rem 0
92
+ padding-left 1.4rem
93
+
94
+ li
95
+ margin .35rem 0
96
+
97
+ code
98
+ font-family ui-monospace, "SF Mono", Menlo, Consolas, monospace
99
+ font-size .88em
100
+ bg $sunk
101
+ color $accent-deep
102
+ padding .12rem .36rem
103
+ radius 6px
104
+
105
+ pre
106
+ bg $code-bg
107
+ color $code-ink
108
+ padding 1.2rem 1.35rem
109
+ margin 1.5rem 0
110
+ radius $radius
111
+ overflow-x auto
112
+ line-height 1.62
113
+ font-size .87rem
114
+ box-shadow inset 0 0 0 1px $code-line
115
+
116
+ pre code
117
+ bg transparent
118
+ color inherit
119
+ padding 0
120
+ font-size 1em
121
+
122
+ blockquote
123
+ margin 1.6rem 0
124
+ padding .6rem 1.3rem
125
+ border-left 3px solid $accent
126
+ color $ink-soft
127
+ font-style italic
@@ -16,7 +16,7 @@ none of them pay for it.
16
16
 
17
17
  That is the whole bargain. A `.md` file is plain CommonMark. Rename it to `.wd`
18
18
  and you unlock directives — state, loops, conditionals — but you only ship the
19
- ~7.7 KB runtime on the pages that actually use them. A listing built from a
19
+ ~6.6 KB runtime on the pages that actually use them. A listing built from a
20
20
  collection loop resolves entirely at build time, so it stays static HTML.
21
21
 
22
22
  ## The extension is the feature gate
@@ -0,0 +1,68 @@
1
+ ---
2
+ html: true
3
+ title: State inside a closed branch
4
+ description: A :state, :store or :theme declared inside an :if branch that starts closed is now hydrated the moment the branch opens, instead of starting undefined and never persisting.
5
+ ---
6
+
7
+ @include /nav.wd
8
+ @include /base.wd
9
+
10
+ <main>
11
+
12
+ <section class="intro">
13
+ <span class="eyebrow">Darkmown 2.7</span>
14
+
15
+ # State inside a closed branch
16
+
17
+ A closed `:if` branch lives in a `<template>`, and `querySelectorAll` does not look inside one. Anything declared in there was therefore never hydrated: it started `undefined`, ignored its `persist` token, and stayed that way for the life of the page. Now the runtime re-claims seed scripts whenever a render changes structure, so a branch that opens brings its declarations with it.
18
+
19
+ </section>
20
+
21
+ <section>
22
+
23
+ ## Open the panel
24
+
25
+ :state panel = false
26
+
27
+ :button "Toggle the advanced panel" -> panel toggle
28
+
29
+ :if panel
30
+
31
+ ::: section .panel
32
+
33
+ :state retries = 3 persist
34
+
35
+ The `:state retries` line above lives **inside** this branch. Before this change it was never declared at all: the counter below read empty and nothing was ever written to storage.
36
+
37
+ Retries: **{ retries }**
38
+
39
+ :button "More" -> retries++
40
+ :button "Fewer" -> retries--
41
+
42
+ Change it, close the panel, reopen it: the value you set is still there, because re-opening a branch does not re-seed a key state already holds. Reload the page and it is still there too, because `persist` now works from in here.
43
+
44
+ :::
45
+
46
+ :else
47
+
48
+ The panel is closed. Everything inside it, including its `:state`, is still in a `<template>`.
49
+
50
+ :endif
51
+
52
+ </section>
53
+
54
+ <section>
55
+
56
+ ## The rules
57
+
58
+ - A seed is claimed once **per key**, not per node. Re-opening a branch clones fresh `<script>` nodes, so a per-node claim would have reset the reader's value every time.
59
+ - The seed recorded when the key is claimed is also its `reset` baseline, so a late declaration is resettable like any other.
60
+ - `persist` and `:store` read through storage at claim time, so a durable value declared in a branch comes back the first time the branch opens.
61
+
62
+ <a class="btn primary" href="/docs/#reactive-directives">Read the docs on reactive directives</a>
63
+
64
+ </section>
65
+
66
+ </main>
67
+
68
+ @include /footer.wd