This document describes how Wordplay is built. It aspires to be a high-level document describing components, responsibilities, patterns, and dependencies. It should be a good first place for getting oriented with the overall implementation. Of course, reading the code will eventually be necessary; as you're reading this, it would be smart to read the code as you go, getting a sense of how the concepts here play out in the implementation.
Wordplay has several major dependencies, each of which is crucial to understand in order to understand Wordplay's implementation:
-
HTML + CSS + JavaScript. You really must know these before you can understand Wordplay's implementation. You don't have to have mastered them, but you're going to see them everywhere, as Wordplay is an inherently web-based application.
-
TypeScript. TypeScript is a superset of JavaScript that adds type information -- that means that it's JavaScript, plus other goodies. Most defects in programs are type errors, and TypeScript catches most type errors, so we use it to catch most defects. Read the tutorial if unfamiliar. As a practice, we do not use
any, unless TypeScript really can't express the type we're trying to express. -
Svelte. Svelte is a front-end framework for building web applications. At the highest level, a Svelte application is a collection of
.sveltefiles, each corresponding to some component, and each Svelte file has a script, markup, and style section, using JavaScript, HTML, and CSS standards. It also adds several other simple language features, however, that make building interactive web applications easier. We use Svelte because it's the fastest front-end framework and the easiest to learn (relative to React, Vue, Angular, and other frameworks). The Svelte tutorial is a must-read. -
SvelteKit. Builds upon Svelte, adding routing, server-side rendering, and other neat features for building web applications. We primarily use it to structure the Wordplay website, define consistent layout features, and interact with backend services, primarily Firebase. It's the obvious choice for a Svelte project.
-
Firebase. We use Firebase to persist the creator's projects and configuration settings, as well as for enabling project sharing. It uses a non-relational database structure for high scalability, which has some unfortunate tradeoffs on software evolution. The worst is that any schema design decisions we make place hard constraints on the views of data we create, since the schema design determines what kinds of queries are feasible. So any time we're doing schema design, we must simultaneously do user interface design, and be highly confident we won't change our minds about interface design.
There are several other more minor dependencies, especially in tooling (Vite, Vitest, Prettier). End-to-end tests run with Playwright against the Firebase emulator, and include an accessibility gate: axe-core scans hold every scanned surface to axe-detectable WCAG 2.2 Level AA in both color schemes, alongside keyboard-navigation, accessibility-tree snapshot, and live-region tests (see the Accessibility section of CLAUDE.md).
The above dependencies, however, are the key ones, because if one were to disappear, we would have a huge amount of work to do to replace it. Make sure you're reasonably comfortable with all of the above before making changes to Wordplay's implementation.
Here are the major components of Svelte, and how they interact with each other.
Database.ts is exactly what it's named: an interface to all data persistence. It keeps a snapshot of creator configuration settings, creator projects, and creator authentication information. It decides which state to persist in a browser's localStorage (because it's device specific) and which to keep in Firebase (because it's account specific).
The database also relies heavily on Svelte stores, offering granular access to settings on the user interface. For example, it keeps a store for the current list of locales selected, and exposes it globally, so that user interfaces can access the current locale or locales, and change the interface based on them, automatically updating whenever the language is changed.
The database should generally be fairly opaque; it shouldn't matter to code using the Database's methods how or where data is stored. It's currently backed by Firebase, but that could change, and no other part of the application should have to care.
Account-specific data is local-first: a single IndexedDB store (WordplayDexie.ts, DB name wordplay, one table per domain — projects, galleries, characters, how-tos, chats) is the durable local mirror of all Firebase data, and Firebase realtime queries are treated as a sync mechanism into that mirror, not as the place pages read from. Firestore itself is configured memory-only (no persistentLocalCache) so it doesn't duplicate the Dexie store or run a competing offline write queue; the Dexie store is the one local source of truth. The Dexie schema version (WordplayDBVersion) is deliberately decoupled from the project document schema version (ProjectSchemaLatestVersion): the former versions table structure, the latter versions document content (handled at deserialize time).
Each domain database (src/db/{projects,galleries,characters,howtos,chats}) follows the same pattern, with projects as the reference implementation:
- Hydrate. On construction, the domain reads its Dexie table (via a
liveQuery) into its in-memory reactive surface (aSvelteMap/$state) and flips ahydratedflag. This is what makes cold start and offline work — the UI has data before any network call. (How-tos hydrate once rather than subscribing live, because their three realtime listeners garbage-collect the in-memory map and a live cache subscription would fight that GC.) - Dual-write. The realtime listener writes each cloud snapshot into both the in-memory surface and the Dexie table; local edits do the same. The in-memory surface — not the raw Dexie rows — stays the authority for live behavior (it carries semantics rows don't: "confirmed-absent" sentinels, chat message merge, gallery role split), so writes go to it directly and Dexie is mirrored alongside.
- Single-item reads are local-first.
Projects.get,Galleries.get,Characters.getByID/getByName,HowTos.getHowTo,Chats.getChatcheck the in-memory surface (i.e. the hydrated local cache) before any Firestore read;Database.read()wraps every fallback network read in an 8s timeout so an unreachable backend fails fast instead of hanging a page. - Clear on identity change.
clearLocal()wipes a domain's cache + in-memory surface on explicit sign-out and when a different account takes over the device (privacy), mirroringProjects.deleteLocal().
On login, Database.startSync() brings the domains online serially in priority order (projects → galleries → characters → how-tos → chats), advancing to the next once the current reports its first snapshot or a timeout elapses. Serializing the listener setup avoids the concurrent-subscription burst that churned the Firestore WebChannel session ("Unknown SID" 400s) on large accounts. Each domain reports a per-domain sync status — initializing → syncing → updated (with a synced count) or failed — exposed via the syncState store and surfaced in the save-status dialog (Status.svelte).
The write/connectivity state machine:
- Read: in-memory (hydrated cache) → Dexie → Firestore (timeout-guarded). Offline, the first two suffice for anything previously synced.
- Write: update the in-memory surface + Dexie immediately (the user sees the change at once), then attempt the Firestore write. Offline, Firestore queues the write in memory and flushes on reconnect; projects additionally survive a reload-while-offline because the Dexie cache keeps an
unsavedflag thatpersist()backfills, and the browseronlineevent nudges a flush. - A stuck save must never look like a saved one. Project batches are bounded by operation count and payload bytes (chunkWrites.ts), and a batch is atomic, so a chunk that fails for a non-connectivity reason is retried document by document — otherwise one unwritable project keeps every project batched with it permanently unsaved. Commits go through
Database.write(timeout-raced) rather thantrack, since an unreachable backend makes a commit hang rather than reject.persist()reportsSavedonly when nothing is left unsaved, and the save-status button agrees with the per-domain counts; each remaining failure carries a reason shown in the dialog. - Deleting a local copy requires positive evidence. The cross-listener sweep infers a server-side delete from a project's absence in every listener's results, but only acts when the local copy holds nothing the cloud doesn't (isSweepable.ts): never a project with unsaved edits or a live coediting session, since deleting either discards the only copy of that work.
- Connectivity is never a page gate. Pages gate only on auth and on each domain's
hydratedflag, never on Firebase reachability. Losing the connection shows only in the save-status button and its dialog (Status.svelte), plus a one-time top-of-page banner — it never deactivates the site. - A disconnection is reported only once confirmed. Every signal that the cloud is unreachable — a timed-out read/write, a listener error, Firestore falling back to cache — is momentary on its own, because a tab wake, a laptop resume, and a Firestore stream reconnect all produce them and all recover in seconds. So they route through a single confirmation window in
Database(DISCONNECT_CONFIRM_MS, 30s) and reach thedisconnectedstore only if they survive it. The window ignores time the browser spent with the tab frozen or hidden, since neither is evidence of an outage. The browser's ownofflineevent is the one exception: it's definitive and instantly reversible, so it reports immediately.
The Dexie mirror above is per-device sync resilience, not a backup of the database itself. Server-side data protection for the Firestore database lives in GCP, not in this repo — Firestore backup schedules and point-in-time recovery (PITR) cannot be expressed in firebase.json (the Firebase CLI has no backup config key), so reading the codebase alone misleadingly suggests there are none. The configuration is documented and reproducible in scripts/firestore-backups.sh (not run automatically; GCP remains the source of truth). Current policy: wordplay-prod has a daily backup schedule (7-day retention), a weekly backup schedule (14-week retention) for a longer horizon, and PITR enabled (minute-granularity recovery over the past 7 days); wordplay-dev has none, by design, to avoid needless cost. Verify live state with gcloud firestore backups list / backups schedules list / databases describe.
Recovering from a loss/corruption incident is documented as a scenario-driven runbook in RECOVERY.md, backed by two tools: scripts/firestore-restore.sh (gcloud building blocks — PITR export, backup restore, scratch databases) and scripts/firestore-recover-docs.ts (a firebase-admin copier for per-user/per-doc recovery, since Firestore's import granularity is the whole collection group). Recovery exports stage through the gs://wordplay-prod-recovery bucket (US multi-region, 30-day object lifecycle).
All Wordplay code starts as strings and is converted to an abstract syntax tree by parseProgram. Parser first tokenizes the strings using Tokenizer.ts to segment the text into a sequence of Token nodes. Parser then translates the sequence of Token nodes into a tree. Root nodes of programs are Source nodes, and then inside /src/nodes are all of the different types of abstract syntax tree nodes that can appear in a Wordplay program.
For the full lexical grammar, syntactic grammar, and evaluation semantics of each construct, see LANGUAGE.md — it's the language specification companion to this architecture document.
Abstract syntax tree nodes follow a common interface defined by Node.ts. Some of the key concepts are that all nodes have a list of child nodes, and a grammar that defines their order, names, and whitespace rules, and other metadata. This metadata is used extensively in editing. Node also provides many interfaces for managing lexical scoping, edits to the tree, and localized descriptions of the node, connecting to the localization components.
Those whitespace rules drive formatting (pretty printing), in getPreferredSpaces.ts — used by the tidy command and by every programmatic edit, which re-spaces the subtree it produced. It decides each token's leading space in three passes over the token list: resolve each token's grammar field and the space it would get unbroken, measure how wide each node that can wrap would be laid out flat, then walk the tokens tracking the current column, breaking a wrap field's container open when it wouldn't fit within MAX_LINE_LENGTH. Two invariants matter: formatting only ever adds newlines, so a break the creator typed always survives (and forces its containers open); and indentation is never stored, but recomputed from tree depth each run, so it is always right for where a node currently sits.
Some nodes add additional interfaces, especially Expression.ts and Type.ts. Expression defines interfaces for compiling expressions to evaluable steps, for getting the type of the expression, and for providing localized descriptions of their evaluation. Type defines all of the different types of values that can exist and the rules for how they can be computed upon.
One important note about AST nodes: they are all immutable. This has a few implications:
- They should never have state that can be modified, so all of their fields are
readonly, unless they are a temporary cache of some derived value (e.g., an expression's type). - They do not know their parent. This is, the parser builds the tree from the bottom up; nodes have to be created before they can become part of other nodes, and so each node's parent doesn't exist until after it's created. However, this is also because immutable nodes can be reused, since they cannot change. One node might appear in many trees.
To work around the lack of a parent, we have Root.ts, which represents the root of an AST, and manages all of the parent information, offering facilities for figuring out the structure of an AST.
A Wordplay Project is a list of Source, with a name, ID, and other metadata. One piece of that metadata is remixOf, the ID of the project a project was remixed from (or null for an original). It's written once when the remix is created and never edited, so it doesn't participate in the per-field merge; it's a plain top-level field because the share dialog queries it directly to find a project's remixes.
Overall, it's best to think of the nodes as the center of everything: they define a program's structure, behavior, description, and more, and so most other things in Wordplay rely on nodes and trees to do their work.
All nodes that are subclasses of Expression.ts have a type. If you're not familiar with types in programming languages, they represent what kinds of values some symbols might store. They're a central idea in TypeScript and also a central idea in Wordplay.
Wordplay allows for types to be declared explicitly, but also to be inferred from context. To enable this, each Expression.ts node has a computeType() function that computes what type the expression has, either from its declared type, or its implicit semantics (e.g., a Boolean literal has a Boolean type, by definition), or inferred from context. To see what kinds of types an expression has and what kinds of type inference it does, check its computeType().
To enable type inference, and to prevent infinite cycles (e.g., a variable referencing itself), we have Context.ts, which is a place to cache information about an AST while it's being traversed and analyzed. This cache stores type information, remembers paths through trees during analysis to prevent cycles, gets roots of nodes, and remembers definitions in scope. It generally exists to make program analysis possible and efficient.
There are many types, each defined as a subclass of Type.ts. Many of these represent values, some represent unknown values. Each defines a function acceptsAll(), which takes a set of types and verifies that all of the types in the set are okay to assign to the type in question. These various implementations of acceptsAll() define the semantics of Wordplay's type system.
Note that all types are subclasses of Node and are therefore immutable. This is because nodes can be explicitly stated in code, and are therefore must be AST nodes. But we use the very same nodes to represent types that are inferred; they just happen to not live in an AST.
There are many ways that an AST might be invalid. They can have type errors, caught by the type system, or they can violate some specific rule (e.g., a conditional should have a Boolean condition). Wordplay does error checking in a computeConflicts() function on each node.
Not every node can have conflicts (e.g., BooleanLiteral). Some can have many. Overall, there are more than 50 types of errors that can occur, only some of which are type errors.
Each conflict gathers a bunch of contextual information about the nodes involved and then defines node to represent the conflict.
Once an AST is built for a Wordplay program, it's not necessarily analyzed for conflicts. It's up to the front end when to call Project.analyze() to find defects. The analysis happens at the project level and many conflicts span multiple Source nodes in a project.
Some conflicts apply only to evaluations of a specific built-in (e.g. warning when a Phrase requests a font weight or style its face doesn't support). Rather than embed that knowledge in Evaluate.computeConflicts(), those checks register an EvaluateAnalyzer keyed by the relevant definition in src/conflicts/evaluateAnalyzers.ts. Evaluate dispatches to any registered analyzers at the end of its general conflict analysis.
Wordplay programs are evaluated, in that they are purely functional. A Wordplay program is one big function, composed of smaller functions, and every Wordpaly program evaluates to a single Value. Values can be as simple as a BoolValue or a Text, or as complex as a Structure with 17 properties, one of which is a List of other Structure values. The most interesting values that a Wordplay program evaluates to are Stage structures, which define the arrangement and appearance of Phrasees.
Only Expression nodes are evaluable. Each one defines a compile() function that converts the node and its children into a series of Step. There are fewer than a dozen types of steps; most do things like bind values to a name in scope, start a function evaluation, jump past some step based on some condition, or do some other low-level operation. Every Wordplay Source therefore compiles down to a sequence of Steps that are evaluated one at a time.
The component that evaluates steps is Evaluator.ts. It takes a Project, compiles its Source, and evaluates each sequence of steps according to the rules of each step. As it does this, it maintains a stack of function evaluations, and for each evaluation, a stack of values, and named scope of key/Value bindings. As each step evaluates, values are pushed and popped onto the value stack, bound to names in memory, and passed as inputs to function evaluations. If any expression ever evaluates to an ExceptionValue value, the Evaluator halts and evaluates to the exception.
A key aspect of Wordplay is that some of its values are StreamValues, which change over time. Streams are sequences of values that are input by the external world, including things like time, mouse buttons, keyboard presses, and other events. Every time a stream has a new value, Evaluator reevaluates the Source that references it. This is what creates interactivity; every time there is some input, the program gets a chance to respond to it by reevaluating.
In the IDE, ProjectView wraps the Evaluator's two internal modes (playing and stepping) in three creator-facing evaluation modes, persisted in the URL's mode param: ✏️ edit (sources, stage, and palette editable; the evaluator paused on a frozen frame; new stream inputs discarded), ⏸️ step (everything read-only; the debug timeline visible; inline values rendered; stage output selectable for inspection), and Evaluator's ignoringInputs flag so stray interactions never extend the recorded input history; an unhandled exception while playing automatically switches to step mode at the exception frame.
All APIs in Wordplay -- the input streams like Key and Button and output data structures like Phrase and Stack -- are defined as Wordplay type definitions. For example, consider Grid, one of the Layout types. Inside that file, there's a function that takes a list of locales and constructs a Wordplay structure definition using those locales, defining its inputs, their documentation, and more. And then, there's a convenience wrapper class defined to store the inputs in a type-safe way for the rendering engine to use. There's also a function to convert the structure value generated by a program into an instance of that wrapper class. This basic pattern of 1) structure definition, 2) wrapper class, and 3) generator occurs for all built-in APIs in the implementation.
Creating new output APIs in the language means following that pattern, and doing a few other key things:
- Creating a similar file like Grid], defining its structure definition with locales, defining a wrapper class for use in the rendering, and writing a function that converts a
StructureValuerepresenting that type as an instance of that wrapper class. - Updating createDefaultShares to call the structure definition creator function, and include the definition in the appropriate set of types.
- Creating placeholders for localization strings for all of the strings defined for the type and its documentation in OutputTexts.ts, where the schema for the output API strings are defined.
- Using the new wrapper class in the output engine in the appropriate place to change rendering. The output engine is generally comprised of the Svelte components
PhraseView,GroupView,StageView,Scene,OutputAnimation,Physics, and other helper classes.
Once these are done, the new API structure should appear in documentation and work in programs.
Other APIs, like streams, and value APIs on things like numbers and lists, are defined elsewhere (e.g., NumberBasis.ts is an example of a basic value structure definition, Key.ts is an example of a stream definition), but follow similar patterns for localization.
src/basis/ defines the standard-library methods and operators on built-in value types. Each primitive — BoolBasis, NumberBasis, TextBasis, ListBasis, SetBasis, MapBasis, TableBasis, NoneBasis, StructureBasis — exports a bootstrap function that builds a StructureDefinition containing that type's FunctionDefinitions and ConversionDefinitions (e.g., + on numbers, length on lists, not on booleans). Basis.ts is the registry: it instantiates one Basis per active locale combination, caches the result, and exposes the structure definitions to the rest of the system.
The bodies of basis functions are not Wordplay code — they're TypeScript callbacks wrapped in InternalExpression.ts, which Evaluator invokes when a basis function is called. Names and documentation come from locale.basis.<TypeName>, so the standard library is fully localized at construction time.
BasisType.getScope() is how the type system finds these definitions: when 5 + 3 is type-checked or evaluated, the lookup of + walks through the NumberBasis structure definition registered here. createDefaultShares is the sibling registry for global definitions (output types, streams); basis is specifically for methods that belong to a type.
Wordplay defines a locale schema in Locale, which is basically one big JSON data structure of named string values. Some of these strings are constant, others are templates that can be given inputs and rendered with concrete values. Wordplay's many nodes and user interfaces generally make deep links into this data structure to get a string or template and render appropriate text.
Localization is intimately connected to accessibility, as many of the localization strings are templated descriptions of nodes, values, and other content.
A template input the grammar of a sentence depends on is a count, declared '#name' and written $#name[…|…], with one version of the sentence per plural form the reading locale has — two for English, one for Japanese, four for Polish, six for Arabic. plurals.ts derives those forms from Intl.PluralRules and is the single source shared by the renderer, the locale verifier, the machine translator's instructions, and the localization editor's per-form preview, so adding a locale needs no code change and no surface can disagree about what a locale's plural strings must look like.
Three fields are unlike the rest. Each locale's guidance is original content written in that locale's own language, recording the writing conventions that locale follows (form of address, tone, gendered forms, terminology the glossary doesn't cover). Each locale's terms is a per-locale word list: keys mapped to plain phrases, substituted (by resolveTerms) wherever $key appears in that locale's text, so a locale can keep the same word consistent everywhere and change it in one place — distinct from a glossary @term, which is a documented term rendered as an interactive link. Each glossary term's forms are the other written forms of its word — plurals, conjugations, synonyms — that a @reference may use, so an inflected occurrence can be one whole link (see the concept-link section of LANGUAGE.md); which forms a language needs is its own business, so their count varies freely per locale and most locales have none yet. Because none of the three is a translation of the English, the locale tooling skips them all — never machine translated, never counted unwritten, never padded or truncated to en-US's length — and all may be empty; checkTerms separately verifies that term keys are valid identifiers disjoint from every template input name, and checkGlossaryForms that a form isn't already claimed by a concept name, another term, or the term's own word. All are shown and edited in localization mode, in the Localizer panel and the /localize workspace.
Database keeps track of which languages and regions are selected, loads the appropriate locale files with the strings and templates, and exposes them as a Svelte store for the user interface and language implementation to use to render localized descriptions of things. When the database receives a request to change languages and regions, these are propagated to all interfaces that depend on the selected locales. All projects are also revised to have the new locales as well. Each locale also has small companion data files fetched alongside its strings: emoji names (<locale>-emojis.json), tutorial content, and date/time formatting data (<locale>-datetimes.json, generated by npm run datetimes from a pinned Unicode CLDR JSON release — independent of the developer's Node/ICU — and used by Moment's localized text conversion; see dateTimeFormats.ts, which, like number formatting, is deterministic committed data rather than runtime Intl. A small bundled core of every locale's default-calendar data — dateTimeData.ts — keeps → ''/language targets working for unselected locales, and the locale verifier checks presence, schema, CLDR-version provenance, and core consistency of all of it).
When more than one locale is chosen, every chunk of UI text is shown in all chosen locales, not just the primary: the primary at full size, each additional locale rendered after it dimmed and successively 80% the size (1.0 → 0.8 → 0.64 …). This is centralized so it reaches everything: Locales resolves the text in each chosen locale (getSecondaryLocaleViews, getMultilingualEntries, getMultilingualMarkup, getMultilingualFrom, and the now-multilingual getPlainText/getMultilingualText), and the render surfaces consume it — LocalizedText (inline labels), MarkupHTMLView (block prose), Hint (tooltips, rendered as rich per-locale markup stacked smaller and dimmed), and TutorialView (dialog lines, echoed from each locale's loaded tutorial by parallel act/scene/line index). Each echo carries its own lang/dir. Visible text must flow through these components rather than the joined getPlainText/getMultilingualText (whose joined string is for title tooltips and other single-string echo surfaces); aria-* attributes and Announcer messages are primary-locale-only via getPrimaryPlainText, since screen readers speak them in one voice; and anything that becomes code — an identifier, name, key, or font — must use getUnannotatedPrimaryText, since a join is not a name. Secondary locales that haven't written a string, or that duplicate the primary, are skipped, and the whole feature is a no-op when only one locale is chosen. Localization mode (the in-app translation editor) shows only the primary, since its editing affordances already grow the layout.
Each script in Scripts.ts carries a writing direction (ltr/rtl) and layout (horizontal-tb/vertical-rl/vertical-lr); a language derives both from its dominant (first) script. Locales exposes these via getDirection() and getLayout(). Two rules keep the platform direction-aware:
- UI chrome and the editor follow the viewer's UI locale: the document
diris set on<html>, and component CSS uses logical properties (margin-inline-start,inset-inline-end,text-align: start, …) rather than physical sides, so the interface mirrors automatically under RTL.npm run rtl(scripts/check-logical-css.ts) guards against new physical properties. - Program output follows the project locale (carried by
RenderContext.locales), which is stable per project regardless of who views it. Under RTL, the spatial arrangements (Row,Grid,Stack) mirror their children's order/alignment viareflectXwhile staying physical primitives — vertical writing is not an axis swap but a text-flow property applied per-phrase as a CSSwriting-mode. Textalignment(</|/>) maps to logicalstart/center/end. Caret/selection geometry in the editor consultsgetDirection(); vertical editing (caret geometry for vertical writing modes) is not yet supported.
The writing layout for output is driven by the writingLayout setting (auto | horizontal-tb | vertical-rl | vertical-lr, default auto). StageView resolves the effective layout (auto → the project locale's getLayout(), else the explicit choice) into RenderContext.layout. A Phrase's direction defaults to ø (inherit), so an un-specified phrase renders at the context's effective layout while an explicit direction wins; the choice is reactive (no re-evaluation). Prose rendered by MarkupHTMLView carries the active locale's lang/dir. A coherent vertical reading mode for UI prose (reorienting reading surfaces and the Speech tail) and vertical editing remain future work.
The stage camera is one Place — x/y pan, z zoom — composed in StageView from three sources that add rather than override, so none can cancel the others. A base focus comes from the program's Stage.place when it sets one, and otherwise from the auto-fit; on top of it sits the audience's pan/zoom as an offset, which is why a viewer can zoom out of a project that moves its own camera without freezing it. The base eases while playing (so a program's camera pans instead of snapping) and the offset applies instantly (so gestures stay responsive); renderedFocus is the composed result, and everything downstream — hit testing, Animator — reads that. The camera math and the three pure decisions behind it live in fit.ts: settleEnvelope frames a box that expands at once but contracts only after a settling window, so auto-fit stops chasing moving content; responsiveZ pulls an authored z back on viewports smaller than it was written for, never closer; and composeZ bounds only how near the audience may come, leaving zoom-out unbounded. The overlay layer renders against a constant screen-centered focus and is deliberately immune to all of it.
Font management lives in src/basis/faces/. Every font is described once in the hand-authored manifest fonts.manifest.ts — the single source of truth. Each entry carries only policy a font file can't express (name, roles = creator and/or fallback, delivery, scripts, weights, italic, format, download source); every unicode-range is derived from the font file's cmap (whole-file faces) or captured from Google's lazy-load slice partition (sliced faces), never hand-written.
The generator in scripts/fonts/ turns the manifest + a per-file {hash, range} lockfile (fonts.lock.json) into every artifact:
faces.generated.ts— the runtime registry, exportingFaces(creator faces, pickable in the font chooser) andFallbackFaces(the lazy per-script chain). This replaces the two hand-authored tables that used to duplicate font metadata; Fonts.ts now owns only theFontManagerloading/measurement logic and re-exports the data. TheFontManagerloads creator faces on demand via theFontFaceAPI; Noto Sans/Mono and the emoji faces are preloaded inapp.html/static/fonts/fonts.css.static/fonts/fonts.css(preloaded + emoji faces) andstatic/fonts/fonts-fallback.css(fallback faces, plus the--wordplay-fallback-fontscustom property). Bespoke faces (the emoji families, with their@supports/keycap/SVG structure) are a hand-authored islandemoji-faces.cssinlined via the manifest's genericoverridefield; their ranges are still cmap-derived and drift-guarded.- renderable.generated.ts — the merged intervals some default-chain font actually has a glyph for (the union of every chain face's cmap).
isCodepointRenderable(renderable.ts) binary-searches it so the glyph chooser never offers a codepoint that would render as tofu. It must use real glyph coverage, not declared ranges: Google's slice partitions declare whole blocks (e.g. all of Arabic Presentation Forms) that the font only partly populates, so a declared-range check over-reports renderability.
Committed vs generated. The committed sources are fonts.manifest.ts, fonts.lock.json (per-file hashes + Google's captured partition, which a cmap can't reconstruct), emoji-faces.css, and renderable.generated.ts (committed because rebuilding its cmap-union reads every font file). faces.generated.ts and the two stylesheets are gitignored build artifacts, regenerated from the committed lockfile by npm run fonts-build in postinstall/build (fast — no cmap reads beyond the two emoji fonts).
Commands (mirroring the locales verify/fix pattern): npm run fonts verifies drift (hashes every font file against the lockfile — fast, and wired into npm test via fontsSync.test.ts); npm run fonts-build re-emits the gitignored artifacts from the lockfile; npm run fonts-fix fully regenerates (re-deriving cmap ranges for changed whole-file faces and the renderable set, updating the lockfile); npm run fonts-download fetches new manifest fonts from Google, then fixes. npm run fonts -- --deep also verifies the renderable set and that cmap-derived ranges don't over-claim their glyphs (Google's partitions are trusted; emoji is guarded by emojiRange.test.ts). So a swapped or updated font file fails npm test with the exact repair command.
Emoji are their own cross-cutting update procedure — spanning codepoints (codes.txt), per-locale names ({locale}-emojis.json), and the two color-emoji fonts (Chromium COLRv1 slices and Safari OT-SVG slices, both partitioned from the ranges in emoji-faces.css) — sequenced in dependency order by a single npm run emoji-update (-- --check reports whether an upstream update is due). It's documented in scripts/emoji/README.md; the tooling (downloadColorEmoji.ts, notocolor.sh, slice-emoji-svg.py, update.ts) lives in scripts/emoji/.
Every font-family chain ends with the fallbacks: the --wordplay-app-font/--wordplay-code-font chains (built by fontChains.ts) reference var(--wordplay-fallback-fonts), and output views and canvas text measurement use the literal CSSFallbackFaces (in Stage.ts), since canvas font strings can't resolve CSS variables. So a glyph no chosen face covers falls back to a downloaded-on-demand Noto script font instead of tofu. The CJK faces (Japanese, Korean, Simplified Chinese) carry both creator and fallback roles — one manifest entry, shared sliced files. Phrase metric caches are keyed on FontManager.getLoadGeneration(), which increments on every document.fonts loadingdone event, so text measured before a lazy face arrived is remeasured after.
The Wordplay logo is a speech bubble holding one glyph, drawn with a stroke matched to Noto Sans 400's stems so bubble and glyph read as one font — a tiny stage on which the bubble can "say anything": the exemplar letter of the viewer's dominant script in-app, or a circle, triangle, and square — the primitives of programmable output, laid on a tilted alignment line — on static surfaces (favicons, share images) and while loading. Its geometry lives in exactly one module, logoMark.ts, consumed by both the Logo.svelte component (decorative/aria-hidden by default; pass a label where the mark is content) and the asset generator in scripts/logo/, so the on-screen mark and the rasterized icons can't diverge. Glyph choice comes from the glyph field on ScriptMetadata in Scripts.ts via logoGlyph.ts; the landing page cycles the glyph in lockstep with its language-chooser rotation (getLogoLanguageCycle), and Spinning.svelte is the mark's shapes face, waving while loading. All motion is behavioral (never drawn) and gated by --animation-factor. The generated assets in static/icons/ (SVG favicon, PNG icons, favicon.ico, maskable icon, 1200×630 og-card.png) are committed and hash-locked in logo.lock.json; npm run logo verifies drift (logoSync.test.ts runs the same checks in npm test) and the manual npm run logo-fix regenerates via the pinned @resvg/resvg-js. The /design page documents the mark and offers the downloadable assets.
All output is rendered in the OutputView component. It renders Exceptions, and other arbitrary values using all of the Svelte views defined in valueToView.ts, which maps Value instances onto views. If a Value corresponds to a Phrase, Group, or Stage, then it is rendered as typographic output. These typographic values are generally converted into classes that provide convenience functions for reasoning about the output without having to use the low level interface of Structure values.
The typographic output is defined by Stage. It's responsible for managing any typographic outputs that are animating, for tracking outputs that have entered the stage, existed, or moved, and for rendering the output in a way that respects various settings, such as the Stage's place (i.e., its zoom level, rotation, etc.). It's also responsible in monitoring for inputs from input devices and passing them to the Evaluator's streams, causing reevaluation.
The predefined animations — Sequence.sway(), Sequence.spin(), Sequence.glow(), and thirty-odd others — are ↑ static functions on the Sequence structure, each returning a whole Sequence rather than the poses map a creator would then have to wrap. They were globals until they crowded out thirty-five ordinary names a creator might want; being statics also puts them under Sequence in the docs browser for free, since StructureConcept already surfaces a structure's statics as sub-concepts.
Each is declared once as data in DefaultSequences.ts — a locale accessor, its own inputs, and Wordplay source for its poses map — and createSequenceType emits them into the structure's block, the way Color and Instrument emit their static binds. Every animation takes its own inputs followed by Sequence's duration, style, count, and description, generated from one shared helper so the two can't drift, and passes them straight through. Generated source refers to definitions by their en-US first name (🤪 for Pose, ⏳ for duration), which is the one name getBind guarantees resolves in every locale.
Because a static's closure is its structure definition rather than an evaluation, a static body used to see nothing outside its own structure; Evaluation.resolve now continues into the defining scope and then the globals, which is what lets an animation body name Pose and build a Sequence.
Sound output is modeled symbolically, not as recordings: a Music output holds Tracks of scale degrees played by Instruments from a fixed palette, following the standard API pattern above (structure definition, wrapper class, converter, registered in createDefaultShares). Two structures carry static binds populated by the same staticBuilder mechanism Color uses for its color terms (a third, Sequence, carries static functions — see Animation below): Music exposes the named scales (🎼.major, 🎼.pentatonic, …, defined in scales.ts) and Instrument exposes the palette (🔈.piano, 🔈.🥁, …). Everything the player and visualizations know about an instrument — pitched vs. kit, kit-degree mapping, visualization hue, synthesis recipe — lives in instruments.ts and synthesis.ts, keyed by the instrument's opaque id; the pure degree-to-semitone math lives in degrees.ts. One palette entry is not a waveform but a whole graph: Instrument.voice is a formant synthesizer, described below. Music joins Say in Stage/Group content, collected by getMusic() mirroring getSays(). The full design is specified in issue #390.
Note lengths are units. The western note values a creator can write on a number in a note list — 3𝅗𝅥 for a half note — are declared once in durations.ts, which is the single source for the unit spellings, the beats each stands for, the union types Track and Note declare, and the glyphs the sheet rendering draws. A quarter is one beat, always, independent of Track.beat; that fixed anchor is what makes the glyph a creator types the glyph the sheet draws back. The spellings must stay NFC: all source is normalized, and Unicode's composition exclusions decompose the precomposed note characters into notehead plus combining stem, permanently. Because the note-list types name the legal units, a stray unit like 2beats is both a conflict and a runtime TypeException rather than a silently ignored annotation.
A note that has started plays to its end. Only two things cut one short, and both are asked for: replay, which means start over, and pausing — either the stage, or one piece with pause. A changed note list or a music leaving the stage stops scheduling and lets whatever is sounding ring out. This is what makes the natural spelling of a sound effect work — a note list derived from momentary state and empty the rest of the time, as Chimes, Building Blocks, and Humming Bird all write it — and it rests on the player knowing when a note genuinely ends, which the audio layer reports as PlayingVoice.endsAt because only it knows the instrument's release tail.
The player splits pure policy from the Web Audio shell, the way announcerQueue.ts splits queue policy from Announcer.svelte — there is no AudioContext under Node or JSDOM, so anything touching Web Audio is untestable. Music.toData() is the boundary: below it, musicData.ts, transport.ts, schedule.ts, reconcile.ts, and voices.ts are pure functions over plain data, unit-tested without any audio. Above it, MusicAudio owns the app's single output AudioContext (distinct from AudioSource's per-microphone input contexts; browsers cap concurrent contexts, and docs pages host many evaluators) and MusicPlayer orchestrates with injected dependencies. players.ts ref-counts one player per evaluator.
The vocal synthesizer follows the same split, and is a caricature on purpose. Instrument.voice sings a track's optional words, a line of IPA syllables. It is a synthesizer in the same sense synth and synthPad are — not a stand-in for a singer we failed to record — and two requirements made that the design rather than a compromise: a voice aiming at realism and missing lands in the uncanny valley, and a realistic voice reads as gendered, because listeners hear gender in vocal tract length far more than in pitch. In a real singer tract length and pitch are coupled, so cutting that coupling answers both at once: the formant table in phonemes.ts is authored once at a neutral ~16cm tract, midway between adult male and adult female measurements, and never moves with the note. Two octaves of melody come out of the same size head, which no throat can do. voice.ts holds the rest — glottal source spectrum, breath, vibrato, and the pair of oscillators detuned six cents against each other that is the audible tell — and articulate.ts hands syllables to notes and lays each syllable's phonemes across the seconds available. All three are pure and unit-tested. Track.words is accepted on any track but sounded only by the voice, and the sheet draws a lyric only under a track that will actually sing it — an ignored input is normal here, but a lyric under a piano reads as a bug; MusicAudio builds a glottal-and-noise source into a parallel bank of four bandpass formants and writes the resulting targets onto AudioParams, which is the only part that needs a browser. Typing IPA is the other half of the feature. A written alphabet nobody has a key for is not usable, so PhonemeChooser — a sibling of the emoji chooser, toggled by the voice's own 🤖 beside it and offered only to a project that has music (projectHasMusic) — lists every symbol in Phonemes order, grouped as the IPA chart groups them, with an example word and a play button. It is the reference as well as the keyboard: the honest definition of a speech sound is the sound, and previewPhoneme.ts plays each one through the voice on a throwaway bus. That also means the reference needs no translation to work, which matters across thirty languages; the example words are English until a speaker of each language writes their own. A glottal stop is framed between vowels, since alone it is silence and a row that does nothing when pressed reads as broken.
Its peak gain and its per-phoneme balance are measured, not chosen, by voiceGain.ts, which renders an offline twin of that graph and meters it with the sample pipeline's own ITU-R BS.1770-4 implementation — without it a bank of four wide noise bands comes out about 13 LU above a vowel and every lyric lurches at its sibilants.
Three timing decisions matter. Notes are scheduled against AudioContext.currentTime on a setInterval lookahead — not the evaluator's animation-frame loop, which stops in background tabs — and the lookahead widens when the tab hides. Reconciliation decides per evaluation whether to keep, splice at the next beat boundary, restart, drain, or stop; replay restarts on any evaluation carrying it, with no edge detection, matching how Motion applies whatever it is handed. Pausing is not one of those decisions, and deliberately so: whether the stage is playing and whether the creator has held one piece with pause are two independent axes, applied as passes either side of reconciliation, so the decision table stays exactly as testable as it was. A pause freezes rather than forgets — the transport is kept, so playing again re-anchors it to the current clock (seekTransport, the same move applySplice makes at a beat boundary) and picks the piece up mid-phrase, re-striking whatever note was cut shortened to the part that had not been heard. That diverges from Say, which re-speaks its line from the top on play, because resuming and repeating are the same thing for a sentence and not for a score. And beats reach the Beat stream when they become audible, not when they are scheduled, so beat-driven visuals stay in step with the sound; Beat is pushed by the player exactly as Physics pushes Collision, and its value structure is named Downbeat because two definitions cannot share a name.
The sampled instruments are built, not bundled by hand. Fourteen of the palette's seventeen instruments ship real recordings; the rest keep their synthesized recipe, and which is which is declared in scripts/instruments/manifest.ts so the gap reads as a roadmap rather than an omission. The pipeline follows the fonts one — hand-authored manifest, lockfile, generator, derived assets in static/, drift test in npm test sharing its checks with the CLI — but fixes two things the font precedent gets wrong for audio: every zone records provenance (source URL, licence, author, upstream hash), and the downloader verifies hashes rather than trusting the first fetch. Licences are checked per zone against AllowedLicenses, and because we ship derivatives, each zone inherits its source's terms — CC0 and CC BY for most of the palette, CC BY-SA for the dog, whose recordings have no CC0 equivalent on Commons. CREDITS.md records the licence and author of every shipped file.
Processing is pure Node — no ffmpeg, so regenerating needs nothing but npm install. Each zone is decoded to mono, attack-trimmed, length-capped with a release fade, loudness-normalized (ITU-R BS.1770), and encoded to mp3. Three decisions came out of measuring rather than assuming: tuning is refined around the known note rather than detected from scratch (blind detection octave-errs on a piano whose third harmonic outweighs its fundamental, and finds nothing at all on a tubular bell, whose pitch is a virtual one implied by inharmonic partials); percussion is normalized by its loudest short-term window rather than integrated loudness, which under-reads a transient hit; and zone files are named by MIDI number, since a # in a filename becomes a URL fragment and every sharp zone would silently fail to load. At runtime InstrumentSamples.ts fetches and decodes lazily, shaped like FontManager; the synthesized recipe covers every note until a zone arrives, so a piece is never silent waiting on the network.
Music renders as well as sounds, and which rendering a viewer sees is theirs to choose (MusicVisualizationSetting): MusicView draws an orchestra of instrument-clustered columns, LightShow tints the stage, and Mood gathers a cloud of colour and form along its floor. The last two share one flash-safety design, and it is not a rate cap: an earlier light show did cap its rate at PhotosensitivityAnalysis's 3Hz threshold and still strobed, because sitting at a threshold is not a margin and because what makes a flash dangerous is the luminance change that design maximised. Both now hold lightness and opacity constant and let hue and chroma carry the music, so a change that isn't a luminance change isn't a flash however often it happens (lightshow.ts, mood.ts). Mood adds a second invariant of the same kind — it conserves total ink, redistributing area between lobes rather than adding it — and permits brightness to breathe only when static analysis proves every track slower than a third of the seizure band. Meanwhile MusicSafetyAnalysis warns about startling volume jumps, sustained loudness, fast pulses, extreme register, and track count through the same start gate, with a DynamicsCompressorNode limiter as the runtime backstop static analysis can't provide. Music ducks while the announcer or Say is speaking (announcerPresenting, ducking.ts); the depth is a setting, the ducking is not. Music itself takes none of the standard style inputs — no size, place, color, background, selectable, pose/animation slots, duration, or style. It has no layout footprint and no appearance of its own to style, since the viewer picks the rendering and it is anchored to the stage; those inputs had no coherent mapping to either rendering, nor an obvious one to renderings we might add later. Creator-authored music visuals come from the Beat stream instead, which is the supported extension point. That stream carries a Downbeat: the whole player state at the moment a beat is heard — the music's name, beat count, tempo, volume, key, and scale, plus a Part per Track reporting instrument, whether it is sounding, the covering degrees and their resolved pitch, volume, pan, the syllable being sung, and that track's own scale, key, and loop; the Downbeat also carries a flat words list, standing to parts as instruments does, since a lyric display wants the syllables rather than the tracks. The values are the resolved ones the player is using, so a visualization describes what is heard rather than what was written; Downbeat/Part are built in Downbeat.ts and Part.ts from the BeatTick that schedule.ts computes purely.
The SensorMonitor component displays live input from sensor streams (microphone, camera) when expanded. This helps creators debug whether a device/permission issue or a program logic error is causing unexpected sensor behavior. Sensor monitors render as compact corner chips by default (using mix-blend-mode: difference for visibility against any stage background), expanding on click/hover to show:
- Microphone: a canvas-based waveform visualization from the AnalyserNode's time-domain data
- Camera: a live video preview from the shared MediaStream, with overlay dots for hand/face landmarks (when applicable). The preview is mirrored, since a creator positioning a hand or face in frame expects a mirror, and because the landmark streams already mirror x on the way to the stage (
CameraLandmarkStream.toStageMeters) — an unmirrored panel would contradict where the stage puts the same hand. The video is mirrored in CSS and the overlay points in cameraPreview.ts, rather than flipping their shared container, soObjects()labels drawn on the canvas stay readable. That module is also where landmarks are scaled: they are normalized to the square center-crop the model was fed, which is exactly what the square,object-fit: coverpanel shows, so the mapping is a plain scale rather than full-sensor arithmetic.Camera()'s emitted pixel matrix is unaffected and stays in raw sensor order.
To avoid opening multiple hardware sessions when multiple sensor streams run concurrently, Wordplay uses ref-counted shared resources:
CameraSource(src/input/CameraSource.ts) — a singleMediaStream+ hidden<video>per device, shared byCamera,Hand,Face, andObjectsstreams. Consumers acquire/release handles; the resource is torn down when the last handle is released. Live frame-rate re-negotiation as consumers come and go.AudioSource(src/input/AudioSource.ts) — a singleMediaStream+AudioContextper microphone device, shared byVolume,Pitch, andSpeechstreams. Each consumer builds its own AnalyserNode at its needed fft-size, connected to the shared source node.
These shared resources eliminate the ~30MB/s frame-pooling cost and redundant getUserMedia calls that plagued earlier implementations.
Before a project plays, OutputView can show a single blocking StartGate that unifies three kinds of reasons to hold evaluation (modeled in gate.ts): pending browser permissions, moderator content warnings/blocks, and photosensitivity warnings. ProjectView holds evaluator.start() until every reason clears — permissions via the consent store, warnings via the viewer clicking Start, and blocks never (the content stays gated). Content warnings apply only to read-only viewers (warn), so previews and the tutorial pass none.
Photosensitivity risks are found by static analysis for read-only viewers: detectPhotosensitivityRisks evaluates the project once with getInitialValue() (no DOM, no animations), walks the resulting output tree (analyzeOutput) for flashing (3–60 Hz), saturated-red flashing, strobes, dense high-contrast patterns, and very fast motion, and scans the source (analyzeSource, same module) for references to fast predefined animations like Sequence.flash() and Sequence.shake() that a single frame can't see. The three read-only hosts (ProjectView, PlayView, OutputPreview) share a ContentGate reactive helper (gate.svelte.ts) that owns the acknowledgment state and holds playback until the viewer clicks Start. The gate then names the detected categories so the viewer knows what to expect.
The Editor.svelte is responsible for both rendering an AST and for modifying an AST.
Rendering is managed by all of the mappings defined in nodeToView.ts; every type of node has a corresponding view. Most views are just very straightforward mappings from its list of children to views of each child. But some have special behaviors. RootView is also very important for doing AST-wide hiding of nodes (e.g., nodes in non-selected locales).
Editing comes in three forms:
-
Typing, which is primarily managed by
Caret.ts, and involves inserting and removing symbols, and moving a caret to select a certain position or node. When edited as text,Sourcedoes its best to avoid reparsing the entire tree, reusing any nodes and tokens that it can. -
Drag and drop involves a global
ProjectViewstate that manages a selected node from anEditor, orDocumentation.svelte. The editor uses Node facilities such as their grammar and types to decide what can be dropped where. -
Menu edits involve taking the caret's current position and asking
Menu(in src/edit/menu/Menu.ts) to generate a set ofRevisionthat are valid for the current selection. These appear as an autocomplete menu. Revisions perform an edit on the AST, usually replacing one node with another, or removing one, and then revising the project with the edited node.
The editor does many other things, including:
- Rendering conflicts based on the current caret position
- Highlighting based on the mouse, touch screen, drag interactions, and editor search matches, defined by
Highlights.ts - Providing descriptions for screen readers
Because the editor has many rich features, long sources are windowed (virtualized) in text mode: WindowedStatements.svelte renders only the root block's statements that intersect the scroll viewport (plus an overscan buffer), with spacer divs preserving the scrollbar geometry. The pure geometry (height estimates, prefix sums, window selection) lives in windowModel.ts, unit-tested; the component adds the scroll choreography (velocity-directional overscan, a held range so nothing unmounts mid-scroll, throttled side-work, and render-on-release for scrollbar thumb drags in engines whose rebuilds are slow — the tradeoffs are documented in its header comment). Off-window nodes have no DOM element, so every editor path that scrolls to or highlights a node goes through the WindowingBridge context in Contexts.ts: scrollToNode brings a statement into the window, and a revision counter tells the editor's element caches and outline effects to re-measure when the window changes.
Because a windowful of node views is rebuilt on every scroll jump, per-node render cost is kept deliberately low, at some cost to uniformity: in text mode a token renders as a single merged element (TokenView plays both the node-view and token-view roles, with the emoji-repair and placeholder rendering inlined rather than delegated to child components), leading space is rendered inline by NodeView's textSpace snippet (the Space component now serves blocks mode only), localized aria-labels are computed at idle rather than at mount, and per-token caret/evaluation state flows through two Editor-computed summaries (CaretTokenSummary and the play-rate-decoupled getSteppedEvaluation context) so caret moves and play-mode broadcasts don't re-run work in every visible token. Highlights remain editor-level SVG overlays; the below layer carries z-index: -1 so fills paint beneath token text without requiring every token to be a positioned box. Similarly, drag-and-drop checks conflicts lazily: mid-drag target resolution and drop-target highlights are structural only, with the full drop simulation run when the pointer rests (feedback) and once at release (the authoritative gate).
src/edit/ groups all of the machinery that turns user intents — typing, dragging, choosing from a menu, tweaking an output property — into new ASTs. Like the rest of Wordplay, edits never mutate; each operation produces a new Source and Project.
The subdirectories:
-
caret/Caret.ts — an immutable snapshot of the editor's cursor: source, position (a text offset, a selected
Node, or a range), column, and entry direction.Caretmethods compute neighboring tokens and the expression at the cursor. -
caret/Complete.ts — keystroke-time autocompletion, applied by
Caret.insert(). Two invariants govern text mode: typing a syntactically correct sequence of characters must produce exactly that program, and a completion must never parse worse than the plain character would. So the only completion text mode runs is delimiter auto-close — text a creator would type later anyway, whichCaret.insertionCompletesDelimiter()then types over — plus the...→…shortcut, which is parse-identical. Every completion that inserts a_placeholder or leaves the caret on a node (evaluate, bind, operator, is, convert, link, example, list/set access) is markedblocksOnly, since blocks mode has no free text input and needs a valid tree. The parseability invariant is enforced by comparing each completion's unparsable-node count against the plain insertion's and discarding a completion that raises it. TypeThrough.test.ts types a corpus of micro-programs and example projects one grapheme at a time and asserts textual identity; the one inherent exception is a program that leaves a delimiter unclosed, whose auto-inserted close is never typed over. -
menu/ — the autocomplete menu shown at the caret.
PossibleEdits.tsanalyzes caret context and generates the candidateRevisions;Menu.tsis an immutable container that organizes them byPurpose(Outputs, Inputs, Decisions, Text, …) into aMenuOrganizationand tracks the user's selection. -
revision/ —
Revisionis the base type for one AST transformation. Subclasses includeReplace,Remove,Append,Assign, andRefer. Each exposesgetEditedNode()(the new node and its revised parent) andgetEdit()(anEditfor the command system to apply). -
drag/Drag.ts — drag-and-drop.
InsertionPointandAssignmentPointdescribe where a dragged node would land;isValidDropTarget()anddropNodeOnSource()validate and produce the revised source. On a palette drop (from the Wellspring/Guide, where the dragged node isn't rooted in a source),dropNodeOnSource()also replaces each placeholder in the dropped subtree with its type's default expression (Type.getDefaultExpression()), so dropped concepts evaluate immediately instead of throwing a placeholder exception. -
output/ — the palette's bridge to AST edits.
OutputExpressionwraps anEvaluaterepresenting aStage,Phrase,Group,Shape, etc., and exposesOutputPropertyaccessors that the palette uses to read and revise individual inputs (color, place, rotation, …).
When a user types, the editor builds a new Caret, asks PossibleEdits for the available Revisions, builds a Menu, and applies the selected revision back to the project. Dragging and palette edits skip the menu but follow the same immutable revision pattern.
Blocks mode is an alternative visual rendering of the same AST, toggled by the $blocks setting. It is not a separate editor: the same Editor.svelte and the same node views are reused, with a Format.block flag propagated through the view hierarchy. When format.block is true, node views render as nested visual blocks with rounded outlines; when false, they render the standard text syntax. Both modes share caret, menu, drag, and palette edits, so any transformation works in either mode.
The dedicated files in src/components/editor/blocks/ are small: Flow.svelte is a flex-layout primitive for arranging block children, and EmptyView.svelte renders localized placeholders (via FieldInfo.label()) for empty optional fields, with menu triggers for inserting a child. Drag-and-drop is fully supported in blocks mode but disabled when the editor is read-only.
Two invariants govern blocks-mode editing, checked by DropSoundness.test.ts and creatable.test.ts:
- Soundness: no blocks-mode edit may make the program structurally invalid. Every mutation path — typing, paste, drop, menu revision, wrap, delete, rename — passes one gate: the edit is rejected iff it introduces a new blocking conflict (
Conflict.isBlocking(), overridden to true only byUnparsableConflict). Semantic conflicts of any severity — type mismatches, unknown names, missing inputs — are permitted and rendered as warnings/errors, since a creator can repair them in place. Because conflict analysis runs on the tree, edits are additionally checked for print round-trip: the edited tree's printed text must reparse to the same structure (Node.isStructurallyEqualTo), enforced ingetEditsAt'ssoundRevisionsfilter and the drop gate'sdropRoundTrips— otherwise a menu item or drop could show one program and save another (e.g. an unparenthesized binary evaluate spliced into an evaluate's inputs). - Expressiveness: every construct a creator can type has a blocks creation path — a palette template (
Templates), a menu suggestion (PossibleNodes+ each class'sgetPossibleReplacements/getPossibleInsertionsstatics), or a token field. Segmenting containers (Names,TypeVariables,TypeInputs,Docs) are never offered as standalone concepts; their affordance is child-level, with the container created implicitly and populated when its field is first set.creatable.test.tsfails any new node type until it's given a path or an explicit exclusion.
After every edit, a project is analyzed for conflicts (in other languages, you might call these errors).
Subclasses of Node can compute conflicts based on their context.
For example, Evaluate can generate many types of conflicts, such as IncompatibleInput, which happens when an input being provided doesn't match the function being evaluated.
We call them conflicts partly because they are inconsistencies in a program, and not necessarily a mistake someone made, but also because we anthropomorphize language constructs, and so "conflict" is a pun: it is a disagreement between different characters in the Wordplay universe.
Conflict.ts is the base class of all conflicts, and all conflicts are required to define methods that describe conflicts, provide references to the nodes in a program that are involved in the conflict, and optionally offer a way to resolve a conflict.
Conflict resolutions are defined by the Resolution type, and generally need a way to describe the resolution, and a method that produces a revised project that resolves the conflict.
You can see an example of a conflict by creating a personally identifiable information conflict (PossiblePII.ts).
Try typing the program email: 'ajko@uw.edu'. It'll give a conflict that it seems to be personally identifiable information.
There's an option there to say "No, it's not", and then the warning goes away.
Clicking that button calls PossiblePII's getResolution method to generate a revised project.
The palette is a special kind of editor that offers user interfaces for transforming the Evaluate nodes that represent Phrases, Groups, and Stages. It constructs detailed models of the Evaluate, and defines many controls for modifying inputs to the Evaluate.
On each edit, the project is revised, reevaluated, and re-rendered. Making this interactive possible requires reevaluating the revised project on the previously provided inputs, to try to get the Evaluator back to the same state it was in previously. This is done by Evaluator.mirror(), which replays the same inputs the Evaluator received on the revised project.
The output selection belongs to the palette. The palette is the only thing on screen that explains a selection, so with it closed the stage renders clean: no borders, no drag handles, and nothing on it is selectable. A double-click on stage (or Enter on a focused output) is the way in — it selects what it landed on and opens the palette. Double-clicking a Phrase edits its text only once the palette is already open. Caret↔selection syncing lives entirely in Palette.svelte, which exists only while its tile does, and it clears the selection as it unmounts. Palette.svelte publishes its own presence through the getPaletteOpen context so the output views can consult it; keying that to the component's lifetime rather than a tile-visibility test covers every route that hides the tile (collapsed, play mode, another tile fullscreen, one-tile arrangements) and keeps the chrome and the selection from ever disagreeing. Unselected outputs draw a dashed grey border and the selected one a solid animated glow, both defined once in OutputView.svelte.
The documentation component takes all of the default FunctionDefinition, StructureDefinition, ConversionDefintion, and StreamDefinition -- Wordplay's standard libraries -- and builds Concept data structures out of them. This creates a ConceptIndex, which contains all of the named things for which there is documentation.
Most documentation is written in Locale, as all of it needs to be localized. But documentation is also written in Doc nodes in programs. ConceptIndex gathers all of these documentations and provides an interactive way to navigate and search them.
ProjectView.svelte defines a set of Tile that represent source files, documentation windows, palettes, output, and other project-level settings. It's basically a window manager and global context store. It also reacts to project revisions, pushing the revised project down to its views to update its appearance. It relies heavily on Svelte to make these updates minimal and fast.
Wordplay ships with an in-app tutorial that uses language constructs themselves as characters in a guided story.
The tutorial is structured as a small theatrical hierarchy in Tutorial.ts: a Tutorial contains Acts (a variable-length list), each act contains Scenes, and each scene contains Lines — a Dialog (a character speaking with an Emotion), a Performance (an embedded Wordplay snippet), or a pause. There are two tutorial modes (TutorialMode.ts): the original complete tutorial and a short quick tour for creators who already know another programming language. /learn shows a choice dialog (TutorialChooser.svelte) when no mode has been chosen, and lets learners switch via a control by the breadcrumbs; the chosen mode and per-tutorial progress are consolidated under a single cloud-synced tutorial setting (TutorialState). The quick tutorial contrasts Wordplay with a prior language the learner picks (a device-level ContrastLanguageSetting); its dialog embeds \<tag>| code\ blocks parsed as the ExternalExample markup node and rendered with syntax highlighting. Lesson content lives in static/locales/<locale>/<locale>-tutorial.json (complete) and <locale>-tutorial-quick.json (quick). Performances.ts is a library of canned Wordplay programs the scenes reference by name (e.g., RainingEmoji, EvaluateDance*). Progress.ts models the learner's (mode, act, scene, pause) position and is persisted per mode in the settings database; URLs like /learn?act=1&scene=2&pause=3&tutorial=quick map back to a Progress (the tutorial parameter is omitted for the default complete mode). Progress also names the per-step project a learner's edits are saved under, and that name ends in a hash of the step's program rather than its position alone, so inserting or rewriting a lesson can't load somebody's saved edit into a different lesson's editor. The UI is rendered by TutorialView.svelte.
Every act and scene title card plays a short looping theme that says who that character is before they speak — Stage's is the lowest and loudest thing in the tutorial, Block's opens on a rest, and None has none at all. A performance names its theme with an untranslated theme field (ThemeNames.ts is the enum, so a misspelling is a schema failure in every locale), and Themes.ts holds the vocabulary as data and renders it to a Music(…). performanceSource places it: appended to the program for most cards, and passed into the template for the ones that build their own Stage, since only the template that wrote that stage knows where in its content list the theme belongs. (Appending would work for those too — toStage merges output standing beside a Stage onto it — but placing it keeps each theme's generated name stable, and a music whose name moves restarts.) Only title cards carry themes, and TutorialView stops one as soon as a line's performance takes over. Themes are authored under bounds (tempo, note length, volume, register) that keep every one clear of MusicSafetyAnalysis, because a title card is read-only and a risk would put a start gate in front of a lesson; Themes.test.ts proves that rather than trusting it.
Lesson content is per-locale, and everything that reads it across locales indexes it positionally, so the 30 files have to agree on what sits at each index. syncTutorialStructure.ts is what keeps them agreeing: it aligns each locale's tutorial to en-US by an LCS merge over signatures built only from untranslated fields — a dialog's character and emotion, a scene's concept, a performance's mode, flags, theme, and #Template name. A performance's literal code is deliberately excluded, since locales legitimately write it in their own language. The merge inserts what en-US has gained (marked $?), propagates en-US's $! onto the translations it invalidates, drops performances and pauses en-US no longer has, and reports rather than deletes any scene, act, or dialog line a locale has and en-US doesn't. npm run locales reports the diff; locales-fix and locales-translate apply it. Without it, a scene added to en-US simply never reached the other locales, which is how one went 28 locales unnoticed.
The cast of characters is defined in src/lore/. BasisCharacters.ts maps language construct names (parentheses, keywords, operators, types, …) to BasisCharacter records, each with the symbol the character uses on stage. Emotion.ts enumerates the emotional states a character can be in. The components in src/components/lore/ — chiefly Speech.svelte and Eyes.svelte — render a character speaking, animating the eyes to match the supplied Emotion. The "conflict" terminology used earlier is part of this same metaphor: language constructs are the cast, and disagreements between them are dramatic conflicts.
Wherever possible, Wordplay uses immutable data structures and pure functions. That means that we generally do not define state and mutate it in place, but rather take existing state, and make a clone with modified values. This drastically reduces the number of places where state lives, simplifying debugging, reducing defects, and making testing easier.
One major exception to this is Svelte components, which often have much internal state, and dependencies on global state. These external dependencies are explicitly defined in component initialization as Svelte context.
Another major exception is Evaluator, since it has to manage substantial state to evaluate programs.
Another exception is Database, which is in charge of persisting state.
Consequently, expect most defects to live in Svelte components, Evaluator, and Database.