Skip to content

feat(react): pilot AffineScript port of Button per migration playbook#15

Merged
hyperpolymath merged 4 commits intomainfrom
react-affinescript-pilot
May 4, 2026
Merged

feat(react): pilot AffineScript port of Button per migration playbook#15
hyperpolymath merged 4 commits intomainfrom
react-affinescript-pilot

Conversation

@hyperpolymath
Copy link
Copy Markdown
Owner

Summary

Step (c) of the TS-elimination plan: the first AffineScript source in this repo, ported from Button.tsx per the migration playbook's Cardinal Rule (re-decompose, don't transliterate).

What's done

  • src/Button.affine — full re-decomposition of Button.tsx:
    • forwardRef class → standalone make_button fn returning own Element
    • String literal unions ('primary' | 'secondary' | …) → AffineScript sum types (ButtonVariant, ButtonSize)
    • extends React.ButtonHTMLAttributes spread escape hatch → dropped; callers compose by mutating the returned own Element
    • iconBefore?: ReactNodeOption[own Element] (caller pre-builds the icon subtree)
    • DOM mutations declared / IO; ready to swap to a typed DOM effect once @affinescript/dom publishes one
    • DOM externs inlined (replace with import @affinescript/dom once the cross-package import story is settled)
  • src/Index.affine — public surface (version, wcag_level) plus a PORTING_NOTES block laying out the Modal decomposition strategy for the next session.
  • Button/Button.tsx and index.ts deleted.

Deferred

  • Modal.tsx stays in place. Its 5 useEffect blocks (portal mount, focus trap, escape-key listener, scroll lock, focus restoration) each become an own-resource with Drop semantics in AffineScript — the design is sketched in Index.affine's PORTING_NOTES but not yet written.
  • Build pipeline. package.json still references rollup + jest + @reach/auto-id. Migration to affinescript-vite is a separate concern, not blocking this pilot.

Caveats / open questions

  1. Cross-package import syntax. AffineScript's import / module story for cross-npm-package consumption isn't established in this repo's docs. I inlined the DOM externs rather than guess; please call out the right idiom in review.
  2. .affine vs .as. affinescript-dom/src/dom.as uses .as; everywhere else (conformance, warmup, examples) uses .affine. I went with .affine to match the playbook + warmup. Happy to rename if .as is the React/browser-target convention.
  3. Effect rows. Marked DOM mutations with / IO. If the conventional row for browser DOM is something more specific (e.g. Web or a DOM effect), trivially renameable.
  4. Sum-type face. Used the canonical face (Primary | Secondary | …). If the convention here is the rattle/cafe/jaffa face, can rewrite.

