@lincyaw/addon-classroom 0.0.0-stage → 1.0.0-rc.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 +202 -0
  2. package/README.md +30 -2
  3. package/components/PmExercise.css +58 -0
  4. package/components/PmExercise.tsx +165 -0
  5. package/components/PmPredict.css +31 -0
  6. package/components/PmPredict.tsx +182 -0
  7. package/components/PmPulse.css +24 -0
  8. package/components/PmPulse.tsx +109 -0
  9. package/components/PmQuestionBox.css +16 -0
  10. package/components/PmQuestionBox.tsx +155 -0
  11. package/components/PmQuiz.css +43 -0
  12. package/components/PmQuiz.tsx +221 -0
  13. package/package.json +44 -3
  14. package/src/client/ClassroomOverlays.tsx +15 -0
  15. package/src/client/ClassroomPanel.css +32 -0
  16. package/src/client/ClassroomPanel.tsx +213 -0
  17. package/src/client/ClassroomToolbar.css +26 -0
  18. package/src/client/ClassroomToolbar.tsx +47 -0
  19. package/src/client/NoteSpot.css +50 -0
  20. package/src/client/NoteSpot.tsx +119 -0
  21. package/src/client/NotesLayer.css +9 -0
  22. package/src/client/NotesLayer.tsx +113 -0
  23. package/src/client/QuestionItem.css +17 -0
  24. package/src/client/QuestionItem.tsx +106 -0
  25. package/src/client/ai-answers.ts +29 -0
  26. package/src/client/browser-store.ts +86 -0
  27. package/src/client/composables.ts +43 -0
  28. package/src/client/env.d.ts +7 -0
  29. package/src/client/ids.ts +3 -0
  30. package/src/client/index.ts +4 -0
  31. package/src/client/plugin.ts +57 -0
  32. package/src/client/style.css +44 -0
  33. package/src/domain/config.ts +25 -0
  34. package/src/domain/exercise.ts +143 -0
  35. package/src/domain/index.ts +40 -0
  36. package/src/domain/messages.ts +298 -0
  37. package/src/domain/notes.ts +12 -0
  38. package/src/domain/place.ts +25 -0
  39. package/src/domain/predict.ts +34 -0
  40. package/src/domain/pulse.ts +19 -0
  41. package/src/domain/questions.ts +42 -0
  42. package/src/domain/quiz.ts +63 -0
  43. package/src/domain/records.ts +97 -0
  44. package/src/domain/source.ts +55 -0
  45. package/src/domain/summary.ts +96 -0
  46. package/src/domain/tally.ts +8 -0
  47. package/src/node/classroom-pass.ts +84 -0
  48. package/src/node/components.ts +10 -0
  49. package/src/node/directives.ts +77 -0
  50. package/src/node/fallbacks.ts +86 -0
  51. package/src/node/notebook.ts +32 -0
  52. package/src/node/presmium.ts +21 -0
  53. package/src/node/reveals.ts +29 -0
