Skip to content

Latest commit

 

History

History
332 lines (271 loc) · 15 KB

ai-skill-web.md

File metadata and controls

332 lines (271 loc) · 15 KB

The VelarScript Web AI skill brief

Load this after velar skill core. This file contains only the Web extension's contract; Core syntax stays owned by the Core brief. It ships with the toolchain and velar skill web prints it verbatim.

Ownership

The manifest activates Web explicitly:

{
  "formatVersion": 2,
  "entry": "src/main.vel",
  "extensions": ["@velarscript/web"],
  "web": {"title": "My App"}
}

@velarscript/web owns component, JSX, state, computed, resource, action, watch, look, keyframes, mount, Web units, its standard modules, and the web manifest key. Without that extension these are not Core syntax. Web visual builders are named imports from velar/look; there is no Look. namespace.

Every word in that list is a contextual keyword that stays available as an ordinary name — except five. mount, tick, viewport, scheme, and motion are reserved bindings: mount and tick are real runtime entry points, and viewport, scheme, and motion name the Look media subjects, so a local binding can never shadow a media condition. A Web module refuses all five as binding names with VEL3007. Three of them are ordinary words for a Web author, so name a scroll container scroller, a colour setting theme, and an animation preference reducedMotion.

@ remains the language-wide marker introducer, and @name is a context marker with a compiler-owned compile-time role. Components own @mounted: and @cleanup:; Look owns conditions such as @hover and @before. They are not decorators, calls, or values, and user code cannot declare new context markers.

The toolchain writes dist/index.html; do not author it. web.title and web.icon supply document metadata, public/ is copied through, and other assets are content-hashed.

Components and reactivity

A component returns JSX directly. The four reactive cells have distinct jobs:

  • state owns a fact.
  • computed name = ... derives synchronously and is read bare.
  • resource name: T = load() owns async loading state.
  • action name(): owns a user operation and exposes pending and error.
component TicketPanel(id: string):
    state draft = ""
    resource ticket: Ticket = loadTicket(id)
    computed heading = ticket.value?.title ?? "Loading"

    action save():
        await saveDraft(id, draft)

    watch id:
        detach ticket.reload()

    return <section>
        <h2>{heading}</h2>
        <textarea bind:value={draft}></textarea>
        <button disabled={save.pending} on:click={save}>Save</button>
    </section>

A resource loads once at mount. Refetching after an input changes is an explicit watch plus detach resource.reload(). Actions do not queue; disable or otherwise guard a trigger when concurrent calls are unwanted.

Markup children are text, and text has no comment form. A // or /* ... */ opener standing at the start of its own line inside a children region is VEL5002, exactly as <!-- ... --> and {/* ... */} are; it used to render as a paragraph of source comment on the page. Put the note above the markup, where // is a comment, and interpolate the line — {"// ..."} — on the rare occasion the text really does begin with an opener.

A watch subject is a reactive name — a state, a computed, a prop, or a resource field — or a read path out of one, such as items[0].done. An operator or a call there is refused; declare the value with computed and watch that name. A subject that is not reactive is refused as well: watch total: over a plain const is a body that can never run. computed is the only spelling for a reactive derived value. A const inside a component is not a second one: its initializer is evaluated once at construction and never recomputes, and no diagnostic marks it.

A watch may write state and declares nothing to do so. Within a flush, watches run in the order they were written: source order in one module, mount order across instances of one component, module initialization order across modules. Two watches that write one state both take effect, in that order. Put a value that must be correct before anything reads it in a computed, which settles before any DOM is written and never depends on watch order.

Three watch shapes are compile errors, because each re-triggers itself on every run with nothing to end it: a body whose top level unconditionally writes the watch's own subject or any part of it (watch count: count = count + 1, watch form: form.name = value, compound assignments and mutating collection calls on the subject or on a collection inside it included); a watch on a resource field whose body reloads that resource; and a watch that unconditionally starts an action or an async def of the same module whose own top level writes the watched value. A write under a condition, a write of another state, and a write further than one call away stay legal, and the runtime's per-task budget of 100,000 observer runs is the backstop for the ones that never converge. Watch the input, not the result.