Test plan

  • AffineScript compiler smoke check: affinescript check Button.affine (requires the compiler in a cwd with the right deps; couldn't run in this environment)
  • Once Modal is also ported, smoke-mount in a browser via affinescript-vite
  • Resolve the four open questions above before merging

🤖 Generated with Claude Code

hyperpolymath and others added 2 commits May 3, 2026 19:18
Step (c) of the TS-elimination plan. First .affine source in this repo.

What's done:
- Button.affine — full re-decomposition of Button.tsx per the playbook's
  Cardinal Rule ("re-decompose, don't transliterate"). Concrete moves:
    * forwardRef class → standalone make_button fn returning own Element
    * React 'a | b | c' string unions → ButtonVariant/ButtonSize sum types
    * HTMLButtonAttrs spread escape hatch → dropped (caller mutates returned own Element)
    * iconBefore?: ReactNode → Option[own Element]
    * Defaults via default_button_props() factory
    * DOM mutations declared `/ IO` until @affinescript/dom publishes a typed DOM effect
    * Inline DOM extern declarations (replace with @affinescript/dom import once
      cross-package import story is settled in this repo)
- Index.affine — version + wcag_level constants + PORTING_NOTES block
  documenting the Modal decomposition strategy for the next session.
- Button/Button.tsx removed
- index.ts removed

What's not done (deferred):
- Modal.tsx left in place. Its 5 useEffect blocks (portal mount, focus trap,
  escape-key, scroll lock, focus restoration) each become a separate own-
  resource with Drop semantics in AffineScript — designed but not written.
  See PORTING_NOTES in Index.affine.
- package.json still references rollup + jest + @reach/auto-id; build pipeline
  swap to affinescript-vite is a separate concern, not blocking this pilot.

Reference: hyperpolymath/affinescript:docs/guides/migration-playbook.adoc

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to 114f7bf which deleted Button.tsx + index.ts but missed staging
the new .affine files due to a chained-shell-command failure mid-pipeline.

The .affine bodies and decomposition rationale are unchanged from what
114f7bf's commit message described.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@chatgpt-codex-connector
Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

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>
@hyperpolymath hyperpolymath merged commit 050767e into main May 4, 2026
21 of 37 checks passed
@hyperpolymath hyperpolymath deleted the react-affinescript-pilot branch May 4, 2026 07:40
hyperpolymath added a commit that referenced this pull request May 4, 2026
…#16)

## Summary

Step **(a)** of the migration plan: AffineScript `.affine` design files
for all 5 Node-bound packages, written alongside the JS fallback ([PR
#14](#14)).
Bodies remain TODO until the Node-target lands
([affinescript#35](hyperpolymath/affinescript#35))
— the type-level contract is the design lock-in.

**Conformance**: every file follows the locked AffineScript conventions
(see `feedback_affinescript_conventions.md` resolution log).
Q1=canonical face, Q2=`.affine` extension, Q3=domain-specific effect
names, Q4=per-package `Externs.affine` shim (interim, pending `.affex`
compatibility filesystem).

## What's in this PR

14 new `.affine` files across the 5 Node-bound packages:

| Package | Files | Effects declared |
|---|---|---|
| `scanner` | Externs, Scanner, Index | `Browser`, `Fs`, `Clock` |
| `core` | Externs, Arangodb, Index | `Db`, `Process` |
| `github-action` | Externs, Action, Index | `Action`, `Github` |
| `cli` | Externs, Cli, Index | `IO`, `Process`, `Fs`, `Path`,
`Commander`, `Spinner`, `Style`, `TableBuilder` |
| `monitoring-api` | Externs, Server | `Net`, `Joi`, `Dotenv`, `IO`,
`Process`, `Clock` (+ re-declared `Db` / `Scanner` from cross-pkg shims)
|

## Key playbook re-decompositions captured at the type level

Per the [migration
playbook](https://github.com/hyperpolymath/affinescript/blob/main/docs/guides/migration-playbook.adoc)
Cardinal Rule (*re-decompose, don't transliterate*):

- **Module-level singletons** (`server.ts`'s top-level `db` / `scanner`)
→ owned `AppState` threaded as `ref` parameter through route handlers —
kills the service-locator pattern the playbook flags.
- **`try/catch` around every handler** → handler effect rows (`Net + Db
+ Joi + Clock + …`) make impurity visible at the signature.
- **Tagged-template `aql\`…\`` queries** → bind-vars-form
`Db.query[B](db, str, bindVars) -> Cursor` per the playbook
recommendation.
- **`'A' | 'AA' | 'AAA'` literal-string unions** → AffineScript sum
types (`A | AA | AAA`).
- **TS class with private fields + methods** (e.g. `ArangoDBService`) →
owned `Service` record + standalone fns taking `ref Service` / `mut
Service`.
- **`React.forwardRef` class** (in [PR
#15](#15))
→ standalone `make_button` fn returning `own Element`.

## Caveats

- **Bodies are stubs.** Each fn has the right signature + effect row but
the body is `// TODO: implement when Node-target lands` plus `let _ = …;
()`-style placeholders. This is the agreed shape of "step (a) design
lock-in" — type-level contract captured, runtime impl a separate
session.
- **Cross-package types** (e.g. monitoring-api consuming core's
`Service` and `Db` effect, or scanner's `ScanResult`) are currently
re-declared as opaque `extern type` + a slim re-declared effect in each
consumer. When `.affex` lands, those re-decls collapse to `use Core` /
`use Scanner` and the duplication disappears.
- **Stdlib-style helpers** (`String.join`, `List.fold`, `Int.to_string`,
etc.) are referenced but not yet pinned to a specific stdlib path — to
settle when the language's stdlib path scheme is finalised.
- **`pub` visibility on top-level `let` / `type`** is used per
`stdlib/Core.affine` precedent; if the canonical face has different
export semantics in some constructs, those are renamings.

## How this fits the four PRs

| Step | Branch | PR | What it gives |
|---|---|---|---|
| (d) | `js-strip-types` |
[#14](#14)
| TS → JS via ts-blank-space; Node packages run today |
| (c) | `react-affinescript-pilot` |
[#15](#15)
| Button.affine pilot; Modal deferred |
| **(a)** | `affinescript-node-design` | this PR | `.affine` design for
the 5 Node-bound packages |

PR #14 keeps the runtime working today; PR #15 + this PR capture the
AffineScript design while context is fresh. The two layers (`.js` +
`.affine`) coexist until the Node-target lands; at that point the `.js`
files retire and `.affine` becomes canonical.

## Test plan

- [ ] AffineScript compile-check via `affinescript check <file>` once a
working environment is set up — couldn't run in this session
- [ ] Specify `.affex` per the parking-lot entry; collapse cross-package
type re-decls
- [ ] Implement TODO-bodies once Node-target lands (multi-session)
- [ ] Modal port (deferred from PR #15)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant