feat(email): headless authoring — projection, lens on the row, rule surface, scope hooks - #97
Open
agreenspan wants to merge 11 commits into
Open
Conversation
agreenspan
force-pushed
the
refactor/email-lookup-owner-table
branch
from
September 10, 2026 03:10
e18b0f8 to
acf6bf5
Compare
…mjml-preset-core for the nesting table Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
The authoring lens lives on the slug's default-tier row as per-slot narrowings over the projection the system provides; tenant rows inherit it through the cascade. A component's expectations are the absolute paths its body demands, derived at save and never authored. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
emailProjection composes a synthetic-root lens (EmailRuleContext → recipient, sender, data) over the whole field map: what is reachable for a template, with no narrowing of its own. emailLens applies the row's per-slot narrowings (engine defaults where the row is silent: recipient = the delivery leaf, sender and data = their scalars) and emailSurface exposes the result for the builder. emailRuleDecoration derives one facet per root relation. Ported from Zealot's rules layer (#1689, #1655, #2171): collectHydrationPaths, walkLensPath, componentExpectations (system.unsubscribeUrl is the rail-provided field here, not a recipient field), collectJsonOpacityWarnings. Component save derives expectations from the body. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
…, authoring barrel Ported from Zealot (#1698, #2090): collectComponentRegions, collapseComponentBodies, removeSlotOverride, slotDefaultContent, canNestMjml/MJML_CHILD_TAGS, and the authoring barrel the editor consumes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
…r a slug POST /api/admin/emailTemplate/ruleSurface returns the projection for the slug (sender model and data shape from the registry entry; recipient-only for a slug the registry does not know) narrowed by the lens on the slug's default-tier row, plus the decoration. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
useEmailRuleSurface fetches a slug's surface. useEmailVariableScope resolves it through
rules-builder and walks it as a scope stack: values are copyable tokens, to-many relations
are loop portals; entering one re-anchors the scope at the element model and wraps every
value copied inside in the enclosing {{#each}} blocks with collision-free kebab bindings.
No components — the forms come later.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
… the lens the row holds Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
…dressable at any depth
The projection had bound data to a declared shape, which is not what data is: the
per-template payload nobody can declare ahead of the event. Its root is now a Json field —
every {{data.…}} path and every rule beneath it is addressable, and validation reports it as
beneath Json (a warning), never missing. A registry entry may still overlay a declared shape.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
…point and relations Each slot on the row is its own lens built from the projection: recipient's entry point is User, sender's is the sender model, and data's is whatever the author chooses (lens.data.model) with relations beneath it (lens.data.narrowing). No entry point = the unknown bag. COMM-012 records the perspective question and the palette/shell plan. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
agreenspan
force-pushed
the
feat/email-authoring-headless
branch
from
September 10, 2026 03:14
f051ce3 to
307e5a9
Compare
…ort from their new homes; registry entries are lenses Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWJqEP
agreenspan
force-pushed
the
feat/email-authoring-headless
branch
from
September 10, 2026 03:15
307e5a9 to
3de1e4d
Compare
…atement Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWJqEP
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #96 (→ #91 → #74). Headless only: no forms, no components.
The vocabulary this PR implements (rulings 2026-09-09)
EmailRuleContext→recipient,sender,data) over the field map, no narrowing of its own; the space a lens is built from. Today the whole map; it must become what the authoring actor's perspective can reach (COMM-012 §1).recipient(entry fixed atUser),sender(entry = the sender model),data(entry chosen by the author,lens.data.model; with none chosen it is the unknown bag — aJsonroot addressable at any depth, validated as beneath-Json, a warning never a rejection).EmailTemplate.lensholds the three, authored on the default-tier row, inherited through the cascade.exposedSurface(lens): what the builder receives. Never carries awhere; the floor is bound from the sender at send time and never stored.packages/email/src/rules/emailProjection.ts:emailProjection→emailLens→emailSurface, plusemailRuleDecoration.Commits
packages/ui; mjml-preset-core for the nesting table.EmailTemplate.lens Json?,EmailComponent.expectations String[].collectHydrationPaths,walkLensPath,componentExpectations(rail-provided field issystem.unsubscribeUrlhere),collectJsonOpacityWarnings. Component save derivesexpectationsfrom the body.regions.ts(collect/collapse/removeSlotOverride/slotDefaultContent),mjmlNesting.ts, and theauthoringbarrel (Zealot #1698/#2090).POST /api/admin/emailTemplate/ruleSurface(superadmin; platform-level authoring for now): projection from the registry entry, narrowed by the default row's lens, plus decoration. Recipient-only for a slug the registry does not know.useEmailRuleSurface(fetch) anduseEmailVariableScope(resolve through rules-builder, walk as a scope stack: values are tokens, to-many relations are loop portals, entering one re-anchors at the element model and wraps copied values in the enclosing{{#each}}blocks with collision-free kebab bindings — the COMM-010 builder-surface ruling).picksvs the row lens, entry-from-row, the model-keyed-surface caveat) and the palette/shell plan for the forms PR.Validation
packages/email422/422 (rules 37, regions/nesting 26, projection 11);apps/api1035 pass / 2 fail (the lens count-scope tests #95 fixes on main); route test 3/3; typecheck clean on db/email/api/ui; biome clean.Not in this PR
The editor shell, variable picker, condition builder, and cascade-region list components (Zealot's ~1,400 lines of admin-dashboard React) — they consume exactly the exports and hooks landed here. Save-time lens validation for tenant saves (needs the registry, so it lands with the save endpoint). The registry-then-row entry fallback in
sendEmail(COMM-012 §4).🤖 Generated with Claude Code
https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP