@ivanbard/dd2l 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +122 -3
  3. package/THIRD_PARTY_NOTICES.md +50 -0
  4. package/bin/dd2l.mjs +7 -0
  5. package/docs/README.md +227 -0
  6. package/package.json +56 -6
  7. package/src/app-runner.mjs +58 -0
  8. package/src/assignment-workspace.mjs +128 -0
  9. package/src/canvas.mjs +17 -0
  10. package/src/cli-banner.mjs +58 -0
  11. package/src/cli.mjs +142 -0
  12. package/src/client.mjs +150 -0
  13. package/src/connector.mjs +587 -0
  14. package/src/discovery.mjs +45 -0
  15. package/src/documents.mjs +154 -0
  16. package/src/links.mjs +99 -0
  17. package/src/llm.mjs +243 -0
  18. package/src/media.mjs +54 -0
  19. package/src/ocr.mjs +24 -0
  20. package/src/office.mjs +61 -0
  21. package/src/pdf-worker.mjs +26 -0
  22. package/src/research.mjs +70 -0
  23. package/src/session.mjs +142 -0
  24. package/src/store.mjs +108 -0
  25. package/src/syllabus.mjs +191 -0
  26. package/src/task-workspace.mjs +138 -0
  27. package/src/ui-data.mjs +123 -0
  28. package/src/ui-demo.mjs +36 -0
  29. package/src/ui-server.mjs +161 -0
  30. package/src/windows-ocr.ps1 +32 -0
  31. package/ui/answer-renderer.js +88 -0
  32. package/ui/answer-view.js +47 -0
  33. package/ui/app.js +385 -0
  34. package/ui/assets/LUCIDE-LICENSE.txt +15 -0
  35. package/ui/assets/avatar.svg +3 -0
  36. package/ui/assets/chevron.svg +5 -0
  37. package/ui/assets/courses.svg +14 -0
  38. package/ui/assets/logo.svg +13 -0
  39. package/ui/assets/new-chat.svg +14 -0
  40. package/ui/assets/search.svg +14 -0
  41. package/ui/assets/send.svg +8 -0
  42. package/ui/assets/settings.svg +14 -0
  43. package/ui/assets/widget-announcements.svg +4 -0
  44. package/ui/assets/widget-shortcuts.svg +5 -0
  45. package/ui/assignment-workspace.js +106 -0
  46. package/ui/canvas-contract.js +41 -0
  47. package/ui/canvas-runtime.js +158 -0
  48. package/ui/canvas.js +96 -0
  49. package/ui/index.html +84 -0
  50. package/ui/loading.js +33 -0
  51. package/ui/style.css +241 -0
  52. package/ui/widget-layout.js +39 -0
  53. package/ui/widgets.js +252 -0
