// Built via el() rather than as a raw HTML block: this document mixes
// executable ojs cells with plain markdown, and pandoc's raw-HTML-block
// passthrough is unreliable in that mix, so every other custom-styled piece
// on this page already goes through el() — this keeps that one convention.
stepsIntro = el("div", { class: "gaidet-steps" }, [
el("div", { class: "gaidet-step" }, [
el("div", { class: "gaidet-step-number" }, "1"),
el("div", { class: "gaidet-step-body" }, [
el("h3", {}, "Choose what is allowed"),
el("p", {}, "Tick the categories of AI use your assignment or course/module permits.")
])
]),
el("div", { class: "gaidet-step" }, [
el("div", { class: "gaidet-step-number" }, "2"),
el("div", { class: "gaidet-step-body" }, [
el("h3", {}, "Describe your AI use"),
el("p", {}, "For each task you used AI for, name the tool, explain what you did, and how you validated the output.")
])
]),
el("div", { class: "gaidet-step" }, [
el("div", { class: "gaidet-step-number" }, "3"),
el("div", { class: "gaidet-step-body" }, [
el("h3", {}, "Automatically generate your declaration"),
el("p", {}, "Review declaration, then copy it into your assignment.")
])
])
])AI Disclosure Generator
> Declare how you used AI in your assignment
AI Disclosure Generator
A free, open-source tool for students that protects your privacy and guides you step by step through generating clear AI-use disclosure statements.
About this tool
The use of AI tools among students continues to grow. Some assignments prohibit generative AI entirely, while others allow it but require students to disclose how it was used. Clear guidance on how to do this is not always available.
If your assignment permits AI use but requires disclosure, this tool can help. It is adapted for student assignments from the GAIDeT Declaration Generator and the Artificial Intelligence Disclosure (AID) Framework.
This tool is built specifically for student coursework. That said, if you’re a researcher looking to document AI use in a publication or grant application, check out the related literature and tools for options aimed at research rather than teaching.
exampleDeclarationHtml = `<p><strong>Declaration on the Use of Artificial Intelligence Tools</strong></p>
<p><strong>AI tools used:</strong> Claude (Sonnet 5), Claude Code (Sonnet 4.6), Microsoft Copilot (GPT-5.5)</p>
<p>The core analysis, writing, and conclusions in this assignment are my own. Generative AI assisted with the following specific, limited tasks:</p>
<ul>
<li><strong>Clarifying definitions or concepts</strong><br>Tool: Claude (Sonnet 5)<br>Summary: I asked Claude to explain a few concepts I was unsure about, then checked the literature it pointed me to against the original sources before relying on it.</li>
<li><strong>Code review</strong><br>Tool: Claude Code (Sonnet 4.6)<br>Summary: I wrote the core analysis logic myself; Claude Code was used only to draft repetitive boilerplate (e.g. file-loading functions), which I reviewed, tested, and adapted to my dataset.</li>
<li><strong>Proofreading</strong><br>Tool: Microsoft Copilot (GPT-5.5)<br>Summary: After completing my own final draft, I asked Copilot to flag grammar and punctuation issues, and manually reviewed and accepted only the suggestions I agreed with.</li>
</ul>
<p>The author confirms that this AI use falls within what was permitted for this assignment, and complies with any other applicable rules, such as their institution's Declaration of Authorship.</p>
<p>Responsibility for the submitted assignment lies entirely with the author of this declaration.</p>
<p>Generative AI tools cannot be listed as authors and do not bear responsibility for the final outcomes.</p>`function formatIcon(view) {
const paths = {
text: '<path d="M4 6h16M4 10h16M4 14h16M4 18h10"/>',
table: '<rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M3.5 9.5h17M3.5 14.5h17M9.5 9.5v10"/>',
// Tick and pencil, for the "Yes, generate" / "No, let me edit" buttons.
check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
edit: '<path d="M4 20h4L19 9l-4-4L4 16v4z"/><path d="M13.5 6.5l4 4"/>',
// Lightbulb, for the "Show an example declaration" tip button.
tip: '<path d="M9 18h6M10 21h4"/><path d="M12 3a6 6 0 0 0-3.6 10.8c.7.5 1.1 1.3 1.1 2.1V16h5v-.1c0-.8.4-1.6 1.1-2.1A6 6 0 0 0 12 3z"/>'
};
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
icon.setAttribute("viewBox", "0 0 24 24");
icon.setAttribute("aria-hidden", "true");
icon.setAttribute("class", "gaidet-format-icon");
icon.innerHTML = paths[view];
return icon;
}
// "Show as: Text / Table" switch for the generated declaration.
function viewSwitch(current, onPick) {
const button = (view, label) => el("button", {
type: "button",
class: current === view ? "gaidet-example-view gaidet-example-view-active" : "gaidet-example-view",
"aria-pressed": current === view ? "true" : "false",
onclick: () => onPick(view)
}, [formatIcon(view), label]);
return el("div", { class: "gaidet-example-views", role: "group", "aria-label": "Declaration format" }, [
el("span", { class: "gaidet-output-actions-label" }, "Show as:"),
button("text", "Text"),
button("table", "Table")
]);
}{
const wrap = el("div", { class: "gaidet-example-wrap" }, [
el("button", {
class: "gaidet-example-toggle gaidet-btn-icon",
"aria-expanded": showExample ? "true" : "false",
onclick: () => { mutable showExample = !showExample; }
}, [formatIcon("tip"), showExample ? "Hide an example declaration" : "Show an example declaration"])
]);
if (showExample) {
const example = el("div", { class: "gaidet-example gaidet-fade-in" }, []);
example.innerHTML = exampleDeclarationHtml;
wrap.append(
el("p", { class: "gaidet-tool-instructions gaidet-example-intro gaidet-fade-in" }, "This is what your generated declaration will look like"),
example
);
}
return wrap;
}Before you start
Rules on generative AI differ between programmes, courses/modules, and even individual assessments. Before using any AI tool, check your syllabus and Learning Management System (LMS) course site for information on AI use, and ask your lecturer whether (and how) it may be used. Some institutions also only allow specific, approved AI tools. Disclosing a task or tool here does not mean you are permitted to use AI, and you remain responsible for following your institution’s and course/module’s policies.
AI can hallucinate, producing false information, fabricated references, or biased output, so verify anything you rely on against reliable sources before submitting. See the full disclaimer at the end of this page for details.
function el(tag, props = {}, children = []) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (v === undefined || v === null) continue;
if (k.startsWith("on") && typeof v === "function") {
node.addEventListener(k.slice(2), v);
} else if (k === "checked" || k === "value" || k === "disabled") {
node[k] = v;
} else if (k === "class") {
node.className = v;
} else {
node.setAttribute(k, v);
}
}
for (const child of [].concat(children)) {
if (child === null || child === undefined) continue;
node.append(child instanceof Node ? child : document.createTextNode(child));
}
return node;
}
// Cabin's free tier only reports page views, not custom events, so this
// piggybacks a "did someone actually generate a declaration" conversion
// signal on top of page-view tracking: a hidden iframe requests a dummy
// static page (count/generate-declaration.html) that carries Cabin's
// tracking script, and Cabin logs that as a page view. It's not dead code,
// see the "Analytics" section of the Privacy Policy page, which discloses
// this. Fires once per confirmed declaration (not on "Update declaration").
function trackGenerateClick() {
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = 'https://disclose-ai.app/count/generate-declaration.html?t=' + Date.now();
document.body.appendChild(iframe);
setTimeout(() => iframe.remove(), 3000);
}
function taskIsComplete(sentence, tools, openEnded, customLabel) {
return !!(sentence && sentence.trim())
&& Object.keys(tools ?? {}).length > 0
&& (!openEnded || !!(customLabel && customLabel.trim()));
}
function countWords(text) {
return (text || "").trim().split(/\s+/).filter(Boolean).length;
}
nativeAutoGrow = typeof CSS !== "undefined" && CSS.supports?.("field-sizing", "content");
function autoGrow(textarea, attempts = 0) {
// Browsers with `field-sizing: content` (see styles.css) size the box
// themselves as part of their normal layout pass, so no JS runs per
// keystroke at all. The rest is the fallback for browsers without it.
if (nativeAutoGrow) return;
if (!textarea.isConnected) {
if (attempts < 30) requestAnimationFrame(() => autoGrow(textarea, attempts + 1));
return;
}
// Deferred to the next frame so the keystroke itself paints immediately;
// reading scrollHeight synchronously here would force a layout reflow
// before the browser can render the typed character.
if (textarea._autoGrowRaf) cancelAnimationFrame(textarea._autoGrowRaf);
textarea._autoGrowRaf = requestAnimationFrame(() => {
// Collapsing to "auto" re-lays out the whole page, so only do it when
// text was removed (the box may need to shrink). While typing forwards,
// growing to fit scrollHeight is enough and much cheaper.
const len = textarea.value.length;
const shrunk = len < (textarea._autoGrowLen ?? 0);
textarea._autoGrowLen = len;
if (shrunk || !textarea.style.height) {
textarea.style.height = "auto";
textarea.style.height = `${textarea.scrollHeight}px`;
} else if (textarea.scrollHeight > textarea.clientHeight) {
textarea.style.height = `${textarea.scrollHeight}px`;
}
});
}
function preserveFocus(build) {
const active = document.activeElement;
// Where the field sits on screen right now, not the page's absolute scroll
// position: restoring the old absolute scrollY fought the new layout when
// an edit changed a box's height, but not correcting for anything at all
// lets the browser's own scroll-anchoring reset to the top of this whole
// block on every rebuild. Keeping the field at the same viewport-relative
// spot avoids both.
let target = active?.dataset?.field ? {
field: active.dataset.field,
selStart: active.selectionStart ?? null,
selEnd: active.selectionEnd ?? null,
beforeTop: active.getBoundingClientRect().top,
time: Date.now()
} : null;
// A commit can trigger two rebuilds in quick succession (e.g. Enter in a
// description box). If the second lands before the first has restored
// focus, focus has already dropped to <body>, so reuse the first rebuild's
// still-pending target instead of losing focus and scroll position.
const pending = preserveFocus.pending;
if (!target && pending && Date.now() - pending.time < 1000) target = pending;
preserveFocus.pending = target;
const node = build();
if (target) {
let attempts = 0;
const done = () => { if (preserveFocus.pending === target) preserveFocus.pending = null; };
const refocus = () => {
attempts += 1;
const next = node.querySelector(`[data-field="${CSS.escape(target.field)}"]`);
if (!next) return done();
if (!next.isConnected) {
if (attempts < 30) requestAnimationFrame(refocus);
else done();
return;
}
next.focus({ preventScroll: true });
if (target.selStart !== null && typeof next.setSelectionRange === "function") {
next.setSelectionRange(target.selStart, target.selEnd);
}
// One more frame before correcting scroll: Safari can apply its own
// scroll adjustment on focus a tick after preventScroll would suggest,
// so measuring immediately can get overridden right after by Safari's
// own late adjustment. Waiting a frame lets that settle first.
requestAnimationFrame(() => {
// Replaced by a newer rebuild in the meantime: a detached node
// measures as top 0, which used to scroll the page all the way up
// to Step 1. The newer rebuild restores focus and scroll instead.
if (!next.isConnected) return;
// Deliberate navigation (Enter jumping to the next task) scrolls on
// its own; correcting here would cancel that scroll midway.
if (Date.now() < (preserveFocus.holdScrollUntil ?? 0)) return done();
const afterTop = next.getBoundingClientRect().top;
// "instant", so the page-wide smooth scrolling doesn't turn this
// small correction into a visible scroll animation.
window.scrollBy({ top: afterTop - target.beforeTop, behavior: "instant" });
done();
});
};
requestAnimationFrame(refocus);
}
return node;
}
// Shared "not available yet" treatment: dims contentEl, makes it fully
// inert, and adds an overlay that explains why on hover/focus. One
// implementation so every locked box looks the same; the message differs
// because a Step 2 category is gated only by Step 1, while Step 3 and the
// prompts question are gated by Step 2 (and transitively Step 1) instead.
function lockBox(contentEl, message = "Locked. Select the relevant categories in Step 1 above first.") {
contentEl.classList.add("gaidet-locked");
contentEl.setAttribute("inert", "");
// Small solid-padlock glyph, drawn inline (no icon font/package) so it
// just inherits text color via currentColor. Purely decorative — the
// accessible name still comes from the plain-text `message` below.
const lockIcon = el("span", { class: "gaidet-lock-icon", "aria-hidden": "true" });
lockIcon.innerHTML = `<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 1a5 5 0 0 0-5 5v3H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V11a2 2 0 0 0-2-2h-1V6a5 5 0 0 0-5-5zm-3 8V6a3 3 0 0 1 6 0v3zm3 4a2 2 0 0 1 1 3.73V19a1 1 0 1 1-2 0v-2.27A2 2 0 0 1 12 13z"/></svg>`;
const lockMessage = el("div", { class: "gaidet-lock-message" }, [lockIcon, message]);
const overlay = el("div", {
class: "gaidet-lock-overlay",
tabindex: "0",
"aria-label": message
});
return el("div", { class: "gaidet-lock-wrap" }, [contentEl, overlay, lockMessage]);
}// Steps 2 and 3 fade almost out of view until Step 1 has a ticked category,
// so the page reads as "start here" rather than a wall of boxes. While faded
// they're inert, and a click anywhere on them pops up a note explaining why.
{
const layout = document.querySelector(".gaidet-layout");
if (!layout) return;
const awaiting = allowedCategories.size === 0;
layout.classList.toggle("gaidet-awaiting-step1", awaiting);
for (const child of layout.children) {
if (!child.classList.contains("gaidet-awaiting-notice")) child.toggleAttribute("inert", awaiting);
}
layout.querySelector(".gaidet-awaiting-notice")?.remove();
if (!awaiting) return;
let hideTimer;
const onClick = (e) => {
if (e.target.closest(".gaidet-awaiting-notice")) return;
layout.querySelector(".gaidet-awaiting-notice")?.remove();
clearTimeout(hideTimer);
// The faded boxes are inert, so the click lands on the layout itself;
// tell Step 2 and Step 3 apart by where it happened.
const step3 = layout.querySelector(".gaidet-layout-bottom");
const onStep3 = step3 && e.clientY >= step3.getBoundingClientRect().top;
const step1Link = el("a", { href: "#generate-declaration" }, "Step 1 above");
const notice = el("div", { class: "gaidet-awaiting-notice", role: "alert" }, onStep3
? [
el("strong", {}, "Step 3 isn’t available yet. "),
"Complete ", step1Link, " and then Step 2 before you generate your declaration."
]
: [
el("strong", {}, "Step 2 isn’t available yet. "),
"Select the categories your assignment allows in ", step1Link, "."
]);
const top = e.clientY - layout.getBoundingClientRect().top;
notice.style.top = `${Math.max(0, top - 20)}px`;
layout.append(notice);
hideTimer = setTimeout(() => notice.remove(), 6000);
};
layout.addEventListener("click", onClick);
invalidation.then(() => {
layout.removeEventListener("click", onClick);
clearTimeout(hideTimer);
});
}{
document.addEventListener("click", (e) => {
for (const { input, closeList } of autocompletePickers.values()) {
if (!input.parentElement?.contains(e.target)) closeList();
}
});
}// "" means not specified. Independent of allowedCategories: it never locks
// or unlocks anything below, it's just an optional label carried into the
// declaration, so it's safe to change back and forth at any time.
mutable aiasLevel = ""// Empty by default: a category only unlocks once a student actively ticks
// it as permitted, rather than assuming everything is allowed unless
// deselected.
mutable allowedCategories = new Set()// When true, the assignment/course/module gives no guidance at all on AI use.
// This overrides allowedCategories: the category gate is disabled and
// cleared, and a warning tells the student to check with their lecturer
// before using this tool, rather than letting them guess at what's allowed.
mutable noGuidance = false{
const hasData = Object.keys(selections).length > 0 || !!aiasLevel;
window.onbeforeunload = hasData ? (e) => { e.preventDefault(); e.returnValue = ""; } : null;
}noVersionTools = new Set([
"Otter.ai",
"Grammarly",
"QuillBot",
"Canva",
"NotebookLM",
"Consensus",
"Elicit",
"Scite"
])promptsToggle = {
// Stays locked until there's at least one ticked task to attach prompts
// to, so it doesn't feel like an active choice before Step 2 has started.
const locked = Object.keys(selections).length === 0;
const box = el("div", { class: "gaidet-prompts-question" }, [
el("p", { class: "gaidet-tool-label" }, "Provide prompts used for each task?"),
el("p", { class: "gaidet-tool-instructions" }, "If you choose \"Yes\", an extra box will appear under each ticked task where you can copy-paste the exact prompt(s) you used with the AI tool, for extra transparency. This is optional and off by default, but some lecturers or assignment briefs may require you to include the full prompts."),
el("div", { class: "gaidet-tool-grid" }, [
el("label", { class: "gaidet-tool-option" }, [
el("input", {
type: "radio",
name: "gaidet-provide-prompts",
checked: providePrompts === true,
onchange: (e) => {
if (e.target.checked) {
mutable providePrompts = true;
document.getElementById("gaidet-output-section")?.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
}),
" Yes"
]),
el("label", { class: "gaidet-tool-option" }, [
el("input", {
type: "radio",
name: "gaidet-provide-prompts",
checked: providePrompts === false,
onchange: (e) => { if (e.target.checked) mutable providePrompts = false; }
}),
" No"
])
])
]);
// Just dimmed + inert, no hover explanation: this question is minor enough
// that a tooltip here would be more noise than help.
if (locked) {
box.classList.add("gaidet-locked");
box.setAttribute("inert", "");
}
return box;
}// The prompts question and the AIAS level are optional, so they sit folded
// away under one small "Optional information" toggle rather than as two
// full boxes. It opens by itself when either already has a value (e.g.
// after loading saved progress), so a choice is never hidden. Otherwise it
// keeps whatever state it had before this cell re-ran (`this` is the
// previous output), without a `mutable` that would redraw it on every click.
optionalInfo = {
const open = !!this?.open || providePrompts === true || aiasLevel !== "";
const details = el("details", { class: "gaidet-optional" }, [
el("summary", { class: "gaidet-optional-summary" }, [
el("span", { class: "gaidet-optional-title" }, "Optional information"),
el("span", { class: "gaidet-optional-hint" }, "Prompts, AI Assessment Scale level")
]),
promptsToggle,
aiasSelector
]);
details.open = open;
return details;
}aiasSelector = {
// Clicking the already-selected level clears it, so there's always an
// obvious, one-click way back to "not specified" without a separate
// reset control.
function selectLevel(levelStr) {
mutable aiasLevel = (aiasLevel === levelStr) ? "" : levelStr;
}
const selectedEntry = data.aiasLevels.find(l => String(l.level) === aiasLevel);
// A single row of pills rather than five stacked cards: the full summary
// for each level lives in its tooltip, so comparing all five costs a
// hover instead of a page's worth of vertical space. Only the selected
// level's instruction is printed out below.
const options = data.aiasLevels.map(lvl => {
const levelStr = String(lvl.level);
const isSelected = aiasLevel === levelStr;
return el("span", { class: "gaidet-aias-option" }, [
el("button", {
type: "button",
class: isSelected ? "gaidet-aias-btn gaidet-aias-btn-selected" : "gaidet-aias-btn",
"aria-pressed": isSelected ? "true" : "false",
onclick: () => selectLevel(levelStr)
}, lvl.name),
el("span", {
class: "gaidet-info-icon",
tabindex: "0",
role: "img",
"aria-label": `What ${lvl.name} means`,
"data-tooltip": `${lvl.summary}\n\n${lvl.instruction}`
}, "ⓘ")
]);
});
return el("div", { class: "gaidet-aias-question" }, [
el("p", { class: "gaidet-tool-label" }, "AI Assessment Scale level (optional)"),
el("p", { class: "gaidet-tool-instructions" }, [
"If your assignment brief or course/module instructions state a level from the ",
el("a", { href: "https://aiassessmentscale.com", target: "_blank", rel: "noopener" }, "AI Assessment Scale"),
", select the matching level below. It will be added to your declaration."
]),
el("div", { class: "gaidet-aias-list" }, options),
selectedEntry ? el("p", { class: "gaidet-aias-instruction" }, selectedEntry.instruction) : null
]);
}aiasLevelLabel = {
const entry = data.aiasLevels.find(l => String(l.level) === aiasLevel);
return entry ? `${entry.level} – ${entry.name}` : "";
}categoryGate = {
const allCatIds = data.categories.map(cat => cat.id);
const allSelected = allCatIds.every(id => allowedCategories.has(id));
const optionClass = (base, checked) =>
checked ? `${base} gaidet-category-gate-option-checked` : base;
const categoryOptions = data.categories.map(cat => {
const isAllowed = allowedCategories.has(cat.id);
return el("label", {
class: optionClass(
noGuidance ? "gaidet-category-gate-option gaidet-category-gate-option-disabled" : "gaidet-category-gate-option",
isAllowed
)
}, [
el("input", {
type: "checkbox",
class: "gaidet-checkbox-input",
checked: isAllowed,
disabled: noGuidance,
onchange: (e) => {
const next = new Set(allowedCategories);
if (e.target.checked) next.add(cat.id); else next.delete(cat.id);
mutable allowedCategories = next;
}
}),
el("span", { class: "gaidet-checkbox-box" }, []),
el("span", {}, cat.label),
// No tooltip for "Additional tasks": its only item is the open-ended
// catch-all itself, so a preview listing it back would be pointless.
cat.id === "additional_tasks" ? null : el("span", {
class: "gaidet-info-icon",
tabindex: "0",
role: "img",
"aria-label": `What ${cat.label} covers`,
"data-tooltip": `This category can include tasks such as:\n${cat.items.filter(i => i.id !== "other").map(i => `• ${i.label}`).join("\n")}`,
// Blocks the click from also toggling the parent <label>'s checkbox
// (the browser forwards any click inside a <label> to its control
// unless the event is cancelled), so tapping the icon just opens the
// tooltip instead of ticking/un-ticking the category by accident.
onclick: (e) => e.preventDefault()
}, "ⓘ")
]);
});
const selectAllOption = el("label", {
class: optionClass(
noGuidance
? "gaidet-category-gate-option gaidet-category-gate-option-all gaidet-category-gate-option-disabled"
: "gaidet-category-gate-option gaidet-category-gate-option-all",
allSelected
)
}, [
el("input", {
type: "checkbox",
class: "gaidet-checkbox-input",
checked: allSelected,
disabled: noGuidance,
onchange: (e) => {
mutable allowedCategories = e.target.checked ? new Set(allCatIds) : new Set();
}
}),
el("span", { class: "gaidet-checkbox-box" }, []),
el("span", {}, "My assignment allows using AI without limiting it to specific categories")
]);
const noGuidanceOption = el("label", {
class: optionClass("gaidet-category-gate-option gaidet-category-gate-option-noguidance", noGuidance)
}, [
el("input", {
type: "checkbox",
class: "gaidet-checkbox-input",
checked: noGuidance,
onchange: (e) => {
mutable noGuidance = e.target.checked;
if (e.target.checked) mutable allowedCategories = new Set();
}
}),
el("span", { class: "gaidet-checkbox-box" }, []),
el("span", {}, "My assignment/course/module gives no guidance at all on AI use")
]);
const noGuidanceWarning = noGuidance
? el("div", { class: "gaidet-category-gate-warning" }, [
el("strong", {}, "Don’t assume. "),
"Treat no guidance as AI not being permitted until your course/module coordinator confirms otherwise, not as permission by default. Ask before using AI or generating a declaration."
])
: null;
return el("div", { class: "gaidet-category-gate" }, [
el("p", { class: "gaidet-tool-label gaidet-step-label" }, "Step 1 (What is allowed): Which categories of AI use does your assignment permit?"),
el("p", { class: "gaidet-tool-instructions gaidet-gate-instructions" }, "Check all categories your assignment brief or course/module instructions allow. Everything else stays locked below until you check it here, so tick only what is permitted."),
el("div", { class: "gaidet-category-gate-list" }, [...categoryOptions, selectAllOption, noGuidanceOption]),
noGuidanceWarning
]);
}step2Label = el("div", {}, [
// The short title only shows while Steps 2 and 3 are collapsed, waiting
// for Step 1; it matches Step 3's "Step 3: Generate your declaration".
el("p", { class: "gaidet-step-label" }, [
el("span", { class: "gaidet-step-label-full" }, "Step 2 (What you used): Tick every task where you used AI"),
el("span", { class: "gaidet-step-label-short" }, "Step 2: Describe how you used AI")
]),
el("p", { class: "gaidet-tool-instructions" }, "Select and describe your use of AI for one or more tasks below.")
])form = {
// A working copy of `selections`, shared by every commit function created
// during this render. `selections` itself is frozen for the lifetime of this
// render (it won't reflect a commit made moments ago by this same render,
// even though the commit did trigger a future re-render), so committing from
// it directly risks one task's update clobbering another's when several
// fields across tasks are edited/committed in quick succession. Every commit
// below reads and writes `pendingSelections` instead, so they chain off each
// other correctly within this render, and only publish to the reactive
// `selections` (starting the next render) as their last step.
let pendingSelections = selections;
if (allowedCategories.size === 0) {
return el("div", { class: "gaidet-output-placeholder gaidet-output-placeholder-warning" }, [
el("p", {}, [
"Complete ",
el("a", { href: "#generate-declaration" }, "Step 1"),
" above first. Once you tick the categories of AI use your assignment allows, they’ll appear here for you to describe."
])
]);
}
return preserveFocus(() => el("div", {}, data.categories.filter(cat => allowedCategories.has(cat.id)).map(cat => {
const categoryEl = el("div", { class: "gaidet-category" }, [
el("h3", {}, cat.label),
...cat.items.map(item => {
const key = `${cat.id}::${item.id}`;
const checked = key in selections;
const sel = selections[key];
const isComplete = checked && taskIsComplete(sel.sentence, sel.tools, item.openEnded, sel.customLabel);
// Tracks this task's tools independently of `selections`, which is frozen
// for the lifetime of this render: a commit updates `selections` and
// schedules a full re-render, but doesn't retroactively unfreeze it for
// code (like markCompleteIfReady, below) still running in this same render.
let currentTools = sel?.tools ?? {};
function buildToolPicker() {
const toolIndex = data.aiToolGroups.flatMap(group =>
group.tools.map(tool => ({ name: tool, category: group.label, needsVersion: !noVersionTools.has(tool) }))
);
function currentSelected() {
return Object.entries(currentTools ?? {}).map(([name, val]) => ({
name,
needsVersion: typeof val === "object",
version: (val && typeof val === "object" && val.version) || ""
}));
}
function isSelected(name) {
return currentSelected().some(t => t.name.toLowerCase() === name.toLowerCase());
}
function commit(nextSelected) {
const next = { ...pendingSelections };
if (!next[key]) return;
const toolsMap = {};
nextSelected.forEach(t => {
toolsMap[t.name] = t.needsVersion ? { version: t.version || "" } : true;
});
currentTools = toolsMap;
next[key] = { ...next[key], tools: toolsMap };
pendingSelections = next;
// Update the visible chip list immediately, rather than waiting for
// the full task list to rebuild (which still happens, for the rest
// of the app to pick up the change, but no longer gates this box's
// own visual feedback).
renderSelected();
mutable selections = next;
}
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function highlight(name, query) {
if (!query) return name;
const re = new RegExp(`(${escapeRegExp(query)})`, "ig");
return name.replace(re, "<mark>$1</mark>");
}
const hasSelection = currentSelected().length > 0;
const input = el("input", {
type: "text",
class: hasSelection ? "gaidet-autocomplete-input gaidet-autocomplete-input-more" : "gaidet-autocomplete-input",
"data-field": `toolSearch::${key}`,
"aria-label": hasSelection ? "Add another AI tool used for this task" : "Search for an AI tool",
placeholder: hasSelection ? "+ Add another tool" : "Start typing to add a tool…",
autocomplete: "off"
});
const list = el("ul", { class: "gaidet-autocomplete-list" }, []);
list.style.display = "none";
const selectedWrap = el("div", { class: "gaidet-autocomplete-selected" }, []);
let activeIndex = -1;
let currentMatches = [];
function closeList() {
list.style.display = "none";
list.innerHTML = "";
activeIndex = -1;
currentMatches = [];
}
function selectTool(entry, { refocus = true } = {}) {
if (!isSelected(entry.name)) {
commit([...currentSelected(), { name: entry.name, needsVersion: entry.needsVersion, version: "" }]);
}
input.value = "";
closeList();
if (refocus) input.focus();
}
// Tools that aren't in the list are accepted as typed, with no extra
// "Add … as a tool" step: on Enter, or when the student leaves the
// field. A typed name matching a listed tool uses that entry.
function acceptTyped(options) {
const q = input.value.trim();
if (!q) return;
const exact = toolIndex.find(t => t.name.toLowerCase() === q.toLowerCase());
selectTool(exact ?? { name: q, needsVersion: true }, options);
}
function renderList(query) {
const q = query.trim();
if (!q) { closeList(); return; }
const ql = q.toLowerCase();
const matches = toolIndex.filter(t => t.name.toLowerCase().includes(ql) && !isSelected(t.name));
list.innerHTML = "";
if (matches.length === 0) { closeList(); return; }
currentMatches = matches;
matches.forEach(entry => {
const li = el("li", { class: "gaidet-autocomplete-item" }, []);
const nameSpan = el("span", {}, []);
nameSpan.innerHTML = highlight(entry.name, q);
const catSpan = el("span", { class: "gaidet-autocomplete-item-category" }, entry.category);
li.append(nameSpan, catSpan);
li.addEventListener("mousedown", (e) => { e.preventDefault(); selectTool(entry); });
list.append(li);
});
activeIndex = -1;
list.style.display = "block";
}
function setActive(i) {
const items = list.querySelectorAll(".gaidet-autocomplete-item");
items.forEach((node, idx) => node.classList.toggle("active", idx === i));
if (items[i]) items[i].scrollIntoView({ block: "nearest" });
}
input.addEventListener("input", (e) => renderList(e.target.value));
input.addEventListener("blur", () => acceptTyped({ refocus: false }));
input.addEventListener("keydown", (e) => {
const items = list.querySelectorAll(".gaidet-autocomplete-item");
if (e.key === "ArrowDown") {
e.preventDefault();
if (!items.length) return;
activeIndex = (activeIndex + 1) % items.length;
setActive(activeIndex);
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (!items.length) return;
activeIndex = (activeIndex - 1 + items.length) % items.length;
setActive(activeIndex);
} else if (e.key === "Enter") {
e.preventDefault();
if (activeIndex >= 0 && currentMatches[activeIndex]) {
selectTool(currentMatches[activeIndex]);
} else if (currentMatches.length === 1) {
selectTool(currentMatches[0]);
} else {
acceptTyped();
}
} else if (e.key === "Escape") {
closeList();
}
});
autocompletePickers.set(key, { input, closeList });
function renderSelected() {
selectedWrap.innerHTML = "";
currentSelected().forEach(entry => {
const row = el("div", { class: "gaidet-autocomplete-chip" }, []);
const header = el("div", { class: "gaidet-autocomplete-chip-header" }, [
el("span", { class: "gaidet-autocomplete-chip-name" }, entry.name),
el("button", {
type: "button",
class: "gaidet-autocomplete-remove",
"aria-label": `Remove ${entry.name}`,
onclick: () => commit(currentSelected().filter(t => t.name !== entry.name))
}, "×")
]);
row.append(header);
if (entry.needsVersion) {
const suggestedVersion = (data.commonVersions ?? {})[entry.name];
row.append(el("div", { class: "gaidet-autocomplete-version-wrap" }, [
el("label", { class: "gaidet-autocomplete-version-label" }, [
`Model/version of ${entry.name} (if available) `,
el("span", {
class: "gaidet-info-icon",
tabindex: "0",
role: "img",
"aria-label": "Why this matters and how to find it",
"data-tooltip": "Capability can vary a lot between versions of the same tool, so the exact model helps others judge what it could and couldn't do.\n\nTo find it: check the tool's model picker/settings, or look at the chat window itself (e.g. ChatGPT shows the model above the chat box; Claude shows it in the model selector).\n\nIf you cannot identify the exact model or version, include the timeframe during which you used the tool (e.g., January 2026)."
}, "ⓘ")
]),
el("input", {
type: "text",
class: "gaidet-autocomplete-version-input",
"data-field": `toolVersion::${key}::${entry.name}`,
"aria-label": `${entry.name} model or version`,
placeholder: suggestedVersion ? `e.g., ${suggestedVersion}` : "Model/version",
value: entry.version,
onblur: (e) => {
commit(currentSelected().map(t => t.name === entry.name ? { ...t, version: e.target.value } : t));
},
onkeydown: (e) => {
if (e.key === "Enter") {
e.preventDefault();
// Blur fires naturally on focus-shift, committing the
// version via the handler above before we move on.
sentenceTextarea.focus();
sentenceTextarea.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
})
]));
}
selectedWrap.append(row);
});
}
renderSelected();
return el("div", { class: "gaidet-autocomplete" }, [
el("p", { class: "gaidet-tool-label" }, "AI tool(s) used for this task:"),
el("p", { class: "gaidet-tool-instructions" }, "Click a suggestion or press Enter to add a tool. If yours isn't listed, just type its name."),
el("div", { class: "gaidet-autocomplete-wrap" }, [input, list]),
selectedWrap
]);
}
const checkboxLabel = el("label", { class: "gaidet-item-checkbox-label" }, [
el("input", {
type: "checkbox",
class: "gaidet-checkbox-input",
checked,
onchange: (e) => {
const next = { ...pendingSelections };
if (e.target.checked) {
next[key] = {
label: item.label,
openEnded: item.openEnded ?? false,
customLabel: next[key]?.customLabel ?? "",
sentence: next[key]?.sentence ?? "",
prompts: next[key]?.prompts ?? "",
tools: next[key]?.tools ?? {},
proceeded: next[key]?.proceeded ?? false
};
} else {
delete next[key];
}
pendingSelections = next;
mutable selections = next;
}
}),
el("span", { class: "gaidet-checkbox-box" }, []),
el("span", { class: "gaidet-item-label" }, item.label),
isComplete ? el("span", { class: "gaidet-item-complete", "aria-label": "Sentence and tool provided" }, " ✓") : null
]);
const sentenceTextarea = el("textarea", {
class: "gaidet-sentence",
"data-field": `sentence::${key}`,
"aria-label": "Description of what you asked the AI to do and how you used and validated the output",
placeholder: "What did you ask the AI to do, and how did you use and validate the output?",
maxlength: "3000",
value: sel?.sentence ?? "",
onblur: (e) => {
const next = { ...pendingSelections };
if (next[key]) {
next[key] = { ...next[key], sentence: e.target.value };
pendingSelections = next;
mutable selections = next;
}
}
});
function markCompleteIfReady() {
// Read sentence/tools/customLabel from live sources (the textarea/input's
// own DOM value, and the `currentTools` tracker) rather than `selections`,
// which is frozen for this render and can be missing an edit that was
// just committed a moment ago in this same render.
const currentCustomLabel = customLabelInput ? customLabelInput.value : (pendingSelections[key]?.customLabel ?? "");
const currentPrompts = promptsTextarea ? promptsTextarea.value : (pendingSelections[key]?.prompts ?? "");
const taskComplete = taskIsComplete(sentenceTextarea.value, currentTools, item.openEnded, currentCustomLabel);
if (!taskComplete) return false;
detailsDiv.classList.add("gaidet-item-details-complete");
const next = { ...pendingSelections };
if (next[key]) {
next[key] = {
...next[key],
tools: currentTools,
sentence: sentenceTextarea.value,
customLabel: currentCustomLabel,
prompts: currentPrompts,
proceeded: true
};
pendingSelections = next;
mutable selections = next;
}
return true;
}
function confirmAndAdvance() {
if (!markCompleteIfReady()) return;
// Jump to the next task's description field, if there is one. The
// target field is captured by its data-field identifier before the
// commit above rebuilds the whole checklist, then located again
// (polling via rAF, since the rebuild isn't necessarily synchronous)
// once it exists in the freshly rebuilt DOM.
const allSentences = Array.from(document.querySelectorAll('textarea[data-field^="sentence::"]'));
const idx = allSentences.indexOf(sentenceTextarea);
const nextField = allSentences[idx + 1]?.dataset.field;
if (!nextField) return;
preserveFocus.holdScrollUntil = Date.now() + 1000;
let attempts = 0;
const tryFocusNext = () => {
attempts += 1;
const next = document.querySelector(`[data-field="${CSS.escape(nextField)}"]`);
if (next && next.isConnected) {
next.focus({ preventScroll: true });
next.scrollIntoView({ behavior: "smooth", block: "center" });
// The commit can rebuild the checklist again after this, replacing
// `next`; once things settle, re-centre whichever copy is focused.
setTimeout(() => {
const current = document.querySelector(`[data-field="${CSS.escape(nextField)}"]`);
if (current && document.activeElement === current) {
current.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, 400);
return;
}
if (attempts < 30) requestAnimationFrame(tryFocusNext);
};
requestAnimationFrame(tryFocusNext);
}
const promptsTextarea = providePrompts ? el("textarea", {
class: "gaidet-sentence",
"data-field": `prompts::${key}`,
"aria-label": "Prompts used for this task",
placeholder: "If applicable, please copy-paste the prompts you have used.",
value: sel?.prompts ?? "",
onblur: (e) => {
const next = { ...pendingSelections };
if (next[key]) {
next[key] = { ...next[key], prompts: e.target.value };
pendingSelections = next;
mutable selections = next;
}
}
}) : null;
const customLabelInput = item.openEnded ? el("input", {
type: "text",
"data-field": `customLabel::${key}`,
"aria-label": "Task or component description",
style: "width:100%;max-width:500px;",
placeholder: "What was this task or component? (short description)",
value: sel?.customLabel ?? "",
onblur: (e) => {
const next = { ...pendingSelections };
if (next[key]) {
next[key] = { ...next[key], customLabel: e.target.value };
pendingSelections = next;
mutable selections = next;
}
}
}) : null;
const sentenceWordCount = el("span", { class: "gaidet-sentence-wordcount" }, `${countWords(sel?.sentence)} words`);
const detailsDiv = checked ? el("div", { class: "gaidet-item-details" }, [
customLabelInput,
buildToolPicker(),
el("div", { class: "gaidet-sentence-wrap" }, [
sentenceTextarea,
sentenceWordCount,
el("button", {
type: "button",
class: "gaidet-sentence-enter",
"aria-label": "Mark this task's description as done and go to the next task",
onclick: () => confirmAndAdvance()
}, "→")
]),
providePrompts ? promptsTextarea : null
]) : null;
if (detailsDiv && isComplete && sel.proceeded) {
detailsDiv.classList.add("gaidet-item-details-complete");
}
if (detailsDiv) {
autoGrow(sentenceTextarea);
sentenceTextarea.addEventListener("input", (e) => {
autoGrow(e.target);
// Plain textContent update on an already-cheap, non-reactive
// listener: no reflow, no `mutable` commit, so this doesn't
// reintroduce the typing lag that was fixed earlier.
sentenceWordCount.textContent = `${countWords(e.target.value)} words`;
});
// Plain Enter acts like clicking the arrow (confirm + advance);
// Shift+Enter still inserts a newline, as usual for a text box.
sentenceTextarea.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
confirmAndAdvance();
}
});
// "Moved to the next section": once focus leaves this task's fields
// (tabbing/clicking to the next task, or down to the declaration),
// mark it complete if it qualifies.
detailsDiv.addEventListener("focusout", () => markCompleteIfReady());
}
if (detailsDiv && providePrompts) {
autoGrow(promptsTextarea);
promptsTextarea.addEventListener("input", (e) => autoGrow(e.target));
// Prompts is the last field for this task when shown, so Enter here
// confirms and advances the same way it does from the description.
promptsTextarea.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
confirmAndAdvance();
}
});
}
const details = detailsDiv;
return el("div", { class: "gaidet-item" }, [checkboxLabel, details]);
})
]);
return categoryEl;
})));
}Generate declaration
// Quarto's floating "Back to top" button scrolls to the very top, but on this
// page the useful place to return to is the generator, so it's pointed at the
// "Generate declaration" section instead (and relabelled to say so). The
// offset keeps the heading clear of the pinned navbar.
{
const btn = document.getElementById("quarto-back-to-top");
if (!btn) return;
btn.removeAttribute("onclick");
btn.setAttribute("href", "#generate-declaration");
btn.replaceChildren(el("i", { class: "bi bi-arrow-up" }, []), " Back to generator");
btn.addEventListener("click", (e) => {
const target = document.getElementById("generate-declaration");
if (!target) return;
e.preventDefault();
const navHeight = document.querySelector("#quarto-header")?.offsetHeight ?? 0;
const top = target.getBoundingClientRect().top + window.scrollY - navHeight - 16;
window.scrollTo({ top, behavior: "smooth" });
});
}promptsReminder = providePrompts
? el("p", { class: "gaidet-prompts-reminder" }, "Please add your prompts for each task below.")
: nullBefore you begin, please read the privacy policy and disclaimer of responsibility at the end of this page for details.
// Closing statements shared by the text, rich-text and table versions of the
// declaration, so the wording can't drift between them.
declarationClosingParas = [
"The author confirms that this AI use falls within what was permitted for this assignment, and complies with any other applicable rules, such as their institution's Declaration of Authorship.",
"Responsibility for the submitted assignment lies entirely with the author of this declaration.",
"Generative AI tools cannot be listed as authors and do not bear responsibility for the final outcomes."
]// One row per ticked task, in taxonomy order. Shared by the real table export
// and the example declaration at the top, so both lay out identically.
function declarationTableRows(sels) {
const rows = [];
for (const cat of data.categories) {
for (const item of cat.items) {
const sel = sels[`${cat.id}::${item.id}`];
if (!sel) continue;
rows.push({
stageId: cat.id,
stage: cat.label,
task: (sel.customLabel && sel.customLabel.trim()) ? sel.customLabel.trim() : sel.label,
tools: Object.entries(sel.tools ?? {}).map(([name, val]) => {
const version = (val && typeof val === "object" && val.version) ? val.version.trim() : "";
return version ? `${name} (${version})` : name;
}).join(", ") || "[no tool specified]",
use: (sel.sentence ?? "").trim() || "[please add an explainer]",
prompts: (sel.prompts ?? "").trim()
});
}
}
// The stage name is shown only on the first row of each group, which reads
// as a merged cell without needing rowspan support (Markdown) or the
// multirow package (LaTeX).
rows.forEach((r, i) => { r.firstInStage = i === 0 || rows[i - 1].stageId !== r.stageId; });
return rows;
}// The declaration as a Word / Google Docs table (rich-text clipboard HTML).
// Also rendered as-is for the example declaration's table view.
function declarationTableHtml(rows, promptRows, aiasLabel) {
const title = "Declaration on the Use of Artificial Intelligence Tools";
const headers = ["Stage", "Task", "AI tool(s)", "How AI was used"];
const escapeHtml = s => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\n/g, "<br>");
// Word ignores width:100% on pasted HTML and sizes columns to their
// content, which can run off the page. Fixed widths in cm, summing to
// 15.9cm (A4 with Word's default 2.54cm margins; also fits US Letter),
// keep the table inside the page, and long text wraps within its cell.
const mainWidths = [2.8, 3.8, 3.0, 6.3];
const promptWidths = [4.5, 11.4];
const cell = (tag, text, widthCm, attrs = "") =>
`<${tag}${attrs} style="border:1px solid #000;padding:4px 6px;vertical-align:top;text-align:left;width:${widthCm}cm;">${text}</${tag}>`;
const htmlTable = (widths, head, bodyRows) =>
`<table style="border-collapse:collapse;table-layout:fixed;width:${widths.reduce((a, b) => a + b, 0).toFixed(1)}cm;">`
+ `<thead><tr>${head.map((h, i) => cell("th", h, widths[i])).join("")}</tr></thead>`
+ `<tbody>${bodyRows.join("")}</tbody></table>`;
const stageSpan = r => rows.filter(o => o.stageId === r.stageId).length;
return [
`<p><strong>${title}</strong></p>`,
aiasLabel ? `<p><strong>AIAS level:</strong> ${escapeHtml(aiasLabel)}</p>` : "",
htmlTable(mainWidths, headers, rows.map(r => "<tr>"
+ (r.firstInStage ? cell("td", escapeHtml(r.stage), mainWidths[0], ` rowspan="${stageSpan(r)}"`) : "")
+ cell("td", escapeHtml(r.task), mainWidths[1])
+ cell("td", escapeHtml(r.tools), mainWidths[2])
+ cell("td", escapeHtml(r.use), mainWidths[3])
+ "</tr>")),
promptRows.length ? `<p></p><p><strong>Prompts used</strong></p>` + htmlTable(promptWidths, ["Task", "Prompt(s)"], promptRows.map(r => "<tr>"
+ cell("td", escapeHtml(r.task), promptWidths[0])
+ cell("td", escapeHtml(r.prompts), promptWidths[1])
+ "</tr>")) : "",
declarationClosingParas.map(t => `<p>${escapeHtml(t)}</p>`).join("")
].join("");
}declarationText = {
const entries = Object.values(selections);
function formatToolWithVersion([name, val]) {
const version = (val && typeof val === "object" && val.version) ? val.version.trim() : "";
return version ? `${name} (${version})` : name;
}
const overallToolNames = [...new Set(entries.flatMap(e =>
Object.entries(e.tools ?? {}).map(formatToolWithVersion)
))].sort((a, b) => a.localeCompare(b));
const overallToolsPart = overallToolNames.length > 0 ? overallToolNames.join(", ") : "[no tools ticked]";
const aiasLine = aiasLevelLabel ? `**AIAS level:** ${aiasLevelLabel}\n` : "";
const heading = `**Declaration on the Use of Artificial Intelligence Tools**\n\n**AI tools used:** ${overallToolsPart}\n${aiasLine}`;
const closing = `\n\n${declarationClosingParas.join("\n\n")}`;
if (entries.length === 0) {
return `${heading}\nNo tasks have been ticked yet.${closing}`;
}
const lines = entries.map(e => {
const labelPart = (e.customLabel && e.customLabel.trim()) ? e.customLabel.trim() : e.label;
const itemToolNames = Object.entries(e.tools ?? {}).map(formatToolWithVersion);
const itemToolsPart = itemToolNames.length > 0 ? itemToolNames.join(", ") : "[no tool specified]";
const toolLabel = itemToolNames.length === 1 ? "Tool" : "Tools";
const sentencePart = e.sentence.trim() || "[please add an explainer]";
const promptsPart = providePrompts && e.prompts && e.prompts.trim() ? `\n Prompt: ${e.prompts.trim()}` : "";
return `- **${labelPart}**\n ${toolLabel}: ${itemToolsPart}\n Summary: ${sentencePart}${promptsPart}`;
});
return `${heading}\nThe following generative AI tasks were delegated as part of this assignment:\n\n${lines.join("\n\n")}${closing}`;
}declarationHtml = {
// A rich-text twin of declarationText, for the "Copy to clipboard" button:
// declarationText's markdown (asterisks, "- " lines) renders correctly in
// markdown-aware editors, but pasted as-is into Word/Docs/Outlook it would
// show the literal asterisks; this HTML version gives those rich-text
// targets a real bold heading and bulleted list instead.
function escapeHtml(s) {
return (s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
const entries = Object.values(selections);
function formatToolWithVersion([name, val]) {
const version = (val && typeof val === "object" && val.version) ? val.version.trim() : "";
return escapeHtml(version ? `${name} (${version})` : name);
}
const overallToolNames = [...new Set(entries.flatMap(e =>
Object.entries(e.tools ?? {}).map(formatToolWithVersion)
))].sort((a, b) => a.localeCompare(b));
const overallToolsPart = overallToolNames.length > 0 ? overallToolNames.join(", ") : "[no tools ticked]";
const aiasLineHtml = aiasLevelLabel ? `<p><strong>AIAS level:</strong> ${escapeHtml(aiasLevelLabel)}</p>` : "";
const headingHtml = `<p><strong>Declaration on the Use of Artificial Intelligence Tools</strong></p><p><strong>AI tools used:</strong> ${overallToolsPart}</p>${aiasLineHtml}`;
const closingHtml = declarationClosingParas.map(t => `<p>${escapeHtml(t)}</p>`).join("");
if (entries.length === 0) {
return `${headingHtml}<p>No tasks have been ticked yet.</p>${closingHtml}`;
}
const items = entries.map(e => {
const labelPart = escapeHtml((e.customLabel && e.customLabel.trim()) ? e.customLabel.trim() : e.label);
const itemToolNames = Object.entries(e.tools ?? {}).map(formatToolWithVersion);
const itemToolsPart = itemToolNames.length > 0 ? itemToolNames.join(", ") : "[no tool specified]";
const toolLabel = itemToolNames.length === 1 ? "Tool" : "Tools";
const sentencePart = escapeHtml(e.sentence.trim() || "[please add an explainer]");
const promptsPart = providePrompts && e.prompts && e.prompts.trim() ? `<br>Prompt: ${escapeHtml(e.prompts.trim())}` : "";
return `<li><strong>${labelPart}</strong><br>${toolLabel}: ${itemToolsPart}<br>Summary: ${sentencePart}${promptsPart}</li>`;
});
return `${headingHtml}<p>The following generative AI tasks were delegated as part of this assignment:</p><ul>${items.join("")}</ul>${closingHtml}`;
}declarationTables = {
// The declaration as a table (one row per ticked task), for pasting into
// Word, LaTeX or Markdown. Only the task list becomes a table; the title,
// AIAS level and closing statements stay as ordinary paragraphs. Prompts,
// when included, go in a second Task | Prompt(s) table so long prompts
// don't stretch the main one.
const rows = declarationTableRows(selections);
const promptRows = providePrompts ? rows.filter(r => r.prompts) : [];
const title = "Declaration on the Use of Artificial Intelligence Tools";
const headers = ["Stage", "Task", "AI tool(s)", "How AI was used"];
// --- Word / Google Docs (rich-text clipboard) ---
const html = declarationTableHtml(rows, promptRows, aiasLevelLabel);
// --- Markdown (pipe table) ---
const escapeMd = s => s.replace(/\|/g, "\\|").replace(/\n/g, "<br>");
const mdRow = cells => `| ${cells.map(escapeMd).join(" | ")} |`;
const mdTable = (head, bodyRows) => [mdRow(head), `|${head.map(() => "---").join("|")}|`, ...bodyRows.map(mdRow)].join("\n");
const markdown = [
`**${title}**`,
aiasLevelLabel ? `**AIAS level:** ${aiasLevelLabel}` : null,
mdTable(headers, rows.map(r => [r.firstInStage ? r.stage : "", r.task, r.tools, r.use])),
promptRows.length ? `**Prompts used**\n\n${mdTable(["Task", "Prompt(s)"], promptRows.map(r => [r.task, r.prompts]))}` : null,
...declarationClosingParas
].filter(Boolean).join("\n\n");
// --- LaTeX ---
// Plain `tabular` with fixed-width p{} columns needs no extra packages, and
// wraps long text inside each cell automatically. \raggedright avoids the
// wide word gaps of justified text in narrow columns; rows end with
// \tabularnewline because \raggedright redefines \\.
const latexSpecial = { "\\": "\\textbackslash{}", "&": "\\&", "%": "\\%", "$": "\\$", "#": "\\#", "_": "\\_", "{": "\\{", "}": "\\}", "~": "\\textasciitilde{}", "^": "\\textasciicircum{}", "|": "\\textbar{}", "<": "\\textless{}", ">": "\\textgreater{}" };
const escapeTex = s => s.replace(/[\\&%$#_{}~^|<>]/g, c => latexSpecial[c]).replace(/\n/g, "\\newline ");
// \hspace{0pt} lets TeX hyphenate a cell's first word (it otherwise never
// does), so long words like "Conceptualisation" don't overflow the column.
const texCell = s => `\\raggedright\\hspace{0pt}${escapeTex(s)}`;
const texRow = cells => `${cells.map(texCell).join(" & ")} \\tabularnewline`;
const texTable = (colSpec, head, bodyLines) => [
"\\begin{center}",
"\\small",
`\\begin{tabular}{${colSpec}}`,
"\\hline",
`${head.map(h => `\\textbf{${h}}`).join(" & ")} \\tabularnewline`,
"\\hline",
...bodyLines,
"\\end{tabular}",
"\\end{center}"
].join("\n");
// Within a stage, a partial rule (columns 2-4) keeps the stage cell looking
// merged; a full \hline marks the start of the next stage.
const texBody = rows.flatMap((r, i) => [
texRow([r.firstInStage ? r.stage : "", r.task, r.tools, r.use]),
(i === rows.length - 1 || rows[i + 1].stageId !== r.stageId) ? "\\hline" : "\\cline{2-4}"
]);
const latex = [
`\\noindent\\textbf{${title}}`,
aiasLevelLabel ? `\\noindent\\textbf{AIAS level:} ${escapeTex(aiasLevelLabel)}` : null,
texTable("|p{0.16\\textwidth}|p{0.2\\textwidth}|p{0.16\\textwidth}|p{0.33\\textwidth}|", headers, texBody),
promptRows.length
? `\\noindent\\textbf{Prompts used}\n\n` + texTable("|p{0.25\\textwidth}|p{0.65\\textwidth}|", ["Task", "Prompt(s)"], promptRows.flatMap(r => [texRow([r.task, r.prompts]), "\\hline"]))
: null,
...declarationClosingParas.map(t => `\\noindent ${escapeTex(t)}`)
].filter(Boolean).join("\n\n");
return { html, markdown, latex };
}// Copies plain text (and, optionally, rich HTML for Word/Docs) to the
// clipboard. Uses a synchronous `copy` event first: it runs inside the click
// itself, which every browser accepts. The async Clipboard API is only a
// fallback, because when it rejects, any retry has already lost the click's
// user activation (Safari especially), leaving the old clipboard untouched.
function copyToClipboard(plain, html) {
const onCopy = e => {
e.clipboardData.setData("text/plain", plain);
if (html) e.clipboardData.setData("text/html", html);
e.preventDefault();
};
document.addEventListener("copy", onCopy);
let ok = false;
try { ok = document.execCommand("copy"); } catch (err) { ok = false; }
document.removeEventListener("copy", onCopy);
if (ok) return;
if (html && typeof ClipboardItem !== "undefined") {
navigator.clipboard.write([new ClipboardItem({
"text/plain": new Blob([plain], { type: "text/plain" }),
"text/html": new Blob([html], { type: "text/html" })
})]).catch(() => navigator.clipboard.writeText(plain));
} else {
navigator.clipboard.writeText(plain);
}
}outputBox = {
const entries = Object.values(selections);
const noTasks = entries.length === 0;
const incompleteEntries = entries.filter(sel =>
!(sel.sentence && sel.sentence.trim())
|| Object.keys(sel.tools ?? {}).length === 0
|| (sel.openEnded && !(sel.customLabel && sel.customLabel.trim()))
);
const incomplete = noTasks || incompleteEntries.length > 0;
const warningContent = noTasks
? "Please tick at least one task below before creating the declaration."
: incompleteEntries.length > 0
? [
"Please add a description and at least one tool before creating the declaration, for:",
el("ul", { class: "gaidet-warning-list" },
incompleteEntries.map(sel => el("li", {},
(sel.customLabel && sel.customLabel.trim()) ? sel.customLabel.trim() : sel.label
))
)
]
: null;
const warning = (incomplete && (copyWarning || declarationConfirmed) && warningContent)
? el("div", { class: "gaidet-warning" }, warningContent)
: null;
const showDeclaration = declarationConfirmed && !incomplete;
const successText = {
download: "Declaration downloaded as a text file. You can open it, paste it into your assignment, and make manual edits if needed.",
word: "Table copied. Paste it into Word or Google Docs and make manual edits if needed.",
latex: "LaTeX table copied. Paste it into your .tex file; it needs no extra packages.",
markdown: "Markdown table copied. Paste it into your document and make manual edits if needed."
}[copySuccess] ?? "Declaration copied to clipboard. You can paste it into your assignment and make manual edits if needed.";
const success = (showDeclaration && copySuccess && declarationText === copiedText)
? el("div", { class: "gaidet-success" }, successText)
: null;
const placeholderText = ["Your declaration will appear here once every ticked task has a tool and a short description.", "It should accurately reflect all uses of generative AI in preparing the assignment.", "If you didn't use AI at all, add: ‘No generative AI tools were used in the preparation of this assignment.’"];
const confirmBox = showConfirmPrompt ? el("div", { class: "gaidet-confirm" }, [
el("p", {}, "Did you disclose all use of generative AI in this assignment?"),
el("div", { class: "gaidet-confirm-actions" }, [
el("button", {
class: "gaidet-btn-primary gaidet-btn-icon",
onclick: () => {
trackGenerateClick();
mutable declarationConfirmed = true;
mutable showConfirmPrompt = false;
mutable copySuccess = false;
}
}, [formatIcon("check"), "Yes, generate declaration"]),
el("button", {
class: "gaidet-btn-secondary gaidet-btn-icon",
onclick: () => { mutable showConfirmPrompt = false; }
}, [formatIcon("edit"), "No, let me edit"])
])
]) : null;
function onCreateClick() {
if (incomplete) {
mutable copyWarning = true;
return;
}
mutable copyWarning = false;
// Every entry is already complete at this point (the `incomplete` check
// above guarantees it), so generating the declaration also marks any
// not-yet-proceeded task as done, turning its box green.
const next = { ...selections };
let anyNewlyProceeded = false;
for (const k of Object.keys(next)) {
if (!next[k].proceeded) {
next[k] = { ...next[k], proceeded: true };
anyNewlyProceeded = true;
}
}
if (anyNewlyProceeded) mutable selections = next;
if (declarationConfirmed) {
mutable copySuccess = false;
mutable justUpdated = true;
setTimeout(() => { mutable justUpdated = false; }, 1800);
} else {
mutable showConfirmPrompt = true;
}
}
const outputDiv = el("div", { class: "gaidet-output", id: "gaidet-output-section" }, [
el("h2", { class: "gaidet-output-title gaidet-step-label" }, "Step 3: Generate your declaration"),
el("div", { class: "gaidet-output-header-actions" }, [
showConfirmPrompt ? null : el("button", {
class: "gaidet-btn-primary",
onclick: onCreateClick
}, declarationConfirmed ? "Update declaration" : "Create declaration")
]),
warning,
confirmBox,
showConfirmPrompt
? null
: showDeclaration
? (() => {
const classes = [justUpdated ? "gaidet-output-flash" : "", outputView === "table" ? "gaidet-table-view" : ""].filter(Boolean).join(" ");
const div = el("div", { id: "gaidet-output-text", class: classes }, []);
div.innerHTML = outputView === "table" ? declarationTables.html : declarationHtml;
return el("div", {}, [
viewSwitch(outputView, (view) => { mutable outputView = view; }),
div
]);
})()
: el("div", { class: "gaidet-output-placeholder" },
Array.isArray(placeholderText)
? placeholderText.map(t => el("p", {}, t))
: [el("p", {}, placeholderText)]
),
// Copy/download sit below the declaration, not above it, so a student
// reads what was generated before acting on it.
(showDeclaration && !showConfirmPrompt) ? el("div", { class: "gaidet-output-actions-secondary" }, [
el("span", { class: "gaidet-output-actions-label" }, "Copy as text:"),
el("button", {
class: "gaidet-btn-secondary gaidet-btn-small gaidet-btn-icon",
onclick: () => {
copyToClipboard(declarationText, declarationHtml);
mutable copySuccess = "copy";
mutable copiedText = declarationText;
}
}, [formatIcon("text"), "Copy to clipboard"]),
el("button", {
class: "gaidet-btn-secondary gaidet-btn-small gaidet-btn-icon",
onclick: () => {
const blob = new Blob([declarationText], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "ai-disclosure-declaration.txt";
a.click();
URL.revokeObjectURL(url);
mutable copySuccess = "download";
mutable copiedText = declarationText;
}
}, [formatIcon("text"), "Download as txt file"])
]) : null,
(showDeclaration && !showConfirmPrompt) ? el("div", { class: "gaidet-output-actions-secondary" }, [
el("span", { class: "gaidet-output-actions-label" }, "Copy as table:"),
el("button", {
class: "gaidet-btn-secondary gaidet-btn-small gaidet-btn-icon",
onclick: () => {
copyToClipboard(declarationTables.markdown, declarationTables.html);
mutable copySuccess = "word";
mutable copiedText = declarationText;
}
}, [formatIcon("table"), "Word"]),
el("button", {
class: "gaidet-btn-secondary gaidet-btn-small gaidet-btn-icon",
onclick: () => {
copyToClipboard(declarationTables.latex);
mutable copySuccess = "latex";
mutable copiedText = declarationText;
}
}, [formatIcon("table"), "LaTeX"]),
el("button", {
class: "gaidet-btn-secondary gaidet-btn-small gaidet-btn-icon",
onclick: () => {
copyToClipboard(declarationTables.markdown);
mutable copySuccess = "markdown";
mutable copiedText = declarationText;
}
}, [formatIcon("table"), "Markdown"])
]) : null,
success,
(showDeclaration && !showConfirmPrompt) ? el("p", { class: "gaidet-output-note" }, [
"It is recommended to review and adjust the declaration to fit your own voice and your course/module's requirements before submitting."
]) : null
]);
// Step 3 stays visibly "off" until Step 2 has at least one ticked task,
// matching the locked look used for unpermitted categories.
return noTasks ? lockBox(outputDiv, "Step 1 and Step 2 need to be completed first before you can generate a declaration.") : outputDiv;
}// Builds a fresh progress bar each call, since the same DOM node can't be
// shown twice. One sits between Step 1 and the (faded) Steps 2-3, outside
// the fade, so it's always readable and says what unlocks the next step; a
// second copy appears between Steps 2 and 3 once Step 2 is available.
makeProgressBar = ({ announce = true } = {}) => {
const entries = Object.values(selections);
const done = [
allowedCategories.size > 0,
entries.length > 0 && entries.every(sel => taskIsComplete(sel.sentence, sel.tools, sel.openEnded, sel.customLabel)),
declarationConfirmed
];
// A step only counts once every step before it is done too.
for (let i = 1; i < done.length; i++) done[i] = done[i] && done[i - 1];
const current = done.indexOf(false);
const message = noGuidance
? "No guidance on AI use means AI is not permitted until your course/module coordinator confirms otherwise."
: [
"Select the categories your assignment allows in Step 1 to continue.",
"Step 1 done. In Step 2, tick every task where you used AI and add a tool and short description for each.",
"Step 2 done. Create your declaration in Step 3.",
][current] ?? "All steps done. Review your declaration before submitting.";
// Steps 2 and 3 in the bar are clickable. Before Step 1 is done, a click
// shows a notice instead; the bar is rebuilt once Step 1 is completed,
// which clears the notice again.
const lockedNotice = el("div", { class: "gaidet-category-gate-warning gaidet-progress-locked", hidden: "" }, []);
const lockedMessages = [
null,
"Step 2 isn’t available yet. Select the categories your assignment allows in Step 1.",
"Step 3 isn’t available yet. Complete Step 1 and then Step 2 before you generate your declaration."
];
const targets = [null, ".gaidet-step2-box", ".gaidet-layout-bottom"];
const goTo = (i) => {
if (!done[0]) {
lockedNotice.textContent = lockedMessages[i];
lockedNotice.hidden = false;
return;
}
document.querySelector(targets[i])?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const labels = ["What is allowed", "What you used", "Your declaration"];
return el("div", { class: "gaidet-progress", role: announce ? "status" : null }, [
el("ol", { class: "gaidet-progress-steps" }, labels.map((label, i) => {
const content = [
el("span", { class: "gaidet-progress-bar" }, []),
el("span", { class: "gaidet-progress-label" }, `${done[i] ? "✓ " : ""}Step ${i + 1}: ${label}`)
];
return el("li", {
class: "gaidet-progress-step" + (done[i] ? " is-done" : i === current ? " is-current" : ""),
"aria-current": i === current ? "step" : null
}, i === 0 ? content : [
el("button", { type: "button", class: "gaidet-progress-link", onclick: () => goTo(i) }, content)
]);
})),
el("p", { class: "gaidet-progress-message" }, message),
lockedNotice
]);
}progressBarAfterStep2 = allowedCategories.size > 0
? makeProgressBar({ announce: false }) // the Step 1 copy already announces changes
: el("div", {}, [])
Save or resume your progress
Nothing is saved automatically. If you want to continue later, click “Save progress” to save a JSON file to your computer, then use “Load progress” to pick it up again on your next visit. This file is saved directly to your device only; nothing is sent to a server.
progressBox = {
const fileInput = el("input", {
type: "file",
accept: "application/json",
style: "display:none;",
onchange: (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const data = JSON.parse(reader.result);
mutable selections = data.selections ?? {};
mutable providePrompts = data.providePrompts ?? false;
mutable aiasLevel = data.aiasLevel ?? "";
} catch (err) {
alert("Could not read this file. Please make sure it's a progress file saved from this tool.");
}
e.target.value = "";
};
reader.readAsText(file);
}
});
return el("div", { class: "gaidet-item" }, [
el("div", { class: "gaidet-output-actions" }, [
el("button", {
type: "button",
class: "gaidet-btn-secondary",
onclick: () => {
const payload = { version: 1, providePrompts, aiasLevel, selections };
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "ai-disclosure-progress.json";
a.click();
URL.revokeObjectURL(url);
}
}, "Save progress"),
el("button", {
type: "button",
class: "gaidet-btn-secondary",
onclick: () => fileInput.click()
}, "Load progress"),
fileInput
])
]);
}
About the maintainer and tool
This tool was built and is maintained by Stefan Müller, with assistance from AI coding agents working under his direction.
I would love to hear how you are using this tool and any suggestions for improving it. Please share your thoughts via this short feedback form.
If you are using this tool in your teaching or research, you can cite it as:
Müller, Stefan. AI Disclosure Generator. https://disclose-ai.app
Information for lecturers
Common questions from lecturers who have tested the tool with their students.
Where do these categories come from?
The categories in Step 1 of the tool are drawn from task categories used in student surveys and existing AI disclosure guidelines. They form a checklist of common use cases, not a recommendation that students use AI for all, or indeed any, of them. Whether AI is appropriate for a given category still depends entirely on your assignment brief and your own instructions.
Step 1 is the crucial filtering step for students: it should reflect only what your assignment or course/module actually permits, not everything AI happens to be capable of. Students who are unsure whether a category applies are told to leave it unticked and ask you rather than guess.
How can I phrase permitted AI use in my assignment brief?
Some lecturers find it useful to name the same categories in their assignment brief as the ones in Step 1, rather than describing permitted and prohibited AI use in separate wording. Students can then tick exactly what has been permitted without having to interpret how a general policy applies to their situation.
One way this can look in an assignment brief:
For this assignment, you may use AI for Conceptualisation and Initial literature screening, for instance to brainstorm ideas or locate relevant sources. AI must not be used for Data management, Coding and automation, Writing and editing, or Audiovisual and interactive components, as these should be your own unassisted work. Anything else can be recorded under Additional tasks. Please use the AI Disclosure Generator to record your AI use and attach the generated declaration to your assignment submission: https://disclose-ai.app.
What if my course/module has not settled on an AI policy yet?
A student who ticks “no guidance” in Step 1 is told to treat that as AI not being permitted, rather than as permission by default, until you or the course/module coordinator confirms otherwise. Settling on even a brief policy, using the wording above, removes that ambiguity for your students.
Where can I find related tools, literature, and advice?
A number of similar tools exist to support transparent AI-use disclosure, including the AID Framework, the GAIDeT Declaration Generator, and sIfA (Statement of Intellectual Fellowship and Accountability), a browser-based tool from Busara that maps AI contributions onto the 14 CRediT contributor roles and is aimed more at researchers documenting a publication than at student coursework. All aim at the same goal: helping students and researchers disclose their AI use clearly and consistently. Use whichever fits your context best.
The optional “AIAS level” field in the tool uses the AI Assessment Scale, a complementary framework instructors use to specify how much AI use is permitted on a given assessment; this tool only lets you record that level, it doesn’t set or enforce it.
This tool’s approach is comparable to the American Journal of Political Science’s AI Policy, which asks authors to describe how AI was used rather than reproduce full transcripts. If you’re an instructor who wants students to submit the entire output of their AI conversations, not just a summary and the prompts used, ask for that separately.
Further reading on AI-use disclosure:
- Suchikova, Yana, Tsybuliak, Natalia, Teixeira da Silva, Jaime A., & Nazarovets, Serhii (2026). “GAIDeT (Generative AI Delegation Taxonomy): A Taxonomy for Humans to Delegate Tasks to Generative Artificial Intelligence in Scientific Research and Publishing“. Accountability in Research, 33(3). 10.1080/08989621.2025.2544331
- Weaver, Kari D. (2024). “The Artificial Intelligence Disclosure (AID) Framework: An Introduction“. College & Research Libraries News, 85(10), 407–411. arxiv.org/abs/2408.01904
- Perkins, Mike, Furze, Leon, Roe, Jasper, & MacVaugh, Jason (2024). “The AI Assessment Scale (AIAS)“. aiassessmentscale.com
- American Journal of Political Science (2026). “AJPS AI Policy“. Updated 11 August 2026. ajps.org/ajps-ai-policy
- Saleh, Engy, & Schomerus, Mareike (2026). “The sIfA Tool for a Statement of Intellectual Fellowship and Accountability: An Invitation to Reflect on How Humans and AI Interact in Producing Knowledge“. Busara. 10.62372/IUPB2137
- Schomerus, Mareike. sIfA Tool (Tool to create a Statement of Intellectual Fellowship and Accountability). Version 1.4. 2026. Busara. 10.5281/zenodo.20285993
Disclaimer of responsibility
This tool is provided purely as a free, unofficial aid to help you structure your own AI-use disclosure. It is not legal, academic, or official institutional guidance, and using it does not constitute compliance with any university, School, programme, or course/module policy.
The author, Stefan Müller, and any contributors accept no responsibility or liability whatsoever for how this tool is used, for whether the tasks and tools listed by the user constitute a complete account of all generative AI use in connection with the relevant work, or for whether such use is declared honestly and in good faith. Nor do they accept liability for any academic, disciplinary, legal, or other consequences arising from the tool’s use or misuse.
Many universities do not have a single institution-wide policy on generative AI. Permission varies by Department, School, programme, and even individual course/module or assessment, and some institutions limit students to a specific list of approved AI tools. You are solely responsible for checking your own university’s and course/module’s generative AI policies and citation requirements (for example in your course/module descriptor and Learning Management System (LMS) course site), and for confirming directly with your lecturer or course/module coordinator whether, and how, AI tools may be used before you submit any work. This includes complying with any separate declaration of authorship or originality statement you must sign.
Disclosing a task or AI tool in a declaration generated here does not constitute permission to use it, nor a means of circumventing any prohibition imposed by your institution, School, programme, course/module, or assessment brief. The author cannot be held legally accountable for any reliance placed on this tool.
This tool is an independent personal project of the author and is not an official service of, or endorsed by, University College Dublin or any other institution.
The AI tools listed or suggested in this tool are trademarks of their respective owners. Their appearance here, including in the autocomplete suggestions, is for identification purposes only and does not imply any affiliation with, or endorsement of, this tool by those companies, nor endorsement of those tools by the author.
See the privacy policy for full details on what data this tool does, and does not, collect.