@khanglvm/relay 0.10.3 → 0.12.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 +72 -0
- package/docs/AGENT.md +72 -1
- package/package.json +6 -2
- package/skills/relay/SKILL.md +12 -2
- package/skills/relay/examples/palette-and-color.json +42 -0
- package/src/cli.js +94 -0
- package/src/mcp-ui/board.js +877 -0
- package/src/mcp-ui/index.html +38 -0
- package/src/mcp.js +454 -0
- package/src/server.js +3 -0
- package/src/spec.js +65 -3
- package/src/ui/annotate.js +8 -1
- package/src/ui/app.js +93 -1
- package/src/ui/blocks.css +40 -0
- package/src/ui/blocks.js +86 -5
- package/src/ui/style.css +28 -2
package/src/ui/app.js
CHANGED
|
@@ -88,6 +88,31 @@
|
|
|
88
88
|
}
|
|
89
89
|
let themeBtn = null;
|
|
90
90
|
|
|
91
|
+
// ---------- font scale (A− / A+) ----------
|
|
92
|
+
// A page-zoom knob: scales the root font-size (every rem) so the whole board
|
|
93
|
+
// grows/shrinks. Persists like the theme — server pref + localStorage mirror —
|
|
94
|
+
// so the choice carries across the random-port boards.
|
|
95
|
+
const FS_KEY = 'qb-fontscale';
|
|
96
|
+
const FS_MIN = 0.85, FS_MAX = 1.5, FS_STEP = 0.1;
|
|
97
|
+
let fontScale = Number((boot.pref && boot.pref.fontScale) || localStorage.getItem(FS_KEY));
|
|
98
|
+
if (!Number.isFinite(fontScale) || fontScale <= 0) fontScale = 1;
|
|
99
|
+
const clampFs = (n) => Math.min(FS_MAX, Math.max(FS_MIN, Math.round(n * 100) / 100));
|
|
100
|
+
fontScale = clampFs(fontScale);
|
|
101
|
+
function applyFontScale() {
|
|
102
|
+
document.documentElement.style.setProperty('--fs', String(fontScale));
|
|
103
|
+
}
|
|
104
|
+
function setFontScale(next) {
|
|
105
|
+
fontScale = clampFs(next);
|
|
106
|
+
applyFontScale();
|
|
107
|
+
try { localStorage.setItem(FS_KEY, String(fontScale)); } catch { /* private mode */ }
|
|
108
|
+
fetch('/api/pref', {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: { 'content-type': 'application/json' },
|
|
111
|
+
body: JSON.stringify({ fontScale }),
|
|
112
|
+
}).catch(() => {});
|
|
113
|
+
}
|
|
114
|
+
applyFontScale();
|
|
115
|
+
|
|
91
116
|
// ---------- localStorage draft mirror ----------
|
|
92
117
|
// Every autosave is ALSO written to localStorage, keyed by board id. This is
|
|
93
118
|
// the durability layer the server file alone can't provide: if the connection
|
|
@@ -698,6 +723,38 @@
|
|
|
698
723
|
return input;
|
|
699
724
|
}
|
|
700
725
|
|
|
726
|
+
// Normalize any CSS color to the #rrggbb the native <input type=color> needs.
|
|
727
|
+
function toHex6(c) {
|
|
728
|
+
const s = String(c || '').trim();
|
|
729
|
+
let m = /^#?([0-9a-fA-F]{6})$/.exec(s);
|
|
730
|
+
if (m) return '#' + m[1].toLowerCase();
|
|
731
|
+
m = /^#?([0-9a-fA-F]{3})$/.exec(s);
|
|
732
|
+
if (m) return '#' + m[1].split('').map((x) => x + x).join('').toLowerCase();
|
|
733
|
+
return '#000000';
|
|
734
|
+
}
|
|
735
|
+
function controlColor(q) {
|
|
736
|
+
const wrap = el('div', { class: 'colorpick' });
|
|
737
|
+
const init = (typeof state.answers[q.id] === 'string' && state.answers[q.id]) || (typeof q.default === 'string' ? q.default : '');
|
|
738
|
+
const swatch = el('input', { type: 'color', class: 'colorswatch' });
|
|
739
|
+
const hex = el('input', { type: 'text', class: 'colorhex', placeholder: q.placeholder || '#rrggbb', spellcheck: 'false', autocapitalize: 'off' });
|
|
740
|
+
swatch.value = toHex6(init || '#888888');
|
|
741
|
+
if (init) { hex.value = init; state.answers[q.id] = init; }
|
|
742
|
+
const set = (val) => { state.answers[q.id] = val; clearErr(q.id); scheduleSave(); };
|
|
743
|
+
swatch.addEventListener('input', () => { hex.value = swatch.value; set(swatch.value); });
|
|
744
|
+
hex.addEventListener('input', () => { const v = hex.value.trim(); if (/^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v)) swatch.value = toHex6(v); set(v); });
|
|
745
|
+
wrap.append(el('div', { class: 'colorrow' }, swatch, hex));
|
|
746
|
+
if (Array.isArray(q.presets) && q.presets.length) {
|
|
747
|
+
const presets = el('div', { class: 'colorpresets' });
|
|
748
|
+
for (const c of q.presets) {
|
|
749
|
+
const b = el('button', { type: 'button', class: 'colorpreset', style: 'background:' + c, title: c });
|
|
750
|
+
b.addEventListener('click', () => { swatch.value = toHex6(c); hex.value = c; set(c); });
|
|
751
|
+
presets.append(b);
|
|
752
|
+
}
|
|
753
|
+
wrap.append(presets);
|
|
754
|
+
}
|
|
755
|
+
return wrap;
|
|
756
|
+
}
|
|
757
|
+
|
|
701
758
|
// ---------- render ----------
|
|
702
759
|
themeBtn = el('button', { class: 'theme-btn', type: 'button' }, '');
|
|
703
760
|
themeBtn.addEventListener('click', () => {
|
|
@@ -712,6 +769,12 @@
|
|
|
712
769
|
});
|
|
713
770
|
applyTheme();
|
|
714
771
|
|
|
772
|
+
const fsDown = el('button', { class: 'fs-btn', type: 'button', title: 'Smaller text', 'aria-label': 'Decrease text size' }, 'A−');
|
|
773
|
+
const fsUp = el('button', { class: 'fs-btn', type: 'button', title: 'Larger text', 'aria-label': 'Increase text size' }, 'A+');
|
|
774
|
+
fsDown.addEventListener('click', () => setFontScale(fontScale - FS_STEP));
|
|
775
|
+
fsUp.addEventListener('click', () => setFontScale(fontScale + FS_STEP));
|
|
776
|
+
const fsCtrl = el('div', { class: 'fs-ctrl' }, fsDown, fsUp);
|
|
777
|
+
|
|
715
778
|
// Just reloaded after a live `rly update`? Flag set before reload below.
|
|
716
779
|
try {
|
|
717
780
|
if (sessionStorage.getItem('relay-updated') === '1') {
|
|
@@ -723,7 +786,7 @@
|
|
|
723
786
|
}
|
|
724
787
|
|
|
725
788
|
const titleEl = el('h1', {}, spec.title);
|
|
726
|
-
app.append(el('header', { class: 'qb-header' }, titleEl, themeBtn));
|
|
789
|
+
app.append(el('header', { class: 'qb-header' }, titleEl, el('div', { class: 'qb-controls' }, fsCtrl, themeBtn)));
|
|
727
790
|
// The board title is commentable too: hovering it shows the pin, like the
|
|
728
791
|
// intro and every other content element. (themeBtn stays out of it.)
|
|
729
792
|
if (spec.title) {
|
|
@@ -757,6 +820,7 @@
|
|
|
757
820
|
else if (q.type === 'multi') control.append(controlMulti(q));
|
|
758
821
|
else if (q.type === 'yesno') control.append(segButtons(q, ['yes', 'no'], ['Yes', 'No']));
|
|
759
822
|
else if (q.type === 'scale') control.append(controlScale(q));
|
|
823
|
+
else if (q.type === 'color') control.append(controlColor(q));
|
|
760
824
|
else control.append(controlText(q, q.type === 'textarea'));
|
|
761
825
|
card.append(control);
|
|
762
826
|
if (q.note) {
|
|
@@ -988,6 +1052,24 @@
|
|
|
988
1052
|
}
|
|
989
1053
|
|
|
990
1054
|
// ---------- heartbeat ----------
|
|
1055
|
+
// True when the user is actively typing — an open annotation comment popover,
|
|
1056
|
+
// or focus in any text-entry field (comment box, notes, text/color answers,
|
|
1057
|
+
// "Other" inputs). Used to defer the live-update reload so in-progress
|
|
1058
|
+
// comments aren't destroyed mid-keystroke.
|
|
1059
|
+
function isEditable(node) {
|
|
1060
|
+
if (!node) return false;
|
|
1061
|
+
if (node.tagName === 'TEXTAREA') return true;
|
|
1062
|
+
if (node.tagName === 'INPUT') {
|
|
1063
|
+
const t = (node.type || 'text').toLowerCase();
|
|
1064
|
+
return ['text', 'search', 'email', 'url', 'tel', 'number', 'color'].includes(t);
|
|
1065
|
+
}
|
|
1066
|
+
return node.isContentEditable === true;
|
|
1067
|
+
}
|
|
1068
|
+
function userIsComposing() {
|
|
1069
|
+
if (window.RelayAnnotate && typeof RelayAnnotate.isComposing === 'function' && RelayAnnotate.isComposing()) return true;
|
|
1070
|
+
return isEditable(document.activeElement);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
991
1073
|
let misses = 0;
|
|
992
1074
|
let reloading = false;
|
|
993
1075
|
let hb = null;
|
|
@@ -1021,6 +1103,16 @@
|
|
|
1021
1103
|
);
|
|
1022
1104
|
}
|
|
1023
1105
|
if (body && typeof body.rev === 'number' && bootRev !== null && body.rev !== bootRev && !submitted && !reloading) {
|
|
1106
|
+
// Don't yank the board out from under someone mid-comment: an open
|
|
1107
|
+
// annotation popover (its add-comment box + reply inputs) holds text
|
|
1108
|
+
// that isn't in the draft until Save, so a reload would discard it —
|
|
1109
|
+
// and reloading while any field is focused drops the user's cursor and
|
|
1110
|
+
// last keystrokes. Defer until they're done; the heartbeat re-checks
|
|
1111
|
+
// every tick, so the update applies the moment they close/blur.
|
|
1112
|
+
if (userIsComposing()) {
|
|
1113
|
+
showNotice('The agent updated this board — it’ll refresh as soon as you finish your comment.', 'info');
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1024
1116
|
reloading = true;
|
|
1025
1117
|
stopHeartbeat();
|
|
1026
1118
|
clearTimeout(saveTimer);
|
package/src/ui/blocks.css
CHANGED
|
@@ -491,6 +491,21 @@ body.blk-full-open { overflow: hidden; }
|
|
|
491
491
|
height: calc(100vh - 44px) !important; width: 100%;
|
|
492
492
|
margin: 0; border: 0; border-radius: 0;
|
|
493
493
|
}
|
|
494
|
+
/* Full-screen diagrams/images: apply() sizes the child to fit the viewport
|
|
495
|
+
(FULL_PAD_* in blocks.js mirrors this padding). margin:auto on the single
|
|
496
|
+
child centers it on both axes AND keeps it scrollable when zoomed past the
|
|
497
|
+
fit — plain flex centering would clip the top/left and trap the overflow. */
|
|
498
|
+
.blk-mermaid.blk-full,
|
|
499
|
+
.blk-graphviz.blk-full,
|
|
500
|
+
.blk-plantuml.blk-full,
|
|
501
|
+
.blk-imagewrap.blk-full {
|
|
502
|
+
display: flex; padding: 44px 24px 24px;
|
|
503
|
+
}
|
|
504
|
+
.blk-mermaid.blk-full > svg,
|
|
505
|
+
.blk-graphviz.blk-full > svg,
|
|
506
|
+
.blk-plantuml.blk-full > svg,
|
|
507
|
+
.blk-plantuml.blk-full > .blk-plantuml-img,
|
|
508
|
+
.blk-imagewrap.blk-full > .blk-img { margin: auto; }
|
|
494
509
|
|
|
495
510
|
/* Small frame on charts + mermaid so they read as a deliberate card, matching
|
|
496
511
|
the graphviz/plantuml/image blocks (already framed). Scoped to the inner
|
|
@@ -545,3 +560,28 @@ body.blk-full-open { overflow: hidden; }
|
|
|
545
560
|
/* code blocks reuse the viewer toolbar (comment + full-screen); the pre keeps
|
|
546
561
|
its own card, the wrapper only hosts the toolbar strip */
|
|
547
562
|
.blk-codewrap { position: relative; }
|
|
563
|
+
|
|
564
|
+
/* ---------- palette block (swatch cards; hover reveals hex, click copies) ---------- */
|
|
565
|
+
.blk-palette .pal-title { font-size: 11px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); margin: 2px 0 10px; }
|
|
566
|
+
.blk-palette .pal-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
|
|
567
|
+
.pal-card { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; background: var(--card); }
|
|
568
|
+
.pal-card.pal-spotlight { margin-bottom: 16px; }
|
|
569
|
+
.pal-swatches { display: flex; height: 72px; }
|
|
570
|
+
.pal-spotlight .pal-swatches { height: 104px; }
|
|
571
|
+
.pal-sw { flex: 1; position: relative; cursor: pointer; border: 0; padding: 0; transition: flex .2s var(--ease); }
|
|
572
|
+
.pal-sw:hover { flex: 1.6; }
|
|
573
|
+
.pal-hex { position: absolute; bottom: 7px; left: 50%; transform: translateX(-50%); font: 600 10px/1 var(--mono, ui-monospace, "SF Mono", monospace); background: rgba(0,0,0,.42); color: #fff; padding: 2px 6px; border-radius: 4px; white-space: nowrap; opacity: 0; transition: opacity .15s; pointer-events: none; }
|
|
574
|
+
.pal-sw:hover .pal-hex, .pal-sw.copied .pal-hex { opacity: 1; }
|
|
575
|
+
.pal-sw.copied::after { content: "Copied"; position: absolute; top: 6px; left: 50%; transform: translateX(-50%); font: 600 9px/1 var(--sans); background: var(--ok); color: #fff; padding: 2px 7px; border-radius: 4px; }
|
|
576
|
+
.pal-info { padding: 10px 14px; display: flex; justify-content: space-between; align-items: center; gap: 10px; }
|
|
577
|
+
.pal-spotlight .pal-info { padding: 12px 16px; }
|
|
578
|
+
.pal-name { font-size: 14px; font-weight: 600; color: var(--fg); }
|
|
579
|
+
.pal-spotlight .pal-name { font-size: 15px; }
|
|
580
|
+
.pal-sub { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
|
581
|
+
.pal-tag { flex: none; font-size: 11px; font-weight: 600; padding: 3px 11px; border-radius: 20px; background: var(--bg-sunken); color: var(--fg-2); }
|
|
582
|
+
.pal-tag.tone-warm { background: #FAECE7; color: #993C1D; }
|
|
583
|
+
.pal-tag.tone-cool { background: #E6F1FB; color: #185FA5; }
|
|
584
|
+
.pal-tag.tone-neutral { background: #F1EFE8; color: #5F5E5A; }
|
|
585
|
+
.pal-tag.tone-nature { background: #EAF3DE; color: #3B6D11; }
|
|
586
|
+
.pal-tag.tone-bold { background: #FBEAF0; color: #72243E; }
|
|
587
|
+
.pal-tag.tone-digital { background: #EEEDFE; color: #3C3489; }
|
package/src/ui/blocks.js
CHANGED
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
|
|
32
32
|
// ---------- chart palette (from contract) ----------
|
|
33
33
|
const PALETTE_LIGHT = ['#c2674b', '#4d8a66', '#5a7ca8', '#b9913f', '#8a6da3', '#57534e'];
|
|
34
|
-
|
|
34
|
+
// Brighter/more saturated than the surfaces behind them so series stay legible
|
|
35
|
+
// on the dark card (#282624) — the muted set washed out at a glance.
|
|
36
|
+
const PALETTE_DARK = ['#e8a07a', '#74cfa0', '#88b4e8', '#e3c46a', '#c4a0db', '#c2b9ad'];
|
|
35
37
|
|
|
36
38
|
function cssVar(name) {
|
|
37
39
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
@@ -743,7 +745,8 @@
|
|
|
743
745
|
|
|
744
746
|
function themeDefaults(ctx) {
|
|
745
747
|
const grid = cssVar('--border') || '#ece9e4';
|
|
746
|
-
|
|
748
|
+
// axis/legend/title text: --fg-2 reads clearly on dark; --muted was too dim
|
|
749
|
+
const tick = cssVar('--fg-2') || '#57534e';
|
|
747
750
|
const fontFamily = cssVar('--sans') || 'sans-serif';
|
|
748
751
|
return { grid, tick, fontFamily };
|
|
749
752
|
}
|
|
@@ -1426,6 +1429,65 @@
|
|
|
1426
1429
|
return container;
|
|
1427
1430
|
}
|
|
1428
1431
|
|
|
1432
|
+
// ---------- palette ----------
|
|
1433
|
+
// Color palettes as swatch cards: hover a swatch to reveal its hex, click to
|
|
1434
|
+
// copy it. One palette may be {featured:true} → a larger spotlight row. Lets
|
|
1435
|
+
// an agent present a curated palette with one structured block instead of
|
|
1436
|
+
// hand-writing HTML. Swatches and the whole block are commentable.
|
|
1437
|
+
function copyColor(text, btn) {
|
|
1438
|
+
const done = () => {
|
|
1439
|
+
btn.classList.add('copied');
|
|
1440
|
+
setTimeout(() => btn.classList.remove('copied'), 900);
|
|
1441
|
+
};
|
|
1442
|
+
try {
|
|
1443
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
1444
|
+
navigator.clipboard.writeText(text).then(done, () => {});
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
} catch { /* fall through */ }
|
|
1448
|
+
done(); // no clipboard (sandbox) — still flash feedback
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
function paletteCard(p, big, ctx, blockId) {
|
|
1452
|
+
const card = el('div', { class: 'pal-card' + (big ? ' pal-spotlight' : '') });
|
|
1453
|
+
const row = el('div', { class: 'pal-swatches' });
|
|
1454
|
+
for (const c of p.colors) {
|
|
1455
|
+
const sw = el('button', { class: 'pal-sw', type: 'button', style: 'background:' + c, title: 'Copy ' + c });
|
|
1456
|
+
sw.append(el('span', { class: 'pal-hex' }, c));
|
|
1457
|
+
sw.addEventListener('click', () => copyColor(c, sw));
|
|
1458
|
+
if (ctx.annotate) {
|
|
1459
|
+
ctx.annotate.register(sw, {
|
|
1460
|
+
blockId,
|
|
1461
|
+
questionId: ctx.questionId,
|
|
1462
|
+
target: { kind: 'swatch', label: (p.name ? p.name + ' · ' : '') + c },
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
row.append(sw);
|
|
1466
|
+
}
|
|
1467
|
+
card.append(row);
|
|
1468
|
+
const info = el('div', { class: 'pal-info' });
|
|
1469
|
+
const meta = el('div', { class: 'pal-meta' });
|
|
1470
|
+
if (p.name) meta.append(el('div', { class: 'pal-name' }, p.name));
|
|
1471
|
+
if (p.sub) meta.append(el('div', { class: 'pal-sub' }, p.sub));
|
|
1472
|
+
info.append(meta);
|
|
1473
|
+
if (p.tag) info.append(el('span', { class: 'pal-tag' + (p.tagTone ? ' tone-' + p.tagTone : '') }, p.tag));
|
|
1474
|
+
if (p.name || p.sub || p.tag) card.append(info);
|
|
1475
|
+
return card;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function renderPalette(block, ctx, blockId) {
|
|
1479
|
+
const wrap = el('div', { class: 'blk-palette' });
|
|
1480
|
+
const palettes = Array.isArray(block.palettes) ? block.palettes : [];
|
|
1481
|
+
if (block.title) wrap.append(el('div', { class: 'pal-title' }, block.title));
|
|
1482
|
+
const grid = el('div', { class: 'pal-grid' });
|
|
1483
|
+
for (const p of palettes) {
|
|
1484
|
+
if (p.featured) wrap.append(paletteCard(p, true, ctx, blockId));
|
|
1485
|
+
else grid.append(paletteCard(p, false, ctx, blockId));
|
|
1486
|
+
}
|
|
1487
|
+
if (grid.childNodes.length) wrap.append(grid);
|
|
1488
|
+
return wrap;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1429
1491
|
// ---------- viewer controls (zoom / fit / full-screen) ----------
|
|
1430
1492
|
// Large diagrams get squeezed to the column width; these controls let the
|
|
1431
1493
|
// user zoom (buttons or cmd/ctrl+wheel) and expand any visual block into a
|
|
@@ -1433,6 +1495,9 @@
|
|
|
1433
1495
|
// the annotation pins/badges/popover usable while expanded (they live on
|
|
1434
1496
|
// <body>, which native fullscreen would hide).
|
|
1435
1497
|
let fullOpen = null; // container currently expanded
|
|
1498
|
+
// full-screen content insets — must mirror the .blk-full padding in blocks.css
|
|
1499
|
+
// so the fit-to-screen scale clears the fixed toolbar + leaves a small margin.
|
|
1500
|
+
const FULL_PAD_X = 24, FULL_PAD_TOP = 44, FULL_PAD_BOTTOM = 24;
|
|
1436
1501
|
|
|
1437
1502
|
// Toolbar icons: full-screen (4-corner expand) and a speech-bubble for the
|
|
1438
1503
|
// "comment on the whole block" button. Zoom is both cmd/ctrl+wheel AND
|
|
@@ -1574,14 +1639,26 @@
|
|
|
1574
1639
|
const target = opts.zoomEl;
|
|
1575
1640
|
const nat = opts.natural();
|
|
1576
1641
|
const z = container._rlyZ;
|
|
1642
|
+
const full = container.classList.contains('blk-full');
|
|
1577
1643
|
// A per-block height caps only the compact inline preview. The moment the
|
|
1578
1644
|
// user zooms in (z !== null) or goes full-screen they want pixel detail, so
|
|
1579
1645
|
// lift the cap there; restore it when back to the inline fit view.
|
|
1580
1646
|
if (opts.fitMaxHeight != null) {
|
|
1581
|
-
|
|
1582
|
-
target.style.maxHeight = fit ? opts.fitMaxHeight + 'px' : 'none';
|
|
1647
|
+
target.style.maxHeight = z === null && !full ? opts.fitMaxHeight + 'px' : 'none';
|
|
1583
1648
|
}
|
|
1584
|
-
if (z === null
|
|
1649
|
+
if (z === null && full && nat && nat.w && nat.h) {
|
|
1650
|
+
// Full-screen default: scale the diagram/image to fill the viewport
|
|
1651
|
+
// (contain — enlarge a small one, shrink a big one) so it's readable at
|
|
1652
|
+
// a glance without reaching for the zoom buttons. CSS margin:auto centers
|
|
1653
|
+
// it; it stays pannable once the user zooms past this fit.
|
|
1654
|
+
const availW = window.innerWidth - FULL_PAD_X * 2;
|
|
1655
|
+
const availH = window.innerHeight - FULL_PAD_TOP - FULL_PAD_BOTTOM;
|
|
1656
|
+
const scale = Math.min(availW / nat.w, availH / nat.h);
|
|
1657
|
+
target.style.maxWidth = 'none';
|
|
1658
|
+
target.style.width = Math.max(1, Math.round(nat.w * scale)) + 'px';
|
|
1659
|
+
target.style.height = 'auto';
|
|
1660
|
+
pct.textContent = 'fit';
|
|
1661
|
+
} else if (z === null || !nat || !nat.w) {
|
|
1585
1662
|
target.style.width = '100%';
|
|
1586
1663
|
target.style.maxWidth = nat && nat.w ? Math.ceil(nat.w) + 'px' : '100%';
|
|
1587
1664
|
target.style.height = 'auto';
|
|
@@ -1753,6 +1830,10 @@
|
|
|
1753
1830
|
inner = renderImage(block, ctx, blockId);
|
|
1754
1831
|
wrapper.append(inner);
|
|
1755
1832
|
break;
|
|
1833
|
+
case 'palette':
|
|
1834
|
+
inner = renderPalette(block, ctx, blockId);
|
|
1835
|
+
wrapper.append(inner);
|
|
1836
|
+
break;
|
|
1756
1837
|
default:
|
|
1757
1838
|
wrapper.append(el('div', { class: 'blk-error' }, 'Unknown block type: ' + esc(String(block.type))));
|
|
1758
1839
|
}
|
package/src/ui/style.css
CHANGED
|
@@ -68,14 +68,17 @@
|
|
|
68
68
|
|
|
69
69
|
* { box-sizing: border-box; }
|
|
70
70
|
html, body { margin: 0; padding: 0; }
|
|
71
|
+
/* Root font size drives every rem on the page; --fs is the user's text-size
|
|
72
|
+
knob (A−/A+ next to the theme toggle). Base bumped 16→17px for readability. */
|
|
73
|
+
html { font-size: calc(17px * var(--fs, 1)); }
|
|
71
74
|
body {
|
|
72
75
|
background: var(--bg);
|
|
73
76
|
color: var(--fg);
|
|
74
|
-
font:
|
|
77
|
+
font: 1rem/1.6 var(--sans);
|
|
75
78
|
-webkit-font-smoothing: antialiased;
|
|
76
79
|
transition: background 200ms var(--ease), color 200ms var(--ease), padding 240ms var(--ease);
|
|
77
80
|
}
|
|
78
|
-
.wrap { max-width:
|
|
81
|
+
.wrap { max-width: 1280px; margin: 0 auto; padding: 32px 20px 28px; }
|
|
79
82
|
|
|
80
83
|
.qb-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 4px; }
|
|
81
84
|
h1 {
|
|
@@ -101,6 +104,18 @@ h1 {
|
|
|
101
104
|
transition: border-color 150ms var(--ease), color 150ms var(--ease);
|
|
102
105
|
}
|
|
103
106
|
.theme-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
107
|
+
/* header controls: font-size knob + theme toggle, grouped at the right */
|
|
108
|
+
.qb-controls { display: flex; align-items: center; gap: 8px; flex: none; }
|
|
109
|
+
.fs-ctrl { display: inline-flex; border: 1px solid var(--border-strong); border-radius: 999px; overflow: hidden; }
|
|
110
|
+
.fs-btn {
|
|
111
|
+
background: transparent; color: var(--fg-2); border: 0;
|
|
112
|
+
padding: 6px 11px; cursor: pointer; line-height: 1; font-family: var(--sans);
|
|
113
|
+
transition: color 150ms var(--ease), background 150ms var(--ease);
|
|
114
|
+
}
|
|
115
|
+
.fs-btn:first-child { font-size: 0.72rem; }
|
|
116
|
+
.fs-btn:last-child { font-size: 0.92rem; }
|
|
117
|
+
.fs-btn + .fs-btn { border-left: 1px solid var(--border-strong); }
|
|
118
|
+
.fs-btn:hover { color: var(--accent); background: var(--accent-soft); }
|
|
104
119
|
|
|
105
120
|
.card {
|
|
106
121
|
background: var(--card);
|
|
@@ -311,3 +326,14 @@ textarea { min-height: 90px; resize: vertical; }
|
|
|
311
326
|
@media (prefers-reduced-motion: reduce) {
|
|
312
327
|
* { transition: none !important; animation: none !important; }
|
|
313
328
|
}
|
|
329
|
+
|
|
330
|
+
/* ---------- color picker question (native input + hex + presets) ---------- */
|
|
331
|
+
.colorpick { display: flex; flex-direction: column; gap: 10px; }
|
|
332
|
+
.colorrow { display: flex; align-items: center; gap: 10px; }
|
|
333
|
+
.colorswatch { width: 48px; height: 38px; padding: 0; border: 1px solid var(--border-strong); border-radius: 10px; background: var(--card); cursor: pointer; }
|
|
334
|
+
.colorswatch::-webkit-color-swatch-wrapper { padding: 3px; }
|
|
335
|
+
.colorswatch::-webkit-color-swatch { border: 0; border-radius: 7px; }
|
|
336
|
+
.colorhex { width: 150px; font-family: var(--mono, ui-monospace, "SF Mono", monospace); text-transform: lowercase; }
|
|
337
|
+
.colorpresets { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
338
|
+
.colorpreset { width: 26px; height: 26px; border-radius: 7px; border: 1px solid rgba(0,0,0,.14); cursor: pointer; padding: 0; transition: transform .12s var(--ease); }
|
|
339
|
+
.colorpreset:hover { transform: scale(1.12); }
|