Every business application gets them. This post shows how to make every page explain itself: a lazy-loaded help drawer driven by one JSON file per page, a build check that refuses to ship a page without help, and the complete, tested code to add it to your own app in a few days. It works in Oracle APEX, React, Angular, or any server-rendered web app.
01The problem
The answers to users' questions usually live in someone's head, in a PDF manual nobody opens, or in a tooltip that says "Enter the name" next to a field called Name. Manuals go stale the day a page changes. Tooltips explain the obvious and skip the rules that actually trip people up.
The goal here is different: help that's about this page, in plain language, there the moment the user needs it, without slowing the app down, and kept up to date by the build itself.
When I rolled this out across 36 pages of a production app, the help ended up with 491 field explanations, 226 business rules, 147 buttons and actions, and 222 common errors with their fix. It added zero requests to page load.
02What the user sees
A Help button on every page opens a side drawer (a full-screen sheet on phones) about this page, in eight collapsible sections:
what it's for, why the business needs it, the goal
numbered steps
what, why, what to enter, mandatory or not, effect on other data, an example
the rule and why it matters
what each does, when, who sees it, what to do if it fails
the exact message, the cause, the fix
the process before and after this page
general help, at the bottom of every page
Four details are why people actually open it:
Here's the situation filter at work. Same page, same help file, two different sets of steps:
And the field "?" in action. It skips the scrolling and lands on the one explanation the user needs:
03Six design decisions that make it work
Content is data, not code
Each page has one JSON file, for example help/p00031.json for page 31 (padded to five digits), plus one global.json. Writers fill a fixed structure: about, steps, fields, rules, actions, errors, next. The drawer renders whatever is in the file, so a content change never needs a code change.
Every entry carries the real element id of its field or button (P31_IBAN, P31_SAVE). That one link powers three features: the situation filter, hiding what the user can't see, and the field "?".
Nothing loads with the page
The page carries only a tiny click handler. The page's JSON is fetched on the first click, then cached in sessionStorage. The URL includes the build version, so a new build fetches fresh content automatically. Expect a first open of roughly 80 to 140 ms and later opens of about 50 ms.
Safe by design
The help is static text with no customer data. Every string goes into the page with textContent, and the JSON is never parsed as HTML. The test below feeds it <script> and <img onerror> samples, and they have to render as plain text. Internal notes (verified_by, gaps) are stripped at build time, so they never reach the browser.
The build fails without help
A small Python check fails the build when a page has no help file or a file breaks the format. It also warns about technical words (ORA-, SQL, NULL, package names) in visible text. Help can't be "forgotten for later". Every new page needs it to ship.
Every statement is verified against the code
Wrong help is worse than none. Check each rule, mandatory flag and error message against the real implementation (page definitions, packages, specs) and record the evidence in verified_by. Anything you can't verify goes into gaps. It's never shown to users, and a developer reviews it instead of anyone guessing.
AI assistants are great for first drafts at scale, as long as each one has to cite the code for every statement. The prompt is in Step 5.
If you can't point to the line of code that proves it, it doesn't go in the help.
Show only what the user has
The drawer asks the page for its mode (create, edit, view or list) and drops entries whose field or button isn't on the page. A Show all situations toggle lifts the filter. That's safe because the content is static text without data.
04How it fits together
05Build it step by step
The kit is framework-free: plain JavaScript, no jQuery, no dependencies. Every file below was run and tested exactly as printed (22 automated browser checks pass).
Create the folder structure
help-kit/
├── help-drawer.js ← Step 2 (deploy)
├── help-drawer.css ← Step 3 (deploy)
├── help-src/ ← Step 5 the help content you write
│ ├── global.json
│ └── p00031.json ← one file per page: "p" + page id padded to 5 digits
├── help_guard.py ← Step 6 build check
├── build/help/ ← created by help_guard.py: the cleaned JSON you deploy
└── test/ ← Step 7 browser test
├── test.html
├── test_help.py
└── help/
├── global.json
└── p00031.json
On your web server you only need help-drawer.js, help-drawer.css and the files from build/help/.
Add the drawer script
This is the whole drawer: lazy loading, the cache, the situation filter, search, text-only rendering, keyboard handling and the field "?" buttons. Save it as help-drawer.js.
/* help-drawer.js - contextual end-user help (starter kit). No dependencies.
* Opens a side drawer with the help of the CURRENT page, loaded on the first click from <base>/p<5-digit page>.json + global.json.
* Config (before or after this file): window.helpConfig = { baseUrl: '/static/help/', pageId: 31, appName: 'My App', mode: function () { return 'edit'; } }
* Open from code: window.appHelp.open(openerElement, fieldItemName?)
*/
(function () {
"use strict";
function cfg() { return window.helpConfig || {}; } // read when used: the config may be defined after this file
var NAMES = { about: 'About this page', steps: 'What should I do here?', fields: 'Field explanations', rules: 'Business rules',
actions: 'Buttons and actions', errors: 'Common errors and solutions', next: 'What happens next?' };
var NOT_YET = 'Help for this page is not available yet.';
var S = { el: null, opener: null };
function pad5(id) { return ('00000' + id).slice(-5); }
function arr(v) { return Array.isArray(v) ? v : (v ? [v] : []); }
function txt(v) { return v === null || v === undefined ? '' : String(v).trim(); }
// text-only element builder: content is NEVER parsed as HTML (XSS-safe)
function h(tag, attrs, kids) {
var e = document.createElement(tag);
Object.keys(attrs || {}).forEach(function (k) {
var v = attrs[k]; if (v === null || v === undefined || v === false) { return; }
if (k === 'text') { e.textContent = v; } else if (k === 'cls') { e.className = v; } else { e.setAttribute(k, v === true ? '' : v); }
});
arr(kids).forEach(function (c) { if (c) { e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); } });
return e;
}
function load(name) {
var c0 = cfg(), url = (c0.baseUrl || 'help/') + name + '.json' + (c0.version ? '?v=' + c0.version : ''), key = 'help|' + url;
try { var c = sessionStorage.getItem(key); if (c) { return Promise.resolve(JSON.parse(c)); } } catch (e) { /* storage off */ }
return fetch(url, { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; })
.then(function (d) {
if (d && typeof d === 'object') { try { sessionStorage.setItem(key, JSON.stringify(d)); } catch (e) { /* full */ } return d; }
return null;
});
}
function currentMode(doc) {
var modes = arr(doc.modes);
if (modes.length < 2) { return modes[0] || null; }
var m = typeof cfg().mode === 'function' ? cfg().mode() : null;
return modes.indexOf(m) >= 0 ? m : modes[0];
}
// show an entry only in its modes and only when its item / button is on the page (nothing about what the user cannot see)
function visible(entry, mode, all) {
if (all) { return true; }
if (entry.modes && mode && arr(entry.modes).indexOf(mode) < 0) { return false; }
var ref = entry.item || entry.button;
if (ref && /^[A-Z][A-Z0-9_]+$/.test(ref)) {
return !!(document.getElementById(ref) || document.querySelector('[data-help-name="' + ref + '"]'));
}
return true;
}
function dl(pairs) {
var d = h('dl', { cls: 'hd-dl' });
pairs.forEach(function (p) { if (txt(p[1])) { d.appendChild(h('dt', { text: p[0] })); d.appendChild(h('dd', { text: txt(p[1]) })); } });
return d;
}
function entry(title, pairs, key) {
return h('div', { cls: 'hd-entry', 'data-key': key || null, tabindex: '-1' }, [h('h4', { text: title }), dl(pairs)]);
}
function build(doc, glob, mode, all) {
var out = [];
function sec(id, kids) { if (kids.length) { out.push({ id: id, kids: kids }); } }
if (doc) {
var a = doc.about || {};
sec('about', [txt(a.summary) && h('p', { text: a.summary }), dl([['Why', a.why], ['Goal', a.goal]])].filter(Boolean));
var st = arr(doc.steps).filter(function (s) { return visible(s, mode, all); });
sec('steps', st.length ? [h('ol', {}, st.map(function (s) { return h('li', { text: txt(s.text) }); }))] : []);
sec('fields', arr(doc.fields).filter(function (f) { return visible(f, mode, all); }).map(function (f) {
return entry(f.label, [['What', f.what], ['Why', f.why], ['What to enter', f.enter], ['Mandatory', f.mandatory],
['Effect', f.effect], ['Example', f.example]], f.item);
}));
sec('rules', arr(doc.rules).map(function (r) { return entry(r.title, [['Rule', r.rule], ['Why', r.why]]); }));
sec('actions', arr(doc.actions).filter(function (x) { return visible(x, mode, all); }).map(function (x) {
return entry(x.label, [['Does', x.does], ['When', x.when], ['Afterwards', x.after], ['Who sees it', x.visible_for],
['If it does not work', x.if_fails]], x.button);
}));
sec('errors', arr(doc.errors).map(function (e) { return entry(e.message, [['Why', e.why], ['Fix', e.fix]]); }));
var n = doc.next || {};
sec('next', [dl([['Before', n.before], ['After', n.after]]),
arr(n.workflow).length && h('ol', {}, arr(n.workflow).map(function (w) { return h('li', { text: txt(w) }); }))].filter(Boolean));
}
if (glob) {
sec('global', [txt((glob.about || {}).summary) && h('p', { text: glob.about.summary })].concat(
arr(glob.rules).map(function (r) { return entry(r.title, [['Rule', r.rule], ['Why', r.why]]); })).filter(Boolean));
}
return out;
}
function render(doc, glob, field, all) {
var mode = doc ? currentMode(doc) : null, body = S.el.querySelector('.hd-body');
body.textContent = '';
S.el.querySelector('.hd-sub').textContent = doc ? txt(doc.page_name) : '';
if (mode && arr(doc.modes).length > 1) {
var b = h('button', { type: 'button', cls: 'hd-all', text: all ? 'Show only my situation' : 'Show all situations' });
b.addEventListener('click', function () { render(doc, glob, null, !all); });
body.appendChild(h('p', { cls: 'hd-mode' }, ['Shown for: ' + mode + ' ', b]));
}
var secs = build(doc, glob, mode, all);
if (!secs.length || !doc) { body.appendChild(h('p', { text: NOT_YET })); }
var q = null;
if (secs.length && doc) {
q = h('input', { type: 'search', cls: 'hd-search', placeholder: 'Search this help', 'aria-label': 'Search this help' });
body.appendChild(q);
}
secs.forEach(function (s, i) {
var open = i < 2 && s.id !== 'global', id = 'hd-sec-' + s.id;
var tog = h('button', { type: 'button', cls: 'hd-tog', 'aria-expanded': String(open), 'aria-controls': id,
text: s.id === 'global' ? 'Working with ' + (cfg().appName || 'this app') : NAMES[s.id] });
var panel = h('div', { id: id, hidden: !open, 'data-open': String(open) }, s.kids);
tog.addEventListener('click', function () { var o = tog.getAttribute('aria-expanded') !== 'true'; tog.setAttribute('aria-expanded', String(o)); panel.hidden = !o; });
body.appendChild(h('section', {}, [h('h3', {}, tog), panel]));
});
// search: keep only the entries / list items that contain the words, open the sections with a hit
if (q) {
var searching = false;
q.addEventListener('input', function () {
var term = q.value.trim().toLowerCase();
if (term && !searching) { // remember which sections were open, to restore them when the search is cleared
body.querySelectorAll('[id^="hd-sec-"]').forEach(function (p) { p.setAttribute('data-open', String(!p.hidden)); });
}
searching = !!term;
body.querySelectorAll('section').forEach(function (sec) {
var tog = sec.querySelector('.hd-tog'), panel = sec.querySelector('[id^="hd-sec-"]'), hits = 0;
var parts = panel.querySelectorAll('.hd-entry, li');
if (!parts.length) { parts = [panel]; }
Array.prototype.forEach.call(parts, function (el) {
var match = !term || el.textContent.toLowerCase().indexOf(term) >= 0;
if (el !== panel) { el.hidden = !match; }
if (match) { hits++; }
});
var titleHit = term && tog.textContent.toLowerCase().indexOf(term) >= 0;
if (titleHit) { Array.prototype.forEach.call(parts, function (el) { if (el !== panel) { el.hidden = false; } }); }
var show = !term || hits > 0 || titleHit, open = term ? show : panel.getAttribute('data-open') === 'true';
sec.hidden = !show; panel.hidden = !open; tog.setAttribute('aria-expanded', String(open));
});
});
}
if (field) {
var target = body.querySelector('[data-key="' + field + '"]');
if (!target && !all) { return render(doc, glob, field, true); }
if (target) {
var p = target.closest('[id^="hd-sec-"]'); p.hidden = false; p.previousSibling.firstChild.setAttribute('aria-expanded', 'true');
target.scrollIntoView({ block: 'start' }); target.focus(); target.classList.add('hd-hit');
setTimeout(function () { target.classList.remove('hd-hit'); }, 2000);
return;
}
}
S.el.querySelector('.hd-title').focus();
}
function close() {
if (!S.el || S.el.hidden) { return; }
S.el.hidden = true; document.documentElement.classList.remove('hd-open');
if (S.opener && S.opener.focus) { S.opener.focus(); }
}
function shell() {
if (S.el) { return S.el; }
var x = h('button', { type: 'button', cls: 'hd-x', 'aria-label': 'Close help', text: '×' });
var panel = h('div', { cls: 'hd-panel', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'hd-title' }, [
h('header', { cls: 'hd-head' }, [h('h2', { id: 'hd-title', cls: 'hd-title', tabindex: '-1', text: 'Help' }), h('span', { cls: 'hd-sub' }), x]),
h('div', { cls: 'hd-body' })]);
S.el = h('div', { cls: 'hd', hidden: true }, [h('div', { cls: 'hd-backdrop' }), panel]);
x.addEventListener('click', close);
S.el.querySelector('.hd-backdrop').addEventListener('click', close);
S.el.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
if (e.key !== 'Tab') { return; }
var f = panel.querySelectorAll('button, [href], input, [tabindex]:not([tabindex="-1"])'), first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
});
document.body.appendChild(S.el);
return S.el;
}
function open(opener, field) {
S.opener = opener || document.activeElement;
shell().hidden = false; document.documentElement.classList.add('hd-open');
S.el.querySelector('.hd-body').textContent = 'Loading…';
Promise.all([load('p' + pad5(cfg().pageId)), load('global')]).then(function (r) { render(r[0], r[1], field, false); });
}
// entry points: any element with [data-help] (header button, floating button) and field "?" buttons [data-help-field]
document.addEventListener('click', function (e) {
var t = e.target.closest && e.target.closest('[data-help], [data-help-field]');
if (!t) { return; }
e.preventDefault(); open(t, t.getAttribute('data-help-field'));
});
// "?" next to the labels of window.helpFields = [[itemName, label], ...] (only enterable fields)
function fieldButtons() {
arr(window.helpFields).forEach(function (p) {
var item = document.getElementById(p[0]), label = document.querySelector('label[for="' + p[0] + '"]');
if (!item || !label || item.disabled || item.readOnly || label.querySelector('.hd-q')) { return; }
label.appendChild(h('button', { type: 'button', cls: 'hd-q', 'data-help-field': p[0], 'aria-label': 'Help for ' + p[1], text: '?' }));
});
}
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', fieldButtons); } else { fieldButtons(); }
window.appHelp = { open: open, close: close };
})();
Add the styles
Change the tokens at the top to your own colours. Dark mode and the phone layout are included. Save it as help-drawer.css.
/* help-drawer.css - starter kit; change the tokens to your design system */
:root { --hd-bg: #fff; --hd-fg: #1d1d1f; --hd-muted: #5f6368; --hd-line: #e5e5ea; --hd-accent: #a3123a; --hd-focus: 0 0 0 3px rgba(163,18,58,.35); }
@media (prefers-color-scheme: dark) { :root { --hd-bg: #1c1c1e; --hd-fg: #f2f2f7; --hd-muted: #a1a1aa; --hd-line: #3a3a3c; } }
.hd[hidden] { display: none; }
.hd { position: fixed; inset: 0; z-index: 1000; }
.hd-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,.35); }
.hd-panel { position: absolute; top: 0; right: 0; bottom: 0; width: min(34rem, 100%); display: flex; flex-direction: column;
background: var(--hd-bg); color: var(--hd-fg); box-shadow: -8px 0 24px rgba(0,0,0,.18); }
.hd-head { display: flex; align-items: baseline; gap: .5rem; padding: 1rem 1.25rem; border-bottom: 1px solid var(--hd-line); }
.hd-title { margin: 0; font-size: 1.25rem; } .hd-sub { flex: 1; color: var(--hd-muted); }
.hd-x { border: 0; background: none; font-size: 1.5rem; line-height: 1; cursor: pointer; color: inherit; }
.hd-body { overflow-y: auto; padding: 1rem 1.25rem 2rem; }
.hd-body h3 { margin: 1rem 0 .25rem; font-size: 1rem; }
.hd-tog { width: 100%; text-align: left; border: 0; background: none; font: inherit; font-weight: 700; color: inherit; cursor: pointer; padding: .5rem 0; }
.hd-tog::before { content: '\25B8 '; } .hd-tog[aria-expanded="true"]::before { content: '\25BE '; }
.hd-entry { padding: .5rem 0; border-top: 1px solid var(--hd-line); } .hd-entry h4 { margin: 0 0 .25rem; }
.hd-dl dt { font-weight: 600; color: var(--hd-muted); font-size: .85rem; } .hd-dl dd { margin: 0 0 .4rem; }
.hd-mode { color: var(--hd-muted); } .hd-all { border: 1px solid var(--hd-line); background: none; color: inherit; border-radius: 1rem; padding: .1rem .6rem; cursor: pointer; }
.hd-hit { outline: 2px solid var(--hd-accent); outline-offset: 4px; }
.hd-q { margin-left: .35rem; width: 1.25rem; height: 1.25rem; border-radius: 50%; border: 1px solid var(--hd-line); background: none; font-size: .75rem; cursor: pointer; }
.hd button:focus-visible, .hd-q:focus-visible, [data-help]:focus-visible { outline: none; box-shadow: var(--hd-focus); }
html.hd-open { overflow: hidden; }
.hd-search { width: 100%; box-sizing: border-box; margin: 0 0 .5rem; padding: .5rem .75rem; border: 1px solid var(--hd-line); border-radius: .5rem; background: none; color: inherit; font: inherit; }
@media (max-width: 640px) { .hd-panel { width: 100%; } }
Add the Help button and the config to your page
Any element with a data-help attribute opens the drawer. Use a header link on normal pages and a floating button on sign-in pages or dialogs:
<!-- in the page header -->
<a href="#" data-help aria-haspopup="dialog">Help</a>
<!-- floating button, for sign-in pages and dialogs -->
<button type="button" data-help class="help-fab">Help</button>
Then tell the drawer where the help files are and which page this is. Put this once in your layout or on each page:
<link rel="stylesheet" href="/static/help-drawer.css">
<script>
window.helpConfig = {
baseUrl: '/static/help/', // where the cleaned JSON files are served
pageId: 31, // this page's id -> loads help/p00031.json
appName: 'My App', // shown as "Working with My App"
version: '2026-10-21-1', // your build version: a new build = fresh help
mode: function () { // optional: 'create' | 'edit' | 'view' | 'list'
var id = document.getElementById('P31_ID');
return id && id.value ? 'edit' : 'create';
}
};
window.helpFields = [['P31_IBAN', 'IBAN']]; // optional: fields that get a "?" (max 5)
</script>
<script src="/static/help-drawer.js" defer></script>
id. If the id differs from the name you use in the JSON, add data-help-name="P31_SAVE" to the element. The config can come before or after the script, because it's read each time the drawer opens.Write the help content
Start with the 3 to 5 pages that generate the most questions. Rules for writers:
- Write for business users: plain language, short sentences, "you".
- Use the exact labels visible on screen, and the real element ids of fields and buttons.
- No technical words in visible text: no table or package names, no ORA- codes, no SQL, no NULL.
- Verify every statement against the code and note the evidence in
verified_by. Put anything uncertain ingaps(never shown).
The file format:
| Key | Shown as | Contents |
|---|---|---|
page_id, page_name | Drawer title | page_id must match the file name |
modes | "Shown for: …" | Any of create, edit, view, list. Two or more turn on the situation filter |
about | About this page | summary (required), why, goal |
steps[] | What should I do here? | text, optional modes |
fields[] | Field explanations | item (element id), label, what, why, enter, source (manual / select / automatic / calculated), mandatory, effect, example, optional modes |
rules[] | Business rules | title, rule, why, verified_by (internal) |
actions[] | Buttons and actions | button (element id), label, does, when, after, visible_for, if_fails, optional modes |
errors[] | Common errors | message (exact text), why, fix |
next | What happens next? | before, after, workflow[] |
gaps[] | Never shown | Open questions for the developer |
A complete page file:
{
"page_id": 31,
"page_name": "Customer Detail",
"modes": [
"create",
"edit",
"view"
],
"about": {
"summary": "Create or change a customer.",
"why": "Visits and invoices need the customer.",
"goal": "The customer is saved."
},
"steps": [
{
"text": "Enter the name.",
"modes": [
"create"
]
},
{
"text": "Change what is wrong, then Save.",
"modes": [
"edit"
]
}
],
"fields": [
{
"item": "P31_NAME",
"label": "Name",
"what": "Full name.",
"enter": "First and last name.",
"source": "manual",
"mandatory": "Yes"
},
{
"item": "P31_IBAN",
"label": "IBAN",
"what": "Bank account.",
"source": "manual",
"mandatory": "Only when payment is direct debit",
"effect": "Used for direct debit."
}
],
"rules": [
{
"title": "Unique name",
"rule": "Two customers cannot have the same name and birthday.",
"why": "No duplicates.",
"verified_by": "customer_api.check_duplicate"
}
],
"actions": [
{
"button": "P31_SAVE",
"label": "Save",
"does": "Saves the customer.",
"changes_data": true
},
{
"button": "P31_DELETE",
"label": "Delete",
"does": "Deletes the customer.",
"visible_for": "Administrators"
}
],
"errors": [
{
"message": "Please enter the name.",
"why": "Name is mandatory.",
"fix": "Enter the first and last name, then Save again."
}
],
"next": {
"after": "Plan the first visit.",
"workflow": [
"Customer",
"Assignment",
"Visit",
"Invoice"
]
},
"gaps": [
"Is the IBAN also needed for transfer payers? (not verifiable in code - ask the business owner)"
]
}
The global file uses the same format with page_id 0. Usually it only needs about and rules: the menu, saving, signing out, anything that's true everywhere.
{
"page_id": 0,
"page_name": "Working with the app",
"about": {
"summary": "Use the menu on the left to move between pages. Every page has a Help button at the top."
},
"rules": [
{
"title": "Saving",
"rule": "There is no autosave.",
"why": "Click Save before you leave a page."
},
{
"title": "Signing out",
"rule": "You are signed out after 30 minutes without activity.",
"why": "This protects the data on shared computers."
}
]
}
Writing at scale with AI. Give one assistant per functional area this prompt, then review its gaps list afterwards:
You write end-user help files for the pages <page list> of <app name>.
Audience: <business users>, English, plain language, short sentences, "you" form.
Format: help-src/p<5-digit page>.json exactly as in the content format (about, steps, fields, rules, actions, errors, next).
For EVERY statement:
- verify it against the implementation: page definition <path>, packages/services <path>, specs <path>;
- put the evidence (file + procedure / spec id) into "verified_by";
- if you cannot verify it, do NOT write it as fact: describe only what the system visibly does and add the question to "gaps".
Use the exact visible labels and the real element ids of fields and buttons (they drive the situation filter and the "?" buttons).
Never mention technical internals in visible text (no table / package / column names, no ORA- codes, no SQL, no NULL).
Never use real personal data in examples.
Cover every input, select, checkbox, report column and filter a user sees; every button and row action; the error messages
the code actually raises; and the real process before and after the page.
When done, list per page: entries written, open gaps.
Add the build check
This script fails the build (exit code 1) when a page has no help file or a file breaks the format. It also warns about technical words and writes cleaned copies without the internal notes. Save it as help_guard.py (Python 3, no packages needed).
"""help_guard.py - build check for the contextual help (starter kit).
usage: python help_guard.py <help source dir> <out dir> <page id> [<page id> ...]
* FAILS (exit 1) when a page has no p<5-digit page>.json, global.json is missing, or a file breaks the schema
* warns about technical words in visible texts
* writes cleaned, compact copies to <out dir> (internal keys verified_by / gaps removed) - upload those, never the sources"""
import json, os, re, sys
MODES = {'create', 'edit', 'view', 'list'}
SOURCES = {'manual', 'select', 'automatic', 'calculated'}
INTERNAL = {'verified_by', 'gaps'}
TECH = re.compile(r'ORA-\d|PL/SQL|\bNULL\b|foreign key|\bSQL\b|_PKG\b|\bAPI_', re.I)
def strip(v):
if isinstance(v, dict): return {k: strip(x) for k, x in v.items() if k not in INTERNAL}
if isinstance(v, list): return [strip(x) for x in v]
return v
def texts(v):
if isinstance(v, dict):
for x in v.values(): yield from texts(x)
elif isinstance(v, list):
for x in v: yield from texts(x)
elif isinstance(v, str): yield v
def check(doc, page):
e = []
if page is not None and doc.get('page_id') != page: e.append(f'page_id {doc.get("page_id")} <> {page}')
if not str(doc.get('page_name', '')).strip(): e.append('page_name missing')
if not set(doc.get('modes', [])) <= MODES: e.append(f'modes must be in {sorted(MODES)}')
if page is not None and not str(doc.get('about', {}).get('summary', '')).strip(): e.append('about.summary missing')
for k in ('steps', 'fields', 'rules', 'actions', 'errors', 'prepare'):
if k in doc and not isinstance(doc[k], list): e.append(f'{k} must be a list')
for i, f in enumerate(doc.get('fields', [])):
if not f.get('label') or not f.get('item'): e.append(f'fields[{i}] needs label + item')
if f.get('source') not in SOURCES: e.append(f'fields[{i}].source must be in {sorted(SOURCES)}')
for i, a in enumerate(doc.get('actions', [])):
if not a.get('label'): e.append(f'actions[{i}].label missing')
for i, x in enumerate(doc.get('errors', [])):
if not x.get('message'): e.append(f'errors[{i}].message missing')
for i, r in enumerate(doc.get('next', {}).get('related', [])):
if not isinstance(r.get('page'), int) or not r.get('label'): e.append(f'next.related[{i}] needs page + label')
return e
def main(src, out, pages):
errors, warn = [], []
os.makedirs(out, exist_ok=True)
for name, page in [('global', None)] + [(f'p{p:05d}', p) for p in pages]:
path = os.path.join(src, name + '.json')
if not os.path.exists(path): errors.append(f'{name}.json missing'); continue
try: doc = json.load(open(path, encoding='utf-8'))
except ValueError as x: errors.append(f'{name}.json invalid JSON: {x}'); continue
errors += [f'{name}.json: {m}' for m in check(doc, page)]
clean = strip(doc)
warn += [f'{name}.json technical word: {t[:60]!r}' for t in texts(clean) if TECH.search(t)]
json.dump(clean, open(os.path.join(out, name + '.json'), 'w', encoding='utf-8'), ensure_ascii=False, separators=(',', ':'))
for w in warn: print('WARNING', w)
if errors:
for m in errors: print('ERROR', m)
sys.exit(1)
print(f'help guard OK: {len(pages)} pages + global')
if __name__ == '__main__':
main(sys.argv[1], sys.argv[2], [int(p) for p in sys.argv[3:]])
Run it in your build with the list of all page ids, then deploy the cleaned files, never the sources:
# page ids of your app; exit code 1 = the build stops
python help_guard.py help-src/ build/help/ 1 10 20 21 30 31
# example output
help guard OK: 6 pages + global
# when a page has no help file
ERROR p00040.json missing
Test it in a real browser
The test opens a small page, clicks around like a user and checks what matters: nothing loads with the page, the dialog gets focus, the situation filter and search work, hidden fields are left out, XSS renders as text, Esc returns focus, the "?" jumps to its field, the cache works, focus stays trapped, the phone layout works, and a missing file shows a fallback message.
The test page:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../help-drawer.css">
<script src="../help-drawer.js"></script>
</head>
<body>
<header><a href="#" data-help id="hb">Help</a></header>
<input type="hidden" id="P31_ID" value="7">
<label for="P31_NAME">Name</label><input id="P31_NAME">
<label for="P31_IBAN">IBAN</label><input id="P31_IBAN">
<input id="P31_X">
<button id="P31_SAVE">Save</button>
<script>
window.helpConfig = { baseUrl: 'help/', pageId: 31, appName: 'Demo',
mode: function () { return document.getElementById('P31_ID').value ? 'edit' : 'create'; } };
window.helpFields = [['P31_IBAN', 'IBAN']];
</script>
</body>
</html>
Test content. It deliberately includes a field that isn't on the page, a button that isn't on the page, and an XSS sample:
{"page_id": 31, "page_name": "Customer Detail", "modes": ["create", "edit", "view"], "about": {"summary": "Create or change a customer.", "why": "Visits and invoices need the customer.", "goal": "The customer is saved."}, "steps": [{"text": "Enter the name.", "modes": ["create"]}, {"text": "Change what is wrong, then Save.", "modes": ["edit"]}], "fields": [{"item": "P31_NAME", "label": "Name", "what": "Full name.", "enter": "First and last name.", "source": "manual", "mandatory": "Yes"}, {"item": "P31_IBAN", "label": "IBAN", "what": "Bank account.", "source": "manual", "mandatory": "Only when payment is direct debit", "effect": "Used for direct debit."}, {"item": "P31_SECRET", "label": "Hidden field", "what": "Not on the page.", "source": "manual"}, {"item": "P31_X", "label": "<img src=x onerror=alert(1)>", "what": "<script>alert(2)</script>", "source": "manual"}], "rules": [{"title": "Unique name", "rule": "Two customers cannot have the same name and birthday.", "why": "No duplicates.", "verified_by": "pkg.check"}], "actions": [{"button": "P31_SAVE", "label": "Save", "does": "Saves.", "changes_data": true}, {"button": "P31_DELETE", "label": "Delete", "does": "Deletes."}], "errors": [{"message": "Please enter the name.", "why": "Name is mandatory.", "fix": "Enter the first and last name, then Save again."}], "next": {"after": "Plan the first visit.", "workflow": ["Customer", "Assignment", "Visit", "Invoice"]}}
{"page_id": 0, "page_name": "Working with the app", "about": {"summary": "Menu on the left, Help on every page."}, "rules": [{"title": "Autosave", "rule": "There is none.", "why": "Click Save."}]}
The test script (needs pip install playwright and playwright install chromium):
"""Browser test for the help drawer. usage: python test/test_help.py (run from the kit folder)"""
import sys, os, http.server, threading, functools
from playwright.sync_api import sync_playwright
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # the kit folder
class Quiet(http.server.SimpleHTTPRequestHandler):
def log_message(self, *a): pass
handler = functools.partial(Quiet, directory=root)
srv = http.server.ThreadingHTTPServer(('127.0.0.1', 0), handler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
url = f'http://127.0.0.1:{srv.server_port}/test/test.html'
results = []
def ok(name, cond):
results.append(bool(cond)); print('PASS' if cond else 'FAIL', name)
def body_text(pg):
return pg.inner_text('.hd-body') # visible text only
with sync_playwright() as p:
b = p.chromium.launch(); pg = b.new_page()
alerts, errs, reqs = [], [], []
pg.on('dialog', lambda d: (alerts.append(1), d.dismiss()))
pg.on('pageerror', lambda e: errs.append(str(e)))
pg.on('request', lambda r: reqs.append(r.url))
pg.goto(url); pg.wait_for_load_state('networkidle')
ok('nothing loaded with the page', not any('/help/' in r for r in reqs))
pg.click('#hb'); pg.wait_for_selector('.hd-tog')
ok('dialog opens', pg.is_visible('[role=dialog]'))
ok('title gets focus', pg.evaluate("document.activeElement.id") == 'hd-title')
t = body_text(pg)
ok('mode detected (edit)', 'Shown for: edit' in t)
ok('create-only step hidden', 'Enter the name.' not in t and 'Change what is wrong' in t)
ok('first two sections open', pg.evaluate("[...document.querySelectorAll('.hd-tog')].slice(0,3).map(b=>b.getAttribute('aria-expanded')).join()") == 'true,true,false')
pg.click('.hd-tog >> text=Field explanations'); t = body_text(pg)
ok('field not on page is left out', 'Hidden field' not in t)
ok('button not on page is left out', 'Deletes.' not in body_text(pg))
ok('XSS shown as plain text', '<img src=x' in t and not alerts and pg.locator('.hd-body img, .hd-body script').count() == 0)
ok('internal notes not shown', 'pkg.check' not in t)
ok('global help at the bottom', 'Working with Demo' in body_text(pg))
pg.fill('.hd-search', 'bank account'); t = body_text(pg)
ok('search keeps matching entry', 'Bank account.' in t)
ok('search hides other entries', 'Full name.' not in t and 'Unique name' not in t)
pg.fill('.hd-search', ''); t = body_text(pg)
ok('clearing search restores view', 'Change what is wrong' in t and 'Full name.' in t)
pg.click('.hd-all'); full = pg.evaluate("document.querySelector('.hd-body').textContent")
ok('"Show all situations" lifts the filter', 'Hidden field' in full and 'Enter the name.' in full)
pg.keyboard.press('Escape')
ok('Esc closes and returns focus', not pg.is_visible('[role=dialog]') and pg.evaluate("document.activeElement.id") == 'hb')
n = len(reqs); pg.click('[data-help-field=P31_IBAN]'); pg.wait_for_selector('.hd-hit')
ok('field "?" jumps to its entry', pg.evaluate("document.activeElement.dataset.key") == 'P31_IBAN')
ok('second open uses the cache', not any('/help/' in r for r in reqs[n:]))
for _ in range(14): pg.keyboard.press('Tab')
ok('focus stays inside the drawer', pg.evaluate("!!document.activeElement.closest('.hd-panel')"))
pg.keyboard.press('Escape'); pg.set_viewport_size({'width': 360, 'height': 740})
pg.click('#hb'); pg.wait_for_selector('.hd-tog')
ok('phone width: full screen, no side scroll', pg.evaluate("document.querySelector('.hd-panel').getBoundingClientRect().width") == 360
and pg.evaluate("document.documentElement.scrollWidth <= 360"))
pg.evaluate("helpConfig.pageId = 99; sessionStorage.clear()"); pg.keyboard.press('Escape')
pg.click('#hb'); pg.wait_for_timeout(500)
ok('missing file shows fallback text', 'not available yet' in body_text(pg))
ok('no JavaScript errors', not errs)
b.close()
srv.shutdown()
print(f'{sum(results)} / {len(results)} passed')
sys.exit(0 if all(results) else 1)
Run it from the folder that contains help-drawer.js:
python test/test_help.py
PASS nothing loaded with the page
PASS dialog opens
PASS title gets focus
...
PASS no JavaScript errors
22 / 22 passed
Here's what both checks look like in a real run. I let the guard fail first on purpose, with a missing page and a technical word in a fix text:
06Oracle APEX setup
In APEX everything maps onto standard Shared Components and page 0. No plug-in needed.
- Upload the files. In Shared Components › Static Application Files, upload
help-drawer.jsandhelp-drawer.css. Upload the cleaned JSON files with the directoryhelp/(so they becomehelp/p00031.jsonandhelp/global.json) and mime typeapplication/json. - Load the script and styles. In Shared Components › User Interface Attributes, add
#APP_FILES#help-drawer#MIN#.jsunder JavaScript File URLs and#APP_FILES#help-drawer#MIN#.cssunder CSS File URLs. (#MIN#picks up a minified copy if you upload one.) - Add the Help button. In your Navigation Bar List, add an entry "Help" with target URL
#, iconfa-question-circle-o, and in Link Attributes:data-help aria-haspopup="dialog". - Add the config on page 0. Create a Static Content region (template Blank with Attributes) with this source:
<script> window.helpConfig = { baseUrl: '#APP_FILES#help/', pageId: &APP_PAGE_ID., appName: 'My App' }; </script>#APP_FILES#already carries the files version, so every new upload means a new URL and fresh help. - Tell the drawer the mode. On a form page, in Execute when Page Loads (it runs after the page 0 config, so it adds to it):
window.helpConfig.mode = function () { return apex.item('P31_ID').getValue() ? 'edit' : 'create'; }; - Make buttons findable. Give each button a Static ID equal to the name used in the JSON (for example
P31_SAVE). Page items already render with their name as the element id. - Modal dialogs and the sign-in page. Page 0 isn't rendered in modal dialogs, so add a button with
data-help(via Custom Attributes) to the dialog footer, plus a small config region on that page. On the public sign-in page, use a floating Help button. - Build. Run
help_guard.pyin your export/import pipeline before the static files are uploaded.
07React, Angular and other single-page apps
Load the script and CSS once in your app shell. Because the drawer reads window.helpConfig each time it opens, you just update it when the route changes:
// e.g. in your router's "after navigation" hook
router.afterEach(function (to) {
window.helpConfig = {
baseUrl: '/static/help/',
pageId: to.meta.helpId, // give each route a help id
appName: 'My App',
version: BUILD_VERSION
};
});
Use stable id attributes (or data-help-name) on the fields and buttons you document, and render the Help button anywhere with data-help. The "?" buttons from window.helpFields are added once, when the page loads. For fields your framework renders later, render your own small button and open the field's help from code with window.appHelp.open(buttonElement, 'P31_IBAN').
08What to expect
| Measure | Typical result |
|---|---|
| Page load | Unchanged, 0 help requests during load |
| First open of the drawer | About 80 to 140 ms (fetches two small JSON files) |
| Later opens | About 50 ms (from the session cache) |
| Safety | Static text only, rendered as text, internal notes stripped, hidden fields and buttons left out |
| Upkeep | The build fails when a page has no help |
The biggest win isn't technical.
Writing verified help for every page surfaces real problems: rules nobody can explain, buttons that aren't where the spec says, error messages that don't help anyone. Writing the help turns into a quality review of the app.
09Rollout checklist
global.json written (menu, saving, signing out)help_guard.py in the build with the full page listgaps reviewed by a developer or the business ownerhelp_guard.py fails the build when a page has no help, so it can't fall behind.gaps, not to users.Start with your five noisiest pages this week, and let the build make sure every page after that explains itself.
Comments
Post a Comment