All notable changes to WebBase-III are documented here.
Format follows Keep a Changelog. Versions follow Semantic Versioning — minor bump per sub-project, patch for fixes, 1.0.0 when feature-complete.
LOOKUPcolumn qualifier — language grammar and storage layer (#58). Any column can declare a constraint on its legal values:LOOKUP <table>.<column> [DISPLAY <column>]for a live table-driven lookup, orLOOKUP ("a","b",...)for a literal list. Parsed byCREATE TABLE/ALTER TABLE ADD/ALTER TABLE ALTER, persisted per-column inColumnMetaStorevia an additive migration (existing declared types are never touched or dropped), and resolvable to concrete{value,label}options via the newsrc/interpreter/LookupResolver.ts(degrades to free entry — never truncates — when the source is missing, empty, or exceeds 1000 distinct values). This is a WebBase-III extension with no dBASE III ancestor.LOOKUPenforcement + BROWSE dropdown (#60).REPLACEand the BROWSE grid'sgrid-editnow reject a value outside a column's declaredLOOKUP, re-resolving the constraint fresh against the live database on every write (so a value that only just became legal, or that just stopped being legal, is judged correctly — never a stale cached list). An unresolvable lookup (source table dropped, empty, or over 1000 values) degrades to free entry with a warning rather than locking the column. BROWSE renders a lookup column as a dropdown —DISPLAYlabels shown while editing, the stored code shown once committed, matchingLIST/report output.- Field-bound
@ SAY GET(#59).@ r,c SAY "…" GET <name>now binds directly to the active table's column when one matches — dBASE III's actual behavior — instead of only ever collecting into a memory variable. Fields take precedence over a memory variable of the same name (why them_prefix convention exists). A field-boundGETneeds a current record and prefills from it; a lookup column renders the same picker BROWSE does.READ's submit validates every field-bound value (declared type + lookup membership) before writing any of them — a rejection sends a newform-errormessage that keeps the form open with the bad fields outlined, rather than silently discarding the valid ones. Writes target the row captured atGETtime, mirroring howgrid-editalready writes by rowid. This is the PR that promotesLOOKUPto the README command reference in full — both BROWSE and forms now declare, enforce, and render it end to end. - Demos adopt
LOOKUP(#61).demos/overtime.prggains aSCHEDULEScatalog table (SCHEDID,DESCR) as the lookup source forEMPLOYEES.SCHEDID— Add Employee is now a check-first, two-form flow where the schedule is picked from a dropdown showing the description ("Standard 40h (08:00-16:30)") instead of typed from memory.demos/crm.prg'sDEALS.STAGEis constrained to a literalLOOKUPlist matching its own seeded vocabulary exactly, exercising the other lookup kind in a real, working demo. - Assistant wizard support (#62). The New-table and Modify-structure wizards gain a
per-column "lookup (optional)" field accepting
TABLE.COLUMN [DISPLAY COLUMN]or a quoted list, so declaring aLOOKUPno longer requires dropping into raw W3Script syntax. This closes out the v1.3.0 lookup-columns milestone (#58–#62): the language, storage, resolver, BROWSE/REPLACE enforcement, field-bound forms, two demo apps, and the GUI wizards all now agree on one constraint, declared once, on the column.
- A memory-variable
@ SAY GETno longer shows blank when the variable already holds a value. Field-binding (#59) rewrote the non-field fallback path to hardcode an empty prefill instead of reading the variable's current value, silently breaking every demo form that pre-fills a default viaSTORE:overtime.prg's Week Monday date (Open/Prep Week, Recalculate Week) and leave date (Register Leave Taken),INVENTORY.prg's stock/ reorder/price/quantity defaults (Add Product, Stock In, Stock Out), andcrm.prg's deal value default (Add Deal). Found by exercising the running app, not by the test suite — every existing test for this path used a variable with no prior value, where an empty prefill happens to be correct either way.
TIMEcolumn type —CREATE TABLE ... (col TIME)/TIME(n)for a minute-granularity qualifier (e.g.TIME(15)for quarter-hour increments). Stores canonicalHH:MM, validated onREPLACE ... WITH(rejects malformed or off-granularity values — no silent coercion), andLIST STRUCTUREprints the declared type instead of the raw SQLite storage class. (#43)WEEK(date)built-in — ISO-8601 week number (1–53): Monday-start weeks, week 1 is the week containing the year's first Thursday. Early-January dates correctly report the previous year's week 52/53, and late-December dates week 1 of the next year. Accepts ISOYYYY-MM-DDorMM/DD/YY; invalid input returns 0. (#44)DATEADD(date, n)built-in — the ISO datendays later (nmay be negative). Computed in UTC so month, year and leap-day boundaries are exact (2024-02-28+ 1 =2024-02-29,2023-02-28+ 1 =2023-03-01). Accepts ISOYYYY-MM-DDorMM/DD/YYand composes withCTOD(); invalid or impossible input returns''. W3Script previously had no date arithmetic at all. (#52)BROWSEnow validates each cell edit against its column's declared type before committing. An invalid edit keeps the cell in edit mode, outlines it in red and shows why (HH:MM,multiple of 15,at most 2 decimal place(s),not a real date, …); the error clears as soon as the value becomes valid.DATE,TIME/TIME(n),NUM(p,s),INTandLOGICALare checked;CHAR/MEMOstay unconstrained. The rules live insrc/shared/cellValidation.tsand run on both the client (instant feedback) and the server (grid-editis now validated authoritatively — previously it wrote straight to SQLite with no check at all). (#45)NUM(p,s)is now a genuinely supported qualifier — the precision and scale are parsed, recorded, and enforced on grid edits (NUM(8,2)accepts123456.78, rejects1.234). Previously the scale silently corrupted the schema; see Fixed. The Assistant's New table wizard accepts a width (8) or a precision,scale pair (8,2). (#45, #50)LIST STRUCTUREprints the declared type of every column (CHAR(10),NUM(8,2),DATE,TIME(15),LOGICAL,INT) rather than SQLite's storage class (TEXT/REAL/INTEGER). Declared types are recorded per(database, table, column)inserver/ColumnMetaStore.ts. (#45)demos/overtime.prg— an Overtime Tracker example app, and the showcase for this release's engine work:TIME(15)columns validated per-cell as you type inBROWSE,WEEK()for the ISO week number, andDATEADD()to walk a week's Monday through Friday. Employees have their own weekly schedule, so standard hours are a real per-employee sum rather than a flat 40; overtime is banked per week and drawn down as leave, with the balance computed live from the source rows (no running-total field to drift when a week is re-edited). Seeds a grouped report (demos/reports/overtimebyemp.json) and is reachable from the splash screen,HELP, and the Assistant (Programs → Run Overtime demo). (#46)
CREATE TABLE t (price NUM(8,2))silently created a phantom column named2of type): the parser read the precision, then treated the scale as the next column definition. The shippedPRODUCTS,DEALSandSALESdemo tables all carried this stray column.NUM(p,s)now parses correctly. Tables created before this fix keep the stray column until recreated. (#45)- Column type metadata is now scoped per database. Two databases holding same-named
tables previously shared (and overwrote) one another's declared column types, so a
TIME(15)column in one database could be validated against another database'sCHAR(20)declaration of the same name. (#45) CREATE TABLEnow rejects a malformed column list instead of silently inventing columns from tokens it doesn't understand.CREATE TABLE t (a CHAR(10) b INT)(missing comma),(a)(no type),(a NUM(8,2,9))and an unclosed paren all now raise a parse error naming the offending column, and create nothing. This permissiveness was the root cause of the phantom-column bug above. (#50)- Index metadata is now scoped per database.
indexes/active_indexeswere keyed by table name alone, so openingPEOPLEin one database silently activated an index defined on a different database'sPEOPLE— pointing the record order at a column that need not even exist there, and breakingBROWSE/LIST. On first run, existing index definitions are adopted into the one database that owns the table; definitions whose owner is ambiguous (same table name in two databases) or missing are dropped and must be recreated withINDEX ON. The underlying SQLite indexes are untouched. (#50) - A bare
INPUT "prompt" TO <var>typed at the REPL silently discarded the value: the submitted form was only applied when a continuation existed, which is never the case for a single statement. Values a form collects are now always stored. (#50) - The
BROWSEcell-validation message was invisible. Grid cells setoverflow: hidden(for the ellipsis on long values), which clipped the absolutely-positioned error tooltip away entirely — an invalid edit showed a red border and no reason. The e2e tests missed it becausetoContainText/toBeVisibledo not account for clipping by an ancestor's overflow; the assertions now usetoBeInViewport(), which does. (#46)
- Removed the
input-request/input-responseWebSocket message types. They were declared in the protocol but never sent or handled by anything —INPUTcollects its value throughform-open/form-submit. (#50) - New
npm run coverage(vitest + v8, reporting only, no thresholds), so modules no test ever executes stop hiding. (#50) - Regenerated every
docs/screenshots/*.pngand the READMEdemo.gifagainst v1.2.0, and addedscreenshot-grid-validation.pngshowingBROWSErejecting an off-quarterTIME(15)edit. (#46)
- Regenerated all
docs/screenshots/*.pngand the READMEdemo.gifagainst the current build — the imagery previously showed the stalev1.0.0banner. Screenshots and GIF now render thev1.1.1status bar so the README matches the shipped version.
- e2e runs now clean up after themselves: a Playwright global teardown deletes the
scratch databases tests create in
data/(keepingsystem.sqlite3), and a newnpm run clean:datadoes the same on demand. (#36) SUM/AVERAGE <field> [FOR <cond>] TO <var>— the dBASETOclause stores the aggregate in a memory variable (and prints nothing) instead of echoing it, so programs can compute a total and place it inline with@ SAY. (#29)- Rebuilt the
crmandinventorydemos into usable example apps — a mini-CRM (companies / contacts / deals) and a stock manager (categories / products / stock movements) — that double as a guided tour of the v1.0.0 + v1.1.0 feature set:SUM/AVERAGE … FOR,SORT ON … TO,JOIN,REPORT FORM, CSV export, work-area relations withalias.field, and a live-propagation tip. Each demo seeds a grouped report definition (demos/reports/*.json, seeded byDemoSeeder.seedDemoReports). The demos are now discoverable from the splash screen,HELP, and the Assistant (Programs → Run CRM demo / Run Inventory demo). (#29) - Assistant sidebar parity for post-v0.6 commands: Export/Import CSV actions, a Sort-to-new-table wizard, a Sum/Average wizard, and Reindex / Pack database actions — closing the drift between the sidebar and the REPL language. (#33)
- Definition of Done now requires every new user-facing command to be surfaced in the Assistant (action and/or wizard) with a Playwright e2e case. (#33)
CONTRIBUTING.mdrewritten for the GitFlow model: fork → branch off the activerelease/vX.Y.Z→ PR against that release branch (notmain), plus a Definition of Done section. Added a PR template and a README "Contributing" pointer. (#31)JOIN WITH <alias> TO <file> FOR <cond> [FIELDS <list>]— materialize a combined snapshot table from two open work areas, computed by SQLite's join planner. Deviations from dBASE III (FOR required,alias.fielddot syntax, SQL-predicate FOR, active-wins collision handling with a warning) are documented in README. (#10)- Live multiuser data propagation (#11): when one session mutates a table, every
other session currently BROWSE-ing that same table refreshes automatically — no
manual re-query. Type in one browser window, watch another repaint.
- New
data-changedWebSocket message andSessionManager.broadcast(db, table)with server-side relevance filtering (only sessions viewing the affected table are notified) and per-table debounce (a burst coalesces into one refresh). - Mutation chokepoint:
ServerDatabaseBridge.exec()fires anonMutatehook, so every write path (REPLACE,APPEND,DELETE,PACK, grid edits, …) triggers propagation with no per-command bookkeeping.
- New
closeDatabaseno longer closes the SQLite handle shared across sessions — one user closing a database no longer breaks everyone else's queries.
COPY TO,APPEND FROM, andREPORT FORMnow work when run inside a program control-flow block (DO WHILE/DO CASE/IF). Previously the server performed the work but the browser never received the CSV download, file picker, or report preview — the per-command client action was swallowed by the block executor (onlyBROWSEand formREADwere threaded through). These three are now delivered as immediate side-effects via a newExecutor.onSideEffectsink, so they fire at any nesting depth. (Bug present since v1.0.0 for CSV and v0.5.0 forREPORT FORM; REPL and Assistant usage were unaffected.)
The feature-complete parity milestone:
?/??,SUM/AVERAGE, the extra built-ins (#4),SORT ON … TO(#8),COPY TO/APPEND FROMCSV (#5), on top of indexing, language completeness, multi-work-area, reports, the Assistant, and MODIFY STRUCTURE. Backed by 239 vitest + 49 Playwright tests, CI-gated.
- The #4 built-ins (
ROUND,MOD,MAX,MIN,TIME,YEAR,MONTH,DAY, shipped in 0.8.0) were implemented inBuiltins.tsbut never registered in the parser'sBUILTIN_FUNCTIONSwhitelist, so calling them from the REPL failed withUnknown command: (. They are now registered and reachable. The unit tests passed only because they called the implementation directly — caught by adding Playwright e2e coverage for the parity commands.
COPY TO/APPEND FROMCSV import/export (#5).COPY TO <file>.csvdownloads the current table (honouring the activeSET FILTERand index order, max 50,000 rows);APPEND FROM <file>.csvopens a browser file picker and bulk-imports (max 5 MB). Deliberate deviation from dBASE III: dBASE used headerless, positionalDELIMITED/SDFformats; WebBase-III uses modern header-based CSV (RFC-4180), mapped by column name. Import is lenient — up to 10 malformed rows are skipped and reported with line number + reason; more than 10 aborts (no rows appended).SUM/AVERAGEcommands (#3) —SUM <field> [FOR <cond>]andAVERAGE <field> [FOR <cond>]aggregate a numeric field over the current table, honouring the activeSET FILTERplus an optionalFORcondition. SQLite does the aggregation server-side; the result prints right-justified like?.?/??print command (#2) — evaluate an expression (or a comma-separated list) and print the result. Strings print unquoted, booleans as.T./.F., and numbers right-justified in a 10-wide field (dBASE III numeric display). A bare?prints a blank line.??is accepted; its "no leading newline" semantics are not expressible in the line-based web terminal, so it shares?'s formatting.
- New W3Script built-in functions (#4, PR #17 by @kas2804):
ROUND(n, decimals),MOD(a, b),MAX(a, b),MIN(a, b),TIME()(current time asHH:MM:SS), and the date-part functionsYEAR(date),MONTH(date),DAY(date). Each has a matching Vitest case intests/Builtins.test.ts.
MODIFY STRUCTURE— alter an existing table's columns without losing data (#6).- Scriptable command family:
ALTER TABLE <t> ADD/DROP/RENAME/ALTER <col> …. MODIFY STRUCTUREopens an Assistant wizard (diff editor) for the active table; also reachable via the sidebar "Modify structure…" action.- Column ops that can invalidate an index drop the table's indexes and warn to rebuild with
INDEX ON.
- Scriptable command family:
SORT ON <field>[/D] TO <newtable>(#8) — writes a sorted copy of the active table to a new table./Dsorts descending (default ascending), and the activeSET FILTERis honoured. Errors if no table is in use, the field doesn't exist, or the target table already exists.
- Implemented as a thin alias over SQLite's
CREATE TABLE … AS SELECT … ORDER BY. The new table is therefore a plain snapshot — column affinities are inferred and the source PK/constraints are not carried over.SORTis largely redundant given live indexes +ORDER BY; it exists for dBASE III dialect fidelity.
- Opening a wizard while a
DOprogram is suspended no longer silently abandons it (#7). The wizard tore down the suspended form/grid client-side, but the server kept the orphaned continuation — which could also misfire on a later unrelatedform-submit. Opening a wizard now sends anabort-suspendedmessage; the server drops the pending continuation, resets program depth, and prints** Program aborted (a wizard was opened).so the abandonment is explicit rather than silent.
- GitHub Codespaces support —
.devcontainer/devcontainer.json(Node 22, autonpm install+npm run dev, ports 5173/3000 forwarded) and an "Open in GitHub Codespaces" badge in the README: one-click try-it-now path, no hosting needed. CONTRIBUTING.md— setup, project layout, test requirements, PR guidelines, faithfulness-vs-modernity policy.- Demo GIF (
docs/screenshots/demo.gif) — recordedUSE→LIST→SEEK→BROWSEsession at the top of the README; generated byscripts/make-demo-gif.mjs+scripts/make-demo-gif.py. - Social preview card (
docs/social-preview.png, sourcedocs/social-preview.html) — 1280×640 image for link unfurls on HN/Reddit/X. - Launch & visibility plan —
docs/superpowers/specs/2026-06-12-launch-visibility-design.md.
- Positioning — new slogan "dBASE III is back. In your browser.
USE customerslike it's 1984." applied to the GitHub repo description,package.json, and a rewritten nostalgia-first README opening. GitHub topics added for discoverability.
- The Assistant — permanent left-sidebar GUI (roadmap sub-project 5): Database / Tables / Data / Search / Reports / Programs categories. Every action generates a W3Script command and submits it through the normal terminal path — commands echo into the terminal history, teaching the language as a side effect.
- Wizards in the main area (like BROWSE/editor): New database, New table, Filter, New index, Find record, and a 3-step report designer producing the existing
ReportDefJSON. Each shows a live W3Script preview while you type. catalog-request→catalogWS pair — structured lists (databases, tables+counts, active-table columns, indexes, report definitions, programs) for sidebar pickers.- Report-store test cleanup — vitest assistant tests clean up their
__report_entries after each run.
- App layout is now sidebar + main area (
#assistant-sidebar/#main-area); all existing view IDs unchanged. - Opening a wizard tears down any active main-area view (grid, form, editor, report preview) so views never double-stack.
- Opening a wizard while a
DOprogram is suspended atREADorBROWSEsilently dismisses the form/grid without resuming or aborting the program; the suspended program is abandoned for the session. Finish or quit a running program before using Assistant wizards. (Resolved in 0.6.2 — the abort is now explicit and announced.)
- SessionStart hook for Claude Code on the web (
.claude/hooks/session-start.sh) — runsnpm installand installs Playwright Chromium. Triesnpx playwright install chromium --with-depsfirst; if that fails (blocked Playwright CDN, broken apt PPA), falls back to downloading the matching Chrome for Testing build (revision and version read fromplaywright-core/browsers.json) from Google'schrome-for-testing-publicbucket intoPLAYWRIGHT_BROWSERS_PATH.
REPORT FORMSession test no longer depends on leftover state — two tests saved their report via asave-reportmessage type thatSession.handleMessagenever handled (silently ignored), so they only passed when a stale report row already existed indata/system.sqlite3. They now save through the realsave-programmessage with the__report_name prefix.
- Demo program seeding —
demos/*.prgare now the single source of truth:server/DemoSeeder.tsseeds them into the program store on every server start, overwriting any drifted store copy (seedDemoPrograms()). ProgramStore.delete(name)— removes a stored program.
- INVENTORY.prg menu — the bottom
===separator overlappedQ. Quit(both on row 13); separator moved to row 14. - Program store pollution — vitest Session tests now delete the
test_*programs they save into the shared store.
DO CASEbranches now resume afterREAD/INPUT/BROWSE— statements following a suspending command inside aCASEbranch were silently dropped (the branch runner didn't thread remaining statements into the form continuation likeIF/DO WHILEdo). This broke every interactive menu option indemos/INVENTORY.prg.- Current-record resolution now honours the active index order —
REPLACE,DELETE, field loading,SET RELATIONevaluation, and the cross-area row cache resolved the record pointer with an unorderedLIMIT/OFFSETquery, which SQLite may serve from an index scan. With an index active this targeted the wrong row (e.g. seedingAPPEND+REPLACEloops overwrote record 1 repeatedly). All sites now resolve through a single index-order-awarefetchCurrentRowhelper. APPEND RECORDpoints at the new record under an active index — the pointer is now set to the new row's position in index order (vialast_insert_rowid()), not the raw record count.REPLACEkeeps the pointer on the record if replacing an indexed field moves it in index order (dBASE semantics).alias.fieldworks outsideLIST— the cross-area row cache is now primed before expression evaluation (STORE,IF,DO WHILE/DO CASEconditions,@ SAY), soCAT.CATNAMEafter a relation seek no longer returnsnull.- dBASE III logical operators
.NOT./.AND./.OR.are now lexed as their bare keyword equivalents —DO WHILE .NOT. EOF()loops work. - Deterministic record order — ordered row queries break ties by
rowid.
STOREno longer echoes assignments while running inside a program (DO <name>), matching dBASE behaviour.- Regression tests:
READinsideDO CASE, seeding under an active index,alias.fieldvia relation outsideLIST,.NOT./.AND./.OR.operators (vitest), plus Playwright suitetests/inventory.spec.tsfordemos/INVENTORY.prg.
CREATE TABLEnow implicitly selects the newly created table in the active work area —INDEX ON,APPEND RECORD,REPLACEetc. work immediately afterCREATE TABLEwithout a separateUSEcall. This matches dBASE III behavior and fixesdemos/INVENTORY.prgfirst-run seeding.
USE <table>on nonexistent table no longer causesRECCOUNT()to throwno such table—refreshRecCountnow guards withtableExistsbefore querying SQLite, so programs that checkIF RECCOUNT() == 0to decide whether to seed data (e.g.demos/INVENTORY.prg) work correctly on first run
CREATE REPORT <name>— create a report definition (JSON) in the program editorMODIFY REPORT <name>— edit an existing report definitionREPORT FORM <name>— run a columnar report: ASCII output to terminal + HTML preview panel in browserLIST REPORTS— list all saved report definitionsDELETE REPORT <name>— delete a report definition- Report definitions stored as JSON in
system.sqlite3(reportstable) - HTML preview panel — print-ready iframe panel, Esc to close, Ctrl+P to print
demos/REPORT.prg— report engine showcase, auto-discovered bydemos.spec.ts
- Executor refactored — index commands extracted to
IndexCommands.ts; report commands inReportCommands.ts; establishes the per-command-group pattern for future sub-projects
LIST DATABASES— lists all.sqlite3databases in the data directory, marks the currently open one with*. AcceptsLIST DBSas alias.demos/directory —.prgdemo programs (crm.prg,INVENTORY.prg) visible in the repo; Playwright smoke tests auto-discover and run all demos
- Unlimited work areas —
SELECT <alias>creates or activates a named work area (no DOS 10-area limit) USE <table> ALIAS <name>— open table with an explicit alias overrideSET RELATION TO <expr> INTO <alias>— link active area to another; auto-seeks on every navigation (GO, SKIP, record pointer moves)SET RELATION TO(no args) — clear relation on active areaalias.fielddot notation — cross-area field access in any expression, LIST column list, or @ SAYLIST AREAS— show all open work areas, record pointers, active indexes, and relationsLIST <col, alias.col, ...>— optional column list with cross-area fieldsCLOSE— close active area's tableCLOSE ALL— close all work areas, reset to single empty area1- Playwright E2E suite for multi-work-area: SELECT, CLOSE ALL, relation auto-seek, alias.field LIST
DO CASE / CASE / OTHERWISE / ENDCASE— multi-branch conditional block- Built-in functions — usable anywhere an expression is accepted (IF, DO WHILE, STORE, REPLACE, INDEX ON, SET FILTER TO):
- Record state:
EOF(),BOF(),FOUND(),RECNO(),RECCOUNT() - String:
SUBSTR(),LEN(),TRIM(),LTRIM(),UPPER(),LOWER(),AT(),SPACE(),REPLICATE() - Numeric:
STR(),VAL(),INT(),ABS() - Date:
DATE(),CTOD(),DTOC()
- Record state:
INDEX ON UPPER(field) TO tag— index expressions now support built-in functions- Version injected from
package.jsonat build time — status bar always shows the correct version
SKIP -1now parses correctly (negative number literal)- Record pointer fields accessible in expression context after GO/SKIP
INDEX ON <expr> TO <tag>— create a named index on any expression; sets it active immediatelySET INDEX TO <tag>— activate a previously created indexSET INDEX TO(no tag) — clear active index, restore natural insert orderREINDEX— rebuild SQLite indexes for current tableLIST INDEXES— show all indexes with*active markerSEEK <expr>— position record pointer at first match in active indexFIND <string>— alias for SEEK (unquoted string, dBASE III legacy form)- Active index persists across sessions (stored in
data/system.sqlite3) - All record-ordered operations (LIST, BROWSE, GO TOP/BOTTOM, SKIP) respect active index
- W3Script interpreter: Lexer → Parser → Executor pipeline
- Commands: USE, USE DATABASE, LIST, LIST STRUCTURE, LIST TABLES, BROWSE, CLEAR, QUIT, HELP
- Commands: CREATE TABLE, DROP TABLE, APPEND RECORD, DELETE, DELETE ALL, PACK
- Commands: GO TOP/BOTTOM/n, SKIP, REPLACE, REPLACE ALL, SET FILTER TO
- Commands: STORE, INPUT, @ SAY GET, READ (form engine)
- Commands: IF/ENDIF, ELSE, DO WHILE/ENDDO
- Commands: DO (run program), EDIT (program editor), LIST PROGRAMS
- BROWSE grid — inline cell editing, keyboard navigation
- Form engine — character-cell @ SAY GET layout
- Program editor — built-in .prg source editor with Ctrl+S save
- Node.js WebSocket server, multi-user sessions, better-sqlite3 with WAL
- Vite frontend, TypeScript throughout