@@ -0,0 +1,24 @@
1
+ .pm-pulse__question {
2
+ font-weight: 600;
3
+ }
4
+ .pm-pulse__distribution {
5
+ margin: 0.4em 0;
6
+ padding: 0;
7
+ list-style: none;
8
+ }
9
+ .pm-pulse__distribution li {
10
+ display: grid;
11
+ grid-template-columns: 6em 1fr 3.5em;
12
+ align-items: center;
13
+ gap: 0.4rem;
14
+ }
15
+ .pm-pulse__bar {
16
+ height: 0.6em;
17
+ min-width: var(--pm-size-hairline);
18
+ border-radius: var(--pm-radius-pill);
19
+ background: var(--pm-color-accent);
20
+ }
21
+ .pm-pulse__count {
22
+ text-align: right;
23
+ font-variant-numeric: tabular-nums;
24
+ }
@@ -0,0 +1,109 @@
1
+ import { Fragment, useState, useRef, useEffect, useMemo, useId } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { useMessages } from '@lincyaw/host'
4
+ import {
5
+ useClassroomStore,
6
+ useCollection,
7
+ useGuardedWrite,
8
+ useParticipation,
9
+ } from '../src/client/composables.ts'
10
+ import { COLLECTIONS, MESSAGES, placeKey, pulseTally, toPulseVote, voteId } from '../src/domain/index.ts'
11
+ import type { Place, PulseVote } from '../src/domain/index.ts'
12
+ import '../src/client/style.css'
13
+ import './PmPulse.css'
14
+ export default function PmPulse(
15
+ props: {
16
+ question?: string
17
+ levels?: string[]
18
+ place?: Place
19
+ } & {
20
+ children?: ReactNode
21
+ className?: string
22
+ onDone?(): void
23
+ onCited?(): void
24
+ onToggleAnswered?(): void
25
+ onSend?(): void
26
+ },
27
+ ) {
28
+ const { place } = props
29
+
30
+ const { me, status, joined, presenter } = useParticipation()
31
+ const store = useClassroomStore()
32
+ const votes = useCollection(() => store, COLLECTIONS.pulse, toPulseVote)
33
+ const { failure, write } = useGuardedWrite()
34
+ const t = useMessages(MESSAGES)
35
+ const question = props.question ?? t.pulse.question
36
+ const levels = props.levels ?? t.pulse.levels
37
+
38
+ const id = props.place ? placeKey(props.place) : ''
39
+ const counts = pulseTally(
40
+ id,
41
+ levels.length,
42
+ votes.map((item) => item.value),
43
+ )
44
+ const total = counts.reduce((sum, n) => sum + n, 0)
45
+ const mine = me ? votes.find((item) => item.id === voteId(id, me!.id))?.value : undefined
46
+ const canVote = joined && !presenter && status === 'active'
47
+
48
+ function vote(level: number): void {
49
+ const participant = me
50
+ if (!store || !participant || !props.place) return
51
+ write(() =>
52
+ store!.put<PulseVote>(COLLECTIONS.pulse, voteId(id, participant.id), {
53
+ ...props.place!,
54
+ pulseId: id,
55
+ participant: participant.id,
56
+ level,
57
+ levels: levels,
58
+ at: Date.now(),
59
+ }),
60
+ )
61
+ }
62
+
63
+ return (
64
+ <>
65
+ <div className="pm-pulse pm-classroom-card" data-pulse={id}>
66
+ <p className="pm-pulse__question">{question}</p>
67
+ {props.children}
68
+ {canVote ? (
69
+ <p className="pm-classroom-actions" role="group" aria-label={question}>
70
+ {levels.map((level, i) => (
71
+ <button
72
+ key={i}
73
+ type="button"
74
+ className="pm-ui-button pm-ui-button--small"
75
+ aria-pressed={mine?.level === i}
76
+ onClick={(event) => {
77
+ vote(i)
78
+ }}
79
+ >
80
+ {level}
81
+ </button>
82
+ ))}
83
+ </p>
84
+ ) : !joined ? (
85
+ <p className="pm-classroom-status">{t.pulse.joinFirst}</p>
86
+ ) : null}
87
+ {presenter ? (
88
+ <ul className="pm-pulse__distribution" aria-label={t.pulse.distribution}>
89
+ {levels.map((level, i) => (
90
+ <li key={i} data-level={level} data-count={counts[i]}>
91
+ <span className="pm-pulse__label">{level}</span>
92
+ <span
93
+ className="pm-pulse__bar"
94
+ style={{ width: `${total ? (counts[i]! / total) * 100 : 0}%` }}
95
+ ></span>
96
+ <span className="pm-pulse__count">{t.people(counts[i]!)}</span>
97
+ </li>
98
+ ))}
99
+ </ul>
100
+ ) : null}
101
+ {failure ? (
102
+ <p className="pm-classroom-error" role="alert">
103
+ {failure}
104
+ </p>
105
+ ) : null}
106
+ </div>
107
+ </>
108
+ )
109
+ }
@@ -0,0 +1,16 @@
1
+ .pm-question-box__list {
2
+ margin: 0.4em 0;
3
+ padding-left: 1.2em;
4
+ }
5
+ .pm-question-box__list li {
6
+ margin: 0.35em 0;
7
+ }
8
+ .pm-question-box__list li[data-answered] {
9
+ color: var(--pm-color-muted);
10
+ }
11
+ .pm-question-box__name {
12
+ font-weight: 600;
13
+ }
14
+ .pm-question-box__answered {
15
+ margin-left: 0.5em;
16
+ }
@@ -0,0 +1,155 @@
1
+ import { Fragment, useState, useRef, useEffect, useMemo, useId } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { useMessages } from '@lincyaw/host'
4
+ import {
5
+ useClassroomStore,
6
+ useCollection,
7
+ useGuardedWrite,
8
+ useParticipation,
9
+ } from '../src/client/composables.ts'
10
+ import { newId } from '../src/client/ids.ts'
11
+ import { COLLECTIONS, MESSAGES, placeText, toQuestion } from '../src/domain/index.ts'
12
+ import type { Place, Question } from '../src/domain/index.ts'
13
+ import '../src/client/style.css'
14
+ import './PmQuestionBox.css'
15
+ export default function PmQuestionBox(
16
+ props: {
17
+ target?: string
18
+ place?: Place
19
+ } & {
20
+ children?: ReactNode
21
+ className?: string
22
+ onDone?(): void
23
+ onCited?(): void
24
+ onToggleAnswered?(): void
25
+ onSend?(): void
26
+ },
27
+ ) {
28
+ const { target, place } = props
29
+
30
+ const { me, status, joined, presenter } = useParticipation()
31
+ const store = useClassroomStore()
32
+ const questions = useCollection(() => store, COLLECTIONS.questions, toQuestion)
33
+ const { failure, write } = useGuardedWrite()
34
+ const [text, setText] = useState('')
35
+ const t = useMessages(MESSAGES)
36
+
37
+ const where = props.place ? placeText({ ...props.place, block: props.target }, t) : ''
38
+ const here = questions
39
+ .filter(({ value }) => value.section === props.place?.section && value.block === props.target)
40
+ .sort((a, b) => a.value.at - b.value.at)
41
+ const shown = presenter ? here : here.filter(({ value }) => value.participant === me?.id)
42
+ const canAsk = joined && !presenter && status === 'active'
43
+
44
+ function ask(): void {
45
+ const participant = me
46
+ const question = text.trim()
47
+ if (!store || !participant || !props.place || !question) return
48
+ const { section, title, index } = props.place
49
+ const asked = write(() =>
50
+ store!.put<Question>(COLLECTIONS.questions, `${participant.id}:${newId()}`, {
51
+ section,
52
+ ...(props.target ? { block: props.target } : {}),
53
+ title,
54
+ index,
55
+ text: question,
56
+ participant: participant.id,
57
+ name: participant.name,
58
+ at: Date.now(),
59
+ }),
60
+ )
61
+ if (asked) setText('')
62
+ }
63
+
64
+ function toggleAnswered(id: string, question: Question): void {
65
+ write(() =>
66
+ store?.put<Question>(COLLECTIONS.questions, id, { ...question, answered: !question.answered }),
67
+ )
68
+ }
69
+
70
+ return (
71
+ <>
72
+ <div className="pm-question-box pm-classroom-card" data-place={where}>
73
+ <div className="pm-question-box__prompt">{props.children}</div>
74
+ {canAsk ? (
75
+ <form
76
+ className="pm-question-box__form"
77
+ onSubmit={(event) => {
78
+ event.preventDefault()
79
+ ask()
80
+ }}
81
+ >
82
+ <textarea
83
+ value={text}
84
+ onChange={(event) => setText(event.target.value)}
85
+ className="pm-ui-textarea"
86
+ aria-label={t.questions.question}
87
+ rows={2}
88
+ maxLength={500}
89
+ placeholder={t.questions.placeholder}
90
+ onKeyDown={(event) => {
91
+ event.stopPropagation()
92
+ }}
93
+ ></textarea>
94
+ <p className="pm-classroom-actions">
95
+ <button
96
+ type="submit"
97
+ className="pm-ui-button pm-ui-button--primary pm-ui-button--small"
98
+ disabled={!text.trim()}
99
+ >
100
+ {t.questions.ask}
101
+ </button>
102
+ <span className="pm-classroom-status">{t.questions.attachedTo(where)}</span>
103
+ </p>
104
+ </form>
105
+ ) : !joined ? (
106
+ <p className="pm-classroom-status">{t.questions.joinFirst}</p>
107
+ ) : status !== 'active' ? (
108
+ <p className="pm-classroom-status">{t.questions.ended}</p>
109
+ ) : null}
110
+ {shown.length ? (
111
+ <ul className="pm-question-box__list" aria-label={presenter ? t.questions.here : t.questions.mine}>
112
+ {shown.map((item) => (
113
+ <li key={item.id} data-answered={item.value.answered ? '' : undefined}>
114
+ {presenter ? (
115
+ <span className="pm-question-box__name">
116
+ {item.value.name}
117
+ {t.colon}
118
+ </span>
119
+ ) : null}
120
+ {item.value.text}
121
+ {item.value.answered ? (
122
+ <span className="pm-question-box__answered pm-ui-badge pm-ui-badge--ok">
123
+ {t.questions.answered}
124
+ </span>
125
+ ) : null}
126
+ {item.value.reply ? (
127
+ <p className="pm-classroom-reply">
128
+ {item.value.reply.provider ? t.questions.reply(item.value.reply.provider) : ''}
129
+ {item.value.reply.text}
130
+ </p>
131
+ ) : null}
132
+ {presenter && status === 'active' ? (
133
+ <button
134
+ type="button"
135
+ className="pm-ui-button pm-ui-button--small"
136
+ onClick={(event) => {
137
+ toggleAnswered(item.id, item.value)
138
+ }}
139
+ >
140
+ {item.value.answered ? t.questions.markUnanswered : t.questions.markAnswered}
141
+ </button>
142
+ ) : null}
143
+ </li>
144
+ ))}
145
+ </ul>
146
+ ) : null}
147
+ {failure ? (
148
+ <p className="pm-classroom-error" role="alert">
149
+ {failure}
150
+ </p>
151
+ ) : null}
152
+ </div>
153
+ </>
154
+ )
155
+ }
@@ -0,0 +1,43 @@
1
+ .pm-quiz__options {
2
+ display: grid;
3
+ gap: 0.4em;
4
+ margin: 0.6em 0;
5
+ padding: 0;
6
+ list-style: none;
7
+ }
8
+ .pm-quiz__option {
9
+ display: flex;
10
+ align-items: center;
11
+ justify-content: space-between;
12
+ gap: 0.5em;
13
+ margin: 0;
14
+ padding: 0.35em 0.75em;
15
+ border: var(--pm-size-hairline) solid var(--pm-color-line);
16
+ border-radius: var(--pm-radius-control);
17
+ background: var(--pm-color-surface);
18
+ }
19
+ .pm-quiz__option label {
20
+ display: flex;
21
+ align-items: center;
22
+ gap: 0.5em;
23
+ cursor: pointer;
24
+ }
25
+ .pm-quiz__option[data-correct] {
26
+ border-color: var(--pm-color-ok);
27
+ background: var(--pm-color-ok-soft);
28
+ }
29
+ .pm-quiz__option[data-correct] .pm-quiz__letter {
30
+ color: var(--pm-color-ok);
31
+ }
32
+ .pm-quiz__letter {
33
+ color: var(--pm-color-accent);
34
+ font-family: var(--pm-font-mono);
35
+ font-weight: 600;
36
+ }
37
+ .pm-quiz__count {
38
+ font-variant-numeric: tabular-nums;
39
+ }
40
+ .pm-quiz__tally {
41
+ margin: 0.25rem 0 0;
42
+ font-weight: 600;
43
+ }
@@ -0,0 +1,221 @@
1
+ import { Fragment, useState, useRef, useEffect, useMemo, useId } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { useMessages } from '@lincyaw/host'
4
+ import {
5
+ useClassroomStore,
6
+ useCollection,
7
+ useGuardedWrite,
8
+ useParticipation,
9
+ } from '../src/client/composables.ts'
10
+ import {
11
+ acceptsAnswers,
12
+ answerId,
13
+ COLLECTIONS,
14
+ countedAnswers,
15
+ isCorrect,
16
+ lateAnswers,
17
+ MESSAGES,
18
+ optionLetter,
19
+ placeKey,
20
+ quizSwitched,
21
+ quizTally,
22
+ toAnswer,
23
+ toQuizState,
24
+ } from '../src/domain/index.ts'
25
+ import type { Answer, Place, QuizState } from '../src/domain/index.ts'
26
+ import '../src/client/style.css'
27
+ import './PmQuiz.css'
28
+ export default function PmQuiz(
29
+ props: {
30
+ options: string[]
31
+ correct: number[]
32
+ place?: Place
33
+ } & {
34
+ children?: ReactNode
35
+ className?: string
36
+ onDone?(): void
37
+ onCited?(): void
38
+ onToggleAnswered?(): void
39
+ onSend?(): void
40
+ },
41
+ ) {
42
+ const { options, correct, place } = props
43
+
44
+ const { me, status, joined, presenter } = useParticipation()
45
+ const store = useClassroomStore()
46
+ const quizzes = useCollection(() => store, COLLECTIONS.quizzes, toQuizState)
47
+ const answers = useCollection(() => store, COLLECTIONS.answers, toAnswer)
48
+ const { failure, write } = useGuardedWrite()
49
+ const t = useMessages(MESSAGES)
50
+ const name = useId()
51
+
52
+ const id = props.place ? placeKey(props.place) : ''
53
+ const state = quizzes.find((item) => item.id === id)?.value
54
+ const counted = countedAnswers(
55
+ id,
56
+ state,
57
+ answers.map((item) => item.value),
58
+ )
59
+ const late = lateAnswers(
60
+ id,
61
+ state,
62
+ answers.map((item) => item.value),
63
+ )
64
+ const result = quizTally(props.options.length, props.correct, counted)
65
+ const multiple = props.correct.length > 1
66
+ const open = acceptsAnswers(status, state)
67
+ const closed = state?.open === false
68
+ const submitted = me ? answers.find((item) => item.id === answerId(id, me!.id))?.value : undefined
69
+ const mine = closed ? counted.find((a) => a.participant === me?.id) : submitted
70
+
71
+ const [choice, setChoice] = useState<number[]>([])
72
+ const [tried, setTried] = useState<number[]>()
73
+ useEffect(() => {
74
+ if (mine && (closed || choice.length === 0)) setChoice([...mine.choice])
75
+ }, [mine, closed])
76
+
77
+ const canAnswer = joined ? !presenter && open : true
78
+ const showTally = joined && (presenter || closed)
79
+ const revealed = joined ? closed : tried !== undefined
80
+ const letters = (choice: number[]) => choice.map(optionLetter).join(t.quiz.letters)
81
+
82
+ const statusText = (() => {
83
+ const { quiz } = t
84
+ if (!joined) {
85
+ if (!tried) return quiz.selfTest
86
+ return isCorrect(tried, props.correct) ? quiz.right : quiz.wrong(letters(props.correct))
87
+ }
88
+ if (presenter)
89
+ return open ? quiz.answering : closed ? quiz.closed : status === 'active' ? quiz.notOpen : quiz.ended
90
+ if (closed) {
91
+ const verdict = mine ? quiz.yours(isCorrect(mine.choice, props.correct)) : ''
92
+ return `${quiz.closed}${verdict}${late.some((a) => a.participant === me?.id) ? quiz.lateMine : ''}`
93
+ }
94
+ if (status !== 'active') return quiz.ended
95
+ if (!open) return quiz.waiting
96
+ return submitted ? quiz.submitted(letters(submitted.choice)) : quiz.open
97
+ })()
98
+
99
+ function toggle(i: number): void {
100
+ if (!multiple) setChoice([i])
101
+ else setChoice(choice.includes(i) ? choice.filter((c) => c !== i) : [...choice, i].sort((a, b) => a - b))
102
+ }
103
+
104
+ function submit(): void {
105
+ if (!joined) {
106
+ setTried([...choice])
107
+ return
108
+ }
109
+ const participant = me
110
+ if (!store || !participant || !canAnswer) return
111
+ write(() =>
112
+ store!.put<Answer>(COLLECTIONS.answers, answerId(id, participant.id), {
113
+ quizId: id,
114
+ participant: participant.id,
115
+ name: participant.name,
116
+ choice: choice,
117
+ at: Date.now(),
118
+ }),
119
+ )
120
+ }
121
+
122
+ function setOpen(next: boolean): void {
123
+ if (!store || !props.place) return
124
+ const quiz = { ...props.place, options: props.options, correct: props.correct, openedAt: state?.openedAt }
125
+ write(() => store!.put<QuizState>(COLLECTIONS.quizzes, id, quizSwitched(quiz, next, counted, Date.now())))
126
+ }
127
+
128
+ return (
129
+ <>
130
+ <div
131
+ className="pm-quiz pm-classroom-card"
132
+ data-quiz={id}
133
+ data-state={open ? 'open' : closed ? 'closed' : 'waiting'}
134
+ >
135
+ <div className="pm-quiz__prompt">{props.children}</div>
136
+ <form
137
+ className="pm-quiz__form"
138
+ onSubmit={(event) => {
139
+ event.preventDefault()
140
+ submit()
141
+ }}
142
+ >
143
+ <ol className="pm-quiz__options">
144
+ {options.map((option, i) => (
145
+ <li
146
+ key={i}
147
+ className="pm-quiz__option"
148
+ data-option={optionLetter(i)}
149
+ data-correct={revealed && correct.includes(i) ? '' : undefined}
150
+ >
151
+ <label>
152
+ <input
153
+ className="pm-ui-checkbox"
154
+ type={multiple ? 'checkbox' : 'radio'}
155
+ name={name}
156
+ checked={choice.includes(i)}
157
+ disabled={!canAnswer}
158
+ onChange={(event) => {
159
+ toggle(i)
160
+ }}
161
+ ></input>
162
+ <span className="pm-quiz__letter">{optionLetter(i)}</span>
163
+ <span>{option}</span>
164
+ </label>
165
+ {showTally ? <span className="pm-quiz__count">{t.people(result.counts[i]!)}</span> : null}
166
+ </li>
167
+ ))}
168
+ </ol>
169
+ <p className="pm-classroom-actions">
170
+ {!presenter ? (
171
+ <button
172
+ type="submit"
173
+ className="pm-ui-button pm-ui-button--primary pm-ui-button--small"
174
+ disabled={!canAnswer || choice.length === 0}
175
+ >
176
+ {t.quiz.submit}
177
+ </button>
178
+ ) : null}
179
+ {presenter && status === 'active' ? (
180
+ <>
181
+ {!open ? (
182
+ <button
183
+ type="button"
184
+ className="pm-ui-button pm-ui-button--primary pm-ui-button--small"
185
+ onClick={(event) => {
186
+ setOpen(true)
187
+ }}
188
+ >
189
+ {t.quiz.openButton}
190
+ </button>
191
+ ) : (
192
+ <button
193
+ type="button"
194
+ className="pm-ui-button pm-ui-button--small"
195
+ onClick={(event) => {
196
+ setOpen(false)
197
+ }}
198
+ >
199
+ {t.quiz.closeButton}
200
+ </button>
201
+ )}
202
+ </>
203
+ ) : null}
204
+ </p>
205
+ </form>
206
+ <p className="pm-classroom-status" role="status">
207
+ {statusText}
208
+ </p>
209
+ {showTally ? (
210
+ <p className="pm-quiz__tally">{t.quiz.tally(result.respondents, result.correct)}</p>
211
+ ) : null}
212
+ {presenter && late.length ? <p className="pm-quiz__late">{t.quiz.late(late.length)}</p> : null}
213
+ {failure ? (
214
+ <p className="pm-classroom-error" role="alert">
215
+ {failure}
216
+ </p>
217
+ ) : null}
218
+ </div>
219
+ </>
220
+ )
221
+ }
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@lincyaw/addon-classroom",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.0-rc.1",
4
+ "description": "Presmium plugin: classroom features: anchored notes, quizzes, checked exercises, questions, feedback, and predict-then-reveal",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Lincyaw/presmium.git",
9
+ "directory": "packages/addon-classroom"
10
+ },
11
+ "homepage": "https://github.com/Lincyaw/presmium/tree/main/packages/addon-classroom#readme",
12
+ "bugs": "https://github.com/Lincyaw/presmium/issues",
13
+ "keywords": [
14
+ "presmium"
15
+ ],
16
+ "type": "module",
17
+ "engines": {
18
+ "node": ">=22.19"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "exports": {
24
+ "./domain": "./src/domain/index.ts",
25
+ "./client": "./src/client/index.ts",
26
+ "./presmium": "./src/node/presmium.ts",
27
+ "./package.json": "./package.json"
28
+ },
29
+ "files": [
30
+ "components",
31
+ "src"
32
+ ],
33
+ "dependencies": {},
34
+ "peerDependencies": {
35
+ "react": "^18.3.1",
36
+ "react-dom": "^18.3.1",
37
+ "@lincyaw/host": "^1.0.0-rc.1",
38
+ "@lincyaw/kernel": "^1.0.0-rc.1"
39
+ },
40
+ "devDependencies": {
41
+ "@types/react": "18.3.28",
42
+ "@types/react-dom": "^18.3.7",
43
+ "@lincyaw/kernel": "1.0.0-rc.1",
44
+ "@lincyaw/cli": "1.0.0-rc.1",
45
+ "@lincyaw/host": "1.0.0-rc.1"
46
+ }
6
47
  }
