Skip to content

Commit 90de888

Browse files
hyperpolymathclaude
andcommitted
fix(react): align AffineScript pilot with locked conventions
Conformance pass against the conventions resolution log (see feedback_affinescript_conventions.md), which were locked 2026-05-03 after PR #15 surfaced four open questions: - Q1 face = canonical: Effect annotation switched from suffix `-> X / IO` to canonical `-{Effects}-> X` between args and return type. - Q3 effects = domain-specific: DOM mutations now declared `-{DOM}->` instead of conflating with terminal `IO` (which the warmup reserves for `println`/`eprintln`/`read_line`). - Q4 cross-pkg = per-package Externs.affine shim (interim, pending the agreed-in-principle `.affex` compatibility filesystem): * New `Externs.affine` declares `pub extern type Element/Event` and `pub effect DOM { ... }` with all DOM extern fns * `Button.affine` and `Index.affine` `use Externs;` and reference `Externs.create_element(...)`, `Externs.DOM`, etc. * Single TODO ref to the .affex parking-lot entry, replacing the point-of-use externs scattered through Button.affine Cardinal stance applied: keeps the affine core of the package (Button, Index) legible — compat plumbing isolated to one file per package. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a926a3c commit 90de888

3 files changed

Lines changed: 112 additions & 64 deletions

File tree

tools/stale/packages/stale/components/react/src/Button.affine

Lines changed: 46 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,13 @@
1515
// * `iconBefore?: ReactNode` → `Option[own Element]`; caller pre-builds the icon
1616
// * Exception path on missing args → defaulted via `default_button_props`
1717
//
18-
// Effect annotation: every DOM mutation is `IO` here. Once @affinescript/dom
19-
// publishes a typed `DOM` effect, swap the `/ IO` rows below to `/ DOM`.
18+
// Conventions: see feedback_affinescript_conventions.md — face = canonical,
19+
// effect annotation = `-{Effects}->`, DOM mutations live under the `DOM`
20+
// effect (declared in Externs.affine).
2021

21-
// ── DOM FFI (inline; replace with `import @affinescript/dom` once that package
22-
// ── has a stable surface and a real cross-package import story in this repo)
22+
module Button;
2323

24-
extern type Element;
25-
extern type Event;
26-
27-
extern fn createElement(tag: ref String) -> own Element / IO;
28-
extern fn createTextNode(text: ref String) -> own Element / IO;
29-
extern fn setAttribute(el: mut Element, name: ref String, value: ref String) -> () / IO;
30-
extern fn appendChild(parent: mut Element, child: own Element) -> () / IO;
31-
extern fn addEventListener(
32-
el: mut Element,
33-
event: ref String,
34-
handler: fn(Event) -> () / IO
35-
) -> () / IO;
24+
use Externs;
3625

3726
// ── Domain types ──────────────────────────────────────────────────────────────
3827

@@ -50,13 +39,13 @@ type ButtonProps = own {
5039
class_extra: String, // appended to the computed class list
5140
button_type: String, // "button" | "submit" | "reset"
5241
aria_label: Option[String],
53-
on_click: Option[fn(Event) -> () / IO],
54-
text: own Element, // pre-built label (text node or richer subtree)
55-
icon_before: Option[own Element],
56-
icon_after: Option[own Element],
42+
on_click: Option[fn(Externs.Event) -{Externs.DOM}-> ()],
43+
text: own Externs.Element, // pre-built label (text node or richer subtree)
44+
icon_before: Option[own Externs.Element],
45+
icon_after: Option[own Externs.Element],
5746
}
5847

