@ivanbard/dd2l 0.1.0 → 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.
- package/LICENSE +21 -21
- package/README.md +47 -8
- package/bin/dd2l.mjs +7 -7
- package/docs/README.md +108 -11
- package/package.json +3 -2
- package/src/app-runner.mjs +58 -35
- package/src/assignment-workspace.mjs +128 -0
- package/src/canvas.mjs +17 -0
- package/src/cli-banner.mjs +58 -0
- package/src/cli.mjs +41 -19
- package/src/client.mjs +150 -137
- package/src/connector.mjs +587 -345
- package/src/discovery.mjs +45 -45
- package/src/documents.mjs +154 -154
- package/src/links.mjs +99 -99
- package/src/llm.mjs +43 -6
- package/src/media.mjs +54 -54
- package/src/ocr.mjs +24 -24
- package/src/office.mjs +61 -61
- package/src/pdf-worker.mjs +26 -26
- package/src/research.mjs +70 -0
- package/src/session.mjs +5 -5
- package/src/store.mjs +108 -106
- package/src/syllabus.mjs +191 -191
- package/src/task-workspace.mjs +138 -0
- package/src/ui-data.mjs +36 -14
- package/src/ui-demo.mjs +17 -1
- package/src/ui-server.mjs +51 -15
- package/src/windows-ocr.ps1 +32 -32
- package/ui/answer-renderer.js +88 -88
- package/ui/answer-view.js +47 -47
- package/ui/app.js +96 -13
- package/ui/assets/widget-announcements.svg +4 -4
- package/ui/assets/widget-shortcuts.svg +5 -5
- package/ui/assignment-workspace.js +106 -0
- package/ui/canvas-contract.js +41 -0
- package/ui/canvas-runtime.js +158 -0
- package/ui/canvas.js +96 -0
- package/ui/index.html +15 -4
- package/ui/loading.js +33 -33
- package/ui/style.css +15 -0
- package/ui/widget-layout.js +39 -39
- package/ui/widgets.js +252 -252
|
@@ -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
CHANGED
|
@@ -26,11 +26,11 @@
|
|
|
26
26
|
<span id="demo-badge" class="demo-badge" hidden>Demo · sample coursework</span>
|
|
27
27
|
<main id="workspace" class="workspace">
|
|
28
28
|
<section class="home" aria-labelledby="heading">
|
|
29
|
-
<h1 id="heading">What
|
|
29
|
+
<h1 id="heading">What do you want to work on?</h1>
|
|
30
30
|
<form id="search-form" class="composer" role="search">
|
|
31
31
|
<div class="prompt-field">
|
|
32
32
|
<label class="sr-only" for="prompt">Search your cached coursework</label>
|
|
33
|
-
<input id="prompt" name="q" type="search" maxlength="500" autocomplete="off" aria-describedby="search-help">
|
|
33
|
+
<input id="prompt" name="q" type="search" maxlength="500" autocomplete="off" placeholder="Describe your task or goal…" aria-describedby="search-help">
|
|
34
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
35
|
</div>
|
|
36
36
|
<div class="composer-footer">
|
|
@@ -41,9 +41,10 @@
|
|
|
41
41
|
<select id="model" name="model" aria-describedby="search-help"><option value="">Local search</option></select>
|
|
42
42
|
<span id="model-loading" class="loading-slot"></span>
|
|
43
43
|
</div>
|
|
44
|
-
<span id="search-help" class="sr-only">
|
|
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
45
|
</form>
|
|
46
|
-
<p id="model-sharing" class="model-sharing" hidden>
|
|
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>
|
|
47
48
|
<p id="notice" class="notice" role="status" hidden></p>
|
|
48
49
|
</section>
|
|
49
50
|
<section id="home-widgets" class="home-widgets" aria-labelledby="widgets-heading">
|
|
@@ -54,11 +55,21 @@
|
|
|
54
55
|
<div id="widget-grid" class="widget-grid"></div>
|
|
55
56
|
<p id="widget-status" class="widget-status" role="status"></p>
|
|
56
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>
|
|
57
67
|
<section id="ai-answer" class="results" aria-label="AI answer" hidden>
|
|
58
68
|
<h2 id="ai-answer-title" tabindex="-1">Your answer</h2><p id="answer-text"></p>
|
|
59
69
|
<div id="answer-view" class="answer-view" hidden></div>
|
|
60
70
|
<h3 class="answer-sources-heading">Sources</h3><div id="answer-sources"></div>
|
|
61
71
|
</section>
|
|
72
|
+
<section id="assignment-workspace" class="results assignment-workspace" aria-labelledby="assignment-heading" hidden></section>
|
|
62
73
|
<section id="results" class="results" aria-label="Coursework results" hidden>
|
|
63
74
|
<div class="results-heading"><h2 id="results-title" tabindex="-1">Sources</h2><span id="result-count"></span></div>
|
|
64
75
|
<p class="retrieval-note">Matching excerpts from your local inventory. These are source materials, not an AI-generated answer.</p>
|
package/ui/loading.js
CHANGED
|
@@ -1,33 +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
|
-
}
|
|
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
|
+
}
|
package/ui/style.css
CHANGED
|
@@ -222,5 +222,20 @@ code { background: #1a1a1a; border-radius: 4px; padding: 3px 6px; font-size: 12p
|
|
|
222
222
|
.workspace.has-results { padding-top: 72px; }
|
|
223
223
|
select { max-width: 90%; }
|
|
224
224
|
}
|
|
225
|
+
.assignment-workspace { max-width: 1000px; }
|
|
226
|
+
.assignment-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); gap: 16px; margin-top: 16px; }
|
|
227
|
+
.assignment-workspace .widget { padding: 24px; min-width: 0; min-height: 0; max-height: none; height: auto; overflow-wrap: anywhere; }
|
|
228
|
+
.assignment-workspace .assignment-overview { background: #203238; border-color: #547982; }
|
|
229
|
+
.assignment-workspace h3 { margin: 8px 0 16px; }
|
|
230
|
+
.task-section { margin-top: 32px; }
|
|
231
|
+
.canvas-workspace { max-width: 1120px; }
|
|
232
|
+
.canvas-heading { display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; }
|
|
233
|
+
.generated-canvas { display:block; width:100%; height:600px; border:0; border-top:1px solid #ffffff18; margin-top:20px; background:#222; }
|
|
234
|
+
.canvas-source { margin-top:24px; padding:24px 0; border-top:1px solid #ffffff25; }
|
|
235
|
+
.canvas-source a { color:#a7d7e5; }
|
|
236
|
+
.assignment-card h4 { font-size: 17px; margin: 8px 0 16px; }
|
|
237
|
+
.assignment-workspace a { color: #a7d7e5; }
|
|
238
|
+
.assignment-workspace summary { cursor: pointer; padding: 8px 0; }
|
|
239
|
+
.assignment-text { white-space: pre-wrap; line-height: 1.65; max-height: 440px; overflow-y: auto; }
|
|
225
240
|
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
|
|
226
241
|
@media (prefers-reduced-motion: reduce) { .dot-loader > span { opacity: calc(.95 - var(--ring) * .125); will-change: auto; } }
|
package/ui/widget-layout.js
CHANGED
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
export const sizes = { small: [1, 1], wide: [2, 1], large: [2, 2], full: [4, 2],
|
|
2
|
-
...Object.fromEntries(Array.from({ length: 32 }, (_, i) => {
|
|
3
|
-
const w = i % 4 + 1, h = Math.floor(i / 4) + 1;
|
|
4
|
-
return [`${w}x${h}`, [w, h]];
|
|
5
|
-
})) };
|
|
6
|
-
export const defaults = () => [
|
|
7
|
-
{ type: 'deadlines', x: 0, y: 0, size: 'wide' },
|
|
8
|
-
{ type: 'announcements', x: 2, y: 0, size: 'large' },
|
|
9
|
-
{ type: 'shortcuts', x: 0, y: 1, size: 'wide' },
|
|
10
|
-
];
|
|
11
|
-
|
|
12
|
-
export function fits(layout, widget) {
|
|
13
|
-
if (!Object.hasOwn(sizes, widget.size) || !Number.isInteger(widget.x) || !Number.isInteger(widget.y)) return false;
|
|
14
|
-
const [w, h] = sizes[widget.size];
|
|
15
|
-
if (widget.x < 0 || widget.x + w > 4 || widget.y < 0 || widget.y + h > 8) return false;
|
|
16
|
-
return layout.every(other => {
|
|
17
|
-
if (other.type === widget.type) return true;
|
|
18
|
-
const [ow, oh] = sizes[other.size];
|
|
19
|
-
return widget.x + w <= other.x || other.x + ow <= widget.x || widget.y + h <= other.y || other.y + oh <= widget.y;
|
|
20
|
-
});
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function restore(value) {
|
|
24
|
-
if (!value || !Array.isArray(value.layout) || value.layout.length > 3 || !Array.isArray(value.pins)) return null;
|
|
25
|
-
const layout = [];
|
|
26
|
-
for (const item of value.layout) {
|
|
27
|
-
if (!item || !['deadlines', 'announcements', 'shortcuts'].includes(item.type) || layout.some(w => w.type === item.type) || !fits(layout, item)) return null;
|
|
28
|
-
layout.push({ type: item.type, x: item.x, y: item.y, size: item.size });
|
|
29
|
-
}
|
|
30
|
-
return { layout, pins: [...new Set(value.pins.filter(pin => typeof pin === 'string' && pin.length < 2000))].slice(0, 20) };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function firstSpace(layout, type, size = 'wide') {
|
|
34
|
-
for (let y = 0; y < 8; y++) for (let x = 0; x < 4; x++) {
|
|
35
|
-
const widget = { type, size, x, y };
|
|
36
|
-
if (fits(layout, widget)) return widget;
|
|
37
|
-
}
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
1
|
+
export const sizes = { small: [1, 1], wide: [2, 1], large: [2, 2], full: [4, 2],
|
|
2
|
+
...Object.fromEntries(Array.from({ length: 32 }, (_, i) => {
|
|
3
|
+
const w = i % 4 + 1, h = Math.floor(i / 4) + 1;
|
|
4
|
+
return [`${w}x${h}`, [w, h]];
|
|
5
|
+
})) };
|
|
6
|
+
export const defaults = () => [
|
|
7
|
+
{ type: 'deadlines', x: 0, y: 0, size: 'wide' },
|
|
8
|
+
{ type: 'announcements', x: 2, y: 0, size: 'large' },
|
|
9
|
+
{ type: 'shortcuts', x: 0, y: 1, size: 'wide' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export function fits(layout, widget) {
|
|
13
|
+
if (!Object.hasOwn(sizes, widget.size) || !Number.isInteger(widget.x) || !Number.isInteger(widget.y)) return false;
|
|
14
|
+
const [w, h] = sizes[widget.size];
|
|
15
|
+
if (widget.x < 0 || widget.x + w > 4 || widget.y < 0 || widget.y + h > 8) return false;
|
|
16
|
+
return layout.every(other => {
|
|
17
|
+
if (other.type === widget.type) return true;
|
|
18
|
+
const [ow, oh] = sizes[other.size];
|
|
19
|
+
return widget.x + w <= other.x || other.x + ow <= widget.x || widget.y + h <= other.y || other.y + oh <= widget.y;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function restore(value) {
|
|
24
|
+
if (!value || !Array.isArray(value.layout) || value.layout.length > 3 || !Array.isArray(value.pins)) return null;
|
|
25
|
+
const layout = [];
|
|
26
|
+
for (const item of value.layout) {
|
|
27
|
+
if (!item || !['deadlines', 'announcements', 'shortcuts'].includes(item.type) || layout.some(w => w.type === item.type) || !fits(layout, item)) return null;
|
|
28
|
+
layout.push({ type: item.type, x: item.x, y: item.y, size: item.size });
|
|
29
|
+
}
|
|
30
|
+
return { layout, pins: [...new Set(value.pins.filter(pin => typeof pin === 'string' && pin.length < 2000))].slice(0, 20) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function firstSpace(layout, type, size = 'wide') {
|
|
34
|
+
for (let y = 0; y < 8; y++) for (let x = 0; x < 4; x++) {
|
|
35
|
+
const widget = { type, size, x, y };
|
|
36
|
+
if (fits(layout, widget)) return widget;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|