@khanglvm/relay 0.12.3 → 0.13.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 +1 -0
- package/docs/AGENT.md +75 -6
- package/package.json +1 -1
- package/skills/relay/SKILL.md +180 -4
- package/src/cli.js +98 -17
- package/src/mcp-ui/board.js +213 -10
- package/src/server.js +8 -3
- package/src/spec.js +272 -47
- package/src/ui/annotate.css +15 -1
- package/src/ui/annotate.js +40 -2
- package/src/ui/app.js +232 -8
- package/src/ui/blocks.css +164 -4
- package/src/ui/blocks.js +430 -33
- package/src/ui/style.css +90 -0
package/src/ui/app.js
CHANGED
|
@@ -212,8 +212,41 @@
|
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
|
+
// Coerce any prior rank value (draft/default) into a complete, deduped
|
|
216
|
+
// permutation of the option values: keep the known ones in their given order,
|
|
217
|
+
// then append any options the prior list missed (in authored order).
|
|
218
|
+
function seedRankOrder(q, prior) {
|
|
219
|
+
const opts = (q.options || []).map((o) => o.value);
|
|
220
|
+
const arr = Array.isArray(prior) ? prior : [];
|
|
221
|
+
const order = arr.filter((v, i) => opts.includes(v) && arr.indexOf(v) === i);
|
|
222
|
+
for (const v of opts) if (!order.includes(v)) order.push(v);
|
|
223
|
+
return order;
|
|
224
|
+
}
|
|
225
|
+
// Allocate: a complete map {optionValue: number≥0}, seeded from a prior/default.
|
|
226
|
+
function seedAllocate(q, prior) {
|
|
227
|
+
const m = prior && typeof prior === 'object' && !Array.isArray(prior) ? prior : {};
|
|
228
|
+
const out = {};
|
|
229
|
+
for (const o of q.options || []) out[o.value] = Math.max(0, Number(m[o.value]) || 0);
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
// Checklist: keep only {knownOption: knownStatus} entries from a prior/default.
|
|
233
|
+
function seedChecklist(q, prior) {
|
|
234
|
+
const m = prior && typeof prior === 'object' && !Array.isArray(prior) ? prior : {};
|
|
235
|
+
const optVals = new Set((q.options || []).map((o) => o.value));
|
|
236
|
+
const stVals = new Set((q.statuses || []).map((s) => s.value));
|
|
237
|
+
const out = {};
|
|
238
|
+
for (const k of Object.keys(m)) if (optVals.has(k) && stVals.has(m[k])) out[k] = m[k];
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
215
242
|
if (initialPrefill) seedFromPrefill(initialPrefill);
|
|
216
243
|
else for (const q of QS) if (q.default !== undefined) state.answers[q.id] = q.default;
|
|
244
|
+
// Rank questions always carry a full, valid permutation of their option values
|
|
245
|
+
// (from a draft/default if present, else the authored order), so a never-touched
|
|
246
|
+
// rank still returns a meaningful ordering.
|
|
247
|
+
for (const q of QS) if (q.type === 'rank') state.answers[q.id] = seedRankOrder(q, state.answers[q.id]);
|
|
248
|
+
for (const q of QS) if (q.type === 'allocate') state.answers[q.id] = seedAllocate(q, state.answers[q.id]);
|
|
249
|
+
for (const q of QS) if (q.type === 'checklist') state.answers[q.id] = seedChecklist(q, state.answers[q.id]);
|
|
217
250
|
// If the local mirror was newer than the server (or the server had nothing),
|
|
218
251
|
// the in-memory state now holds input the server hasn't seen — flush it once
|
|
219
252
|
// the rest of the app is wired (see the post-init flush near the heartbeat).
|
|
@@ -241,6 +274,18 @@
|
|
|
241
274
|
return v === 'yes' || v === 'no' ? v : undefined;
|
|
242
275
|
case 'scale':
|
|
243
276
|
return typeof v === 'number' ? v : undefined;
|
|
277
|
+
case 'rank':
|
|
278
|
+
// always a full, valid permutation (seeded below) → always answered
|
|
279
|
+
return Array.isArray(v) && v.length ? [...v] : undefined;
|
|
280
|
+
case 'checklist': {
|
|
281
|
+
const m = v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
282
|
+
return Object.keys(m).length ? { ...m } : undefined;
|
|
283
|
+
}
|
|
284
|
+
case 'allocate': {
|
|
285
|
+
const m = v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
286
|
+
const sum = Object.values(m).reduce((a, n) => a + (Number(n) || 0), 0);
|
|
287
|
+
return sum > 0 ? { ...m } : undefined;
|
|
288
|
+
}
|
|
244
289
|
default: {
|
|
245
290
|
const t = typeof v === 'string' ? v.trim() : '';
|
|
246
291
|
return t || undefined;
|
|
@@ -721,6 +766,144 @@
|
|
|
721
766
|
return row;
|
|
722
767
|
}
|
|
723
768
|
|
|
769
|
+
// Reorderable priority list. The user drags an item or uses ↑/↓; the answer is
|
|
770
|
+
// the ordered array of option values (highest priority first). state.answers[q.id]
|
|
771
|
+
// is kept a full, valid permutation by seedRankOrder so it's always submittable.
|
|
772
|
+
function controlRank(q) {
|
|
773
|
+
const wrap = el('div', { class: 'rank' });
|
|
774
|
+
const byVal = new Map(q.options.map((o) => [o.value, o]));
|
|
775
|
+
let dragFrom = null;
|
|
776
|
+
function move(from, to) {
|
|
777
|
+
const arr = state.answers[q.id];
|
|
778
|
+
if (!Array.isArray(arr) || to < 0 || to >= arr.length || from === to) return;
|
|
779
|
+
const [x] = arr.splice(from, 1);
|
|
780
|
+
arr.splice(to, 0, x);
|
|
781
|
+
paint();
|
|
782
|
+
clearErr(q.id);
|
|
783
|
+
scheduleSave();
|
|
784
|
+
}
|
|
785
|
+
function paint() {
|
|
786
|
+
wrap.replaceChildren();
|
|
787
|
+
const order = state.answers[q.id] || [];
|
|
788
|
+
order.forEach((val, i) => {
|
|
789
|
+
const o = byVal.get(val);
|
|
790
|
+
if (!o) return;
|
|
791
|
+
const up = el('button', { type: 'button', class: 'rank-btn', title: 'Move up', 'aria-label': `Move "${o.label}" up` }, '↑');
|
|
792
|
+
const down = el('button', { type: 'button', class: 'rank-btn', title: 'Move down', 'aria-label': `Move "${o.label}" down` }, '↓');
|
|
793
|
+
up.disabled = i === 0;
|
|
794
|
+
down.disabled = i === order.length - 1;
|
|
795
|
+
up.addEventListener('click', () => move(i, i - 1));
|
|
796
|
+
down.addEventListener('click', () => move(i, i + 1));
|
|
797
|
+
const item = el('div', { class: 'rank-item', draggable: 'true' },
|
|
798
|
+
el('span', { class: 'rank-badge', 'aria-hidden': 'true' }, String(i + 1)),
|
|
799
|
+
el('div', { class: 'rank-body' },
|
|
800
|
+
el('div', { class: 'ol' }, o.label),
|
|
801
|
+
o.description ? el('div', { class: 'od' }, o.description) : null),
|
|
802
|
+
el('div', { class: 'rank-ctrls' }, up, down)
|
|
803
|
+
);
|
|
804
|
+
item.addEventListener('dragstart', (e) => {
|
|
805
|
+
dragFrom = i; item.classList.add('dragging');
|
|
806
|
+
try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(i)); } catch (_) {}
|
|
807
|
+
});
|
|
808
|
+
item.addEventListener('dragend', () => { dragFrom = null; item.classList.remove('dragging'); });
|
|
809
|
+
item.addEventListener('dragover', (e) => { if (dragFrom !== null) { e.preventDefault(); item.classList.add('drop-into'); } });
|
|
810
|
+
item.addEventListener('dragleave', () => item.classList.remove('drop-into'));
|
|
811
|
+
item.addEventListener('drop', (e) => {
|
|
812
|
+
e.preventDefault();
|
|
813
|
+
const from = dragFrom; dragFrom = null;
|
|
814
|
+
if (from !== null && from !== i) move(from, i);
|
|
815
|
+
});
|
|
816
|
+
wrap.append(item);
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
paint();
|
|
820
|
+
return wrap;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Checklist: each option is a row with a segmented status control (Pass/Fail/
|
|
824
|
+
// N/A by default). Answer is a map {optionValue: statusValue} for set rows.
|
|
825
|
+
function controlChecklist(q) {
|
|
826
|
+
const wrap = el('div', { class: 'checklist' });
|
|
827
|
+
const cur = () => (state.answers[q.id] && typeof state.answers[q.id] === 'object' && !Array.isArray(state.answers[q.id])
|
|
828
|
+
? state.answers[q.id]
|
|
829
|
+
: (state.answers[q.id] = {}));
|
|
830
|
+
for (const o of q.options) {
|
|
831
|
+
const seg = el('div', { class: 'chk-seg' });
|
|
832
|
+
const buttons = [];
|
|
833
|
+
for (const s of q.statuses) {
|
|
834
|
+
const b = el('button', { type: 'button', class: 'chk-status' + (s.tone ? ' tone-' + s.tone : '') }, s.label);
|
|
835
|
+
if (cur()[o.value] === s.value) b.classList.add('sel');
|
|
836
|
+
b.addEventListener('click', () => {
|
|
837
|
+
const m = cur();
|
|
838
|
+
if (m[o.value] === s.value) delete m[o.value];
|
|
839
|
+
else m[o.value] = s.value;
|
|
840
|
+
for (const x of buttons) x.btn.classList.toggle('sel', m[o.value] === x.val);
|
|
841
|
+
clearErr(q.id);
|
|
842
|
+
scheduleSave();
|
|
843
|
+
});
|
|
844
|
+
buttons.push({ btn: b, val: s.value });
|
|
845
|
+
seg.append(b);
|
|
846
|
+
}
|
|
847
|
+
wrap.append(el('div', { class: 'chk-row' },
|
|
848
|
+
el('div', { class: 'chk-body' },
|
|
849
|
+
el('div', { class: 'ol' }, o.label),
|
|
850
|
+
o.description ? el('div', { class: 'od' }, o.description) : null),
|
|
851
|
+
seg
|
|
852
|
+
));
|
|
853
|
+
}
|
|
854
|
+
return wrap;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Allocate: distribute q.total across the options with per-option sliders; a
|
|
858
|
+
// live total bar shows the running sum / remaining / overage. Answer is a map
|
|
859
|
+
// {optionValue: number}; an all-zero allocation submits as unanswered.
|
|
860
|
+
function controlAllocate(q) {
|
|
861
|
+
const wrap = el('div', { class: 'allocate' });
|
|
862
|
+
const total = q.total || 100;
|
|
863
|
+
const unit = q.unit ? ' ' + q.unit : '';
|
|
864
|
+
const cur = () => (state.answers[q.id] = seedAllocate(q, state.answers[q.id]));
|
|
865
|
+
const sumEl = el('span', { class: 'alloc-sum' });
|
|
866
|
+
const fill = el('div', { class: 'alloc-bar-fill' });
|
|
867
|
+
const nums = [];
|
|
868
|
+
const refresh = () => {
|
|
869
|
+
const m = cur();
|
|
870
|
+
const sum = (q.options || []).reduce((a, o) => a + (Number(m[o.value]) || 0), 0);
|
|
871
|
+
const tail = sum > total ? ` (over by ${sum - total})` : sum < total ? ` (${total - sum} left)` : ' ✓';
|
|
872
|
+
sumEl.textContent = `${sum} / ${total}${unit}${tail}`;
|
|
873
|
+
sumEl.classList.toggle('over', sum > total);
|
|
874
|
+
sumEl.classList.toggle('exact', sum === total);
|
|
875
|
+
fill.style.width = Math.min(100, (sum / total) * 100) + '%';
|
|
876
|
+
fill.classList.toggle('over', sum > total);
|
|
877
|
+
for (const n of nums) n.el.textContent = String(m[n.value] || 0);
|
|
878
|
+
};
|
|
879
|
+
for (const o of q.options) {
|
|
880
|
+
const m = cur();
|
|
881
|
+
const range = el('input', { type: 'range', min: '0', max: String(total), step: '1', class: 'alloc-range', 'aria-label': o.label });
|
|
882
|
+
range.value = String(m[o.value] || 0);
|
|
883
|
+
const num = el('span', { class: 'alloc-num' }, String(m[o.value] || 0));
|
|
884
|
+
range.addEventListener('input', () => {
|
|
885
|
+
cur()[o.value] = Number(range.value) || 0;
|
|
886
|
+
refresh();
|
|
887
|
+
clearErr(q.id);
|
|
888
|
+
scheduleSave();
|
|
889
|
+
});
|
|
890
|
+
nums.push({ value: o.value, el: num });
|
|
891
|
+
wrap.append(el('div', { class: 'alloc-row' },
|
|
892
|
+
el('div', { class: 'alloc-rowhead' },
|
|
893
|
+
el('div', { class: 'ol' }, o.label),
|
|
894
|
+
num),
|
|
895
|
+
range,
|
|
896
|
+
o.description ? el('div', { class: 'od' }, o.description) : null
|
|
897
|
+
));
|
|
898
|
+
}
|
|
899
|
+
wrap.append(el('div', { class: 'alloc-total' },
|
|
900
|
+
el('div', { class: 'alloc-bar' }, fill),
|
|
901
|
+
el('div', { class: 'alloc-sumwrap' }, 'Total: ', sumEl)
|
|
902
|
+
));
|
|
903
|
+
refresh();
|
|
904
|
+
return wrap;
|
|
905
|
+
}
|
|
906
|
+
|
|
724
907
|
function controlText(q, multiline) {
|
|
725
908
|
const input = multiline
|
|
726
909
|
? el('textarea', { placeholder: q.placeholder || '' })
|
|
@@ -734,35 +917,73 @@
|
|
|
734
917
|
return input;
|
|
735
918
|
}
|
|
736
919
|
|
|
737
|
-
//
|
|
738
|
-
|
|
739
|
-
|
|
920
|
+
// Resolve ANY CSS color (named / rgb() / hsl() / hex) to #rrggbb for the
|
|
921
|
+
// native <input type=color>, using the browser's own parser — multi color-
|
|
922
|
+
// system support with no library. Returns null for an unparseable color.
|
|
923
|
+
function cssColorToHex(str) {
|
|
924
|
+
const s = String(str || '').trim();
|
|
740
925
|
let m = /^#?([0-9a-fA-F]{6})$/.exec(s);
|
|
741
926
|
if (m) return '#' + m[1].toLowerCase();
|
|
742
927
|
m = /^#?([0-9a-fA-F]{3})$/.exec(s);
|
|
743
928
|
if (m) return '#' + m[1].split('').map((x) => x + x).join('').toLowerCase();
|
|
744
|
-
|
|
929
|
+
try {
|
|
930
|
+
const d = document.createElement('div');
|
|
931
|
+
d.style.color = '';
|
|
932
|
+
d.style.color = s;
|
|
933
|
+
if (!d.style.color) return null; // browser rejected it → invalid color
|
|
934
|
+
d.style.display = 'none';
|
|
935
|
+
document.body.appendChild(d);
|
|
936
|
+
const rgb = getComputedStyle(d).color;
|
|
937
|
+
document.body.removeChild(d);
|
|
938
|
+
const mm = rgb.match(/(\d+)[,\s]+(\d+)[,\s]+(\d+)/);
|
|
939
|
+
if (!mm) return null;
|
|
940
|
+
const h = (n) => Math.max(0, Math.min(255, Number(n))).toString(16).padStart(2, '0');
|
|
941
|
+
return '#' + h(mm[1]) + h(mm[2]) + h(mm[3]);
|
|
942
|
+
} catch { return null; }
|
|
745
943
|
}
|
|
944
|
+
function toHex6(c) { return cssColorToHex(c) || '#000000'; }
|
|
945
|
+
|
|
746
946
|
function controlColor(q) {
|
|
747
947
|
const wrap = el('div', { class: 'colorpick' });
|
|
748
948
|
const init = (typeof state.answers[q.id] === 'string' && state.answers[q.id]) || (typeof q.default === 'string' ? q.default : '');
|
|
749
949
|
const swatch = el('input', { type: 'color', class: 'colorswatch' });
|
|
750
|
-
const hex = el('input', { type: 'text', class: 'colorhex', placeholder: q.placeholder || '#rrggbb', spellcheck: 'false', autocapitalize: 'off' });
|
|
950
|
+
const hex = el('input', { type: 'text', class: 'colorhex', placeholder: q.placeholder || '#rrggbb / rgb() / name', spellcheck: 'false', autocapitalize: 'off' });
|
|
751
951
|
swatch.value = toHex6(init || '#888888');
|
|
752
952
|
if (init) { hex.value = init; state.answers[q.id] = init; }
|
|
753
|
-
|
|
953
|
+
let syncPaletteSel = () => {};
|
|
954
|
+
const set = (val) => { state.answers[q.id] = val; syncPaletteSel(); clearErr(q.id); scheduleSave(); };
|
|
754
955
|
swatch.addEventListener('input', () => { hex.value = swatch.value; set(swatch.value); });
|
|
755
|
-
hex.addEventListener('input', () => { const v = hex.value.trim();
|
|
956
|
+
hex.addEventListener('input', () => { const v = hex.value.trim(); const h = cssColorToHex(v); if (h) swatch.value = h; set(v); });
|
|
957
|
+
|
|
958
|
+
// "Pick from a palette" — labeled swatch cards. Click selects that color as
|
|
959
|
+
// the answer; each card is registered for annotation (per-color comments).
|
|
960
|
+
if (Array.isArray(q.palette) && q.palette.length) {
|
|
961
|
+
const grid = el('div', { class: 'colorpalette' });
|
|
962
|
+
const cards = [];
|
|
963
|
+
for (const p of q.palette) {
|
|
964
|
+
const labelled = p.label && p.label !== p.value;
|
|
965
|
+
const card = el('button', { type: 'button', class: 'colorpal-sw', style: 'background:' + p.value, title: (labelled ? p.label + ' · ' : '') + p.value });
|
|
966
|
+
if (labelled) card.append(el('span', { class: 'colorpal-label' }, p.label));
|
|
967
|
+
card.addEventListener('click', () => { const h = cssColorToHex(p.value); if (h) swatch.value = h; hex.value = p.value; set(p.value); });
|
|
968
|
+
if (Annotate) Annotate.register(card, { blockId: null, questionId: q.id, target: { kind: 'swatch', label: (labelled ? p.label + ' · ' : '') + p.value } });
|
|
969
|
+
cards.push({ card, val: p.value });
|
|
970
|
+
grid.append(card);
|
|
971
|
+
}
|
|
972
|
+
syncPaletteSel = () => { for (const c of cards) c.card.classList.toggle('sel', state.answers[q.id] === c.val); };
|
|
973
|
+
wrap.append(grid);
|
|
974
|
+
}
|
|
975
|
+
|
|
756
976
|
wrap.append(el('div', { class: 'colorrow' }, swatch, hex));
|
|
757
977
|
if (Array.isArray(q.presets) && q.presets.length) {
|
|
758
978
|
const presets = el('div', { class: 'colorpresets' });
|
|
759
979
|
for (const c of q.presets) {
|
|
760
980
|
const b = el('button', { type: 'button', class: 'colorpreset', style: 'background:' + c, title: c });
|
|
761
|
-
b.addEventListener('click', () => {
|
|
981
|
+
b.addEventListener('click', () => { const h = cssColorToHex(c); if (h) swatch.value = h; hex.value = c; set(c); });
|
|
762
982
|
presets.append(b);
|
|
763
983
|
}
|
|
764
984
|
wrap.append(presets);
|
|
765
985
|
}
|
|
986
|
+
syncPaletteSel();
|
|
766
987
|
return wrap;
|
|
767
988
|
}
|
|
768
989
|
|
|
@@ -832,6 +1053,9 @@
|
|
|
832
1053
|
else if (q.type === 'yesno') control.append(segButtons(q, ['yes', 'no'], ['Yes', 'No']));
|
|
833
1054
|
else if (q.type === 'scale') control.append(controlScale(q));
|
|
834
1055
|
else if (q.type === 'color') control.append(controlColor(q));
|
|
1056
|
+
else if (q.type === 'rank') control.append(controlRank(q));
|
|
1057
|
+
else if (q.type === 'checklist') control.append(controlChecklist(q));
|
|
1058
|
+
else if (q.type === 'allocate') control.append(controlAllocate(q));
|
|
835
1059
|
else control.append(controlText(q, q.type === 'textarea'));
|
|
836
1060
|
card.append(control);
|
|
837
1061
|
if (q.note) {
|
package/src/ui/blocks.css
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
.blk-markdown .md h2 { font-size: 1.2rem; }
|
|
34
34
|
.blk-markdown .md h3 { font-size: 1.05rem; }
|
|
35
35
|
.blk-markdown .md p { margin: 10px 0; }
|
|
36
|
+
.blk-markdown .md img.md-img { max-width: 100%; height: auto; border-radius: 8px; margin: 8px 0; }
|
|
36
37
|
.blk-markdown .md a { color: var(--accent); text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 150ms var(--ease); }
|
|
37
38
|
.blk-markdown .md a:hover { border-bottom-color: var(--accent); }
|
|
38
39
|
.blk-markdown .md strong { font-weight: 600; }
|
|
@@ -237,6 +238,36 @@
|
|
|
237
238
|
}
|
|
238
239
|
.blk-difftoggle:hover { color: var(--accent); border-color: var(--accent); }
|
|
239
240
|
|
|
241
|
+
/* multi-file diff: per-file filename header + a jump bar of file chips */
|
|
242
|
+
.diff-file + .diff-file { margin-top: 6px; }
|
|
243
|
+
.diff-filehead {
|
|
244
|
+
position: sticky; top: 0; z-index: 1;
|
|
245
|
+
padding: 5px 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
246
|
+
font-size: 0.76rem; color: var(--fg-2);
|
|
247
|
+
background: var(--card); border-bottom: 1px solid var(--border);
|
|
248
|
+
}
|
|
249
|
+
.diff-files {
|
|
250
|
+
display: flex; flex-wrap: wrap; gap: 6px;
|
|
251
|
+
padding: 8px 0;
|
|
252
|
+
}
|
|
253
|
+
.diff-file-chip {
|
|
254
|
+
cursor: pointer; font: inherit; font-size: 0.72rem;
|
|
255
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
256
|
+
color: var(--fg-2); background: var(--bg-sunken);
|
|
257
|
+
border: 1px solid var(--border); border-radius: 6px; padding: 3px 8px;
|
|
258
|
+
max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
259
|
+
transition: color 150ms var(--ease), border-color 150ms var(--ease);
|
|
260
|
+
}
|
|
261
|
+
.diff-file-chip:hover { color: var(--accent); border-color: var(--accent); }
|
|
262
|
+
|
|
263
|
+
/* clickable line-number gutter: hover a number to comment on that line */
|
|
264
|
+
.blk-gutter-c { padding: 14px 0; }
|
|
265
|
+
.blk-gutter-c .blk-gutterline {
|
|
266
|
+
padding: 0 10px 0 14px; cursor: pointer; position: relative;
|
|
267
|
+
transition: background 120ms var(--ease), color 120ms var(--ease);
|
|
268
|
+
}
|
|
269
|
+
.blk-gutter-c .blk-gutterline:hover { background: var(--accent-soft); color: var(--accent); }
|
|
270
|
+
|
|
240
271
|
/* ---------- video (iframe embed / local stream / direct URL) ---------- */
|
|
241
272
|
.blk-videowrap { margin: 0; }
|
|
242
273
|
.blk-video, .blk-video-embed {
|
|
@@ -277,6 +308,23 @@
|
|
|
277
308
|
.blk-table tbody tr:hover { background: var(--bg-sunken); }
|
|
278
309
|
.blk-table tbody tr:last-child td { border-bottom: 0; }
|
|
279
310
|
|
|
311
|
+
/* table toolbar: live filter box + CSV export (opt-in via filterable/exportable) */
|
|
312
|
+
.blk-tablebar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
|
313
|
+
.blk-tablefilter {
|
|
314
|
+
flex: 1; min-width: 0; max-width: 280px;
|
|
315
|
+
padding: 6px 10px; font: inherit; font-size: 0.85rem;
|
|
316
|
+
background: var(--bg-sunken); color: var(--fg);
|
|
317
|
+
border: 1px solid var(--border); border-radius: 8px;
|
|
318
|
+
}
|
|
319
|
+
.blk-tablefilter:focus { outline: none; border-color: var(--accent); }
|
|
320
|
+
.blk-tableexport {
|
|
321
|
+
flex: none; padding: 6px 12px; font: inherit; font-size: 0.8rem; cursor: pointer;
|
|
322
|
+
background: var(--card); color: var(--fg-2);
|
|
323
|
+
border: 1px solid var(--border-strong); border-radius: 8px;
|
|
324
|
+
transition: border-color 150ms var(--ease), color 150ms var(--ease);
|
|
325
|
+
}
|
|
326
|
+
.blk-tableexport:hover { border-color: var(--accent); color: var(--accent); }
|
|
327
|
+
|
|
280
328
|
/* ---------- chart ---------- */
|
|
281
329
|
/* NO height here: renderChart always sets the inline height, and this class
|
|
282
330
|
also lands on the outer .blk.blk-chart dispatch wrapper — a CSS height
|
|
@@ -316,7 +364,7 @@
|
|
|
316
364
|
/* ---------- mermaid ---------- */
|
|
317
365
|
.blk-mermaid {
|
|
318
366
|
overflow: auto;
|
|
319
|
-
max-height:
|
|
367
|
+
max-height: 800px;
|
|
320
368
|
text-align: center;
|
|
321
369
|
}
|
|
322
370
|
.blk-mermaid svg {
|
|
@@ -392,7 +440,7 @@
|
|
|
392
440
|
/* ---------- graphviz (offline, vendored Viz.js) ---------- */
|
|
393
441
|
.blk-graphviz {
|
|
394
442
|
overflow: auto;
|
|
395
|
-
max-height:
|
|
443
|
+
max-height: 800px;
|
|
396
444
|
text-align: center;
|
|
397
445
|
/* graphviz draws on its own white canvas; frame it as a deliberate card so
|
|
398
446
|
it reads intentional in dark mode instead of a stray white rectangle */
|
|
@@ -411,7 +459,7 @@
|
|
|
411
459
|
/* ---------- plantuml (server-rendered image) ---------- */
|
|
412
460
|
.blk-plantuml {
|
|
413
461
|
overflow: auto;
|
|
414
|
-
max-height:
|
|
462
|
+
max-height: 800px;
|
|
415
463
|
text-align: center;
|
|
416
464
|
background: #fff;
|
|
417
465
|
border: 1px solid var(--border);
|
|
@@ -426,7 +474,7 @@
|
|
|
426
474
|
/* ---------- image ---------- */
|
|
427
475
|
.blk-imagewrap {
|
|
428
476
|
overflow: auto;
|
|
429
|
-
max-height:
|
|
477
|
+
max-height: 800px;
|
|
430
478
|
border: 1px solid var(--border);
|
|
431
479
|
border-radius: 10px;
|
|
432
480
|
background: var(--bg-sunken);
|
|
@@ -483,6 +531,13 @@ body.blk-full-open { overflow: hidden; }
|
|
|
483
531
|
background: var(--card); border-bottom: 1px solid var(--border);
|
|
484
532
|
z-index: 51;
|
|
485
533
|
}
|
|
534
|
+
/* When the comment rail is docked open (≥980px), inset the full-screen overlay
|
|
535
|
+
AND its toolbar from the right by the rail width — otherwise the overlay slides
|
|
536
|
+
under the (higher z-index) rail and its close/comment buttons get hidden. */
|
|
537
|
+
@media (min-width: 980px) {
|
|
538
|
+
body.ann-rail-open .blk-full { right: 340px; }
|
|
539
|
+
body.ann-rail-open .blk-full > .blk-tools { right: 340px; }
|
|
540
|
+
}
|
|
486
541
|
.blk-chart.blk-full {
|
|
487
542
|
height: 100vh !important; width: auto !important;
|
|
488
543
|
padding: 44px 16px 16px;
|
|
@@ -585,3 +640,108 @@ body.blk-full-open { overflow: hidden; }
|
|
|
585
640
|
.pal-tag.tone-nature { background: #EAF3DE; color: #3B6D11; }
|
|
586
641
|
.pal-tag.tone-bold { background: #FBEAF0; color: #72243E; }
|
|
587
642
|
.pal-tag.tone-digital { background: #EEEDFE; color: #3C3489; }
|
|
643
|
+
|
|
644
|
+
/* ---------- kpi (stat cards) ---------- */
|
|
645
|
+
.blk-kpi { margin: 4px 0; }
|
|
646
|
+
.kpi-title { font-weight: 600; color: var(--fg-2); margin-bottom: 10px; }
|
|
647
|
+
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }
|
|
648
|
+
.kpi-card {
|
|
649
|
+
border: 1px solid var(--border); border-radius: 12px;
|
|
650
|
+
background: var(--card); padding: 14px 16px;
|
|
651
|
+
}
|
|
652
|
+
.kpi-label { color: var(--muted); font-size: 0.82rem; }
|
|
653
|
+
.kpi-value { font-family: var(--serif); font-size: 1.8rem; line-height: 1.1; margin: 4px 0 2px; letter-spacing: -0.02em; }
|
|
654
|
+
.kpi-delta { font-size: 0.85rem; font-weight: 600; color: var(--fg-2); }
|
|
655
|
+
.kpi-delta.kpi-up { color: var(--ok); }
|
|
656
|
+
.kpi-delta.kpi-down { color: var(--danger); }
|
|
657
|
+
.kpi-sub { color: var(--muted); font-size: 0.78rem; margin-top: 2px; }
|
|
658
|
+
|
|
659
|
+
/* ---------- typography (specimens) ---------- */
|
|
660
|
+
.blk-typography { margin: 4px 0; }
|
|
661
|
+
.typo-title { font-weight: 600; color: var(--fg-2); margin-bottom: 10px; }
|
|
662
|
+
.typo-row { padding: 10px 0; border-bottom: 1px solid var(--border); }
|
|
663
|
+
.typo-row:last-child { border-bottom: 0; }
|
|
664
|
+
.typo-meta { color: var(--muted); font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; }
|
|
665
|
+
.typo-sample { color: var(--fg); font-size: 1.1rem; line-height: 1.3; overflow-wrap: anywhere; }
|
|
666
|
+
|
|
667
|
+
/* ---------- compare (before/after slider) ---------- */
|
|
668
|
+
.blk-comparewrap { margin: 4px 0; }
|
|
669
|
+
.cmp-frame {
|
|
670
|
+
position: relative; overflow: hidden; user-select: none; touch-action: none;
|
|
671
|
+
border: 1px solid var(--border); border-radius: 12px; line-height: 0;
|
|
672
|
+
cursor: ew-resize;
|
|
673
|
+
}
|
|
674
|
+
.cmp-img { display: block; width: 100%; height: auto; }
|
|
675
|
+
.cmp-before { position: absolute; inset: 0; width: 50%; overflow: hidden; }
|
|
676
|
+
.cmp-before .cmp-img-before { position: absolute; left: 0; top: 0; height: 100%; max-width: none; }
|
|
677
|
+
.cmp-handle {
|
|
678
|
+
position: absolute; top: 0; bottom: 0; left: 50%; width: 2px;
|
|
679
|
+
background: #fff; box-shadow: 0 0 0 1px rgba(0,0,0,0.25); transform: translateX(-1px);
|
|
680
|
+
}
|
|
681
|
+
.cmp-handle-grip {
|
|
682
|
+
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
|
683
|
+
width: 30px; height: 30px; border-radius: 50%;
|
|
684
|
+
background: #fff; color: #333; box-shadow: 0 1px 4px rgba(0,0,0,0.35);
|
|
685
|
+
display: grid; place-items: center; font-size: 13px; cursor: ew-resize;
|
|
686
|
+
}
|
|
687
|
+
.cmp-handle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
688
|
+
.cmp-tag {
|
|
689
|
+
position: absolute; bottom: 10px; padding: 3px 8px; border-radius: 6px;
|
|
690
|
+
font-size: 0.72rem; line-height: 1.4; color: #fff; background: rgba(0,0,0,0.55);
|
|
691
|
+
pointer-events: none;
|
|
692
|
+
}
|
|
693
|
+
.cmp-tag-before { left: 10px; }
|
|
694
|
+
.cmp-tag-after { right: 10px; }
|
|
695
|
+
|
|
696
|
+
/* ---------- reference links (open a visual in a modal) ---------- */
|
|
697
|
+
.rly-reflink {
|
|
698
|
+
display: inline-flex; align-items: center; gap: 0.3em;
|
|
699
|
+
color: var(--accent); cursor: pointer; text-decoration: none;
|
|
700
|
+
border-bottom: 1px solid transparent; border-radius: 4px;
|
|
701
|
+
transition: border-color 150ms var(--ease);
|
|
702
|
+
}
|
|
703
|
+
.rly-reflink:hover { border-bottom-color: var(--accent); }
|
|
704
|
+
.rly-reflink:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
705
|
+
.rly-reflink-ico {
|
|
706
|
+
flex: none; width: 0.85em; height: 0.85em;
|
|
707
|
+
border: 1.6px solid currentColor; border-radius: 50%;
|
|
708
|
+
position: relative;
|
|
709
|
+
}
|
|
710
|
+
.rly-reflink-ico::after {
|
|
711
|
+
content: ''; position: absolute; width: 0.42em; height: 1.6px;
|
|
712
|
+
background: currentColor; right: -0.28em; bottom: 0.02em;
|
|
713
|
+
transform: rotate(45deg); transform-origin: left center;
|
|
714
|
+
}
|
|
715
|
+
/* floating "↩ Back" pill shown after a reference jumps to a non-modal block */
|
|
716
|
+
.rly-refback {
|
|
717
|
+
position: fixed; left: 18px; bottom: 18px; z-index: 60;
|
|
718
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
719
|
+
padding: 8px 14px; font: inherit; font-size: 0.85rem; font-weight: 500;
|
|
720
|
+
color: var(--accent-fg); background: var(--accent);
|
|
721
|
+
border: 0; border-radius: 999px; cursor: pointer;
|
|
722
|
+
box-shadow: var(--shadow-lift);
|
|
723
|
+
animation: rly-refback-in 180ms var(--ease);
|
|
724
|
+
}
|
|
725
|
+
.rly-refback:hover { filter: brightness(1.06); }
|
|
726
|
+
@keyframes rly-refback-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
|
727
|
+
/* brief highlight when a reference jumps to a non-modal block */
|
|
728
|
+
.rly-refflash { animation: rly-refflash 1.2s var(--ease); }
|
|
729
|
+
@keyframes rly-refflash {
|
|
730
|
+
0%, 100% { box-shadow: 0 0 0 0 transparent; }
|
|
731
|
+
20% { box-shadow: 0 0 0 3px var(--accent); }
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/* ---------- image coordinate pins (block.pins) ---------- */
|
|
735
|
+
.blk-img-pinnable { cursor: crosshair; }
|
|
736
|
+
.blk-imgpins { position: absolute; inset: 0; pointer-events: none; }
|
|
737
|
+
.blk-imgpin {
|
|
738
|
+
position: absolute; transform: translate(-50%, -50%);
|
|
739
|
+
min-width: 22px; height: 22px; padding: 0 6px;
|
|
740
|
+
border-radius: 11px; border: 2px solid #fff;
|
|
741
|
+
background: var(--accent); color: #fff;
|
|
742
|
+
font: inherit; font-size: 0.72rem; font-weight: 600; line-height: 1;
|
|
743
|
+
cursor: pointer; pointer-events: auto;
|
|
744
|
+
box-shadow: 0 1px 4px rgba(0,0,0,0.35);
|
|
745
|
+
display: grid; place-items: center;
|
|
746
|
+
}
|
|
747
|
+
.blk-imgpin:hover { filter: brightness(1.08); }
|