HomeDeveloper docsBuild Customize forms and native module windows
Implementation tutorials 5 min read

Build Customize forms and native module windows

A complete personal settings form with migration, CAS, translations, modal lifecycle and preferences mounting.

The installed module saved a personal preference through Customize and the native modal. Gravewright 0.3.0, English interface.
The installed module saved a personal preference through Customize and the native modal. Gravewright 0.3.0, English interface. · Open full screenshot ↗

What to build

A module with a personal table note, a preferences-surface panel and a real Customize form in the extension list. It uses storage.user, so each account in each campaign owns its note. It explicitly migrates a legacy string into schema:1 and refuses unknown formats.

1. Create the project and inspect its JavaScript files

Create personal-note. Download the complete ZIP or save the complete files below. main.mjs owns mounting, translation and modal UI; settings.mjs owns reading, migration and writes. Game data is unaffected.

2. Package and install

From the project folder run:

powershell
python -m zipfile -c ../personal-note-1.0.0.zip manifest.json main.mjs settings.mjs styles.css LICENSE.txt

Use python3 on Linux/macOS. As owner, install via Inside → Modules → Install module → Install from ZIP. As GM, open the campaign and select Gravewright → Extensions → Activate for Personal note workshop 1.0.0. Test one floating tutorial panel at a time.

3. Open Customize and save a preference

  1. In Gravewright → Extensions, click Customize beside Personal note workshop. The runtime displays this because the module exports customize(context).

  2. Note text loads your current preference. Enter “Prepare session materials” and click Save preference.

  3. Wait for Saved. Close and click Refresh on the note panel. Your new text appears.

  4. Leave/reopen the campaign; the value persists. Sign in as a player and choose another note: accounts do not overwrite each other.

  5. Open the table menu’s Preferences section. Its preferences mount displays the same value with a refresh action, without modifying host-internal preferences.

4. Understand the modal and closing

api.ui.openModal receives an accessibly named section and owns focus, background state and Escape. During writes, buttons are disabled and canClose guards Escape. gravewright:sheet-flush also joins the pending write. Deactivation aborts the mount and releases the modal.

Preview table for 2 seconds calls suspend, exposes the table and later resumes. onDispose cancels the timer. For an actual map picker, replace only the interval with a selection mechanism supported by the target contract; do not invent a selection API.

5. Evolve data without losing preferences

settings.mjs accepts a missing key, a legacy string and a schema:1 object. Migration occurs in memory; server data changes only on Save preference. The spread preserves additional fields in the accepted shape. Unknown schemas disable editing instead of deleting data. get supplies the CAS revision used by set.

Open forms in two tabs. Save in the first, then save in the second. The second receives conflict, reloads the current preference and asks for review, without an automatic forced retry. Unsaved field edits stay local and are discarded when closed.

6. Complete source to adapt

manifest.json

json
{
  "id": "example.personal-note",
  "name": "Personal note workshop",
  "version": "1.0.0",
  "sdk": {
    "requires": ">=1.0.0 <2.0.0",
    "tested": "1.0.0"
  },
  "entry": "main.mjs",
  "description": "A real Customize form, personal settings migration and translated live panel.",
  "author": "Gravewright documentation",
  "license": "MIT"
}

main.mjs

