Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ alters surface syntax updates ALL THREE in the same change.
why a trade-off was taken. Never narrate what code obviously does,
never reference project history or future plans. A comment that
states a stale fact is a bug (rule 8 applies to comments).
- **A component body packs its declarations and paragraphs its logic.** A one-line prop, state, offer, accept, or computed runs straight into the member below it and into `render`. A method, an effect, or any member with an indented body is followed by a blank line, so `render` takes a blank after logic and none after declarations.
- **Names come from the established vocabulary**: SourceFile, TokenTape,
NodeStore, RoleStore, MappingStore, CodeBuilder, semanticKind, role,
`_` (structural constant), grammarRef (null for literal-sourced
Expand Down
277 changes: 229 additions & 48 deletions dist/@rip/rip.js

Large diffs are not rendered by default.

388 changes: 194 additions & 194 deletions dist/@rip/rip.min.js

Large diffs are not rendered by default.

Binary file modified dist/@rip/rip.min.js.br
Binary file not shown.
19 changes: 19 additions & 0 deletions docs/HMR.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,25 @@ These are load-bearing invariants, not folklore:
gone before the rebuilt view can claim it. The pool drains at the end
of the parent's setup and on any teardown, so a claim can only land
during the rebuild and nothing outlives it.
8. **A rebuilt child rebinds the part that adopted it.** Under
`asChild` a part's host is its child's root element, so a child's
patch replaces the part's host. After its own setup the child hands
the new root to the adopting part through `_setChildren`, and the
part rehosts: released as a patch releases it but with the DOM kept,
since the host's place is the child's, then rebuilt by the ordinary
create/setup path, which adopts the new element; a part above that
adopted this one's root rebinds in turn. A patch of an adopting part
itself keeps the host in place for the same reason: it releases
without detaching and skips the reinsertion.
9. **A changed module's importers patch with it.** A parent's `@event`
binding on a child component is a listener the parent's own
`_create` adds to the child's root, and a child's patch replaces
that root. The binding survives a child edit only because the
applier patches every module the loader's invalidation walk reaches,
the child first and each importer after it, so the parent's rebuild
attaches the listener to the new root. A patch of a child alone
(`__hmrPatch` on the child instance with its importers untouched)
leaves the binding on the detached root.

---

Expand Down
4 changes: 3 additions & 1 deletion docs/TYPES.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,9 @@ A component's name at a USE site hovers the component's signature — `component

A component body has two kinds of name. The names it declares — state, computed, readonly, methods, gates, and props at their reads — resolve bare, and `@name` is the same read spelled through the instance: it never shadows, so it is the spelling to reach for when a local carries the member's name. The names it is provided — `stash`, `router`, `params`, `query`, and under `extends` the `rest` view of the undeclared caller props — appear nowhere in the body, so they take the sigil alone: `@stash`, `@router`, `@params`, `@query`, `@rest`. The runtime fields never resolve bare because they are not members; a bare `rest` inside an `extends` component is rejected by the emitter, while a local the author binds as `rest` is their own name and stays bare. A caller may pass an undeclared key a value or a reactive name (`Btn disabled: busy`): the props surface admits either, and the name's container may hold `undefined` as the value may; a read of the view answers the value and tracks the name, so `@rest.disabled is true` and `@rest.id ?? mint()` mean what they say however the caller spelled it. A declared prop with a default is the one slot whose container holds the value alone: its default fills an omission, a caller's `undefined` reaches the member past it, and a caller-supplied container that may hold `undefined` is refused, since the member reads as `T` inside. The refusal is stricter than the runtime, and stands: the runtime admitting a cell it would then read past the default is the same lie under another name. The view is also never assigned: it reads through the map the runtime forwards from, so the emitter rejects every write that reaches it — `@rest` itself, any chain rooted there, and the same shapes inside a destructuring pattern; a value the caller should see is set on the element in render or declared as a prop. The view is typed as the tag's passthrough object — each attribute under its own spelling, typed through the tag's DOM interface, plus the `data-`/`aria-` templates, never a catch-all — named on the face through a per-tag alias the editor shows as `Rest<tag>` and spelled inline in the shipped declarations, so `@rest.disabled` on a `button` is `boolean | undefined` and hovers `(rest) rest: Rest<button>`.

`extends` names a host, and the host may be a component the module binds or imports as well as a tag: the Drawer's `Popup = component extends Dialog.Popup` forwards the caller's undeclared props onto the first class-scope construction of `Dialog.Popup` in its render, spread into that construction's props ahead of the line's own keys, and a later update to a rest key reaches the host through its own prop updater — a declared prop of the host writes the member, an undeclared one rides on to the host's rest, and a key the line itself passes is the line's. The tag form keeps the same rule: an attribute the element's own line sets, its selector's `class` and `id` included, is never written from rest, whether the caller's value is fixed or reactive; listeners are not keys, so the line's and the caller's both run, and `@rest` still reads the caller's value, so a line that wants the override spells it (`id: @rest.id ?? mint()`). The props surface says the same: a key the host line sets is left out of it, on both hosts, unless the body reads it back through `@rest.<key>`, so `DialogTrigger type: 'submit'` is refused at the keyboard rather than dropped at the element. This is delegation, never inheritance: nothing of the host's body is visible in the wrapper, no member is overridden, and the wrapper's class is not a subclass of the host's. The props surface is the wrapper's own props intersected with the host's less the keys the wrapper declares (their bind twins and `children` included), spelled through `ConstructorParameters<typeof Host>` and distributed over the host's required-prop arms, and `@rest` holds that object. A render that never constructs the host at class scope is rejected as the tag form is; so is a component extending itself.
`extends` names a host, and the host may be a component the module binds or imports as well as a tag: the Drawer's `Popup = component extends Dialog.Popup` forwards the caller's undeclared props onto the first class-scope construction of `Dialog.Popup` in its render, spread into that construction's props ahead of the line's own keys, and a later update to a rest key reaches the host through its own prop updater — a declared prop of the host writes the member, an undeclared one rides on to the host's rest, and a key the line itself passes is the line's. The tag form keeps the same rule: an attribute the element's own line sets, its selector's `id` included, is never written from rest, whether the caller's value is fixed or reactive; listeners are not keys, so the line's and the caller's both run, and `@rest` still reads the caller's value, so a line that wants the override spells it (`id: @rest.id ?? mint()`). The props surface says the same: a key the host line sets is left out of it, on both hosts, unless the body reads it back through `@rest.<key>`, so `DialogTrigger type: 'submit'` is refused at the keyboard rather than dropped at the element. Two keys merge instead of shutting rest out, in two modes decided by whether the body reads the key back. In automatic mode, the default, a tag host's `class` write ends with the rest view's `class` read, so the line's classes and the caller's both hold, and its `style` merges by key, the caller's keys the line does not set written alongside the line's; each write is an effect even when the line's value is static, since the caller's may be reactive or arrive through an update, and a rest write touches the view. A style key both sides set is refused, never resolved by precedence: a literal line style has known keys, which the props surface subtracts from the caller's object (and a string, which merges by no key, is not admitted there), while a computed line style is checked at each write by the runtime, which throws naming the part and the key. Manual mode is entered by reading: a body that reads `@rest.class` owns the class list, with no automatic append, and one that reads `@rest.style` owns the style object, with no merge and no shared-key check, since it is negotiating keys on purpose. A component host follows the same two modes on the value its line passes down: in automatic mode the wrapper passes `class` as the line's list with the caller's appended and `style` as the runtime merge of the line's object with the caller's, re-pushed through the updater effect on every rest write, and the host then merges that one value with its own line as it does any caller's; a body that reads the key back passes exactly what it spells. In both modes the surface admits `class`, `className`, and `style` on every extends component: a tag host's surface carries them, a wrapper's inherits them through its host's, and a wrapper's literal line style subtracts its own keys the way a tag host's does, while the host's line keys stay the host's to refuse at its write. `class` and `className` are one key in the rest map, so a caller's `className` reaches a `@rest.class` read and the merge alike. This is delegation, never inheritance: nothing of the host's body is visible in the wrapper, no member is overridden, and the wrapper's class is not a subclass of the host's. The props surface is the wrapper's own props intersected with the host's less the keys the wrapper declares (their bind twins and `children` included), spelled through `ConstructorParameters<typeof Host>` and distributed over the host's required-prop arms, and `@rest` holds that object. A render that never constructs the host at class scope is rejected as the tag form is; so is a component extending itself.

**`asChild`: the projected element is the host.** A caller of a tag-extending component may pass `asChild` (the boolean shorthand of `asChild: true`) with exactly one element as the body, and the component renders that element as its host instead of creating its tag: the host line in `_create` reads `this._asChild ? this._adoptChild() : document.createElement('button')`, every line after it writes to whichever element the fork chose, and the slot's append is skipped, since the slot is the host. So the line's attributes and listeners, its `data-part` stamp, the `ref:` on the host line, the setup effects, and the caller's other undeclared props all land on the projected element, under the extends rules unchanged: a key the line sets is the line's and rest never writes it, a listener the line adds and one the caller passes both run, and a `class` from rest replaces the element's own, so the element's styles belong on the child. A `style` object on the line replaces the child's as well, since the style writer remembers one object per element. A component child qualifies through its root element; text, a fragment, the comment a failed child leaves, or no body throws at mount naming the part. `asChild` takes `true` or nothing and is fixed at construction: a container or any other value is refused there, an update is refused, and it is never written as an attribute. It rides the rest map, so a wrapper built with `extends Dialog.Trigger` forwards it to the part it constructs and the part that binds the tag consumes it, and the props surface of a tag-extending component carries `asChild?: boolean`, which a wrapper's surface inherits. A declared prop named `asChild` on an extends component rejects at compile, as `rest` does, and a member named `_asChild` collides with the runtime's slot. Under HMR a rebuilt child hands its new root to the part above it (docs/HMR.md).

**A component named through a member path.** A tag, an `extends` host, and an accept provider each take a member path as well as a bare name: `Menu.Popup side: 'top'`, `component extends Dialog.Popup`, `accept open from Dialog.Root`. The path is plain property access rooted at an identifier, any depth, and its leaf is a component name; a lowercase leaf is a member read, never a tag, and `this` roots no path. The root resolves as a bare tag's name does, so a module namespace serves its parts behind the qualifier (`import * as Menu from 'rip/ui'`, or `export * as Menu from './menu.rip'` re-published by a package entry), a tag constructs through an object or a class static holding a component the same way, and a root bound nowhere rejects at compile naming the path. A host or a provider named through a path must root at an import: their types reach the declaration file through the path, and a declaration file carries imports and the module's own components, never a plain value, so a path rooted at a module binding rejects at compile. The lowering constructs through the member (`new Menu.Popup({…})`), the child's failure comments and the extends record carry the dotted text, and the runtime is unchanged: the context walk matches the class the member holds. On the face, the accept indexes `__offers` through the path (`typeof Dialog.Root`), the host is referenced through it, and each segment maps to its own bytes, so the part's name hovers the component's signature under its own name, defines into its module, and takes the token a bare tag takes, while the qualifier hovers as the module it is. A tag whose target is no component is the face's report, not the emitter's, since the emitter cannot tell what an import holds: tsgo's construction diagnostic (TS7009 under strict, TS2351 for a value with no construct signature) re-anchors on the tag's own bytes as `'Menu.Trigger' is not a component, so it cannot be rendered as one`, through the sites the emitter records (`componentUses`); gradual holds TS7009 with the rest of the implicit-any family, and the runtime reports the failed child at mount.

Expand Down
2 changes: 0 additions & 2 deletions packages/sites/demos/cart/app/components/button.rip
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ export Button = component extends button
@outline?: boolean
@link?: boolean
@loading?: boolean

render
button
aria-busy: loading
Expand All @@ -19,7 +18,6 @@ export Button = component extends button
export ButtonLink = component extends a
@variant?: 'primary' | 'secondary' := 'primary'
@outline?: boolean

render
a
role: 'button'
Expand Down
1 change: 1 addition & 0 deletions packages/tui/bench/keys.rip
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Select = component
when 'ArrowDown' then @at = (@at + 1) % @items.length
when 'ArrowUp' then @at = (@at - 1 + @items.length) % @items.length
when 'Enter' then @chosen = @items[@at]

render
div ref: el, focusable: true, autofocus: true, @keydown: @move, borderStyle: 'round', borderColor: (if el?.focused then 'cyan' else 'gray')
for item, n in @items
Expand Down
1 change: 1 addition & 0 deletions packages/tui/examples/counter.rip
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Counter = component
pressed: (event) ->
@count += 1 if event.key is 'ArrowUp'
@count -= 1 if event.key is 'ArrowDown'

render
Box @keydown: @pressed, focusable: true, autofocus: true, borderStyle: 'round', paddingX: 1
Text color: 'green', "count #{@count}"
Expand Down
1 change: 0 additions & 1 deletion packages/tui/examples/files.rip
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,6 @@ Browser = component
pane ~= { flexDirection: 'column', height: tall + 2, overflow: 'hidden', borderStyle: 'round' }
paneLeft ~= { ...pane, width: 32, contentOffsetY: top, borderColor: (if list?.focused then 'cyan' else 'gray') }
paneRight ~= { ...pane, flexGrow: 1, paddingX: 1, contentOffsetY: lineTop, borderColor: (if view?.focused then 'cyan' else 'gray') }

render
Box flexDirection: 'column', @keydown: @any
Box flexDirection: 'row', gap: 1
Expand Down
1 change: 0 additions & 1 deletion packages/tui/examples/ink/counter.rip
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { run, clock, Text } from 'rip/tui'

export Counter = component
tick = clock 100

render
Text color: 'green', "#{tick.frame} tests passed"

Expand Down
1 change: 0 additions & 1 deletion packages/tui/examples/ink/use-focus.rip
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { run, focus, Box, Text } from 'rip/tui'
Item = component
@label := ''
el := null

render
span ref: el, focusable: true
"#{@label} "
Expand Down
2 changes: 2 additions & 0 deletions packages/tui/test/events.rip
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,7 @@ Select =! component
when 'ArrowDown' then @at = (@at + 1) % @items.length
when 'ArrowUp' then @at = (@at - 1 + @items.length) % @items.length
when 'Enter' then @chosen = @items[@at]

render
div ref: el, focusable: true, autofocus: true, @keydown: @move, borderStyle: 'round', borderColor: (if el?.focused then 'cyan' else 'gray')
for item, n in @items
Expand Down Expand Up @@ -796,6 +797,7 @@ Input =! component
@value = parts.slice(0, -1).join ''
else if Array.from(event.key).length is 1 and not event.ctrlKey and not event.altKey and not event.metaKey
@value += event.key

render
Box focusable: true, autofocus: true, borderStyle: 'single', width: 20, cursor: { x: 1 + Bun.stringWidth(@value), y: 1 }, @keydown: @typed, @paste: ((event) => @value += event.text)
Text "#{@value}"
Expand Down
2 changes: 2 additions & 0 deletions packages/tui/test/events/ink.rip
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,7 @@ Logger =! component
heard: (event) ->
@inputs.push (if event.ctrlKey then "Ctrl+#{event.key}" else event.key)
event.preventDefault() if @keep

render
Box focusable: true, autofocus: true, @keydown: @heard, @paste: ((event) => @pastes.push event.text)
Text "log"
Expand Down Expand Up @@ -617,6 +618,7 @@ InputApp =! component
@line = @line.slice 0, -1
else if Array.from(event.key).length is 1 and not event.ctrlKey and not event.altKey and not event.metaKey
@line += event.key

render
Box focusable: true, autofocus: true, cursor: { x: 2 + line.length, y: 0 }, @keydown: @heard
Text "> #{line}"
Expand Down
1 change: 1 addition & 0 deletions packages/tui/tui.rip
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export Static = component extends div
view = live.view
view.statics.push el
-> view.statics = view.statics.filter (other) -> other isnt el

render
div ref: el, display: 'none'
slot
Expand Down
Loading
Loading