@@ -0,0 +1,15 @@
1
+ import { claimFirst } from '@lincyaw/host'
2
+ import { useEffect, useState } from 'react'
3
+
4
+ import NotesLayer from './NotesLayer.tsx'
5
+ export default function ClassroomOverlays() {
6
+ const [owns, setOwns] = useState(false)
7
+ useEffect(() => {
8
+ const claim = claimFirst('presmium.classroom.notes')
9
+ const update = () => setOwns(claim.owns.getSnapshot())
10
+ const stop = claim.owns.subscribe(update)
11
+ update()
12
+ return () => { stop(); claim.release() }
13
+ }, [])
14
+ return owns ? <NotesLayer /> : null
15
+ }
@@ -0,0 +1,32 @@
1
+ .pm-classroom-panel {
2
+ display: grid;
3
+ gap: 0.5rem;
4
+ color: var(--pm-color-ink);
5
+ font-family: var(--pm-font-sans);
6
+ font-size: 0.875rem;
7
+ line-height: 1.5;
8
+ text-align: left;
9
+ }
10
+ .pm-classroom-panel section {
11
+ display: grid;
12
+ gap: 0.35rem;
13
+ padding-top: 0.5rem;
14
+ border-top: var(--pm-size-hairline) solid var(--pm-color-line);
15
+ }
16
+ .pm-classroom-panel section:first-child {
17
+ padding-top: 0;
18
+ border-top: 0;
19
+ }
20
+ .pm-classroom-panel p {
21
+ margin: 0;
22
+ }
23
+ .pm-classroom-panel ul {
24
+ margin: 0.15rem 0 0.35rem;
25
+ padding-left: 1.2em;
26
+ }
27
+ .pm-classroom-panel li {
28
+ margin: 0.25rem 0;
29
+ }
30
+ .pm-classroom-panel__where {
31
+ font-weight: 600;
32
+ }