tessera-learn 0.0.1 → 0.0.2

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.
@@ -1,7 +1,5 @@
1
- import type { PersistenceAdapter, SavedState } from '../persistence.js';
2
- import type { Interaction } from '../interaction.js';
3
- import { buildScormInteractionFields } from '../interaction-format.js';
4
- import { WriteQueue, callSync, withRetry, formatISO8601Duration } from './retry.js';
1
+ import { BaseScormAdapter, type ScormDialect } from './scorm-base.js';
2
+ import { formatISO8601Duration } from './retry.js';
5
3
 
6
4
  /**
7
5
  * SCORM 2004 API interface.
@@ -17,6 +15,26 @@ export interface SCORM2004API {
17
15
  GetDiagnostic(errorCode: string): string;
18
16
  }
19
17
 
18
+ const SCORM2004_DIALECT: ScormDialect<SCORM2004API> = {
19
+ sessionTimeKey: 'cmi.session_time',
20
+ formatDuration: formatISO8601Duration,
21
+ interactionFields: {
22
+ responseField: 'learner_response',
23
+ timestampField: 'timestamp',
24
+ timestamp: () => new Date().toISOString(),
25
+ // SCORM 2004 accepts the canonical interaction `type` strings unchanged.
26
+ typeValue: (t) => t,
27
+ resultLabels: { correct: 'correct', incorrect: 'incorrect' },
28
+ },
29
+ initialize: (api) => api.Initialize(''),
30
+ terminate: (api) => api.Terminate(''),
31
+ getValue: (api, key) => api.GetValue(key),
32
+ setValue: (api, key, value) => api.SetValue(key, value),
33
+ commit: (api) => api.Commit(''),
34
+ getLastError: (api) => api.GetLastError(),
35
+ getErrorString: (api, code) => api.GetErrorString(code),
36
+ };
37
+
20
38
  /**
21
39
  * SCORM 2004 persistence adapter.
22
40
  *
@@ -24,70 +42,19 @@ export interface SCORM2004API {
24
42
  * On terminate, the queue is drained synchronously (single attempt)
25
43
  * since async retries cannot complete during page unload.
26
44
  */
27
- export class SCORM2004Adapter implements PersistenceAdapter {
28
- #api: SCORM2004API;
29
- #queue = new WriteQueue();
30
- #state: SavedState | null = null;
31
- #terminated = false;
32
- #interactionCount = 0;
33
-
45
+ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
34
46
  constructor(api: SCORM2004API) {
35
- this.#api = api;
36
- // Wire up GetLastError/GetErrorString so retry warnings can name the
37
- // real LMS failure (e.g. "405 Incorrect Data Type") instead of a
38
- // generic "LMS call failed" — production triage needs the code.
39
- this.#queue.errorReporter = {
40
- code: () => this.#api.GetLastError(),
41
- message: (c) => this.#api.GetErrorString(c),
42
- };
43
- }
44
-
45
- /** Expose the underlying SCORM 2004 API so xAPI actor synthesis can read learner fields. */
46
- getAPI(): SCORM2004API {
47
- return this.#api;
48
- }
49
-
50
- async init(): Promise<void> {
51
- await withRetry(() => this.#api.Initialize(''));
52
-
53
- try {
54
- const raw = this.#api.GetValue('cmi.suspend_data');
55
- if (raw && raw.trim()) {
56
- this.#state = JSON.parse(raw);
57
- }
58
- } catch {
59
- this.#state = null;
60
- }
61
-
62
- // Continue cmi.interactions.n indexing where the previous session left
63
- // off. Restarting at 0 would overwrite prior records.
64
- try {
65
- const count = this.#api.GetValue('cmi.interactions._count');
66
- const n = parseInt(count, 10);
67
- if (Number.isFinite(n) && n >= 0) this.#interactionCount = n;
68
- } catch {
69
- // Fallback to 0 if _count read fails.
70
- }
71
- }
72
-
73
- getState(): SavedState | null {
74
- return this.#state;
75
- }
76
-
77
- saveState(state: SavedState): void {
78
- this.#state = state;
79
- const json = JSON.stringify(state);
80
- this.#queue.enqueue(() => this.#api.SetValue('cmi.suspend_data', json));
47
+ super(api, SCORM2004_DIALECT);
81
48
  }
82
49
 
83
50
  setScore(score: number): void {
84
- this.#queue.enqueue(() =>
85
- this.#api.SetValue('cmi.score.raw', String(score))
51
+ this.queue.enqueue(() =>
52
+ this.api.SetValue('cmi.score.raw', String(score))
86
53
  );
87
- this.#queue.enqueue(() => this.#api.SetValue('cmi.score.min', '0'));
88
- this.#queue.enqueue(() => this.#api.SetValue('cmi.score.max', '100'));
89
- this.#queue.enqueue(() =>
90
- this.#api.SetValue('cmi.score.scaled', String(score / 100))
54
+ this.queue.enqueue(() => this.api.SetValue('cmi.score.min', '0'));
55
+ this.queue.enqueue(() => this.api.SetValue('cmi.score.max', '100'));
56
+ this.queue.enqueue(() =>
57
+ this.api.SetValue('cmi.score.scaled', String(score / 100))
91
58
  );
92
59
  }
93
60
 
@@ -96,67 +63,19 @@ export class SCORM2004Adapter implements PersistenceAdapter {
96
63
  // logic internally via course.config.js settings.
97
64
  setCompletionStatus(status: 'incomplete' | 'complete'): void {
98
65
  const value = status === 'complete' ? 'completed' : 'incomplete';
99
- this.#queue.enqueue(() =>
100
- this.#api.SetValue('cmi.completion_status', value)
66
+ this.queue.enqueue(() =>
67
+ this.api.SetValue('cmi.completion_status', value)
101
68
  );
102
69
  }
103
70
 
104
71
  setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
105
72
  // "unknown" is a valid SCORM 2004 value — setting it explicitly prevents
106
73
  // LMSes (notably SCORM Cloud) from rolling up a null status to "passed".
107
- this.#queue.enqueue(() =>
108
- this.#api.SetValue('cmi.success_status', status)
109
- );
110
- }
111
-
112
- setDuration(seconds: number): void {
113
- const formatted = formatISO8601Duration(seconds);
114
- this.#queue.enqueue(() =>
115
- this.#api.SetValue('cmi.session_time', formatted)
116
- );
74
+ this.queue.enqueue(() => this.api.SetValue('cmi.success_status', status));
117
75
  }