@@ -0,0 +1,41 @@
1
+ export const canvasSchema = {
2
+ type: 'object', additionalProperties: false,
3
+ properties: {
4
+ title: { type: 'string', maxLength: 160 },
5
+ answer: { type: 'string', maxLength: 6000 },
6
+ html: { type: 'string', maxLength: 60_000 },
7
+ css: { type: 'string', maxLength: 20_000 },
8
+ js: { type: 'string', maxLength: 60_000 },
9
+ },
10
+ required: ['title', 'answer', 'html', 'css', 'js'],
11
+ };
12
+
13
+ export function validCanvas(value) {
14
+ const fields = { title: 160, answer: 6000, html: 60_000, css: 20_000, js: 60_000 };
15
+ return !!value && typeof value === 'object' && !Array.isArray(value) &&
16
+ Object.keys(value).length === 5 && Object.entries(fields).every(([key, size]) =>
17
+ typeof value[key] === 'string' && value[key].length <= size) && !!value.title.trim() && !!value.html.trim();
18
+ }
19
+
20
+ export function validCanvasState(value) {
21
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
22
+ const queue = [[value, 0]];
23
+ let nodes = 0;
24
+ while (queue.length) {
25
+ const [item, depth] = queue.pop();
26
+ if (++nodes > 4000 || depth > 12) return false;
27
+ if (item === null || typeof item === 'boolean' || typeof item === 'string') continue;
28
+ if (typeof item === 'number') { if (!Number.isFinite(item)) return false; continue; }
29
+ if (typeof item !== 'object') return false;
30
+ if (Object.keys(item).some(key => ['__proto__', 'constructor', 'prototype'].includes(key))) return false;
31
+ for (const child of Object.values(item)) queue.push([child, depth + 1]);
32
+ }
33
+ try { return JSON.stringify(value).length <= 32_000; } catch { return false; }
34
+ }
35
+
36
+ export function validCanvasContext(value) {
37
+ return value === null || (!!value && typeof value === 'object' && !Array.isArray(value) &&
38
+ Object.keys(value).every(key => ['program', 'state', 'error'].includes(key)) &&
39
+ (value.error === undefined || (typeof value.error === 'string' && value.error.length <= 600)) &&
40
+ validCanvas(value.program) && validCanvasState(value.state));
41
+ }
@@ -0,0 +1,158 @@
1
+ // This function is embedded by the server in a dedicated opaque-origin frame.
2
+ export function canvasRuntime(validState) {
3
+ 'use strict';
4
+ function workerRuntime() {
5
+ let run, state, sources, initial;
6
+ self.onmessage = ({ data }) => {
7
+ try {
8
+ if (data.type === 'init') {
9
+ state = data.state;
10
+ sources = data.sources;
11
+ initial = data.html;
12
+ run = new Function('state', 'event', 'sources', `"use strict";\n${data.js}`);
13
+ }
14
+ const event = data.event || { type: 'init', action: '', target: {}, values: {} };
15
+ const inputs = { ...state.inputs, ...(event.type === 'init' ? {} : event.values) };
16
+ state.inputs = inputs;
17
+ const result = run(state, event, sources) || {};
18
+ state = result.state || state;
19
+ state.inputs = { ...inputs, ...state.inputs };
20
+ initial = result.html ?? initial;
21
+ self.postMessage({ type: 'render', html: initial, state });
22
+ } catch (error) { self.postMessage({ type: 'error', message: String(error.message).slice(0, 500) }); }
23
+ };
24
+ }
25
+
26
+ const root = document.getElementById('canvas');
27
+ const style = document.getElementById('generated-style');
28
+ const tags = new Set('a article aside b blockquote br button caption circle code col colgroup dd defs details div dl dt ellipse em fieldset figcaption figure footer form g h1 h2 h3 h4 h5 h6 header hr i input label legend li line lineargradient main marker nav ol optgroup option p path polygon polyline pre progress rect section select small span stop strong sub summary sup svg table tbody td text textarea th thead time tr tspan ul'.split(' '));
29
+ const attributes = new Set('id class title role style name type value checked selected disabled readonly required min max step minlength maxlength placeholder rows cols multiple for open colspan rowspan scope datetime tabindex viewbox d x y x1 x2 y1 y2 cx cy r rx ry width height points fill stroke stroke-width stroke-linecap stroke-linejoin transform opacity offset stop-color marker-end preserveaspectratio'.split(' '));
30
+ let port, worker, timer, pending = 0, state = {}, snapshotRequests = [], active = false, rendered = false, currentHtml = '';
31
+ const send = data => port?.postMessage(data);
32
+
33
+ function sanitize(html) {
34
+ const template = document.createElement('template');
35
+ template.innerHTML = html;
36
+ const visit = parent => {
37
+ for (const node of [...parent.children]) {
38
+ if (!tags.has(node.localName.toLowerCase()) || !['http://www.w3.org/1999/xhtml', 'http://www.w3.org/2000/svg'].includes(node.namespaceURI)) { node.remove(); continue; }
39
+ for (const attribute of [...node.attributes]) {
40
+ const name = attribute.name.toLowerCase();
41
+ if (!attributes.has(name) && !name.startsWith('aria-') && !['data-action', 'data-source'].includes(name)) node.removeAttribute(attribute.name);
42
+ }
43
+ if (node.localName === 'input' && !['text', 'number', 'range', 'checkbox', 'radio', 'search', 'email', 'time', 'date', 'color', 'button', 'submit'].includes(node.type)) node.type = 'text';
44
+ if (['input', 'textarea', 'select', 'form'].includes(node.localName)) node.setAttribute('autocomplete', 'off');
45
+ if (node.localName === 'textarea' || (node.localName === 'input' && ['text', 'search', 'email'].includes(node.type))) {
46
+ node.maxLength = node.maxLength > 0 ? Math.min(4000, node.maxLength) : 4000;
47
+ }
48
+ visit(node);
49
+ }
50
+ };
51
+ visit(template.content);
52
+ return template.content;
53
+ }
54
+
55
+ function controls() {
56
+ const values = {};
57
+ for (const field of root.querySelectorAll('input,textarea,select')) {
58
+ const key = field.name || field.id;
59
+ if (!key || ['__proto__', 'constructor', 'prototype'].includes(key)) continue;
60
+ if (field.type === 'radio' && !field.checked && values[key]?.checked) continue;
61
+ values[key] = { value: field.value.slice(0, 4000), checked: !!field.checked };
62
+ if (field.localName === 'select' && field.multiple) values[key].values = [...field.selectedOptions].map(option => option.value);
63
+ }
64
+ return values;
65
+ }
66
+
67
+ function restore() {
68
+ for (const field of root.querySelectorAll('input,textarea,select')) {
69
+ const saved = state.inputs?.[field.name || field.id];
70
+ if (!saved) continue;
71
+ if (field.type === 'radio') { field.checked = !!saved.checked && field.value === saved.value; continue; }
72
+ if (field.localName === 'select' && field.multiple && Array.isArray(saved.values)) {
73
+ for (const option of field.options) option.selected = saved.values.includes(option.value);
74
+ continue;
75
+ }
76
+ field.value = saved.value;
77
+ if (['checkbox', 'radio'].includes(field.type)) field.checked = saved.checked;
78
+ }
79
+ }
80
+
81
+ function snapshots() {
82
+ if (!pending) for (const id of snapshotRequests.splice(0)) send({ type: 'snapshot', id, state });
83
+ }
84
+
85
+ function stop(message) {
86
+ worker?.terminate();
87
+ worker = null;
88
+ clearTimeout(timer);
89
+ pending = 0;
90
+ if (!rendered) root.textContent = 'The workspace could not run. Open Sources or describe what to fix.';
91
+ send({ type: 'error', message });
92
+ snapshots();
93
+ }
94
+
95
+ function dispatch(data) {
96
+ if (!worker) return;
97
+ if (++pending > 100) { stop('Too many workspace updates. Restart the workspace to continue.'); return; }
98
+ if (!timer) timer = setTimeout(() => stop('The generated workspace took too long to respond. Your last saved progress is retained.'), 2000);
99
+ worker.postMessage(data);
100
+ }
101
+
102
+ addEventListener('message', event => {
103
+ if (active || event.source !== parent || !event.ports[0] || event.data?.type !== 'canvas-init') return;
104
+ active = true;
105
+ port = event.ports[0];
106
+ state = validState(event.data.state) ? event.data.state : {};
107
+ style.textContent = event.data.program.css;
108
+ root.textContent = 'Opening workspace…';
109
+ port.onmessage = ({ data }) => {
110
+ if (data.type === 'snapshot') { snapshotRequests.push(data.id); snapshots(); }
111
+ };
112
+ const url = URL.createObjectURL(new Blob([`(${workerRuntime.toString()})()`], { type: 'text/javascript' }));
113
+ worker = new Worker(url);
114
+ URL.revokeObjectURL(url);
115
+ worker.onmessage = ({ data }) => {
116
+ if (data.type === 'error') { stop(`Workspace error: ${data.message}`); return; }
117
+ if (data.type !== 'render' || typeof data.html !== 'string' || data.html.length > 120_000 || !validState(data.state)) {
118
+ stop('The workspace returned an invalid update. Your last saved progress is retained.'); return;
119
+ }
120
+ const focused = document.activeElement;
121
+ const focusKey = focused?.name || focused?.id;
122
+ const selection = [focused?.selectionStart, focused?.selectionEnd];
123
+ state = data.state;
124
+ pending = Math.max(0, pending - 1);
125
+ // Older worker results must not replace controls while newer input is queued.
126
+ if (!pending) {
127
+ if (data.html !== currentHtml) { root.replaceChildren(sanitize(data.html)); currentHtml = data.html; }
128
+ if (!rendered) requestAnimationFrame(() => requestAnimationFrame(() => send({ type: 'ready' })));
129
+ rendered = true;
130
+ restore();
131
+ }
132
+ if (!pending && focusKey) {
133
+ const field = [...root.querySelectorAll('input,textarea,select,button')].find(item => (item.name || item.id) === focusKey);
134
+ field?.focus({ preventScroll: true });
135
+ try { if (selection[0] != null) field?.setSelectionRange(...selection); } catch {}
136
+ }
137
+ clearTimeout(timer); timer = null;
138
+ if (pending) timer = setTimeout(() => stop('The workspace stopped responding. Your last saved progress is retained.'), 2000);
139
+ send({ type: 'state', state });
140
+ snapshots();
141
+ };
142
+ worker.onerror = () => stop('The generated workspace could not run. Your sources remain available.');
143
+ dispatch({ type: 'init', ...event.data.program, state, sources: event.data.sources });
144
+ new ResizeObserver(() => send({ type: 'height', height: Math.min(1600, Math.max(360, document.documentElement.scrollHeight)) })).observe(root);
145
+ });
146
+
147
+ for (const type of ['click', 'input', 'change', 'submit']) root.addEventListener(type, event => {
148
+ if (type === 'submit') event.preventDefault();
149
+ if (!event.isTrusted && !['input', 'change'].includes(type)) return;
150
+ const source = event.target.closest('[data-source]');
151
+ if (type === 'click' && source) { event.preventDefault(); send({ type: 'source', id: source.dataset.source }); return; }
152
+ const target = event.target;
153
+ const action = target.closest('[data-action]')?.dataset.action || '';
154
+ if (type === 'click' && !action) return;
155
+ dispatch({ type: 'event', event: { type, action: action.slice(0, 160),
156
+ target: { id: target.id, name: target.name || '', value: String(target.value || '').slice(0, 4000), checked: !!target.checked }, values: controls() } });
157
+ });
158
+ }
package/ui/canvas.js ADDED
@@ -0,0 +1,96 @@
1
+ import { validCanvas, validCanvasState } from './canvas-contract.js';
2
+
3
+ export function createCanvas(target, report) {
4
+ let program, state = {}, frame, port, requestId = 0, generation = 0, lastError = '';
5
+ const snapshots = new Map();
6
+ const inspector = document.getElementById('canvas-source');
7
+ let sourceRequest;
8
+
9
+ function clear() {
10
+ generation++;
11
+ sourceRequest?.abort();
12
+ port?.close(); port = null;
13
+ for (const { reject, timer } of snapshots.values()) { clearTimeout(timer); reject(new Error('Workspace changed. Please send your request again.')); }
14
+ snapshots.clear();
15
+ frame?.remove(); frame = null;
16
+ target.hidden = true;
17
+ inspector.hidden = true;
18
+ inspector.replaceChildren();
19
+ program = undefined; state = {}; lastError = '';
20
+ }
21
+
22
+ async function inspect(id, sources, current) {
23
+ if (!sources.some(source => source.id === id)) return;
24
+ sourceRequest?.abort();
25
+ const controller = sourceRequest = new AbortController();
26
+ try {
27
+ const response = await fetch(`/api/source?${new URLSearchParams({ id })}`, { signal: controller.signal });
28
+ const source = await response.json();
29
+ if (!response.ok) throw new Error(source.error || 'This source is no longer available.');
30
+ if (generation !== current || sourceRequest !== controller) return;
31
+ const title = document.createElement('h3'); title.textContent = source.title; title.tabIndex = -1;
32
+ const status = document.createElement('p'); status.className = 'source-meta';
33
+ status.textContent = `${source.courseName} · ${source.stale ? 'Stale cache' : 'Cached'} · ${source.lastSuccess || 'Retrieval date unknown'}`;
34
+ const text = document.createElement('p'); text.className = 'assignment-text'; text.textContent = source.text;
35
+ const link = document.createElement('a'); link.textContent = 'Open in Brightspace ↗';
36
+ link.href = source.url; link.target = '_blank'; link.rel = 'noopener noreferrer';
37
+ inspector.replaceChildren(title, status, text, link); inspector.hidden = false; title.focus();
38
+ } catch (error) { if (error.name !== 'AbortError' && generation === current) report(error.message); }
39
+ }
40
+
41
+ function render(next, sources, savedState = state) {
42
+ if (!validCanvas(next) || !validCanvasState(savedState)) throw new Error('The generated workspace could not be verified.');
43
+ clear();
44
+ program = next; state = savedState;
45
+ const current = generation;
46
+ frame = document.createElement('iframe');
47
+ frame.title = next.title;
48
+ frame.className = 'generated-canvas';
49
+ frame.dataset.ready = 'false';
50
+ frame.inert = true;
51
+ frame.setAttribute('sandbox', 'allow-scripts');
52
+ frame.setAttribute('referrerpolicy', 'no-referrer');
53
+ frame.setAttribute('allow', "camera 'none'; microphone 'none'; geolocation 'none'; clipboard-read 'none'; clipboard-write 'none'");
54
+ frame.addEventListener('load', () => {
55
+ const channel = new MessageChannel();
56
+ port = channel.port1;
57
+ port.onmessage = ({ data }) => {
58
+ if (generation !== current || !data || typeof data !== 'object') return;
59
+ if (['state', 'snapshot'].includes(data.type) && validCanvasState(data.state)) {
60
+ state = data.state;
61
+ if (data.type === 'snapshot' && snapshots.has(data.id)) {
62
+ const pending = snapshots.get(data.id); snapshots.delete(data.id); clearTimeout(pending.timer);
63
+ pending.resolve({ program, state: structuredClone(state), error: lastError });
64
+ }
65
+ } else if (data.type === 'ready') {
66
+ frame.inert = false;
67
+ frame.focus({ preventScroll: true });
68
+ frame.dataset.ready = 'true';
69
+ }
70
+ else if (data.type === 'height' && Number.isFinite(data.height)) frame.style.height = `${Math.min(1600, Math.max(360, data.height))}px`;
71
+ else if (data.type === 'error' && typeof data.message === 'string') {
72
+ frame.inert = true;
73
+ lastError = data.message.slice(0, 600); report(lastError);
74
+ }
75
+ else if (data.type === 'source' && typeof data.id === 'string') inspect(data.id, sources, current);
76
+ };
77
+ frame.contentWindow.postMessage({ type: 'canvas-init', program,
78
+ state, sources: sources.map(({ id, title, courseName, category, excerpt, dueDate, stale }) => ({ id, title, courseName, category, text: excerpt, dueDate, stale })) }, '*', [channel.port2]);
79
+ }, { once: true });
80
+ frame.src = '/canvas-frame';
81
+ target.replaceChildren(frame); target.hidden = false;
82
+ }
83
+
84
+ function snapshot() {
85
+ if (!program) return Promise.resolve(null);
86
+ if (!port) return Promise.reject(new Error('The workspace is still loading. Please try again.'));
87
+ const id = ++requestId;
88
+ return new Promise((resolve, reject) => {
89
+ const timer = setTimeout(() => { snapshots.delete(id); reject(new Error('The workspace is still updating. Please try again.')); }, 3000);
90
+ snapshots.set(id, { resolve, reject, timer });
91
+ port.postMessage({ type: 'snapshot', id });
92
+ });
93
+ }
94
+
95
+ return { render, clear, snapshot };
96
+ }
package/ui/index.html ADDED
@@ -0,0 +1,84 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="dark">
7
+ <title>dd2l · Your coursework</title>
8
+ <link rel="icon" href="/assets/logo.svg" type="image/svg+xml">
9
+ <link rel="stylesheet" href="/style.css">
10
+ <script type="module" src="/app.js"></script>
11
+ </head>
12
+ <body>
13
+ <a class="skip-link" href="#prompt">Skip to search</a>
14
+ <aside class="sidebar" aria-label="Main navigation">
15
+ <a class="brand" href="/" aria-label="dd2l home"><img src="/assets/logo.svg" alt="" width="30" height="30"></a>
16
+ <nav>
17
+ <button class="icon-button" id="new-chat" type="button" title="New search" aria-label="New search"><img src="/assets/new-chat.svg" alt="" width="16" height="16"></button>
18
+ <button class="icon-button" id="search-nav" type="button" title="Search coursework" aria-label="Search coursework"><img src="/assets/search.svg" alt="" width="16" height="16"></button>
19
+ <button class="icon-button" id="courses-nav" type="button" title="Courses" aria-label="Courses"><img src="/assets/courses.svg" alt="" width="16" height="16"></button>
20
+ </nav>
21
+ <div class="utilities">
22
+ <button class="icon-button" id="settings-nav" type="button" title="Connection status" aria-label="Connection status"><img src="/assets/settings.svg" alt="" width="16" height="16"></button>
23
+ <button class="profile" id="profile" type="button" title="Local profile" aria-label="Local profile"><img src="/assets/avatar.svg" alt="" width="40" height="40"><span>U</span></button>
24
+ </div>
25
+ </aside>
26
+ <span id="demo-badge" class="demo-badge" hidden>Demo · sample coursework</span>
27
+ <main id="workspace" class="workspace">
28
+ <section class="home" aria-labelledby="heading">
29
+ <h1 id="heading">What do you want to work on?</h1>
30
+ <form id="search-form" class="composer" role="search">
31
+ <div class="prompt-field">
32
+ <label class="sr-only" for="prompt">Search your cached coursework</label>
33
+ <input id="prompt" name="q" type="search" maxlength="500" autocomplete="off" placeholder="Describe your task or goal…" aria-describedby="search-help">
34
+ <button id="send" type="submit" disabled aria-label="Search"><img src="/assets/send.svg" alt="" width="32" height="32"><span id="busy" hidden></span></button>
35
+ </div>
36
+ <div class="composer-footer">
37
+ <label class="sr-only" for="course">Course</label>
38
+ <select id="course" name="course"><option value="">All courses</option></select>
39
+ <span id="course-loading" class="loading-slot"></span>
40
+ <label class="sr-only" for="model">Search mode or model</label>
41
+ <select id="model" name="model" aria-describedby="search-help"><option value="">Local search</option></select>
42
+ <span id="model-loading" class="loading-slot"></span>
43
+ </div>
44
+ <span id="search-help" class="sr-only">Select a ChatGPT model for an adaptive workspace around any coursework goal. Local search uses keywords and assignment matching.</span>
45
+ </form>
46
+ <p id="model-sharing" class="model-sharing" hidden>Adaptive workspace · The model can request read-only Brightspace discovery. Sending shares your goal, workspace progress, course previews and retrieved excerpts with OpenAI.</p>
47
+ <p id="adaptive-help" class="model-sharing">For adaptive workspaces, connect ChatGPT in Settings and select a model. Local search uses keyword matching.</p>
48
+ <p id="notice" class="notice" role="status" hidden></p>
49
+ </section>
50
+ <section id="home-widgets" class="home-widgets" aria-labelledby="widgets-heading">
51
+ <div class="widgets-heading"><div><p class="eyebrow">YOUR SPACE</p><h2 id="widgets-heading">At a glance <span id="widget-loading" class="loading-slot"></span></h2></div>
52
+ <div class="widget-actions"><button id="refresh-widgets" type="button">Refresh cache view</button><button id="add-widget" type="button" hidden>+ Add widget</button><button id="customize-widgets" type="button" aria-pressed="false">Customize</button></div>
53
+ </div>
54
+ <p id="widget-help" class="widget-help">From your cached coursework.</p>
55
+ <div id="widget-grid" class="widget-grid"></div>
56
+ <p id="widget-status" class="widget-status" role="status"></p>
57
+ </section>
58
+ <section id="canvas-workspace" class="results canvas-workspace" aria-labelledby="canvas-title" hidden>
59
+ <header class="canvas-heading"><h2 id="canvas-title" tabindex="-1">Your workspace</h2><div class="widget-actions">
60
+ <button id="canvas-sources" type="button" aria-expanded="false" aria-controls="assignment-workspace">Sources</button>
61
+ <button id="canvas-restart" type="button">Restart progress</button>
62
+ </div></header>
63
+ <p class="retrieval-note">AI-generated workspace · Verify requirements against the linked coursework. Progress is kept for this visit.</p>
64
+ <div id="canvas-host" hidden></div>
65
+ <aside id="canvas-source" class="canvas-source" aria-label="Verified coursework source" hidden></aside>
66
+ </section>
67
+ <section id="ai-answer" class="results" aria-label="AI answer" hidden>
68
+ <h2 id="ai-answer-title" tabindex="-1">Your answer</h2><p id="answer-text"></p>
69
+ <div id="answer-view" class="answer-view" hidden></div>
70
+ <h3 class="answer-sources-heading">Sources</h3><div id="answer-sources"></div>
71
+ </section>
72
+ <section id="assignment-workspace" class="results assignment-workspace" aria-labelledby="assignment-heading" hidden></section>
73
+ <section id="results" class="results" aria-label="Coursework results" hidden>
74
+ <div class="results-heading"><h2 id="results-title" tabindex="-1">Sources</h2><span id="result-count"></span></div>
75
+ <p class="retrieval-note">Matching excerpts from your local inventory. These are source materials, not an AI-generated answer.</p>
76
+ <div id="result-list"></div>
77
+ </section>
78
+ </main>
79
+ <dialog id="panel" aria-labelledby="panel-title">
80
+ <header><h2 id="panel-title"></h2><span id="panel-loading" class="loading-slot"></span><button id="close-panel" type="button" aria-label="Close panel">×</button></header>
81
+ <div id="panel-body"></div>
82
+ </dialog>
83
+ </body>
84
+ </html>
package/ui/loading.js ADDED
@@ -0,0 +1,33 @@
1
+ // Square 12 / Origin Wave, adapted for dd2l from zzzzshawn/matrix. See THIRD_PARTY_NOTICES.md.
2
+ const active = new WeakMap();
3
+
4
+ export function startLoading(target, label = 'Loading') {
5
+ let state = active.get(target);
6
+ if (!state) {
7
+ const loader = document.createElement('span');
8
+ loader.className = 'dot-loader';
9
+ loader.setAttribute('role', 'status');
10
+ loader.setAttribute('aria-label', label);
11
+ for (let row = 0; row < 5; row++) for (let col = 0; col < 5; col++) {
12
+ const dot = document.createElement('span');
13
+ dot.setAttribute('aria-hidden', 'true');
14
+ dot.style.setProperty('--ring', Math.abs(row - 1) + Math.abs(col - 1));
15
+ loader.append(dot);
16
+ }
17
+ state = { loader, count: 0, previousBusy: target.getAttribute('aria-busy') };
18
+ active.set(target, state);
19
+ target.append(loader);
20
+ target.setAttribute('aria-busy', 'true');
21
+ }
22
+ state.count++;
23
+ let finished = false;
24
+ return () => {
25
+ if (finished) return;
26
+ finished = true;
27
+ if (--state.count) return;
28
+ state.loader.remove();
29
+ if (state.previousBusy === null) target.removeAttribute('aria-busy');
30
+ else target.setAttribute('aria-busy', state.previousBusy);
31
+ active.delete(target);
32
+ };
33
+ }