59-
fn default_button_props(text: own Element) -> own ButtonProps {
48+
pub fn default_button_props(text: own Externs.Element) -> own ButtonProps {
6049
ButtonProps {
6150
variant: Primary,
6251
size: Md,
@@ -72,7 +61,7 @@ fn default_button_props(text: own Element) -> own ButtonProps {
7261
}
7362
}
7463

75-
// ── Class-name helpers (pure) ─────────────────────────────────────────────────
64+
// ── Class-name helpers (pure, no effect row) ──────────────────────────────────
7665

7766
fn variant_class(v: ref ButtonVariant) -> String {
7867
match v {
@@ -96,28 +85,29 @@ fn loading_class(loading: Bool) -> String {
9685
}
9786

9887
// Joins non-empty parts with single spaces.
99-
// (Stdlib presumably has String.join; this is a sketch using concat operators.)
88+
// (Stdlib presumably has String.join; this is a sketch using `++` per Q1.)
10089
fn join_classes(
10190
base: ref String,
10291
v: ref String,
10392
s: ref String,
10493
l: ref String,
10594
extra: ref String
10695
) -> String {
107-
let with_v = if v == "" { base } else { base ++ " " ++ v };
108-
let with_s = if s == "" { with_v } else { with_v ++ " " ++ s };
109-
let with_l = if l == "" { with_s } else { with_s ++ " " ++ l };
110-
let with_e = if extra == "" { with_l } else { with_l ++ " " ++ extra };
96+
let with_v = if v == "" { base } else { base ++ " " ++ v };
97+
let with_s = if s == "" { with_v } else { with_v ++ " " ++ s };
98+
let with_l = if l == "" { with_s } else { with_s ++ " " ++ l };
99+
let with_e = if extra == "" { with_l } else { with_l ++ " " ++ extra };
111100
with_e
112101
}
113102

114103
// ── The builder ───────────────────────────────────────────────────────────────
115104
//
116105
// Returns an owned button element. The caller is responsible for mounting it
117-
// (via @affinescript/dom's `mount`) and ultimately for letting it drop, which
118-
// the runtime will detach from the live DOM.
106+
// (via @affinescript/dom's `mount` once cross-package import lands) and
107+
// ultimately for letting it drop, which the runtime will detach from the
108+
// live DOM.
119109

120-
fn make_button(props: own ButtonProps) -> own Element / IO {
110+
pub fn make_button(props: own ButtonProps) -{Externs.DOM}-> own Externs.Element {
121111
let busy: Bool = props.disabled || props.loading;
122112

123113
let classes: String = join_classes(
@@ -128,65 +118,65 @@ fn make_button(props: own ButtonProps) -> own Element / IO {
128118
props.class_extra,
129119
);
130120

131-
let btn = createElement("button");
132-
setAttribute(btn, "type", props.button_type);
133-
setAttribute(btn, "class", classes);
121+
let btn = Externs.create_element("button");
122+
Externs.set_attribute(btn, "type", props.button_type);
123+
Externs.set_attribute(btn, "class", classes);
134124

135125
if busy {
136-
setAttribute(btn, "disabled", "");
137-
setAttribute(btn, "aria-disabled", "true");
126+
Externs.set_attribute(btn, "disabled", "");
127+
Externs.set_attribute(btn, "aria-disabled", "true");
138128
}
139129

140130
if props.loading {
141-
setAttribute(btn, "aria-busy", "true");
131+
Externs.set_attribute(btn, "aria-busy", "true");
142132
}
143133

144134
match props.aria_label {
145-
Some(label) => setAttribute(btn, "aria-label", label)
135+
Some(label) => Externs.set_attribute(btn, "aria-label", label)
146136
None => ()
147137
}
148138

149139
match props.on_click {
150-
Some(handler) => addEventListener(btn, "click", handler)
140+
Some(handler) => Externs.add_event_listener(btn, "click", handler)
151141
None => ()
152142
}
153143

154144
// Children, in order:
155145
// loading: spinner only (icons + text suppressed)
156146
// else: [icon_before?] text [icon_after?]
157147
if props.loading {
158-
let spinner = createElement("span");
159-
setAttribute(spinner, "class", "a11y-button__spinner");
160-
setAttribute(spinner, "aria-hidden", "true");
161-
appendChild(btn, spinner);
148+
let spinner = Externs.create_element("span");
149+
Externs.set_attribute(spinner, "class", "a11y-button__spinner");
150+
Externs.set_attribute(spinner, "aria-hidden", "true");
151+
Externs.append_child(btn, spinner);
162152
} else {
163153
match props.icon_before {
164154
Some(icon) => {
165-
let wrap = createElement("span");
166-
setAttribute(wrap, "class", "a11y-button__icon-before");
167-
setAttribute(wrap, "aria-hidden", "true");
168-
appendChild(wrap, icon);
169-
appendChild(btn, wrap);
155+
let wrap = Externs.create_element("span");
156+
Externs.set_attribute(wrap, "class", "a11y-button__icon-before");
157+
Externs.set_attribute(wrap, "aria-hidden", "true");
158+
Externs.append_child(wrap, icon);
159+
Externs.append_child(btn, wrap);
170160
}
171161
None => ()
172162
}
173163
}
174164

175165
// Text wrapper is always present (loading just hides icons, not the label
176166
// — preserves layout stability per WCAG 2.1.4.13 advisory).
177-
let text_wrap = createElement("span");
178-
setAttribute(text_wrap, "class", "a11y-button__text");
179-
appendChild(text_wrap, props.text);
180-
appendChild(btn, text_wrap);
167+
let text_wrap = Externs.create_element("span");
168+
Externs.set_attribute(text_wrap, "class", "a11y-button__text");
169+
Externs.append_child(text_wrap, props.text);
170+
Externs.append_child(btn, text_wrap);
181171

182172
if !props.loading {
183173
match props.icon_after {
184174
Some(icon) => {
185-
let wrap = createElement("span");
186-
setAttribute(wrap, "class", "a11y-button__icon-after");
187-
setAttribute(wrap, "aria-hidden", "true");
188-
appendChild(wrap, icon);
189-
appendChild(btn, wrap);
175+
let wrap = Externs.create_element("span");
176+
Externs.set_attribute(wrap, "class", "a11y-button__icon-after");
177+
Externs.set_attribute(wrap, "aria-hidden", "true");
178+
Externs.append_child(wrap, icon);
179+
Externs.append_child(btn, wrap);
190180
}
191181
None => ()
192182
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
//
3+
// Externs — the package's compatibility shim.
4+
//
5+
// Per the AffineScript conventions resolution log Q4 (per-package shim,
6+
// pending the `.affex` compatibility filesystem), every package centralises
7+
// its `extern` declarations in this single file and re-exports them as
8+
// public AffineScript symbols. Other files in the package `use Externs` and
9+
// reference normal AffineScript names — the affine core stays legible, and
10+
// the future migration to `.affex` is a one-file edit per package.
11+
//
12+
// TODO: migrate this file's contents to `.affex` once that filesystem is
13+
// specified. See feedback_affinescript_conventions.md parking lot.
14+
15+
module Externs;
16+
17+
// ── Browser DOM extern types ──────────────────────────────────────────────────
18+
//
19+
// `Element` and `Event` are opaque on the AffineScript side — the host JS
20+
// runtime owns their representation. Affine ownership tracks who is allowed
21+
// to mutate / append to an element; the extern fns below carry the
22+
// borrow-mode (own / mut / ref) appropriate to each operation.
23+
24+
pub extern type Element;
25+
pub extern type Event;
26+
27+
// ── Browser DOM effect ────────────────────────────────────────────────────────
28+
//
29+
// `DOM` is the named effect for any operation that creates or mutates live
30+
// DOM. Per Q3 of the conventions log, this is *not* `IO` — `IO` is reserved
31+
// for terminal IO (`println`/`eprintln`) per the existing warmup convention.
32+
33+
pub effect DOM {
34+
fn create_element(tag: ref String) -> own Element;
35+
fn create_text_node(text: ref String) -> own Element;
36+
fn set_attribute(el: mut Element, name: ref String, value: ref String) -> ();
37+
fn append_child(parent: mut Element, child: own Element) -> ();
38+
fn add_event_listener(
39+
el: mut Element,
40+
event: ref String,
41+
handler: fn(Event) -{DOM}-> ()
42+
) -> ();
43+
}

tools/stale/packages/stale/components/react/src/Index.affine

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,29 @@
66
// Modal stays as Modal.tsx in this revision — its focus-trap + portal + scroll-
77
// lock + escape-handler bundle needs deeper effect-modelling (see PORTING_NOTES
88
// at the bottom of this file), and is the next session's work.
9+
//
10+
// Conventions: see feedback_affinescript_conventions.md.
11+
12+
module Index;
13+
14+
use Button;
15+
16+
// Re-export the Button surface
17+
pub type ButtonVariant = Button.ButtonVariant
18+
pub type ButtonSize = Button.ButtonSize
19+
pub type ButtonProps = Button.ButtonProps
20+
21+
pub fn make_button(props: own ButtonProps) -{Externs.DOM}-> own Externs.Element {
22+
Button.make_button(props)
23+
}
924

10-
// Re-export Button surface
11-
type ButtonVariant = Primary | Secondary | Ghost | Danger
12-
type ButtonSize = Sm | Md | Lg
25+
pub fn default_button_props(text: own Externs.Element) -> own ButtonProps {
26+
Button.default_button_props(text)
27+
}
1328

14-
// Library version + WCAG conformance level (these are pure data; effect-free).
15-
let version: String = "1.0.0"
16-
let wcag_level: String = "AA"
29+
// Library metadata (pure data; effect-free)
30+
pub let version: String = "1.0.0"
31+
pub let wcag_level: String = "AA"
1732

1833
// ── PORTING_NOTES for Modal (next session) ────────────────────────────────────
1934
//
@@ -28,8 +43,8 @@ let wcag_level: String = "AA"
2843
// drop restores `body.style.overflow`
2944
// * `useEffect` previous-focus restoration → captured-once Element ref stored
3045
// on the `own Modal` record
31-
// * `useId` → effect `Id { fn fresh() -> String }`
32-
// * `useState(mounted)` → no equivalent; SSR-portal toggling
46+
// * `useId` → effect `Id { fn fresh() -> String }`
47+
// * `useState(mounted)` → no equivalent; SSR-portal toggling
3348
// is irrelevant outside React
3449
//
3550
// In short: every "useEffect cleanup" becomes a Drop-on-own resource. The React

0 commit comments

Comments
 (0)