118
76
 
119
77
  setExit(mode: 'suspend' | 'normal'): void {
120
78
  // SCORM 2004 §4.2 cmi.exit vocabulary: time-out, suspend, logout, normal, "".
121
- this.#queue.enqueue(() => this.#api.SetValue('cmi.exit', mode));
122
- }
123
-
124
- reportInteraction(
125
- questionId: string,
126
- interaction: Interaction,
127
- correct: boolean | null
128
- ): void {
129
- const n = this.#interactionCount++;
130
- const fields = buildScormInteractionFields(
131
- `cmi.interactions.${n}`,
132
- questionId,
133
- interaction,
134
- correct,
135
- {
136
- responseField: 'learner_response',
137
- timestampField: 'timestamp',
138
- timestamp: new Date().toISOString(),
139
- typeValue: interaction.type,
140
- resultLabels: { correct: 'correct', incorrect: 'incorrect' },
141
- }
142
- );
143
- for (const [key, value] of fields) {
144
- this.#queue.enqueue(() => this.#api.SetValue(key, value));
145
- }
146
- }
147
-
148
- commit(): void {
149
- this.#queue.enqueue(() => this.#api.Commit(''));
150
- }
151
-
152
- terminate(): void {
153
- if (this.#terminated) return;
154
- this.#terminated = true;
155
- // During page unload, async retries can't run.
156
- // Drain any pending queue operations synchronously (single attempt each),
157
- // then commit and terminate synchronously.
158
- this.#queue.drainSync();
159
- callSync(() => this.#api.Commit(''));
160
- callSync(() => this.#api.Terminate(''));
79
+ this.queue.enqueue(() => this.api.SetValue('cmi.exit', mode));
161
80
  }
162
81
  }
@@ -10,6 +10,12 @@ import {
10
10
  } from './contexts.js';
11
11
  import { buildQuizInteractions } from '../components/quiz-payload.js';
12
12
  import type { QuizContext } from '../components/quiz-payload.js';
13
+ import {
14
+ resolveFeedbackMode,
15
+ resolveRetryStrategy,
16
+ type QuizPolicyConfig,
17
+ type QuizQuestionResult,
18
+ } from './quiz-policy.js';
13
19
 
14
20
  export interface UseQuestionOptions {
15
21
  /** Stable identifier used for LMS interaction reporting. Must be unique on the page. */
@@ -22,6 +28,8 @@ export interface UseQuestionOptions {
22
28
  * Default 1; ignored in standalone mode.
23
29
  */
24
30
  weight?: number;
31
+ /** Standalone retry cap. Default `Infinity`. Ignored inside a `<Quiz>`. */
32
+ maxRetries?: number;
25
33
  /** Called on submit — returns the current learner response payload. */
26
34
  response: () => Interaction;
27
35
  /**
@@ -38,8 +46,12 @@ export interface UseQuestionOptions {
38
46
  export interface UseQuestionHandle {
39
47
  submit(): void;
40
48
  reset(): void;
49
+ /** Standalone retry. No-op once `maxRetries` is hit or inside a `<Quiz>`. */
50
+ retry(): void;
41
51
  readonly submitted: boolean;
42
52
  readonly correct: boolean | null;
53
+ readonly canRetry: boolean;
54
+ readonly retryCount: number;
43
55
  readonly mode: 'standalone' | 'quiz';
44
56
  /** Index returned by the parent Quiz registration, used for per-question context reads. Undefined in standalone mode. */
45
57
  readonly quizIndex: number | undefined;
@@ -68,18 +80,23 @@ export function useQuestion(opts: UseQuestionOptions): UseQuestionHandle {
68
80
  return {
69
81
  submit() {},
70
82
  reset() { opts.reset?.(); },
83
+ retry() {},
71
84
  get submitted() { return quizCtx.submitted ?? false; },
72
85
  get correct() {
73
86
  if (!(quizCtx.submitted ?? false)) return null;
74
87
  return isCorrectInteraction(opts.response());
75
88
  },
89
+ canRetry: false,
90
+ retryCount: 0,
76
91
  mode: 'quiz' as const,
77
92
  quizIndex,
78
93
  };
79
94
  }
80
95
 
96
+ const maxRetries = opts.maxRetries ?? Infinity;
81
97
  let submitted = $state(false);
82
98
  let correct = $state<boolean | null>(null);
99
+ let retryCount = $state(0);
83
100
 
84
101
  function submit() {
85
102
  if (submitted) return;
@@ -111,11 +128,20 @@ export function useQuestion(opts: UseQuestionOptions): UseQuestionHandle {
111
128
  opts.reset?.();
112
129
  }
113
130
 
131
+ function retry() {
132
+ if (retryCount >= maxRetries) return;
133
+ retryCount++;
134
+ reset();
135
+ }
136
+
114
137
  return {
115
138
  submit,
116
139
  reset,
140
+ retry,
117
141
  get submitted() { return submitted; },
118
142
  get correct() { return correct; },
143
+ get canRetry() { return retryCount < maxRetries; },
144
+ get retryCount() { return retryCount; },
119
145
  mode: 'standalone' as const,
120
146
  quizIndex: undefined,
121
147
  };
@@ -267,7 +293,6 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
267
293
  );
268
294
  }
269
295
  const quizConfig = pageCtx.quiz;
270
- const passingScore = pageCtx.passingScore ?? 70;
271
296
 
272
297
  // Dev-mode warning: a second useQuiz on the same page silently overwrites
273
298
  // the first quiz's pageIndex-keyed score. We can't prevent it (some pages
@@ -283,9 +308,15 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
283
308
 
284
309
  const maxAttempts = quizConfig.maxAttempts ?? Infinity;
285
310
  const showFeedback = quizConfig.showFeedback ?? true;
286
- const feedbackMode: 'review' | 'immediate' =
287
- showFeedback && quizConfig.feedbackMode === 'immediate' ? 'immediate' : 'review';
288
- const retryMode: 'full' | 'incorrect-only' = quizConfig.retryMode ?? 'full';
311
+
312
+ const policyCfg = quizConfig as QuizPolicyConfig;
313
+ const feedbackPredicate = resolveFeedbackMode(policyCfg);
314
+ const retryPredicate = resolveRetryStrategy(policyCfg);
315
+ // Lock the answer once feedback is revealed in 'immediate' mode and under any
316
+ // custom predicate (opaque; lock conservatively). 'review' mode is post-submit.
317
+ const revealsLockAnswer =
318
+ policyCfg.feedbackMode === 'immediate' ||
319
+ typeof policyCfg.feedbackMode === 'function';
289
320
 
290
321
  interface InternalQuestion extends UseQuizQuestionApi {
291
322
  weight: number;
@@ -380,9 +411,14 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
380
411
 
381
412
  function feedbackVisible(index: number): boolean {
382
413
  if (!showFeedback) return false;
383
- if (feedbackMode === 'immediate' && feedbackShown.has(index)) return true;
384
- if (submitted && reviewing) return true;
385
- return false;
414
+ return feedbackPredicate({
415
+ questionIndex: index,
416
+ submitted,
417
+ reviewing,
418
+ hasAnswered: answers.has(index),
419
+ revealed: feedbackShown.has(index),
420
+ attemptCount,
421
+ });
386
422
  }
387
423
 
388
424
  function revealFeedback(index: number): void {
@@ -442,7 +478,6 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
442
478
  attemptCount++;
443
479
 
444
480
  const interactions = buildQuizInteractions(questions, answers);
445
- void passingScore; // reserved for future custom-shell extensions
446
481
  el.dispatchEvent(
447
482
  new CustomEvent('tessera-quiz-complete', {
448
483
  detail: { score: rounded, interactions },
@@ -462,26 +497,25 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
462
497
 
463
498
  function retry(): void {
464
499
  if (!canRetry) return;
465
- if (retryMode === 'incorrect-only') {
466
- const newLocked = new Set<number>();
467
- const preserved = new Map<number, unknown>();
468
- for (let i = 0; i < questions.length; i++) {
469
- const a = answers.has(i) ? answers.get(i) : undefined;
470
- if (questions[i].checkAnswer(a)) {
471
- newLocked.add(i);
472
- preserved.set(i, a);
473
- }
474
- }
475
- lockedCorrect = newLocked;
476
- answers.clear();
477
- for (const [i, a] of preserved) answers.set(i, a);
478
- for (let i = 0; i < questions.length; i++) {
479
- if (!newLocked.has(i) && questions[i].reset) questions[i].reset!();
480
- }
481
- } else {
482
- lockedCorrect = new Set();
483
- answers.clear();
484
- for (const q of questions) q.reset?.();
500
+ const results: QuizQuestionResult[] = [];
501
+ for (let i = 0; i < questions.length; i++) {
502
+ const a = answers.has(i) ? answers.get(i) : undefined;
503
+ results.push({
504
+ interaction: questions[i].interaction?.() ?? ({} as never),
505
+ correct: questions[i].checkAnswer(a),
506
+ weight: questions[i].weight,
507
+ });
508
+ }
509
+ const newLocked = retryPredicate(results);
510
+ const preserved = new Map<number, unknown>();
511
+ for (const i of newLocked) {
512
+ if (answers.has(i)) preserved.set(i, answers.get(i));
513
+ }
514
+ lockedCorrect = newLocked;
515
+ answers.clear();
516
+ for (const [i, a] of preserved) answers.set(i, a);
517
+ for (let i = 0; i < questions.length; i++) {
518
+ if (!newLocked.has(i) && questions[i].reset) questions[i].reset!();
485
519
  }
486
520
  answersVersion++;
487
521
  feedbackShown = new Set();
@@ -507,7 +541,7 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
507
541
  return (i: number) =>
508
542
  submitted ||
509
543
  lockedCorrect.has(i) ||
510
- (feedbackMode === 'immediate' && feedbackShown.has(i));
544
+ (revealsLockAnswer && feedbackShown.has(i));
511
545
  },
512
546
  get isLockedCorrect() { return (i: number) => lockedCorrect.has(i); },
513
547
  });
@@ -6,8 +6,8 @@ import type {
6
6
  Statement,
7
7
  DestinationOutcome,
8
8
  } from './types.js';
9
- import { XAPIPublisher, XAPIConfigError } from './publisher.js';
10
- import { validatePartialStatement } from './validation.js';
9
+ import { XAPIPublisher } from './publisher.js';
10
+ import { validatePartialStatement, XAPIConfigError } from './validation.js';
11
11
  import { uuidv4 } from './uuid.js';
12
12
 
13
13
  /**
@@ -15,15 +15,12 @@ import {
15
15
  XAPIConfigError,
16
16
  XAPIStatementError,
17
17
  } from './validation.js';
18
+ import { RETRY_ATTEMPTS, backoffMs } from '../adapters/retry.js';
18
19
 
19
20
  /** cmi5 §9.6.2 — well-known IRI owned by ADL for the cmi5 session id extension. */
20
21
  const CMI5_SESSIONID_EXT =
21
22
  'https://w3id.org/xapi/cmi5/context/extensions/sessionid';
22
23
 
23
- // Re-exported so existing callers (xapi/client.ts, tests, etc.) that pull
24
- // these symbols from the publisher entry point keep working.
25
- export { XAPIConfigError, XAPIStatementError, validateAgent, validateAuthCredential };
26
-
27
24
  /**
28
25
  * Combine a field label (e.g. `xapi.actor`) with the prefix-friendly suffix
29
26
  * returned by `validateAgent`. Sub-field suffixes start with `.` and chain
@@ -59,6 +56,8 @@ export interface XAPIPublisherOptions {
59
56
  * Set by the cmi5 adapter and by 'lms'-inherited destinations under cmi5.
60
57
  */
61
58
  cmi5Mode?: boolean;
59
+ /** When set, every send method rejects with the returned Error without hitting the network. */
60
+ unavailableReason?: () => Error;
62
61
  }
63
62
 
64
63
  interface SendOutcome {
@@ -67,7 +66,7 @@ interface SendOutcome {
67
66
  error?: Error;
68
67
  }
69
68
 
70
- const STATEMENT_RETRY_ATTEMPTS = 3;
69
+ const STATEMENT_RETRY_ATTEMPTS = RETRY_ATTEMPTS;
71
70
  /**
72
71
  * Soft cap on the number of in-flight statements queued behind the head of
73
72
  * the chain. We log a one-time warning when the queue grows past this so
@@ -106,6 +105,9 @@ export class XAPIPublisher {
106
105
  readonly #sessionId: string;
107
106
  readonly #cmi5Mode: boolean;
108
107
 
108
+ // When set, every send method short-circuits with a rejected promise.
109
+ readonly #unavailableReason: (() => Error) | null;
110
+
109
111
  // Auth — string or resolver. Cached after first resolution.
110
112
  readonly #authValue: string | (() => string | Promise<string>);
111
113
  #cachedAuth: string | null = null;
@@ -149,6 +151,7 @@ export class XAPIPublisher {
149
151
  this.#authValue = opts.auth;
150
152
  this.#actorValue = opts.actor;
151
153
  this.#sessionId = opts.sessionId ?? uuidv4();
154
+ this.#unavailableReason = opts.unavailableReason ?? null;
152
155
 
153
156
  if (typeof this.#actorValue !== 'function') {
154
157
  this.#cachedActor = this.#actorValue;
@@ -298,6 +301,9 @@ export class XAPIPublisher {
298
301
  partial: PartialStatement,
299
302
  options?: SendStatementOptions & { id?: string }
300
303
  ): Promise<SendStatementResult> {
304
+ if (this.#unavailableReason) {
305
+ return Promise.reject(this.#unavailableReason());
306
+ }
301
307
  try {
302
308
  validatePartialStatement(partial);
303
309
  } catch (err) {
@@ -332,6 +338,9 @@ export class XAPIPublisher {
332
338
  statementOrBatch: Statement | Statement[],
333
339
  options?: SendStatementOptions
334
340
  ): Promise<DestinationOutcome> {
341
+ if (this.#unavailableReason) {
342
+ return Promise.reject(this.#unavailableReason());
343
+ }
335
344
  if (this.#queueDepth >= QUEUE_DEPTH_SATURATED) {
336
345
  return Promise.resolve<DestinationOutcome>({
337
346
  endpoint: this.#endpoint,
@@ -449,7 +458,7 @@ export class XAPIPublisher {
449
458
  }
450
459
  if (isFinal) return outcome;
451
460
  return new Promise<void>((r) =>
452
- setTimeout(r, 100 * Math.pow(2, n))
461
+ setTimeout(r, backoffMs(n))
453
462
  ).then(() => attempt(n + 1));
454
463
  });
455
464
  };
@@ -42,28 +42,21 @@ class XAPIDevFallbackError extends Error {
42
42
  }
43
43
 
44
44
  /**
45
- * Build a stub publisher whose sends reject with `error`. Used for both
46
- * dev-fallback paths: cmi5 `endpoint: 'lms'` with no launch params, and
47
- * SCORM explicit endpoints that depend on a learner identity the dev
48
- * fallback can't synthesize. The placeholder publisher carries a static
49
- * actor so its constructor invariants hold and `XAPIClient.buildStatement`
50
- * can run without throwing only the network-bound methods reject.
45
+ * Build a stub publisher whose sends reject with the supplied error. Used for
46
+ * both dev-fallback paths: cmi5 `endpoint: 'lms'` with no launch params, and
47
+ * SCORM explicit endpoints that depend on a learner identity the dev fallback
48
+ * can't synthesize. The placeholder carries a static actor so the constructor
49
+ * invariants hold and `XAPIClient.buildStatement` can run without throwing —
50
+ * the `unavailableReason` opt makes only the network-bound methods reject.
51
51
  */
52
52
  function makeRejectingPublisher(error: () => Error): XAPIPublisher {
53
- const pub = new XAPIPublisher({
53
+ return new XAPIPublisher({
54
54
  endpoint: 'http://localhost/__tessera_dev_fallback__/',
55
55
  auth: '',
56
56
  actor: { mbox: 'mailto:nobody@example.invalid', objectType: 'Agent' },
57
57
  activityId: 'http://localhost/__tessera_dev_fallback__',
58
+ unavailableReason: error,
58
59
  });
59
- // The static actor is cached at construction so getActor()/buildStatement
60
- // work without a separate init() call. We only override the methods that
61
- // would otherwise hit the network so author code surfaces the explicit
62
- // error rather than silently no-oping.
63
- const reject = (): Promise<never> => Promise.reject(error());
64
- (pub as any).sendStatement = reject;
65
- (pub as any).enqueueBuilt = reject;
66
- return pub;
67
60
  }
68
61
 
69
62
  function makeDevFallbackPublisher(): XAPIPublisher {
package/styles/layout.css CHANGED
@@ -315,6 +315,27 @@
315
315
  text-align: center;
316
316
  }
317
317
 
318
+ /* ---- Buttons ---- */
319
+ .tessera-btn-primary {
320
+ background-color: var(--tessera-primary);
321
+ color: #ffffff;
322
+ border: none;
323
+ border-radius: 6px;
324
+ cursor: pointer;
325
+ min-height: 44px;
326
+ font-family: var(--tessera-font-family);
327
+ transition: background-color var(--tessera-transition-fast), opacity var(--tessera-transition-fast);
328
+ }
329
+
330
+ .tessera-btn-primary:hover:not(:disabled) {
331
+ background-color: var(--tessera-primary-dark);
332
+ }
333
+
334
+ .tessera-btn-primary:disabled {
335
+ opacity: 0.4;
336
+ cursor: not-allowed;
337
+ }
338
+
318
339
  /* ---- Error Page ---- */
319
340
  .tessera-error {
320
341
  text-align: center;
@@ -338,16 +359,6 @@
338
359
  padding: var(--tessera-spacing-sm) var(--tessera-spacing-lg);
339
360
  font-size: 0.875rem;
340
361
  font-weight: 600;
341
- color: #ffffff;
342
- background-color: var(--tessera-primary);
343
- border: none;
344
- border-radius: 6px;
345
- cursor: pointer;
346
- transition: background-color var(--tessera-transition-fast);
347
- }
348
-
349
- .tessera-error-retry:hover {
350
- background-color: var(--tessera-primary-dark);
351
362
  }
352
363
 
353
364
  /* ---- Responsive: Tablet (max-width: 1024px) ---- */
package/styles/theme.css CHANGED
@@ -9,7 +9,11 @@
9
9
  --tessera-bg-secondary: #f9fafb;
10
10
  --tessera-border: #e5e7eb;
11
11
  --tessera-success: #16a34a;
12
+ --tessera-success-bg: color-mix(in srgb, var(--tessera-success) 8%, transparent);
13
+ --tessera-success-border: color-mix(in srgb, var(--tessera-success) 25%, transparent);
12
14
  --tessera-error: #dc2626;
15
+ --tessera-error-bg: color-mix(in srgb, var(--tessera-error) 8%, transparent);
16
+ --tessera-error-border: color-mix(in srgb, var(--tessera-error) 25%, transparent);
13
17
  --tessera-warning: #d97706;
14
18
 
15
19
  /* Typography */