Build Customize forms and native module windows
A complete personal settings form with migration, CAS, translations, modal lifecycle and preferences mounting.

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:
python -m zipfile -c ../personal-note-1.0.0.zip manifest.json main.mjs settings.mjs styles.css LICENSE.txtUse 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
In Gravewright → Extensions, click Customize beside Personal note workshop. The runtime displays this because the module exports customize(context).
Note text loads your current preference. Enter “Prepare session materials” and click Save preference.
Wait for Saved. Close and click Refresh on the note panel. Your new text appears.
Leave/reopen the campaign; the value persists. Sign in as a player and choose another note: accounts do not overwrite each other.
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
{
"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
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
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
.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
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
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.