ELEMENT KITS — authoring a kit of reusable page elements: the SOURCE format a store-module package ships under kit/{kitId}/ (the spec is templates/README.md), checked by the kit lint (core-lib kitLint, rows kit_*) when the package is packed and uploaded, and MOUNTED into a store's element library when the module is installed. Element libraries reach a store ONLY through Modules — there are no platform kits to import and no kit publish script any more (both retired 2026-10-18). A package that is ONLY an element library is an ELEMENTS module: manifest hosting: "none" + kits: [...] (category elements), README, the kits — no endpoint, token, events, config or /m/{id}/* routes (worked example: examples/module-elements/). This is the SOURCE format — editing an element already in a store is writeElement territory, not this.
KIT LAYOUT — one folder per kit:
template.json— the manifest:{id, name, description?, categories[], order[], global?: {css?, js?}, runtime?}.idequals the kit's folder /manifest.kits[]entry; it and every element folder name are kebab-case (^[a-z0-9]+(-[a-z0-9]+)*$), and every element id starts{moduleId}-(a module's kit never shadows a store's own elements).order[]must list EXACTLY the folders underelements/(checked both directions) and doubles as the picker display order. Every element'scategorymust appear incategories[].global: {css: "global.css", js: "global.js"}— each one file at the kit root (kit_global_missingotherwise) — is the module's SHARED layer, MOUNTED with the module's runtime doc and loaded ONCE per page, on exactly the pages that place one of the module's elements, ahead of the element files:global.cssbefore the element CSS (so an element's ownstyle.cssoverrides it, and the store's page CSS overrides both),global.js— the module's shared storefront script (window.Drop{Module}) — before the element scripts (see themodule-elementsrule).runtime: "runtime.js"is the legacy spelling ofglobal.js— the same slot: declare ONE, never both (kit_runtime_conflict). A kit with aglobal.js/runtimeships the element guidedocs/elements.mdin the package. Manifestassets(and anassets/folder) are still NOT mounted (kit_assets_ignoredwarns) — no fonts or images in the package; reference external URLs or the store's design system fromglobal.css. A legacyvisibilityfield still validates but scopes nothing; never add one.elements/{name}/— per element:element.json({name, description?, category?}; name required, category groups it in the picker),index.html(required),schema.json(required),sample.json(required),style.css+script.js(optional).
SCHEMA (schema.json) — a JSON array of FieldSchema, the SAME field language as proposeSchema/updateSchemaFields (see the FIELD KINDS rule): every field needs key + kind; label names it, required/description help the editor; {general:true} for locale-shared values; {hidden:true} hides a field from content editors in placement forms (managers can reveal) and {default:<value>} pre-fills it in every NEW placement — see the cross-kind props bullet in the FIELD KINDS rule. Each key becomes the Handlebars variable of the same name in index.html ({{heading}}, {{cta.label}}, {{#each items}}). Every href/URL field is kind:"link" (a content editor then picks a store page or types a URL; the renderer resolves a picked page to its localized address) — text only for addresses that are never a store page (a social profile URL, an uploaded file). A link or a file never stands alone — everything a visitor clicks as one thing is ONE object holding the address plus what stands for it: a button {key:"cta", kind:"object", fields:[{key:"label", kind:"text"}, {key:"href", kind:"link"}]} (as the example below shows), a download {label:text, file:file}, a linked picture {image:image, href:link}, an icon link {icon:icon, label:text, href:link}, a list of them an array of that object — never a flat cta_label + cta_href or pdf + pdf_label pair: the Studio edits the object as one form, so the editor who changes the label sees the link or file beside it (the GROUPING RULE in the FIELD KINDS rule). The object is per-locale, address included — on a multi-language site the link or file may differ by language (a localized external page, a per-language PDF), and a store page is the same envelope in every locale, resolved to that language's slug. general:true belongs only on a standalone link or file with no label whose address never changes by language. Every PICTURE is an image field (or a set of them a gallery field) carrying its own metadata:[{key:"alt",kind:"text",…}] — NEVER an alt/caption text field beside it, and never an array of {image, alt, caption} objects for a photo set: the fragment binds {{cover.alt}} / {{#each gallery}}…{{this.alt}} off the image value itself, and the array shape costs the editor one upload per row (see the FIELD KINDS rule). The reference/form/element kinds exist for store schemas but rarely belong in a kit element. Example — a heading, an image, a nested object with a link, an array of objects:
[
{"key": "heading", "kind": "text", "label": "Heading", "required": true},
{"key": "image", "kind": "image", "label": "Image", "max": {"width": 2048, "height": 2048}},
{"key": "cta", "kind": "object", "label": "Call to action", "fields": [
{"key": "label", "kind": "text", "label": "Label"},
{"key": "href", "kind": "link", "label": "Link"}
]},
{"key": "items", "kind": "array", "label": "Questions", "item_type": "object",
"item": {"key": "item", "kind": "object", "fields": [
{"key": "question", "kind": "text", "label": "Question"},
{"key": "answer", "kind": "text", "label": "Answer"}
]}}
]MARKUP (index.html) — a plain Handlebars fragment (no <html>/<head>; one root section is the convention), rendered with the field values as the context:
<section class="el-hero{{#if image}} has-media{{/if}}">
<h1>{{heading}}</h1>
{{#if cta.label}}<a href="{{cta.href}}">{{cta.label}}</a>{{/if}}
{{#each items}}<details{{#if @first}} open{{/if}}><summary>{{this.question}}</summary><div>{{this.answer}}</div></details>{{/each}}
</section>Helper vocabulary is FIXED: built-ins (if, each, with, unless, lookup) plus eq ne gt lt gte lte and or not add subtract multiply ifEq t route — the kit lint compiles and runs every fragment, but leniently (an unknown helper renders empty there, so only a parse error fails the pack) — in production an unknown helper drops the whole page to raw source, so nothing outside this set, ever. {{expr}} for text (escaped), {{{expr}}} only for intentionally-HTML values — editor:"html" text fields (whose values, in sample.json too, are the HTML fragment as a plain string — never a {type:"html"} wrapper; a plain-TEXT field never carries tags), block-editor and icon fields, .html fragments; a plain string is plain text and binds with double braces (the braces and the value shape must agree — see the handlebars-binding rule); guard optional fields with {{#if}}. NO literal visitor-facing text in the fragment — every human-readable string (headings, labels, badges, alt/placeholder/aria-label) binds through a field (that is what makes the element reusable and localizable); the literal copy belongs in sample.json. The fragment must be safe to place TWICE on one page: no ids, class names scoped with a distinctive element prefix.
STYLE (style.css) — element CSS is joined into ONE shared per-store stylesheet: prefix every selector with the element's unique namespace class (.el-hero …); no shadow DOM exists. Style FROM the store's design tokens (the design-tokens rule — vocabulary 2 names, every geometric value on the --space-unit grid) WITH literal fallbacks so the kit looks native in any store and never breaks in one missing a token — and because a store may still be on the original vocabulary 1, the v1 name is the FIRST fallback wherever one exists, the literal the last: background: var(--color-surface-card, var(--surface-card, #fff)), color: var(--color-text-muted, var(--text-muted, #555)), border: var(--border-width, 1px) solid var(--color-border, var(--border-subtle, #e5e5e5)), border-radius: var(--radius-full, var(--radius-pill, 999px)), box-shadow: var(--shadow-sm, var(--elevation-1, none)), padding: var(--space-6, 24px) (spacing has no second name — on a v1 store --space-N reads the old ladder's N-th step and corrects itself when the store migrates), background: var(--color-primary-soft, #eef) (the role pairs and ramps are the same in both). Token families in use: --space-N (+ --space-unit, --section-space), the roles --color-{role} / -hover / -on / -soft / -strong / -{50..900} (eight roles, primary secondary neutral accent success warning error info), the semantic set --color-surface|-alt|-card|-inverse, --color-text|-muted, --color-border, fonts --font-heading / --font-body / --font-button, type roles --text-{display-lg,display,headline,title,subtitle,body,label} (+ --leading-*, --weight-*, --tracking-*) and the scale --text-{xs,sm,base,lg,…,5xl} with --leading-ui for controls, --radius-{sm,md,lg,xl,full,panel}, --shadow-{sm,md,lg}, motion --motion-duration[-slow] / --motion-easing and the --reveal-* constants. Never a bare #fff on a role (--color-{role}-on), never a raw stop for text, never a typed box-shadow or off-grid padding; a brand value no token covers is a custom token on the store's design or a kit global.css slot inside :where(:root) — never a literal repeated per element. Elements must be responsive on their own (media queries inside the element CSS) — they can land on any page at any width. The kit global.css holds only what two or more elements share (the module's base, shared components, a token bridge) and follows the same rules; anything PAGE-WIDE in it (a reset, body/html typography, a token layer) goes inside :where() — :where(body) {…}, :where(:root) {--acme-accent: var(--color-primary, #2f5bea)} — so its specificity is zero and the store's own design wins; no !important, no token VALUES on a bare :root, and every class namespaced by the module (.acme-…) — two modules on one page share the cascade.
BEHAVIOR — split JS by scope (same channel contract as store elements, see the elements rule): behavior belonging to ONE element goes in that element's script.js — the mount writes it onto the element's js, so it loads ONLY on pages that place the element. Only code genuinely shared by several elements (a reveal/entrance engine, one generic carousel/lightbox engine, the module's API client) goes in the kit global.js (the shared slot; runtime is its legacy alias — never both), wired via data attributes (<div class="el-carousel" data-drop-carousel>). Conventions (both): dependency-free vanilla IIFE; the global script exposes one namespace (window.Drop{Module}) with init*(root?) functions element scripts may call; auto-init by querying the data attributes (run immediately when the DOM is already parsed, on DOMContentLoaded otherwise — previews inject markup late); guard double-init with a …-ready attribute; degrade gracefully — gate JS-dependent styling behind a class the script itself stamps on <html>, so a page whose script never loads renders static, not broken.
PREVIEW DATA (sample.json) — one object matching the schema; it is what the admin gallery and element previews render, and what new placements copy — every value MUST validate against the field's declared kind (a {type:"collection"} envelope under a kind:"array" field fails the kit lint kit_element_sample_shape and writeStoreElement refuses it; declare the field as the kind that stores the shape instead). Realistic copy, every optional branch exercised; link fields keep STRING hrefs here ("href": "#") — the lint compile-runs the fragment against the sample with no resolver, so a link envelope in a sample would print [object Object]; for images an inline data URI keeps the kit self-contained: "image": "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='1200' height='800'><rect width='1200' height='800' fill='%23e2ddd5'/></svg>" — a gallery field's sample is an ARRAY of those strings. A sample image is a bare URL string, so its metadata sub-bindings ({{image.alt}}, {{this.caption}}) render EMPTY in the gallery preview: that is expected, and the fragment must stay legible without them (never guard the whole picture on its alt text).
SIZE BUDGETS (kit lint): each element (its files together) ≤ 900 KB (kit_element_size); global.js / runtime ≤ 128 KB (kit_runtime_size); global.css ≤ 128 KB (kit_global_size). The package's own caps (per file, total) are in the module docs.
VALIDATE: npm run module:pack -- ./{module} runs the kit lint with everything above (ids, order↔folder match, categories, compile-and-run, schema shape, sample shape, budgets) and writes the zip only when no row is an error; the upload re-runs it. What a store gets is the MOUNT (docs/modules.md §8.2a): an element the store has not edited is replaced by the next module version; one it edited is CUSTOMIZED and module updates skip it — durable fixes belong in the kit source, not the store copy.
LINKS & LANGUAGES — a store may serve languages under URL prefixes (/en/about, setup/web.localeUrl). An element never builds a language prefix: links come from kind:"link" fields (the renderer localizes them), literal root-absolute hrefs are prefixed by the renderer, and scripts build page links as DROP_BASE + (DROP_LOCALE_BASE || '') + path; POST routes (/form, /content, /cart, …) stay DROP_BASE + '/{route}/' + DROP_LOCALE (never prefixed). A language switcher reads {{#each alternates}} ({locale, code, hreflang, label, href, current} — this page in every store language) and, in script, window.DROP_LOCALE_MODE ('path' → follow DROP_BASE + href; absent → cookie mode: set drop_locale=<locale>; path=/; max-age=31536000 and reload).
NEW-ELEMENT CHECKLIST: kebab-case folder listed in manifest order[] · element.json with name (+ category from the manifest list) · schema.json keys match the variables in index.html · markup uses only registered helpers, {{ }} vs {{{ }}} matches each field's kind (raw only for editor:"html"/editor/icon/.html) and sample.json stores rich text as plain HTML strings on editor:"html" fields (no {type:"html"} wrapper), optional fields guarded · CSS namespaced + tokens with fallbacks + responsive · shared CSS in global.css (page-wide rules in :where()) · behavior via data attributes on the kit global.js, degrading without JS · sample.json realistic and branch-complete · module:pack passes.