@khanglvm/relay 0.5.4 → 0.7.0
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/README.md +16 -0
- package/docs/AGENT.md +53 -17
- package/package.json +1 -1
- package/skills/relay/SKILL.md +10 -3
- package/src/cli.js +119 -2
- package/src/server.js +111 -14
- package/src/spec.js +7 -4
- package/src/ui/annotate.css +93 -1
- package/src/ui/annotate.js +259 -11
- package/src/ui/app.js +125 -39
- package/src/ui/blocks.css +25 -7
- package/src/ui/blocks.js +54 -1
- package/src/ui/kit.js +335 -27
- package/src/ui/style.css +8 -2
package/src/ui/kit.js
CHANGED
|
@@ -32,13 +32,26 @@
|
|
|
32
32
|
* sortable: click column header to sort ascending/descending.
|
|
33
33
|
*
|
|
34
34
|
* relayKit.commentable(el, label, detail?)
|
|
35
|
-
*
|
|
36
|
-
*
|
|
35
|
+
* Make one element commentable: hover shows a comment pin, a per-element
|
|
36
|
+
* badge counts comments, click opens the board's annotation popover anchored
|
|
37
|
+
* to the element. Thin shim over relayKit.annotate.register().
|
|
38
|
+
*
|
|
39
|
+
* relayKit.annotate.auto()
|
|
40
|
+
* Injected automatically by the server into every custom-HTML iframe. With no
|
|
41
|
+
* explicit signals it makes a sensible set of content/interactive elements
|
|
42
|
+
* hover-commentable so the user can annotate ANY meaningful part of the HTML.
|
|
43
|
+
* If the author marks elements with data-relay-annotate[="Label"] (+ optional
|
|
44
|
+
* data-relay-detail) or calls commentable(), auto-mode backs off to just those.
|
|
45
|
+
* Opt out with data-relay-annotate="off" on <html> or <body>.
|
|
37
46
|
*/
|
|
38
47
|
|
|
39
48
|
(function () {
|
|
40
49
|
'use strict';
|
|
41
50
|
|
|
51
|
+
// Idempotent: the server may inject a /kit.js load on top of one the author
|
|
52
|
+
// already added. First load wins; a second execution no-ops.
|
|
53
|
+
if (window.relayKit) return;
|
|
54
|
+
|
|
42
55
|
// ---------------------------------------------------------------------------
|
|
43
56
|
// Theme
|
|
44
57
|
// ---------------------------------------------------------------------------
|
|
@@ -357,39 +370,333 @@
|
|
|
357
370
|
}
|
|
358
371
|
|
|
359
372
|
// ---------------------------------------------------------------------------
|
|
360
|
-
//
|
|
373
|
+
// annotate — element-level commenting inside the sandboxed iframe
|
|
361
374
|
// ---------------------------------------------------------------------------
|
|
375
|
+
// The board's annotation engine lives in the PARENT page and can't reach into
|
|
376
|
+
// this cross-origin iframe (sandbox without allow-same-origin), so everything
|
|
377
|
+
// here runs iframe-side and talks to the parent over postMessage:
|
|
378
|
+
// iframe → parent {relay:'annotate-ready'} request counts
|
|
379
|
+
// iframe → parent {relay:'annotate-request', ref, label, detail?, rect}
|
|
380
|
+
// parent → iframe {relay:'annotate-counts', counts:{ref:n}} draw badges
|
|
381
|
+
//
|
|
382
|
+
// `ref` is a compact, reload-stable CSS path used as the element's identity so
|
|
383
|
+
// comments re-bind and badges count per element.
|
|
384
|
+
|
|
385
|
+
const annotate = (() => {
|
|
386
|
+
// Auto-mode picks anything the user would plausibly point at: every element
|
|
387
|
+
// with its OWN direct text (so div/span-based mockups work, not just
|
|
388
|
+
// semantic tags), plus meaningful leaves (icons, buttons, media) and a few
|
|
389
|
+
// semantic containers worth commenting whole. closest() resolves overlaps to
|
|
390
|
+
// the innermost target on hover.
|
|
391
|
+
const LEAF_TAGS = /^(IMG|BUTTON|A|INPUT|SELECT|TEXTAREA|svg|VIDEO|CANVAS|SUMMARY)$/;
|
|
392
|
+
const SKIP_TAGS = /^(SCRIPT|STYLE|HEAD|META|LINK|TITLE|BASE|NOSCRIPT|TEMPLATE)$/;
|
|
393
|
+
const CONTAINER_SELECTOR =
|
|
394
|
+
'li,td,th,figure,blockquote,article,.card,[role="button"],[role="listitem"],[role="option"]';
|
|
395
|
+
const MAX_AUTO = 400;
|
|
396
|
+
// Replaced elements that can't host a badge child — overlay the badge instead.
|
|
397
|
+
const NO_CHILD = /^(IMG|INPUT|HR|BR|EMBED|CANVAS|VIDEO|svg)$/;
|
|
398
|
+
|
|
399
|
+
const byRef = new Map(); // ref -> element
|
|
400
|
+
let mode = null; // 'auto' | 'explicit' (decided on first scan)
|
|
401
|
+
let started = false;
|
|
402
|
+
let hot = null; // currently outlined element
|
|
403
|
+
let pin = null; // floating pin button
|
|
404
|
+
let hideTimer = 0;
|
|
405
|
+
let lastCounts = {};
|
|
406
|
+
let overlayBadges = []; // [{el, badge}] for replaced elements
|
|
407
|
+
let repoTimer = 0;
|
|
408
|
+
|
|
409
|
+
const accent = () => theme.colors.accent;
|
|
410
|
+
|
|
411
|
+
// Compact, reload-stable CSS path — the element's identity.
|
|
412
|
+
function refOf(el) {
|
|
413
|
+
if (el.__relayRef) return el.__relayRef;
|
|
414
|
+
const parts = [];
|
|
415
|
+
let n = el;
|
|
416
|
+
while (n && n.nodeType === 1 && n !== document.body && parts.length < 10) {
|
|
417
|
+
let seg = n.tagName.toLowerCase();
|
|
418
|
+
const p = n.parentNode;
|
|
419
|
+
if (p && p.children) {
|
|
420
|
+
const sibs = Array.prototype.filter.call(p.children, (c) => c.tagName === n.tagName);
|
|
421
|
+
if (sibs.length > 1) seg += ':nth-of-type(' + (sibs.indexOf(n) + 1) + ')';
|
|
422
|
+
}
|
|
423
|
+
parts.unshift(seg);
|
|
424
|
+
n = p;
|
|
425
|
+
}
|
|
426
|
+
const ref = parts.join('>') || el.tagName.toLowerCase();
|
|
427
|
+
el.__relayRef = ref;
|
|
428
|
+
return ref;
|
|
429
|
+
}
|
|
362
430
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
431
|
+
// Human label when the author didn't supply one.
|
|
432
|
+
function labelOf(el) {
|
|
433
|
+
const aria = el.getAttribute && el.getAttribute('aria-label');
|
|
434
|
+
if (aria) return aria.trim().slice(0, 80) || el.tagName.toLowerCase();
|
|
435
|
+
if (el.tagName === 'IMG') return (el.getAttribute('alt') || 'Image').trim().slice(0, 80) || 'Image';
|
|
436
|
+
const txt = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
|
437
|
+
return txt ? txt.slice(0, 80) : el.tagName.toLowerCase();
|
|
438
|
+
}
|
|
368
439
|
|
|
369
|
-
|
|
370
|
-
|
|
440
|
+
function injectStyle() {
|
|
441
|
+
if (document.getElementById('relay-ann-style')) return;
|
|
442
|
+
const s = document.createElement('style');
|
|
443
|
+
s.id = 'relay-ann-style';
|
|
444
|
+
s.textContent = [
|
|
445
|
+
'.relay-ann-hot{outline:2px solid ' + accent() + ' !important;outline-offset:1px !important;}',
|
|
446
|
+
'.relay-ann-badge{position:absolute;top:-7px;right:-7px;min-width:16px;height:16px;padding:0 4px;' +
|
|
447
|
+
'box-sizing:border-box;border-radius:9px;background:' + accent() + ';color:#fff;font:600 10px/16px ' + SANS + ';' +
|
|
448
|
+
'text-align:center;z-index:2147483646;cursor:pointer;box-shadow:0 1px 2px rgba(0,0,0,.25);}',
|
|
449
|
+
].join('\n');
|
|
450
|
+
(document.head || document.documentElement).appendChild(s);
|
|
451
|
+
}
|
|
371
452
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
453
|
+
function ensurePin() {
|
|
454
|
+
if (pin || !document.body) return pin;
|
|
455
|
+
pin = document.createElement('button');
|
|
456
|
+
pin.type = 'button';
|
|
457
|
+
pin.setAttribute('aria-label', 'Add a comment');
|
|
458
|
+
pin.title = 'Add a comment';
|
|
459
|
+
pin.style.cssText = [
|
|
460
|
+
'position:fixed', 'z-index:2147483647', 'display:none', 'width:22px', 'height:22px',
|
|
461
|
+
'padding:0', 'border:none', 'border-radius:50%', 'cursor:pointer', 'background:' + accent(),
|
|
462
|
+
'color:#fff', 'align-items:center', 'justify-content:center', 'line-height:0',
|
|
463
|
+
'box-shadow:0 1px 4px rgba(0,0,0,.3)',
|
|
464
|
+
].join(';');
|
|
465
|
+
pin.innerHTML =
|
|
466
|
+
'<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">' +
|
|
467
|
+
'<path d="M3 2.5h10A1.5 1.5 0 0 1 14.5 4v5a1.5 1.5 0 0 1-1.5 1.5H8.4L5 13.4v-2.9H3A1.5 1.5 0 0 1 1.5 9V4A1.5 1.5 0 0 1 3 2.5Z" fill="currentColor"/></svg>';
|
|
468
|
+
pin.addEventListener('mouseenter', () => clearTimeout(hideTimer));
|
|
469
|
+
pin.addEventListener('mouseleave', scheduleHide);
|
|
470
|
+
pin.addEventListener('click', (e) => {
|
|
471
|
+
e.preventDefault();
|
|
381
472
|
e.stopPropagation();
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
473
|
+
if (hot) request(hot);
|
|
474
|
+
hidePin();
|
|
475
|
+
});
|
|
476
|
+
document.body.appendChild(pin);
|
|
477
|
+
return pin;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function showPinFor(el) {
|
|
481
|
+
if (!ensurePin()) return;
|
|
482
|
+
clearTimeout(hideTimer);
|
|
483
|
+
if (hot && hot !== el) hot.classList.remove('relay-ann-hot');
|
|
484
|
+
hot = el;
|
|
485
|
+
el.classList.add('relay-ann-hot');
|
|
486
|
+
const r = el.getBoundingClientRect();
|
|
487
|
+
const w = 22;
|
|
488
|
+
pin.style.display = 'flex';
|
|
489
|
+
pin.style.left = Math.max(2, Math.min(r.right - w / 2, window.innerWidth - w - 2)) + 'px';
|
|
490
|
+
pin.style.top = Math.max(2, Math.min(r.top - w / 2, window.innerHeight - w - 2)) + 'px';
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function scheduleHide() {
|
|
494
|
+
clearTimeout(hideTimer);
|
|
495
|
+
hideTimer = setTimeout(hidePin, 220); // grace so the pin itself stays clickable
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function hidePin() {
|
|
499
|
+
clearTimeout(hideTimer);
|
|
500
|
+
if (hot) hot.classList.remove('relay-ann-hot');
|
|
501
|
+
hot = null;
|
|
502
|
+
if (pin) pin.style.display = 'none';
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Ask the parent to open its annotation popover for this element.
|
|
506
|
+
function request(el) {
|
|
507
|
+
const r = el.getBoundingClientRect();
|
|
508
|
+
const msg = {
|
|
509
|
+
relay: 'annotate-request',
|
|
510
|
+
ref: refOf(el),
|
|
511
|
+
label: String(el.__relayLabel || labelOf(el)).slice(0, 200),
|
|
512
|
+
rect: { left: r.left, top: r.top, width: r.width, height: r.height },
|
|
513
|
+
};
|
|
514
|
+
if (el.__relayDetail != null) msg.detail = String(el.__relayDetail).slice(0, 500);
|
|
515
|
+
try { parent.postMessage(msg, '*'); } catch (_) {}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function positionOverlay(el, badge) {
|
|
519
|
+
const r = el.getBoundingClientRect();
|
|
520
|
+
badge.style.left = (r.right + window.scrollX - 9) + 'px';
|
|
521
|
+
badge.style.top = (r.top + window.scrollY - 7) + 'px';
|
|
522
|
+
badge.style.right = 'auto';
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function repositionOverlays() {
|
|
526
|
+
for (const o of overlayBadges) if (o.el.isConnected) positionOverlay(o.el, o.badge);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function renderBadges(counts) {
|
|
530
|
+
lastCounts = counts || {};
|
|
531
|
+
for (const b of document.querySelectorAll('.relay-ann-badge')) b.remove();
|
|
532
|
+
overlayBadges = [];
|
|
533
|
+
for (const ref of Object.keys(lastCounts)) {
|
|
534
|
+
const n = lastCounts[ref];
|
|
535
|
+
if (!n) continue;
|
|
536
|
+
const el = byRef.get(ref);
|
|
537
|
+
if (!el || !el.isConnected) continue;
|
|
538
|
+
const badge = document.createElement('span');
|
|
539
|
+
badge.className = 'relay-ann-badge';
|
|
540
|
+
badge.textContent = String(n);
|
|
541
|
+
badge.title = n + (n === 1 ? ' comment' : ' comments');
|
|
542
|
+
badge.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); request(el); });
|
|
543
|
+
if (NO_CHILD.test(el.tagName)) {
|
|
544
|
+
badge.style.position = 'absolute';
|
|
545
|
+
document.body.appendChild(badge);
|
|
546
|
+
overlayBadges.push({ el, badge });
|
|
547
|
+
positionOverlay(el, badge);
|
|
548
|
+
} else {
|
|
549
|
+
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
|
|
550
|
+
el.appendChild(badge);
|
|
388
551
|
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function add(el, label, detail) {
|
|
556
|
+
if (!el || el.nodeType !== 1) return;
|
|
557
|
+
byRef.set(refOf(el), el);
|
|
558
|
+
el.classList.add('relay-annotatable');
|
|
559
|
+
if (label != null && label !== '') el.__relayLabel = String(label);
|
|
560
|
+
if (detail != null) el.__relayDetail = detail;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// True when the element holds non-whitespace text of its OWN (a direct text
|
|
564
|
+
// node), not just text inherited from descendants.
|
|
565
|
+
function hasDirectText(el) {
|
|
566
|
+
for (const n of el.childNodes) {
|
|
567
|
+
if (n.nodeType === 3 && n.nodeValue && n.nodeValue.trim()) return true;
|
|
568
|
+
}
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function autoPick() {
|
|
573
|
+
let all;
|
|
574
|
+
try { all = document.body.querySelectorAll('*'); } catch (_) { return []; }
|
|
575
|
+
let containers;
|
|
576
|
+
try { containers = new Set(document.querySelectorAll(CONTAINER_SELECTOR)); } catch (_) { containers = new Set(); }
|
|
577
|
+
const out = [];
|
|
578
|
+
for (const el of all) {
|
|
579
|
+
if (out.length >= MAX_AUTO) break;
|
|
580
|
+
if (SKIP_TAGS.test(el.tagName)) continue;
|
|
581
|
+
if (pin && pin.contains(el)) continue; // our own pin
|
|
582
|
+
if (el.classList && el.classList.contains('relay-ann-badge')) continue;
|
|
583
|
+
if (LEAF_TAGS.test(el.tagName) || containers.has(el) || hasDirectText(el)) out.push(el);
|
|
584
|
+
}
|
|
585
|
+
return out;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function scan() {
|
|
589
|
+
const signalled = Array.prototype.filter.call(
|
|
590
|
+
document.querySelectorAll('[data-relay-annotate]'),
|
|
591
|
+
(el) => el.getAttribute('data-relay-annotate') !== 'off'
|
|
592
|
+
);
|
|
593
|
+
// ANY explicit signal (attribute or a prior commentable() call) → the
|
|
594
|
+
// author scopes what's annotatable; auto-mode stays off.
|
|
595
|
+
if (mode === null) mode = signalled.length > 0 || byRef.size > 0 ? 'explicit' : 'auto';
|
|
596
|
+
if (mode === 'explicit') {
|
|
597
|
+
for (const el of signalled) add(el, el.getAttribute('data-relay-annotate') || null, el.getAttribute('data-relay-detail'));
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
for (const el of autoPick()) add(el, null, undefined);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function optedOut() {
|
|
604
|
+
const v = (document.body && document.body.getAttribute('data-relay-annotate')) ||
|
|
605
|
+
document.documentElement.getAttribute('data-relay-annotate');
|
|
606
|
+
return v === 'off';
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function announce() {
|
|
610
|
+
try { parent.postMessage({ relay: 'annotate-ready' }, '*'); } catch (_) {}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function start() {
|
|
614
|
+
if (started) return;
|
|
615
|
+
if (!document.body) { document.addEventListener('DOMContentLoaded', start, { once: true }); return; }
|
|
616
|
+
started = true;
|
|
617
|
+
injectStyle();
|
|
618
|
+
ensurePin();
|
|
619
|
+
// Delegated hover — closest() resolves nested targets to the innermost.
|
|
620
|
+
document.addEventListener('mouseover', (e) => {
|
|
621
|
+
const t = e.target && e.target.closest && e.target.closest('.relay-annotatable');
|
|
622
|
+
if (t) showPinFor(t);
|
|
623
|
+
}, true);
|
|
624
|
+
document.addEventListener('mouseout', (e) => {
|
|
625
|
+
const t = e.target && e.target.closest && e.target.closest('.relay-annotatable');
|
|
626
|
+
if (t) scheduleHide();
|
|
627
|
+
}, true);
|
|
628
|
+
window.addEventListener('scroll', () => {
|
|
629
|
+
hidePin();
|
|
630
|
+
if (overlayBadges.length) { cancelAnimationFrame(repoTimer); repoTimer = requestAnimationFrame(repositionOverlays); }
|
|
631
|
+
}, true);
|
|
632
|
+
window.addEventListener('resize', () => { hidePin(); repositionOverlays(); });
|
|
633
|
+
window.addEventListener('message', (e) => {
|
|
634
|
+
const m = e.data;
|
|
635
|
+
if (m && typeof m === 'object' && m.relay === 'annotate-counts') renderBadges(m.counts || {});
|
|
389
636
|
});
|
|
390
|
-
|
|
391
|
-
|
|
637
|
+
// Re-pick up DOM that author JS builds after load. We observe childList
|
|
638
|
+
// only (not attributes, so our own hover-class toggles don't fire it) and
|
|
639
|
+
// ignore mutations that only touch our own pin/badge nodes — otherwise
|
|
640
|
+
// rendering a badge would re-trigger the observer in a tight loop.
|
|
641
|
+
try {
|
|
642
|
+
const ours = (n) =>
|
|
643
|
+
n.nodeType === 1 &&
|
|
644
|
+
(n === pin || (pin && pin.contains(n)) || n.id === 'relay-ann-style' ||
|
|
645
|
+
(n.classList && n.classList.contains('relay-ann-badge')));
|
|
646
|
+
const mo = new MutationObserver((muts) => {
|
|
647
|
+
let relevant = false;
|
|
648
|
+
for (const m of muts) {
|
|
649
|
+
for (const n of m.addedNodes) if (!ours(n)) { relevant = true; break; }
|
|
650
|
+
if (relevant) break;
|
|
651
|
+
for (const n of m.removedNodes) if (!ours(n)) { relevant = true; break; }
|
|
652
|
+
if (relevant) break;
|
|
653
|
+
}
|
|
654
|
+
if (!relevant) return;
|
|
655
|
+
clearTimeout(mo._t);
|
|
656
|
+
mo._t = setTimeout(() => { scan(); renderBadges(lastCounts); announce(); }, 250);
|
|
657
|
+
});
|
|
658
|
+
mo.observe(document.body, { childList: true, subtree: true });
|
|
659
|
+
} catch (_) {}
|
|
660
|
+
announce();
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Server-injected entrypoint. Idempotent.
|
|
664
|
+
function auto() {
|
|
665
|
+
try {
|
|
666
|
+
if (optedOut()) return;
|
|
667
|
+
if (!document.body) { document.addEventListener('DOMContentLoaded', auto, { once: true }); return; }
|
|
668
|
+
scan();
|
|
669
|
+
start();
|
|
670
|
+
} catch (err) {
|
|
671
|
+
console.warn('[relayKit] annotate.auto error:', err);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Explicit per-element registration (also used by commentable()).
|
|
676
|
+
function register(el, label, detail) {
|
|
677
|
+
try {
|
|
678
|
+
add(el, label, detail);
|
|
679
|
+
if (started) renderBadges(lastCounts);
|
|
680
|
+
else start();
|
|
681
|
+
} catch (err) {
|
|
682
|
+
console.warn('[relayKit] annotate.register error:', err);
|
|
683
|
+
}
|
|
392
684
|
}
|
|
685
|
+
|
|
686
|
+
return { auto, register };
|
|
687
|
+
})();
|
|
688
|
+
|
|
689
|
+
// ---------------------------------------------------------------------------
|
|
690
|
+
// commentable() — back-compat shim over annotate.register()
|
|
691
|
+
// ---------------------------------------------------------------------------
|
|
692
|
+
|
|
693
|
+
function commentable(el, label, detail) {
|
|
694
|
+
if (!el) return;
|
|
695
|
+
annotate.register(
|
|
696
|
+
el,
|
|
697
|
+
label != null ? String(label).slice(0, 200) : null,
|
|
698
|
+
detail != null ? String(detail).slice(0, 500) : undefined
|
|
699
|
+
);
|
|
393
700
|
}
|
|
394
701
|
|
|
395
702
|
// ---------------------------------------------------------------------------
|
|
@@ -403,6 +710,7 @@
|
|
|
403
710
|
mermaid,
|
|
404
711
|
table,
|
|
405
712
|
commentable,
|
|
713
|
+
annotate,
|
|
406
714
|
};
|
|
407
715
|
|
|
408
716
|
window.relayKit = relayKit;
|
package/src/ui/style.css
CHANGED
|
@@ -73,7 +73,7 @@ body {
|
|
|
73
73
|
color: var(--fg);
|
|
74
74
|
font: 16px/1.6 var(--sans);
|
|
75
75
|
-webkit-font-smoothing: antialiased;
|
|
76
|
-
transition: background 200ms var(--ease), color 200ms var(--ease);
|
|
76
|
+
transition: background 200ms var(--ease), color 200ms var(--ease), padding 240ms var(--ease);
|
|
77
77
|
}
|
|
78
78
|
.wrap { max-width: 860px; margin: 0 auto; padding: 32px 20px 28px; }
|
|
79
79
|
|
|
@@ -193,7 +193,7 @@ input[type="text"]:focus, textarea:focus {
|
|
|
193
193
|
textarea { min-height: 90px; resize: vertical; }
|
|
194
194
|
|
|
195
195
|
.qnotewrap { margin-top: 10px; }
|
|
196
|
-
.qnotewrap .qnote { font-size: 0.88rem; padding: 8px 12px; }
|
|
196
|
+
.qnotewrap .qnote { font-size: 0.88rem; padding: 8px 12px; min-height: 52px; resize: vertical; }
|
|
197
197
|
|
|
198
198
|
.errmsg { color: var(--danger); font-size: 0.85rem; margin: 8px 0 0; display: none; }
|
|
199
199
|
.card.error .errmsg { display: block; }
|
|
@@ -220,8 +220,14 @@ textarea { min-height: 90px; resize: vertical; }
|
|
|
220
220
|
position: sticky; top: 0;
|
|
221
221
|
background: var(--danger); color: var(--danger-fg);
|
|
222
222
|
text-align: center; padding: 9px 16px; font-size: 0.9rem;
|
|
223
|
+
line-height: 1.45;
|
|
223
224
|
z-index: 10; display: none;
|
|
225
|
+
border-bottom: 1px solid transparent;
|
|
224
226
|
}
|
|
227
|
+
/* Calm status notes (timeout hand-back / lost connection). Never the red error
|
|
228
|
+
state — the board stays usable, so these only inform. */
|
|
229
|
+
.banner.info { background: var(--accent-soft); color: var(--accent); }
|
|
230
|
+
.banner.warn { background: var(--bg-sunken); color: var(--fg-2); border-bottom-color: var(--border-strong); }
|
|
225
231
|
|
|
226
232
|
/* Live-update toast: flagged after a `rly update` reload (see app.js).
|
|
227
233
|
Fixed top-center, accent-soft bg, accent text; JS auto-removes it. */
|