A watch runs before the DOM its own change produces, so layout read inside a watch is the layout from before the change — scrollMetrics and measure answer the previous frame, silently and with no diagnostic. Read layout from a detached detach statement that does await frame() first; the same applies immediately after scrollElementTo. tick() waits for the flush (rendered text and structure); frame() waits for the paint (geometry).

@mounted: runs after insertion and may await. @cleanup: runs before removal and is synchronous. A component may still declare ordinary methods named mounted or cleanup because compiler-owned names occupy a separate namespace.

Events use on:click={handler}. Writable form paths use bind:value, bind:checked, or bind:group; do not read event.target. A JSX tag body is received only through an explicitly declared children: WebNode prop. Conditional rendering is an expression, and repeated children need stable key values. Reuse asks two questions: the key must match, and the row it names must still be the same value. items = items.map(item => { ...item, done: true }) rebuilds every row, so the keyed list recognises none of them and destroys and rebuilds all of its children — an input being typed into loses focus. Change the field in place, items[index].done = true. A computed that builds a fresh record per source element is the same rebuild, once per recompute; there, render the source rows and change the field on those, or carry the source records through. The compiler raises advisory A4 for both spellings where the rebuilt rows are the ones a keyed position renders; // velar-allow A4: <reason> suppresses it where building the rows is the only spelling, which a readonly list or one API response makes it.

Native attributes separate presence from text. false and null remove an attribute, true writes an empty value, and a string writes its exact text. Write aria-expanded={str(open)} when the token "true" or "false" is required. Advisory A14 recognizes the exact equivalent conversion from open ? "true" : "false" to str(open) on a native text attribute. It offers the quick fix when discarded source has no comment; comments inside open survive because that source is copied verbatim. HTML bool-presence attributes and component props stay outside the rewrite.

A native element rejects every attribute name beginning with on other than the on: directive itself. onclick, onClick, and ONCLICK are one attribute to the browser, and the browser compiles that attribute's value as script in the application's origin, which VelarScript reserves for unsafe:html. The prefix is reserved by name rather than by a roster of handler names, so the next handler spelling the platform adds is closed in advance. A component prop may still be named onSave; the reservation belongs to the native element.

host marks the element that receives what an invocation attaches to a component — class, look, look:*, and style:*. class:* is a native element directive and is not among them; an invocation that writes it is reported as an unknown prop. A component whose root is a single native element or a single component needs no marker. A nested component's host names that component's host and never the enclosing one's, wherever the nested component sits: buried inside one of the enclosing component's elements, or standing directly among its roots. An enclosing component forwards to a nested component's host only when it marks no native element of its own, so a component whose roots are two nested components must mark a native element of its own to say which one receives the invocation.

Props are live reactive inputs. Their data is mutable unless the author writes an explicit readonly view.

Look

look: is checked visual data. CSS keywords are strings, property names use DOM camelCase, and units are literals:

import {border, rgb, spacing} from "velar/look"

const buttonLook = look:
    border = border(0px, rgb(220, 224, 235))
    borderRadius = 10px
    padding = spacing(10px, 14px)
    cursor = "pointer"

    if @hover:
        background = rgb(235, 240, 255)

A module-level Look literal is built once and cannot read reactive state. Put a changing visual on the element with look={active ? enabledLook : disabledLook} or a typed look:color={...} binding. Checked motion is a module-level keyframes: value passed to animate from velar/look; do not use a raw CSS animation string. Unsupported styling uses explicit import css unsafe "./file.css" before look or after look.

A design system's CSS custom property is read with token("--name") from velar/look. It is legal in every Look property — sizes, colours, shadows, transitions, fonts — so a design-token codebase stays inside Look rather than falling out to import css unsafe:

import {token} from "velar/look"

const shellChrome = look:
    width = token("--shell-sidebar-expanded-width")
    borderRadius = token("--ui-radius-panel")
    boxShadow = token("--shell-sidebar-shadow")
    transition = token("--ui-transition-fast")
    color = token("--ui-color-foreground")