javascript
import {load,save} from './settings.mjs';
export default {
  start({styles,i18n}){
    styles.use('styles.css');
    i18n.register({en:{title:'Personal note',refresh:'Refresh'},'pt-BR':{title:'Nota pessoal',refresh:'Atualizar'},es:{title:'Nota personal',refresh:'Actualizar'}});
  },
  register({register}){
    register('table.interface',ctx=>this.mount(ctx,true));
    register('preferences',ctx=>this.mount(ctx,false));
  },
  async mount({root,api,storage,signal,onDispose},floating){
    const doc=root.ownerDocument,panel=doc.createElement('aside');
    panel.className='personal-note'+(floating?' personal-note--floating':'');
    const title=doc.createElement('strong'),text=doc.createElement('p'),refresh=doc.createElement('button');
    title.textContent=api.i18n.t('title');refresh.type='button';refresh.textContent=api.i18n.t('refresh');
    panel.append(title,text,refresh);root.append(panel);
    let generation=0;
    async function read(){
      const ticket=++generation;refresh.disabled=true;
      try {const current=await load(storage);if(!signal.aborted&&ticket===generation)text.textContent=current.value.label;}
      catch(error){if(!signal.aborted&&ticket===generation)text.textContent=error.code||error.message;}
      finally{if(!signal.aborted&&ticket===generation)refresh.disabled=false;}
    }
    refresh.addEventListener('click',()=>void read(),{signal});
    onDispose(()=>{generation++;panel.remove();});await read();
  },
  async customize({api,storage,signal,onDispose}){
    const panel=document.createElement('section');panel.className='personal-note-settings';
    panel.setAttribute('role','dialog');panel.setAttribute('aria-modal','true');panel.setAttribute('aria-label','Personal note settings');
    const heading=document.createElement('h2');heading.textContent='Personal note settings';
    const form=document.createElement('form'),label=document.createElement('label');label.textContent='Note text';
    const input=document.createElement('input');input.maxLength=120;label.append(input);
    const submit=document.createElement('button');submit.type='submit';submit.textContent='Save preference';
    const preview=document.createElement('button');preview.type='button';preview.textContent='Preview table for 2 seconds';
    const close=document.createElement('button');close.type='button';close.textContent='Close';
    const status=document.createElement('p');status.setAttribute('role','status');
    form.append(label,submit,preview,close,status);panel.append(heading,form);
    let current,pending,timer;
    function enabled(value){input.disabled=!value;submit.disabled=!value;preview.disabled=!value;close.disabled=!value;}
    enabled(false);
    const modal=api.ui.openModal(panel,{canClose:()=>!pending});
    async function refresh(){current=await load(storage);if(!signal.aborted)input.value=current.value.label;}
    panel.addEventListener('gravewright:sheet-flush',event=>{if(pending)event.detail.waitUntil(pending);},{signal});
    form.addEventListener('submit',event=>{
      event.preventDefault();if(pending||!current)return;enabled(false);
      pending=(async()=>{
        try{await save(storage,current,input.value);await refresh();if(!signal.aborted)status.textContent='Saved. Close and refresh the note panel.';}
        catch(error){
          if(!signal.aborted){
            if(error.code==='conflict'){await refresh();status.textContent='Conflict: current server preference loaded. Review before saving again.';}
            else status.textContent=error.code||error.message;
          }
          throw error;
        }finally{pending=undefined;if(!signal.aborted)enabled(true);}
      })();void pending.catch(()=>{});
    },{signal});
    preview.addEventListener('click',()=>{
      modal.suspend();clearTimeout(timer);
      timer=setTimeout(()=>{if(!signal.aborted)modal.resume();},2000);
    },{signal});
    close.addEventListener('click',()=>void modal.close(),{signal});
    onDispose(()=>{clearTimeout(timer);void modal.close();});
    try{await refresh();if(!signal.aborted)enabled(true);}
    catch(error){if(!signal.aborted){status.textContent=error.code||error.message;close.disabled=false;}}
  },
  stop(){}
};

settings.mjs

javascript
export const key='panel-preferences';
export function migrate(value){
  if(value===null || value===undefined)return {schema:1,label:'My campaign note'};
  if(typeof value==='string')return {schema:1,label:value};
  if(value?.schema===1 && typeof value.label==='string')return {...value};
  throw new Error('Unsupported preference schema. Export/migrate before saving.');
}
export async function load(storage){
  const current=await storage.user.get(key);
  return {value:migrate(current?.value),revision:current?.revision??null};
}
export async function save(storage,current,label){
  if(typeof label!=='string' || !label.trim() || label.length>120)throw new Error('Enter 1–120 characters');
  return storage.user.set(key,{...current.value,schema:1,label:label.trim()},{expectedRevision:current.revision});
}

styles.css

css
.gw-module-root[data-module-id="example.personal-note"] .personal-note { padding: var(--space-4); font-size: 14px; }
.gw-module-root[data-module-id="example.personal-note"] .personal-note--floating { position: fixed; left: 16px; bottom: 112px; z-index: 10; max-width: min(340px, calc(100vw - 32px)); background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-md); pointer-events: auto; }
.personal-note-settings { width: min(520px, calc(100vw - 48px)); padding: var(--space-5); font-size: 14px; }
.personal-note-settings label { display: block; margin-bottom: var(--space-4); }
.personal-note-settings input { width: 100%; display: block; }
.personal-note-settings button { margin-right: var(--space-3); margin-bottom: var(--space-3); }

LICENSE.txt

text
MIT License

Copyright (c) 2026 Gravewright contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Practical tests

Test a legacy/unknown schema, two tabs, another player/campaign, interrupted network and deactivation during reads/writes. textContent renders HTML-like input as text. Original en/pt-BR/es catalogs follow the explicit account language and disappear with the activation lifetime.

How to use AI for customization

text
Adapt Personal note workshop for [specific preference] on Gravewright 0.3.0. Preserve customize, preferences mounting, CAS and schema migration. Use a native modal and localized labels without changing host internals. Deliver complete files and test two tabs, player access, closing and version updates.