@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
package/ui/style.css ADDED
@@ -0,0 +1,241 @@
1
+ :root { font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', 'Segoe UI', sans-serif; color: #fff; background: #222; color-scheme: dark; font-synthesis: none; }
2
+ * { box-sizing: border-box; }
3
+ body { margin: 0; min-width: 320px; }
4
+ button, input, select { font: inherit; }
5
+ button, a, select { -webkit-tap-highlight-color: transparent; }
6
+ button { cursor: pointer; }
7
+ button:disabled { cursor: default; }
8
+ button, select { color: inherit; }
9
+ button:focus-visible, a:focus-visible, select:focus-visible, input:focus-visible, h2:focus-visible { outline: 2px solid #75c9e2; outline-offset: 4px; }
10
+ [hidden] { display: none !important; }
11
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
12
+ .skip-link { position: fixed; top: -80px; left: 75px; z-index: 5; background: #333; color: white; padding: 12px; }
13
+ .skip-link:focus { top: 12px; }
14
+ .sidebar { position: fixed; inset: 0 auto 0 0; width: 60px; height: 100dvh; background: #1a1a1a; border-right: 1px solid #5e5e5e66; display: flex; flex-direction: column; align-items: center; z-index: 2; }
15
+ .brand { display: grid; place-items: center; width: 44px; height: 44px; margin-top: 12px; }
16
+ .brand img, .icon-button img { display: block; }
17
+ nav { margin-top: 20px; display: grid; gap: 8px; }
18
+ .icon-button { width: 44px; height: 44px; padding: 14px; border: 0; border-radius: 10px; background: transparent; transition: background .15s ease; }
19
+ .icon-button:hover, .icon-button[aria-current] { background: #ffffff0c; }
20
+ .utilities { margin-top: auto; display: flex; align-items: center; flex-direction: column; gap: 12px; padding-bottom: 9px; }
21
+ .profile { display: grid; place-items: center; position: relative; width: 40px; height: 40px; padding: 0; border: 0; background: transparent; border-radius: 50%; }
22
+ .profile img, .profile span { grid-area: 1 / 1; }
23
+ .profile span { font-size: 20px; }
24
+ .workspace { padding: 88px 48px 64px 108px; }
25
+ .home { width: min(560px, 100%); margin: 0 auto; }
26
+ h1 { font-size: 24px; line-height: 29px; font-weight: 400; text-align: center; margin: 0 0 6px; letter-spacing: -.4px; }
27
+ .composer { height: 128px; padding: 20px; border-radius: 42px; background: linear-gradient(137deg, #313131 -10%, #1a1a1a 86%); box-shadow: 5px 7px 13.5px 8px #0004; }
28
+ .prompt-field { height: 44px; border-radius: 22px; background: #2f2f2f; border: 1px solid #5e5e5e66; display: flex; align-items: center; padding: 5px 7px 5px 15px; transition: border-color .15s ease; }
29
+ .prompt-field:focus-within { border-color: #818181; }
30
+ input { min-width: 0; width: 100%; border: 0; outline: none; color: #fff; background: transparent; font-size: 16px; }
31
+ input:focus-visible { outline: none; }
32
+ input::-webkit-search-cancel-button { display: none; }
33
+ #send { display: grid; place-items: center; flex: 0 0 32px; height: 32px; border: 0; padding: 0; background: transparent; border-radius: 50%; transition: filter .15s ease, transform .15s ease; }
34
+ #send img { display: block; }
35
+ .composer[aria-busy=true] #send img { display: none; }
36
+ #send:not(:disabled) { filter: brightness(2); }
37
+ #send:not(:disabled):hover { transform: translateY(-1px); filter: brightness(2.6); }
38
+ .composer-footer { position: relative; display: flex; align-items: center; gap: 4px; margin-top: 12px; height: 32px; }
39
+ .composer-footer select { min-width: 0; max-width: 55%; }
40
+ .composer-footer #model { margin-left: auto; max-width: 45%; }
41
+ .loading-slot:empty { display: none; }
42
+ .loading-slot { display: inline-flex; vertical-align: middle; flex-shrink: 0; }
43
+ .dot-loader { display: inline-grid; grid-template-columns: repeat(5, 3px); grid-template-rows: repeat(5, 3px); gap: 2.25px; width: 24px; height: 24px; flex: 0 0 24px; vertical-align: middle; color: inherit; }
44
+ .dot-loader > span { width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: .24; animation: dot-origin-wave 1500ms ease-in-out infinite; animation-delay: calc(var(--ring) * 240ms); will-change: opacity; }
45
+ @keyframes dot-origin-wave { 0%, 100% { opacity: .1; } 34% { opacity: 1; } 60% { opacity: .24; } }
46
+ #panel-loading { margin-left: auto; margin-right: 12px; }
47
+ .account-action[aria-busy=true] { display: flex; align-items: center; gap: 12px; }
48
+ select { appearance: none; field-sizing: content; max-width: min(350px, 85%); padding: 8px 30px 8px 12px; border: 0; border-radius: 8px; font-size: 13px; line-height: 16px; background: transparent url('/assets/chevron.svg') right 10px center / 14px 14px no-repeat; color: #bababa; cursor: pointer; text-overflow: ellipsis; }
49
+ select:hover { background-color: #ffffff08; }
50
+ option { background: #292929; color: white; }
51
+ #busy { color: #bababa; font-size: 12px; }
52
+ .notice { color: #bababa; font-size: 13px; line-height: 1.6; margin: 24px 12px 0; text-align: center; }
53
+ .demo-badge { position: fixed; right: 20px; top: 18px; color: #aaa; font-size: 12px; }
54
+ .workspace.has-results { padding-top: 64px; }
55
+ .has-results .home { width: min(680px, 100%); transform: none; }
56
+ .has-results h1 { font-size: 20px; margin-bottom: 18px; }
57
+ .results { max-width: 680px; margin: 44px auto 0; animation: reveal .18s ease-out; }
58
+ .results-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; }
59
+ h2 { font-size: 18px; font-weight: 500; margin: 0; }
60
+ #result-count, .retrieval-note, .source-meta { font-size: 12px; color: #aaa; line-height: 1.6; }
61
+ .retrieval-note { margin: 12px 0 22px; }
62
+ .source { padding: 22px 0; border-top: 1px solid #ffffff12; }
63
+ .source h3 { font-size: 16px; font-weight: 500; margin: 8px 0 12px; }
64
+ .source p { font-size: 14px; color: #d0d0d0; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; margin: 0 0 14px; }
65
+ .source a { color: #a7d7e5; text-decoration: none; font-size: 12px; }
66
+ .source a:hover { text-decoration: underline; }
67
+ .stale { color: #e6c58a; }
68
+ .empty { padding: 24px 0; color: #bababa; line-height: 1.7; font-size: 14px; }
69
+ dialog { width: min(460px, calc(100% - 40px)); max-height: 80dvh; padding: 24px; color: #eee; background: #262626; border: 1px solid #484848; border-radius: 20px; box-shadow: 0 20px 80px #0008; }
70
+ dialog::backdrop { background: #0007; }
71
+ dialog header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
72
+ #close-panel { width: 32px; height: 32px; background: transparent; border: 0; border-radius: 8px; color: #bbb; font-size: 24px; }
73
+ #close-panel:hover { background: #ffffff0c; }
74
+ #panel-body { color: #bbb; font-size: 14px; line-height: 1.7; }
75
+ #panel-body p { margin: 12px 0; }
76
+ .course-item { display: block; width: 100%; text-align: left; background: transparent; border: 0; border-top: 1px solid #ffffff12; padding: 16px 4px; color: #eee; line-height: 1.6; }
77
+ .course-item:hover { background: #ffffff06; }
78
+ .course-item small { display: block; color: #999; }
79
+ code { background: #1a1a1a; border-radius: 4px; padding: 3px 6px; font-size: 12px; }
80
+ .model-sharing { color: #aaa; font-size: 11px; line-height: 1.5; margin: 18px 12px 0; }
81
+ #panel-body a { color: #a7d7e5; }
82
+ .account-action { border: 1px solid #555; background: #333; color: #eee; border-radius: 8px; padding: 10px 14px; }
83
+ .account-action { display: block; margin: 12px 0; }
84
+ #answer-text { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.7; color: #ddd; }
85
+ .auth-page { min-height: 100svh; display: grid; place-items: center; padding: 40px 24px; }
86
+ .auth-content { width: min(480px, 100%); }
87
+ .auth-brand { display: inline-flex; align-items: center; gap: 12px; color: #eee; text-decoration: none; font-size: 20px; margin-bottom: 64px; }
88
+ .auth-brand img { display: block; }
89
+ .auth-status { color: #a7d7e5; font-size: 13px; margin: 0 0 16px; }
90
+ .auth-content h1 { text-align: left; font-size: 32px; line-height: 1.2; letter-spacing: -.6px; margin: 0; }
91
+ .auth-message { color: #bbb; font-size: 15px; line-height: 1.7; margin: 20px 0 32px; }
92
+ .auth-return { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 20px; border: 1px solid #555; border-radius: 12px; background: #303030; color: #eee; text-decoration: none; }
93
+ .auth-return:hover { background: #383838; border-color: #777; }
94
+ .auth-usage { display: inline-block; margin-top: 24px; color: #aaa; font-size: 13px; text-underline-offset: 4px; }
95
+ @keyframes reveal { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
96
+ .home-widgets { max-width: 1120px; margin: 64px auto 0; }
97
+ .has-results .home-widgets { display: none; }
98
+ .widgets-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; }
99
+ .eyebrow { color: #999; font: 10px / 1.4 ui-monospace, 'Cascadia Mono', monospace; letter-spacing: 1px; margin: 0 0 8px; }
100
+ #widgets-heading { font-size: 25px; font-weight: 400; letter-spacing: -.5px; }
101
+ .widget-actions { display: flex; flex-wrap: wrap; gap: 8px; }
102
+ .widget-actions button, .widget-tools button, .pin-shortcut, .shortcut-row > button:not(.shortcut-link) { background: #ffffff08; border: 1px solid #ffffff1c; border-radius: 8px; color: #ddd; padding: 9px 12px; font-size: 12px; }
103
+ .widget-actions button:hover, .widget-tools button:hover, .pin-shortcut:hover { background: #ffffff14; }
104
+ #customize-widgets[aria-pressed=true] { background: #a7d7e5; color: #17252a; }
105
+ .widget-help, .widget-status { font-size: 12px; color: #aaa; line-height: 1.6; }
106
+ .widget-help { margin: 16px 0 24px; }
107
+ .widget-status { min-height: 20px; }
108
+ .widget-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); grid-auto-rows: 160px; gap: 16px; }
109
+ .widget { container-type: inline-size; position: relative; isolation: isolate; min-width: 0; display: flex; flex-direction: column; border: 1px solid #ffffff12; border-radius: 7px; background: #171819; padding: 18px 22px; overflow: hidden; box-shadow: inset 0 1px #ffffff0c, 0 2px 3px #0003, 0 12px 24px -12px #0009; transition: border-color .2s, box-shadow .2s, translate .2s; animation: reveal .25s ease-out; }
110
+ .widget::before { content: ''; position: absolute; inset: 0; z-index: -1; pointer-events: none; opacity: .22; background: repeating-linear-gradient(0deg, transparent 0 2px, #ffffff0b 2px 3px), repeating-linear-gradient(91deg, transparent 0 3px, #0004 3px 4px); }
111
+ .widget:hover, .widget:focus-within { border-color: #ffffff30; box-shadow: inset 0 1px #ffffff12, 0 3px 5px #0004, 0 18px 32px -12px #000a; }
112
+ .widget:not(.widget-preview):hover { translate: 0 -2px; }
113
+ .editing .widget:not(.widget-preview):hover { translate: none; }
114
+ .widget-edition { position: relative; z-index: 1; display: flex; justify-content: space-between; gap: 10px; flex-shrink: 0; margin-bottom: 12px; color: #a3a5a5; font: 9px / 1 ui-monospace, 'Cascadia Mono', monospace; letter-spacing: 1.2px; }
115
+ .widget-edition span:last-child { letter-spacing: -.5px; font-weight: 700; }
116
+ .widget-mark { position: absolute; z-index: -1; pointer-events: none; right: -16px; top: 6%; width: 52%; height: 90%; opacity: .5; background-position: center; background-size: contain; background-repeat: no-repeat; }
117
+ .widget-announcements .widget-mark { background-image: url('/assets/widget-announcements.svg'); }
118
+ .widget-shortcuts .widget-mark { background-image: url('/assets/widget-shortcuts.svg'); right: -32px; }
119
+ .widget-deadlines .widget-mark { display: flex; flex-direction: column; align-items: center; justify-content: center; color: #b1d9e8; opacity: .7; transform: rotate(-7deg); }
120
+ .widget-deadlines .widget-mark span { font-size: clamp(72px, 28cqi, 180px); font-weight: 850; letter-spacing: -.09em; line-height: .78; background: radial-gradient(circle, #b1d9e8 1px, transparent 1.2px) 0 0 / 3px 3px; background-clip: text; color: transparent; }
121
+ .widget-deadlines .widget-mark small { margin-top: 12px; padding-left: 12px; font: 10px / 1 ui-monospace, monospace; letter-spacing: 7px; }
122
+ .widget-header { position: relative; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; margin-top: auto; margin-bottom: 9px; flex-shrink: 0; }
123
+ .widget h3 { color: #dededb; font-size: clamp(17px, 4.4cqi, 25px); line-height: 1.05; letter-spacing: -.8px; font-weight: 750; margin: 0; }
124
+ .widget-body { position: relative; width: 72%; overflow: auto; min-height: 0; scrollbar-width: thin; scrollbar-color: #555 transparent; }
125
+ .widget-item { display: block; text-align: left; width: 100%; background: none; border: 0; border-top: 1px solid #ffffff12; padding: 12px 0; color: #eee; }
126
+ .widget-item:first-child { border-top: 0; padding-top: 2px; }
127
+ .widget-item:hover strong { color: #a7d7e5; }
128
+ .widget-item small, .widget-item strong, .widget-item span { display: block; overflow-wrap: anywhere; }
129
+ .widget-item small { font: 10px / 1.5 ui-monospace, 'Cascadia Mono', monospace; color: #b1b3b3; margin-bottom: 4px; }
130
+ .widget-item strong { font-size: 14px; font-weight: 500; line-height: 1.4; }
131
+ .widget-item span { color: #c3c4c2; font-size: 12px; line-height: 1.6; margin-top: 4px; }
132
+ .widget-empty { font-size: 13px; line-height: 1.6; color: #aaa; margin: 0 0 12px; }
133
+ .shortcut-row { display: flex; align-items: center; gap: 8px; border-top: 1px solid #ffffff12; }
134
+ .shortcut-row:first-child { border-top: 0; }
135
+ .shortcut-link { flex: 1; min-width: 0; background: none; border: 0; text-align: left; color: #d0d1ce; font: 11px / 1.5 ui-monospace, 'Cascadia Mono', monospace; padding: 8px 0; overflow-wrap: anywhere; }
136
+ .shortcut-link::after { content: ' ↗'; color: #a7d7e5; }
137
+ .pin-shortcut { white-space: nowrap; }
138
+ .widget .pin-shortcut, .widget-tools button { color: #bfc0bb; font: 9px / 1.4 ui-monospace, monospace; border-radius: 3px; padding: 5px 7px; background: #ffffff06; }
139
+ .editing { min-height: 688px; background: repeating-linear-gradient(0deg, #ffffff03 0px, #ffffff03 160px, transparent 160px, transparent 176px); border-radius: 18px; }
140
+ .widget-tools { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 12px; flex-shrink: 0; }
141
+ .widget-tools button { padding: 5px 8px; }
142
+ .widget-drag { cursor: grab; touch-action: none; background: none; border: 0; color: #aaa; font-size: 24px; padding: 0 6px; }
143
+ .editing .widget { cursor: grab; touch-action: none; user-select: none; animation: none; }
144
+ .editing .widget.dragging { z-index: 4; cursor: grabbing; border-color: #a7d7e5; box-shadow: 0 16px 40px #0008; will-change: transform; transition: none; }
145
+ .widget-preview { pointer-events: none; background: #a7d7e51a; border: 1px dashed #a7d7e5; box-shadow: none; animation: none; }
146
+ .widget-preview::before { display: none; }
147
+ .widget-preview.blocked { background: #e8909014; border-color: #e89090; }
148
+ .widget.dragging.blocked { border-color: #e89090; }
149
+ .widget-resize { position: absolute; z-index: 2; padding: 0; border: 0; background: transparent; touch-action: none; }
150
+ .widget-resize:hover, .widget-resize:focus-visible { background: #a7d7e550; }
151
+ .resize-n, .resize-s { left: 14px; right: 14px; height: 10px; cursor: ns-resize; }
152
+ .resize-n { top: 0; } .resize-s { bottom: 0; }
153
+ .resize-e, .resize-w { top: 14px; bottom: 14px; width: 10px; cursor: ew-resize; }
154
+ .resize-e { right: 0; } .resize-w { left: 0; }
155
+ .resize-ne, .resize-nw, .resize-se, .resize-sw { width: 16px; height: 16px; }
156
+ .resize-ne, .resize-sw { cursor: nesw-resize; } .resize-nw, .resize-se { cursor: nwse-resize; }
157
+ .resize-ne { top: 0; right: 0; } .resize-nw { top: 0; left: 0; }
158
+ .resize-se { bottom: 0; right: 0; border-right: 2px solid #a7d7e580; border-bottom: 2px solid #a7d7e580; border-radius: 0 0 7px 0; }
159
+ .resize-sw { bottom: 0; left: 0; }
160
+ .col-0 { grid-column-start: 1; } .col-1 { grid-column-start: 2; } .col-2 { grid-column-start: 3; } .col-3 { grid-column-start: 4; }
161
+ .row-0 { grid-row-start: 1; } .row-1 { grid-row-start: 2; } .row-2 { grid-row-start: 3; } .row-3 { grid-row-start: 4; }
162
+ .row-4 { grid-row-start: 5; } .row-5 { grid-row-start: 6; } .row-6 { grid-row-start: 7; } .row-7 { grid-row-start: 8; }
163
+ .size-small { grid-column-end: span 1; grid-row-end: span 1; }
164
+ .size-wide { grid-column-end: span 2; grid-row-end: span 1; }
165
+ .size-large { grid-column-end: span 2; grid-row-end: span 2; }
166
+ .size-full { grid-column-end: span 4; grid-row-end: span 2; }
167
+ #ai-answer { max-width: 1000px; }
168
+ #ai-answer-title { font-size: 28px; font-weight: 700; letter-spacing: -.7px; overflow-wrap: anywhere; }
169
+ #answer-text { max-width: 720px; }
170
+ .answer-view { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: 16px; margin: 28px 0 40px; }
171
+ .widget.generated-card { min-height: 0; max-height: none; padding: 24px; }
172
+ .generated-card::after { content: ''; pointer-events: none; position: absolute; z-index: -1; right: -20px; top: 0; width: 180px; height: 180px; background: url('/assets/widget-announcements.svg') center / contain no-repeat; opacity: .07; }
173
+ .generated-card.answer-checklist::after { background-image: url('/assets/widget-shortcuts.svg'); }
174
+ .generated-card .widget-edition { margin-bottom: 28px; }
175
+ .generated-card .widget-header { margin: 0 0 14px; }
176
+ .generated-card h3 { font-size: 23px; line-height: 1.2; overflow-wrap: anywhere; }
177
+ .generated-card p, .generated-card li { font-size: 14px; color: #d0d1ce; line-height: 1.65; overflow-wrap: anywhere; }
178
+ .answer-progress, .answer-session-note { color: #aaa; font: 11px / 1.5 ui-monospace, monospace; }
179
+ .answer-checklist { list-style: none; margin: 4px 0 20px; padding: 0; }
180
+ .answer-checklist li { border-top: 1px solid #ffffff12; padding: 16px 0; }
181
+ .answer-checklist label { display: flex; align-items: flex-start; gap: 12px; cursor: pointer; }
182
+ .answer-checklist input { appearance: auto; flex: 0 0 18px; width: 18px; height: 18px; margin: 3px 0 0; accent-color: #a7d7e5; }
183
+ .answer-checklist input:focus-visible { outline: 2px solid #a7d7e5; outline-offset: 4px; }
184
+ .answer-checklist .completed label span { text-decoration: line-through; color: #888; }
185
+ .answer-references { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
186
+ .answer-references a { color: #b1d9e8; font: 10px / 1.5 ui-monospace, monospace; text-decoration: none; padding: 3px 6px; border: 1px solid #ffffff1a; border-radius: 3px; }
187
+ .answer-references a:hover { background: #ffffff09; text-decoration: underline; }
188
+ .answer-checklist .answer-references { margin-left: 30px; }
189
+ .answer-comparison { grid-column: 1 / -1; }
190
+ .answer-table-scroll { overflow-x: auto; }
191
+ .answer-table-scroll table { border-collapse: collapse; width: 100%; min-width: 420px; text-align: left; color: #d0d1ce; font-size: 13px; }
192
+ .answer-table-scroll th, .answer-table-scroll td { padding: 16px 12px; border-top: 1px solid #ffffff16; vertical-align: top; overflow-wrap: anywhere; max-width: 320px; white-space: pre-wrap; }
193
+ .answer-table-scroll thead th { font: 10px / 1.5 ui-monospace, monospace; color: #aaa; }
194
+ .answer-table-scroll tbody th { font-weight: 500; }
195
+ .answer-table-scroll .answer-references { margin: 0; }
196
+ .answer-source-cards > section { padding: 18px 0; border-top: 1px solid #ffffff12; }
197
+ .answer-source-cards h4 { margin: 0 0 8px; font-size: 16px; letter-spacing: -.3px; }
198
+ .answer-source-cards p { margin: 0; white-space: pre-wrap; }
199
+ .answer-sources-heading { font: 11px / 1.5 ui-monospace, monospace; color: #aaa; margin: 32px 0 12px; }
200
+ #answer-sources { max-width: 720px; }
201
+ #answer-sources .source { scroll-margin-top: 24px; }
202
+ @container (max-width: 300px) {
203
+ .widget-mark { opacity: .17; width: 65%; }
204
+ .widget-body { width: 100%; }
205
+ .widget h3 { font-size: 17px; letter-spacing: -.4px; }
206
+ }
207
+ @media (max-width: 760px) {
208
+ .widget-grid { display: flex; flex-direction: column; min-height: 0; }
209
+ .widget { max-height: 360px; min-height: 160px; }
210
+ .widget.size-large, .widget.size-full { min-height: 260px; }
211
+ .home-widgets { overflow-x: auto; }
212
+ .widget-grid.editing { display: grid; width: 800px; min-height: 688px; }
213
+ .widget-grid.editing .widget { min-height: 0; max-height: none; }
214
+ .widgets-heading { align-items: flex-start; flex-direction: column; gap: 14px; }
215
+ .home-widgets { margin-top: 40px; }
216
+ }
217
+ @media (max-width: 700px) {
218
+ .workspace { padding: 80px 20px 40px 80px; }
219
+ .home { transform: none; }
220
+ h1 { font-size: 20px; line-height: 1.4; margin-bottom: 14px; letter-spacing: -.3px; }
221
+ .composer { padding: 16px; height: 120px; border-radius: 32px; }
222
+ .workspace.has-results { padding-top: 72px; }
223
+ select { max-width: 90%; }
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; }
240
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
241
+ @media (prefers-reduced-motion: reduce) { .dot-loader > span { opacity: calc(.95 - var(--ring) * .125); will-change: auto; } }
@@ -0,0 +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
+ }
package/ui/widgets.js ADDED
@@ -0,0 +1,252 @@
1
+ import { defaults, fits, firstSpace, restore, sizes } from './widget-layout.js';
2
+ import { startLoading } from './loading.js';
3
+
4
+ const $ = id => document.getElementById(id);
5
+ const names = { deadlines: 'Upcoming deadlines', announcements: 'Class announcements', shortcuts: 'Your shortcuts' };
6
+ const node = (tag, text, cls) => {
7
+ const el = document.createElement(tag);
8
+ if (text !== undefined) el.textContent = text;
9
+ if (cls) el.className = cls;
10
+ return el;
11
+ };
12
+ const button = (label, action, cls) => {
13
+ const el = node('button', label, cls);
14
+ el.type = 'button';
15
+ el.addEventListener('click', action);
16
+ return el;
17
+ };
18
+ const date = value => new Date(value).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' });
19
+
20
+ export function createWidgets(browse) {
21
+ let data, layout = [], pins = [], editing = false, storageKey;
22
+ const message = text => { $('widget-status').textContent = text; };
23
+ function save() {
24
+ try { localStorage.setItem(storageKey, JSON.stringify({ layout, pins })); }
25
+ catch { message('Your changes work for this visit, but this browser could not save them.'); }
26
+ }
27
+ function move(widget, change) {
28
+ const next = { ...widget, ...change };
29
+ if (!fits(layout, next)) { message('That space is occupied or outside the grid. Move another widget first.'); return; }
30
+ Object.assign(widget, next);
31
+ message(`${names[widget.type]} moved to column ${widget.x + 1}, row ${widget.y + 1}.`);
32
+ render(); save();
33
+ $(`widget-${widget.type}`)?.focus();
34
+ }
35
+ function place(tile, widget) {
36
+ tile.className = `widget widget-${widget.type} col-${widget.x} row-${widget.y} size-${widget.size}`;
37
+ const [w, h] = sizes[widget.size];
38
+ tile.style.gridColumnEnd = `span ${w}`;
39
+ tile.style.gridRowEnd = `span ${h}`;
40
+ }
41
+ function customize(tile, widget) {
42
+ tile.tabIndex = 0;
43
+ tile.setAttribute('aria-describedby', 'widget-help');
44
+ tile.addEventListener('keydown', event => {
45
+ if (event.target !== tile && !event.target.classList.contains('widget-resize')) return;
46
+ const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key];
47
+ if (!delta) return;
48
+ event.preventDefault();
49
+ const [dx, dy] = delta, [w, h] = sizes[widget.size];
50
+ move(widget, event.shiftKey || event.target !== tile ? { size: `${w + dx}x${h + dy}` } : { x: widget.x + dx, y: widget.y + dy });
51
+ });
52
+ tile.addEventListener('click', event => {
53
+ if (event.target.closest('.widget-item, .shortcut-link')) { event.preventDefault(); event.stopPropagation(); }
54
+ }, true);
55
+ tile.addEventListener('pointerdown', event => {
56
+ const edge = event.target.closest('.widget-resize')?.dataset.edge;
57
+ if (event.button !== 0 || (!edge && event.target.closest('button:not(.widget-drag):not(.widget-item):not(.shortcut-link), a, input, select'))) return;
58
+ event.preventDefault();
59
+ tile.focus({ preventScroll: true });
60
+ const grid = $('widget-grid'), rect = tile.getBoundingClientRect();
61
+ const cell = (grid.getBoundingClientRect().width + 16) / 4;
62
+ const startX = event.clientX, startY = event.clientY, scrollX = window.scrollX, scrollY = window.scrollY;
63
+ const [w, h] = sizes[widget.size];
64
+ const preview = node('div');
65
+ preview.setAttribute('aria-hidden', 'true');
66
+ let next = { ...widget }, changed = false;
67
+ grid.append(preview);
68
+ function update(e) {
69
+ const dx = e.clientX - startX + window.scrollX - scrollX, dy = e.clientY - startY + window.scrollY - scrollY;
70
+ changed ||= Math.abs(dx) + Math.abs(dy) > 4;
71
+ const left = edge?.includes('w') ? dx : 0, top = edge?.includes('n') ? dy : 0;
72
+ const width = Math.max(80, rect.width + (edge?.includes('e') ? dx : -left));
73
+ const height = Math.max(80, rect.height + (edge?.includes('s') ? dy : -top));
74
+ tile.style.transform = `translate(${edge ? left : dx}px, ${edge ? top : dy}px)`;
75
+ if (edge) { tile.style.width = `${width}px`; tile.style.height = `${height}px`; }
76
+ const x = widget.x + Math.round((edge ? left : dx) / cell);
77
+ const y = widget.y + Math.round((edge ? top : dy) / 176);
78
+ next = { ...widget, x, y, size: edge ? `${Math.round((width + 16) / cell)}x${Math.round((height + 16) / 176)}` : widget.size };
79
+ const valid = fits(layout, next);
80
+ // Invalid dimensions have no grid span; keep the last cell preview and mark it blocked.
81
+ if (Object.hasOwn(sizes, next.size) && x >= 0 && x < 4 && y >= 0 && y < 8) place(preview, next);
82
+ preview.classList.add('widget-preview');
83
+ preview.classList.toggle('blocked', !valid);
84
+ tile.classList.toggle('blocked', !valid);
85
+ }
86
+ function finish(commit) {
87
+ tile.removeEventListener('pointermove', update);
88
+ tile.removeEventListener('pointerup', up);
89
+ tile.removeEventListener('pointercancel', cancel);
90
+ tile.removeEventListener('lostpointercapture', cancel);
91
+ document.removeEventListener('keydown', escape);
92
+ window.removeEventListener('blur', cancel);
93
+ preview.remove();
94
+ if (tile.hasPointerCapture(event.pointerId)) tile.releasePointerCapture(event.pointerId);
95
+ if (commit && changed) move(widget, next);
96
+ // A canceled or blocked gesture restores the original geometry without saving.
97
+ if (tile.isConnected) { tile.removeAttribute('style'); place(tile, widget); }
98
+ }
99
+ const up = e => { update(e); finish(true); };
100
+ const cancel = () => finish(false);
101
+ const escape = e => { if (e.key === 'Escape') { e.preventDefault(); cancel(); } };
102
+ tile.setPointerCapture(event.pointerId);
103
+ tile.classList.add('dragging');
104
+ tile.addEventListener('pointermove', update);
105
+ tile.addEventListener('pointerup', up);
106
+ tile.addEventListener('pointercancel', cancel);
107
+ tile.addEventListener('lostpointercapture', cancel);
108
+ document.addEventListener('keydown', escape);
109
+ window.addEventListener('blur', cancel);
110
+ update(event);
111
+ });
112
+ for (const edge of ['n', 'e', 's', 'w', 'ne', 'se', 'sw', 'nw']) {
113
+ const grip = button('', () => {}, `widget-resize resize-${edge}`);
114
+ grip.tabIndex = -1;
115
+ grip.dataset.edge = edge;
116
+ grip.setAttribute('aria-label', `Resize ${names[widget.type]} ${edge}`);
117
+ tile.append(grip);
118
+ }
119
+ }
120
+ function choose(title, items, action) {
121
+ $('panel-title').textContent = title;
122
+ $('panel-body').replaceChildren();
123
+ const filter = node('input');
124
+ filter.type = 'search';
125
+ filter.placeholder = 'Find a class or material…';
126
+ filter.setAttribute('aria-label', 'Find a class or material');
127
+ const list = node('div');
128
+ const show = () => {
129
+ list.replaceChildren();
130
+ for (const item of items.filter(item => item.label.toLowerCase().includes(filter.value.toLowerCase()))) list.append(button(item.label, () => {
131
+ action(item); $('panel').close();
132
+ }, 'course-item'));
133
+ if (!list.children.length) list.append(node('p', 'No available items.'));
134
+ };
135
+ if (items.length > 3) $('panel-body').append(filter);
136
+ $('panel-body').append(list);
137
+ filter.addEventListener('input', show);
138
+ show();
139
+ $('panel').showModal();
140
+ }
141
+ function pinPicker() {
142
+ const items = [
143
+ ...data.courses.map(c => ({ key: `course:${c.id}`, label: c.name })),
144
+ ...data.sources.map(s => ({ key: `source:${s.ref}`, label: `${s.courseName} · ${s.title}` })),
145
+ ];
146
+ const unique = [...new Map(items.map(item => [item.key, item])).values()];
147
+ choose('Pin a class or course material', unique.filter(item => !pins.includes(item.key)), item => {
148
+ if (pins.length >= 20) { message('You can pin up to 20 shortcuts. Remove one to add another.'); return; }
149
+ pins.push(item.key); save(); render();
150
+ });
151
+ }
152
+ function sourceRow(source, detail) {
153
+ const row = button('', () => browse(source.course, source.title), 'widget-item');
154
+ row.append(node('small', `${source.courseName}${source.stale ? ' · Stale cache' : ''}`), node('strong', source.title));
155
+ if (detail) row.append(node('span', detail));
156
+ return row;
157
+ }
158
+ function render() {
159
+ if (!data) return;
160
+ const grid = $('widget-grid');
161
+ grid.replaceChildren();
162
+ grid.classList.toggle('editing', editing);
163
+ $('customize-widgets').textContent = editing ? 'Done' : 'Customize';
164
+ $('customize-widgets').setAttribute('aria-pressed', String(editing));
165
+ $('add-widget').hidden = !editing;
166
+ $('widget-help').textContent = editing ? 'Drag a widget to move it. Drag its edges or corners to resize. Arrow keys move a focused widget; Shift + arrows resize. Escape cancels a drag. Changes save automatically.' : 'From your cached coursework · Select an item to browse its source.';
167
+ for (const widget of [...layout].sort((a, b) => a.y - b.y || a.x - b.x)) {
168
+ const tile = node('article', undefined, `widget widget-${widget.type} col-${widget.x} row-${widget.y} size-${widget.size}`);
169
+ place(tile, widget);
170
+ tile.id = `widget-${widget.type}`;
171
+ tile.tabIndex = -1;
172
+ tile.setAttribute('aria-label', names[widget.type]);
173
+ const edition = node('div', undefined, 'widget-edition');
174
+ edition.append(node('span', { deadlines: '01 / PLANNER', announcements: '02 / BULLETIN', shortcuts: '03 / COLLECTION' }[widget.type]), node('span', 'dd2l'));
175
+ const mark = node('div', undefined, 'widget-mark');
176
+ mark.setAttribute('aria-hidden', 'true');
177
+ if (widget.type === 'deadlines') {
178
+ const due = data.deadlines[0]?.dueDate;
179
+ mark.append(node('span', due ? new Date(due).toLocaleDateString([], { day: '2-digit' }) : '—'),
180
+ node('small', due ? new Date(due).toLocaleDateString([], { month: 'short' }).toUpperCase() : 'NO DUE DATE'));
181
+ }
182
+ tile.append(edition, mark);
183
+ const header = node('header', undefined, 'widget-header');
184
+ header.append(node('h3', names[widget.type]));
185
+ if (widget.type === 'shortcuts') header.append(button('+ Pin shortcut', pinPicker, 'pin-shortcut'));
186
+ if (editing) {
187
+ const handle = button('⠿', () => {}, 'widget-drag');
188
+ handle.setAttribute('aria-label', `Drag ${names[widget.type]}`);
189
+ handle.tabIndex = -1;
190
+ header.append(handle);
191
+ }
192
+ tile.append(header);
193
+ if (editing) {
194
+ const controls = node('div', undefined, 'widget-tools');
195
+ controls.append(button('Remove', () => {
196
+ layout = layout.filter(w => w !== widget); save(); render(); $('add-widget').focus();
197
+ }));
198
+ tile.append(controls);
199
+ }
200
+ const body = node('div', undefined, 'widget-body');
201
+ if (widget.type === 'shortcuts') {
202
+ for (const pin of pins) {
203
+ const course = data.courses.find(c => pin === `course:${c.id}`);
204
+ const source = data.sources.find(s => pin === `source:${s.ref}`);
205
+ if (!course && !source) continue;
206
+ const row = node('div', undefined, 'shortcut-row');
207
+ row.append(button(course?.name || source.title, () => browse(course?.id || source.course, source?.title || ''), 'shortcut-link'));
208
+ if (editing) {
209
+ const remove = button('×', () => { pins = pins.filter(p => p !== pin); save(); render(); });
210
+ remove.setAttribute('aria-label', `Unpin ${course?.name || source.title}`);
211
+ row.append(remove);
212
+ }
213
+ body.append(row);
214
+ }
215
+ if (!body.children.length) body.append(node('p', 'Keep the classes and materials you use most within reach.', 'widget-empty'));
216
+ } else {
217
+ for (const source of data[widget.type]) body.append(sourceRow(source, widget.type === 'deadlines' ? `Due ${date(source.dueDate)}` : `${source.publishedAt ? date(source.publishedAt) + ' · ' : ''}${source.excerpt}`));
218
+ if (!body.children.length) body.append(node('p', widget.type === 'deadlines' ? 'No upcoming deadlines in the cache. Sync onQ to check for updates.' : 'No announcements in the cache. Sync onQ to check for updates.', 'widget-empty'));
219
+ }
220
+ tile.append(body);
221
+ if (editing) customize(tile, widget);
222
+ grid.append(tile);
223
+ }
224
+ if (!layout.length) grid.append(node('p', 'Your homescreen is clear. Choose Add widget to get started.', 'widget-empty'));
225
+ }
226
+ $('customize-widgets').addEventListener('click', () => { editing = !editing; render(); });
227
+ $('add-widget').addEventListener('click', () => choose('Add a widget', Object.entries(names)
228
+ .filter(([type]) => !layout.some(w => w.type === type)).map(([type, label]) => ({ type, label })), ({ type }) => {
229
+ const widget = firstSpace(layout, type);
230
+ if (!widget) { message('The grid is full. Remove or resize a widget first.'); return; }
231
+ layout.push(widget); save(); render();
232
+ }));
233
+ return async function refresh() {
234
+ const finish = startLoading($('widget-loading'), 'Refreshing coursework widgets');
235
+ $('refresh-widgets').disabled = true;
236
+ try {
237
+ const response = await fetch('/api/home');
238
+ if (!response.ok) throw new Error('Could not refresh widgets. Try again.');
239
+ const next = await response.json();
240
+ const key = `dd2l:widgets:v1:${next.scope}`;
241
+ if (storageKey !== key) {
242
+ let stored;
243
+ try { stored = restore(JSON.parse(localStorage.getItem(key))); } catch {}
244
+ layout = stored?.layout || defaults();
245
+ pins = stored?.pins || next.courses.slice(0, 2).map(c => `course:${c.id}`);
246
+ storageKey = key;
247
+ }
248
+ data = next; render(); message('Widgets updated from the local cache.');
249
+ } catch (error) { message(error.message); }
250
+ finally { finish(); $('refresh-widgets').disabled = false; }
251
+ };
252
+ }