The name must be a literal starting with --; a computed name is refused. There is no fallback argument — a missing token is fixed where the design system defines it. Do not write var(--name) as a string and do not wrap it in color(...): both are refused with the token() spelling named, and velar fix migrates them. animation is the exception: motion is a keyframes: value passed to animate(...), never a token.

Do not write a CSS shorthand beside a longhand it writes — padding with paddingTop, border with borderColor, background with backgroundColor. Each Look entry becomes its own CSS rule and the stylesheet orders them module-wide, so the block's own order does not decide which survives; the pair is refused, and the refusal names the longhands to write instead. There is no font property: it is a shorthand whose value nothing can check, and a value that is not a legal font shorthand silently resets fontSize, fontFamily, fontWeight and lineHeight along with it. Write the longhands, and put a size token in fontSize = token("--ui-font-size-body").

A look written on a component invocation composes after the look the component applies to its own host, per property and per condition: declare a property unconditionally to override the component's outright, and declare it under a condition to refine that condition alone and leave the component's other values standing.

Realtime client

velar/websocket.connect is the sole raw socket transport. velar/realtime.realtimeClient is the application layer: a typed codec, explicit lifecycle, finite reconnect policy, connection generations, and one callback where subscriptions can be rebuilt after reconnect.

import {Bytes} from "velar/binary"
import {RealtimeClient, RealtimeClientFailureAction, RealtimeOpen, realtimeClient} from "velar/realtime"

type ServerEvent:
    event: string

type Command:
    operation: string

def decode(message: string | Bytes) -> ServerEvent:
    if message is string: return Json.parse(message, ServerEvent)
    throw Error("Binary events are not supported")

def encode(command: Command) -> string | Bytes: return Json.stringify(command)

async def opened(client: RealtimeClient<Command>, open: RealtimeOpen):
    if open.reconnected: await client.send({operation: "resync"})

export def liveClient(url: string) -> RealtimeClient<Command>:
    return realtimeClient(
        url,
        {decode, encode},
        async (event, _client) => print(event.event),
        opened=opened,
        failed=async (_failure, _client) => RealtimeClientFailureAction.reconnect,
        options={reconnectDelays: [0ms, 1s, 2s, 5s], reconnectJitter: 0.2},
    )

Call await client.start() from owned startup and await client.close() from cleanup. whenOpen() waits for the current or next generation; whenClosed() waits for terminal shutdown. The client does not queue or replay commands across a disconnect. Put message IDs, acknowledgements, resume cursors, and idempotency in the shared application protocol when stronger delivery is required. A URL function may refresh a signed URL on every attempt. Initial retry is off unless retryInitial: true is explicit.

Storage and tests

velar/storage stores JSON and validates on read. A generic type spelling is not a runtime value, so name the complete type:

import {storage} from "velar/storage"

type SavedItem:
    title: string

type SavedItems = List<SavedItem>

const items = storage.get("reading", SavedItems, [])
storage.set("reading", items)

Keep src/main.vel as the mounted entry and put testable components and logic in imported modules. Plain *.test.vel files run headlessly. A *.browser.test.vel file drives the already-built page through velar/web-test; it does not mount a second app and cannot use JSX or document in its test body:

import {expect} from "velar/test"
import {browser, localStorage} from "velar/web-test"

test "adding a link shows it in the list":
    await browser.open("/")
    await browser.fill("#title", "Vel")
    await browser.click("#add")
    await browser.waitForText("[data-item]", "Vel")
    expect(await browser.text("[data-count]")).toBe("1")
    expect(await localStorage.get("reading")).toBe(`[{"title":"Vel"}]`)

Bare velar test --browser means Chromium; use --browser=all for a cross-browser claim. velar/web-test is legal only in browser-test modules. Application code that needs the page uses checked velar/browser APIs.

Finish

Run velar format, velar check, velar test, the project's browser tests, and velar build. The complete runnable vocabulary lives in examples/tour/web/; diagnostics outrank this brief when the two appear to disagree.