@ammduncan/easel 0.6.1 → 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.
@@ -47,65 +47,6 @@
47
47
  const PRESETS = ["paper", "aurora", "slate"];
48
48
  const DENSITIES = ["carded", "flat"];
49
49
 
50
- /* The token block injected into every iframe wrapper — six combos so
51
- pushed HTML themes correctly regardless of host preset/mode. */
52
- const PRESET_TOKENS_CSS = `
53
- :root[data-preset="paper"][data-theme="light"] {
54
- --ds-bg:#f4efe2;--ds-bg-elev:#f8f3e6;--ds-surface:#faf6ee;--ds-surface-soft:#f0ead9;
55
- --ds-ink:#2a261e;--ds-ink-soft:#524b3c;--ds-muted:#756c57;
56
- --ds-line:#d5cdb6;--ds-line-soft:#e3dcc6;
57
- --ds-accent:#c97a1c;--ds-accent-soft:#f7e8c0;--ds-accent-ink:#fff;
58
- --ds-code-bg:#2a261e;--ds-code-ink:#eae5d5;
59
- --ds-shadow-md:0 1px 2px rgba(70,50,10,.06),0 18px 36px rgba(70,50,10,.1);
60
- color-scheme:light;
61
- }
62
- :root[data-preset="paper"][data-theme="dark"] {
63
- --ds-bg:#1c1b18;--ds-bg-elev:#25241f;--ds-surface:#25241f;--ds-surface-soft:#20201c;
64
- --ds-ink:#ede9e0;--ds-ink-soft:#bbb5a8;--ds-muted:#888273;
65
- --ds-line:#423f37;--ds-line-soft:#312f29;
66
- --ds-accent:#f4bf5e;--ds-accent-soft:#3d3322;--ds-accent-ink:#1f1d18;
67
- --ds-code-bg:#161514;--ds-code-ink:#eae5d5;
68
- --ds-shadow-md:inset 0 1px 0 rgba(255,255,255,.045),0 1px 2px rgba(0,0,0,.55),0 18px 38px rgba(0,0,0,.45);
69
- color-scheme:dark;
70
- }
71
- :root[data-preset="aurora"][data-theme="light"] {
72
- --ds-bg:#f5f3fa;--ds-bg-elev:#fafaff;--ds-surface:#fff;--ds-surface-soft:#f0eef7;
73
- --ds-ink:#1c1d24;--ds-ink-soft:#4a4d5a;--ds-muted:#7a7d8c;
74
- --ds-line:#e1dff0;--ds-line-soft:#ebe9f5;
75
- --ds-accent:#6d4eff;--ds-accent-soft:#ebe7ff;--ds-accent-ink:#fff;
76
- --ds-code-bg:#1c1d24;--ds-code-ink:#ebe7ff;
77
- --ds-shadow-md:0 1px 2px rgba(60,50,120,.05),0 18px 36px rgba(60,50,120,.08);
78
- color-scheme:light;
79
- }
80
- :root[data-preset="aurora"][data-theme="dark"] {
81
- --ds-bg:#0d0f14;--ds-bg-elev:#14171f;--ds-surface:#161a23;--ds-surface-soft:#11141a;
82
- --ds-ink:#e7e9ee;--ds-ink-soft:#b9bdc6;--ds-muted:#8b909a;
83
- --ds-line:rgba(143,160,200,.14);--ds-line-soft:rgba(143,160,200,.08);
84
- --ds-accent:#b8c8ff;--ds-accent-soft:rgba(140,170,255,.12);--ds-accent-ink:#0d0f14;
85
- --ds-code-bg:#07080a;--ds-code-ink:#e7e9ee;
86
- --ds-shadow-md:inset 0 1px 0 rgba(255,255,255,.045),0 0 0 1px rgba(123,97,255,.06),0 24px 60px rgba(0,0,0,.55),0 0 80px -20px rgba(123,97,255,.25);
87
- color-scheme:dark;
88
- }
89
- :root[data-preset="slate"][data-theme="light"] {
90
- --ds-bg:#ecebe5;--ds-bg-elev:#f6f4ee;--ds-surface:#f6f4ee;--ds-surface-soft:#ecebe3;
91
- --ds-ink:#1a1916;--ds-ink-soft:#34322d;--ds-muted:#76746c;
92
- --ds-line:#d8d5cb;--ds-line-soft:#e1ddd2;
93
- --ds-accent:#2f5fd1;--ds-accent-soft:#e4ebfb;--ds-accent-ink:#fff;
94
- --ds-code-bg:#1c1b18;--ds-code-ink:#f1ede1;
95
- --ds-shadow-md:0 1px 2px rgba(40,30,10,.05),0 16px 32px rgba(40,30,10,.08);
96
- color-scheme:light;
97
- }
98
- :root[data-preset="slate"][data-theme="dark"] {
99
- --ds-bg:#0c0d10;--ds-bg-elev:#15171c;--ds-surface:#15171c;--ds-surface-soft:#1c1f25;
100
- --ds-ink:#f5f5f5;--ds-ink-soft:#d4d4d8;--ds-muted:#9ca3af;
101
- --ds-line:#23262d;--ds-line-soft:#1c1f25;
102
- --ds-accent:#7dd3fc;--ds-accent-soft:rgba(125,211,252,.16);--ds-accent-ink:#07242e;
103
- --ds-code-bg:#07080a;--ds-code-ink:#f5f5f5;
104
- --ds-shadow-md:0 1px 2px rgba(0,0,0,.4),0 12px 28px rgba(0,0,0,.45);
105
- color-scheme:dark;
106
- }
107
- `;
108
-
109
50
  /* Semantic chips — universal across presets. Authors use:
110
51
  <span class="chip bug">BUG</span> / .ux / .polish / .ok / .info
111
52
  to get accessible, glow-haloed badges that work in both modes. */
@@ -142,12 +83,11 @@
142
83
  block, so stripping these in fidelity mode left the skill's own guidance
143
84
  ("wrap a mockup in .window") producing unstyled output. */
144
85
  const STRUCTURAL_PRIMITIVES_CSS = `
145
- /* Bind the CSS color-scheme to the host theme so any author CSS that uses
146
- light-dark() (text ink, surfaces, borders) tracks the easel light/dark
147
- TOGGLE rather than the OS preference. The default wrapper already gets this
148
- via PRESET_TOKENS_CSS, but app-fidelity (kind:"mockup") pushes omit the
149
- preset tokens without this rule their light-dark() ink follows the OS
150
- scheme and washes out whenever the OS disagrees with the easel toggle. */
86
+ /* Bind the CSS color-scheme to the card's sealed theme so any author CSS that
87
+ uses light-dark() (text ink, surfaces, borders) including the kit's token
88
+ fallbacks — resolves against the frozen mode rather than the OS preference.
89
+ Injected in every wrapper (default + app-fidelity), the only place the canvas
90
+ mode is now declared since the preset token block was removed. */
151
91
  :root[data-theme="light"] { color-scheme: light; }
152
92
  :root[data-theme="dark"] { color-scheme: dark; }
153
93
 
@@ -167,18 +107,19 @@
167
107
  canvas with pinned dark ink and color:inherit re-scoped to every child so the
168
108
  host's light-dark() ink can never leak in. Add the dark class
169
109
  (class="window dark") for a genuinely dark-UI mockup. */
170
- .window {
110
+ .window, .easel-window, [data-easel="window"] {
171
111
  position: relative;
172
112
  padding-top: 40px;
173
113
  border-radius: 12px;
174
114
  border: 1px solid #e2e2e2;
175
115
  box-shadow: 0 14px 48px rgba(0, 0, 0, 0.16);
176
116
  overflow: hidden;
177
- background: #ffffff;
178
- color: #1a1a1a;
117
+ /* Locked surface + ink, !important so .wrap * { color: inherit } can't flip it. */
118
+ background: #ffffff !important;
119
+ color: #1a1a1a !important;
179
120
  }
180
- .window * { color: inherit; }
181
- .window::before {
121
+ .window *, .easel-window *, [data-easel="window"] * { color: inherit; }
122
+ .window::before, .easel-window::before, [data-easel="window"]::before {
182
123
  content: "";
183
124
  position: absolute;
184
125
  top: 0;
@@ -193,7 +134,7 @@
193
134
  radial-gradient(circle at 59px 20px, #28c840 6px, transparent 6.5px);
194
135
  background-repeat: no-repeat;
195
136
  }
196
- .window::after {
137
+ .window::after, .easel-window::after, [data-easel="window"]::after {
197
138
  content: attr(data-title);
198
139
  position: absolute;
199
140
  top: 0;
@@ -208,18 +149,18 @@
208
149
  color: #6b6b6b;
209
150
  pointer-events: none;
210
151
  }
211
- .window.dark {
152
+ .window.dark, .easel-window.dark, [data-easel="window"].dark {
212
153
  border-color: #2a2a2a;
213
- background: #161616;
214
- color: #e6edf3;
154
+ background: #161616 !important;
155
+ color: #e6edf3 !important;
215
156
  box-shadow: 0 14px 48px rgba(0, 0, 0, 0.4);
216
157
  }
217
- .window.dark::before {
158
+ .window.dark::before, .easel-window.dark::before, [data-easel="window"].dark::before {
218
159
  background-color: #1f1f1f;
219
160
  border-bottom-color: #2a2a2a;
220
161
  }
221
- .window.dark::after { color: #9b9b9b; }
222
- .window.desktop {
162
+ .window.dark::after, .easel-window.dark::after, [data-easel="window"].dark::after { color: #9b9b9b; }
163
+ .window.desktop, .easel-window.desktop, [data-easel="window"].desktop {
223
164
  min-height: 900px;
224
165
  }
225
166
  /* Locked-dark code / terminal primitive. Reach for this instead of hand-rolling
@@ -231,9 +172,18 @@
231
172
  palette so syntax highlighting reads against #0f172a without per-token tuning.
232
173
  Usage: <div class="code"><span class="kw">gcloud</span> services enable …</div>
233
174
  .terminal is an alias; add .terminal for a prompt feel (same colors). */
234
- .code, .terminal {
235
- background: #0f172a;
236
- color: #e6edf3;
175
+ .code, .terminal, .easel-code, .easel-terminal,
176
+ [data-easel="code"], [data-easel="terminal"] {
177
+ /* Locked dark surface + ink. Committed with !important so the documented
178
+ .wrap * { color: inherit } can't flip the ink onto this fixed background —
179
+ that tie (both (0,1,0); author rule later in source) is the recurring
180
+ invisible dark-on-dark bug. !important is on the CONTAINER only, not on the
181
+ descendant rules below, so syntax tokens still win by specificity.
182
+ Generic names collide with author markup like <td class="code"> — prefer the
183
+ namespaced .easel-code / [data-easel="code"]; bare code/terminal are kept as
184
+ deprecated aliases. */
185
+ background: #0f172a !important;
186
+ color: #e6edf3 !important;
237
187
  border-radius: 12px;
238
188
  padding: 18px 22px;
239
189
  font-family: ui-monospace, "SF Mono", Menlo, monospace;
@@ -242,25 +192,26 @@
242
192
  overflow: auto;
243
193
  margin: 16px 0 24px;
244
194
  }
245
- .code *, .terminal * { color: inherit; }
246
- .code .kw, .terminal .kw { color: #ff7b72; } /* keywords, control flow */
247
- .code .string, .terminal .string { color: #a5d6ff; } /* strings, attr values */
248
- .code .fn, .terminal .fn { color: #d2a8ff; } /* function names */
249
- .code .prop, .terminal .prop { color: #79c0ff; } /* identifiers, properties */
250
- .code .num, .terminal .num { color: #ffa657; } /* numbers, constants */
251
- .code .comment, .terminal .comment { color: #8b949e; } /* comments */
252
- .code .muted, .terminal .muted { color: #94a3b8; } /* dim / secondary */
253
- .code .accent, .terminal .accent { color: #6ee7b7; } /* highlight / success */
195
+ :is(.code, .terminal, .easel-code, .easel-terminal, [data-easel="code"], [data-easel="terminal"]) * { color: inherit; }
196
+ :is(.code, .terminal, .easel-code, .easel-terminal) .kw { color: #ff7b72; } /* keywords, control flow */
197
+ :is(.code, .terminal, .easel-code, .easel-terminal) .string { color: #a5d6ff; } /* strings, attr values */
198
+ :is(.code, .terminal, .easel-code, .easel-terminal) .fn { color: #d2a8ff; } /* function names */
199
+ :is(.code, .terminal, .easel-code, .easel-terminal) .prop { color: #79c0ff; } /* identifiers, properties */
200
+ :is(.code, .terminal, .easel-code, .easel-terminal) .num { color: #ffa657; } /* numbers, constants */
201
+ :is(.code, .terminal, .easel-code, .easel-terminal) .comment { color: #8b949e; } /* comments */
202
+ :is(.code, .terminal, .easel-code, .easel-terminal) .muted { color: #94a3b8; } /* dim / secondary */
203
+ :is(.code, .terminal, .easel-code, .easel-terminal) .accent { color: #6ee7b7; } /* highlight / success */
254
204
  @media print {
255
205
  /* Force the locked-dark primitives light for print — browsers drop background
256
206
  colours by default, which would otherwise strand their light ink on white
257
207
  paper. Applies in both normal and app-fidelity mode. The !important here
258
208
  also (intentionally) overrides the normal branch's non-print-gated pre/code
259
209
  theming, so code reads as dark-on-light on paper regardless of host theme. */
260
- pre, code, .code, .terminal { background: #f4f3ed !important; color: #111 !important; border: 1px solid #ddd; }
261
- .code *, .terminal * { color: #111 !important; }
262
- .window.dark { background: #ffffff !important; color: #111 !important; }
263
- .window.dark * { color: #111 !important; }
210
+ pre, code, .code, .terminal, .easel-code, .easel-terminal,
211
+ [data-easel="code"], [data-easel="terminal"] { background: #f4f3ed !important; color: #111 !important; border: 1px solid #ddd; }
212
+ :is(.code, .terminal, .easel-code, .easel-terminal, [data-easel="code"], [data-easel="terminal"]) * { color: #111 !important; }
213
+ .window.dark, .easel-window.dark, [data-easel="window"].dark { background: #ffffff !important; color: #111 !important; }
214
+ :is(.window, .easel-window, [data-easel="window"]).dark * { color: #111 !important; }
264
215
  }
265
216
  `;
266
217
 
@@ -514,7 +465,11 @@
514
465
  /* ignore */
515
466
  }
516
467
  }
517
- broadcastConfigToIframes({ preset, theme, density });
468
+ // NOTE: we deliberately do NOT push theme/preset/density into rendered
469
+ // iframes. A pushed card is an immutable snapshot — its canvas is sealed at
470
+ // render time. applyConfig only restyles Easel's own chrome (host <html>
471
+ // tokens) and the default for FUTURE pushes; it never reaches back into
472
+ // existing cards.
518
473
  if (!opts || !opts.skipServer) {
519
474
  pushConfigToServer({ preset, theme, density });
520
475
  }
@@ -531,20 +486,6 @@
531
486
  });
532
487
  }
533
488
 
534
- function broadcastConfigToIframes(cfg) {
535
- iframes.forEach((iframe) => {
536
- try {
537
- iframe.contentWindow &&
538
- iframe.contentWindow.postMessage(
539
- { type: "easel:config", ...cfg },
540
- "*",
541
- );
542
- } catch (e) {
543
- /* ignore */
544
- }
545
- });
546
- }
547
-
548
489
  async function pushConfigToServer(cfg) {
549
490
  try {
550
491
  await fetch("/api/config", {
@@ -831,7 +772,12 @@
831
772
  iframe.setAttribute("scrolling", "no");
832
773
  iframe.setAttribute("title", push.title || "push " + push.index);
833
774
  iframe.dataset.pushId = push.id;
834
- iframe.srcdoc = wrapPushedHtml(push.html, currentTheme(), push.id, push.kind);
775
+ // Explicit push.theme wins; absent → snapshot the global at render. Either
776
+ // way it's frozen — the card never flips on a later global toggle.
777
+ const sealedTheme = push.theme === "light" || push.theme === "dark"
778
+ ? push.theme
779
+ : currentTheme();
780
+ iframe.srcdoc = wrapPushedHtml(push.html, sealedTheme, push.id, push.kind);
835
781
  iframe.addEventListener("load", () => {
836
782
  iframes.add(iframe);
837
783
  // Primary path: the iframe self-measures and posts back size via
@@ -1051,8 +997,13 @@ window.htmlToImage.toSvg(de,{width:w,height:h,cacheBust:true})
1051
997
  "function hasDirectText(el){for(var i=0;i<el.childNodes.length;i++){var n=el.childNodes[i];if(n.nodeType===3&&n.nodeValue.trim().length>0)return true}return false}" +
1052
998
  "function fmt(c){return'rgb('+Math.round(c.r)+','+Math.round(c.g)+','+Math.round(c.b)+')'}" +
1053
999
  "function scan(){if(!document.body)return;var offenders=[];var seen=0;var all=document.body.querySelectorAll('*');for(var i=0;i<all.length&&seen<2000;i++){var el=all[i];if(!hasDirectText(el))continue;seen++;var cs=getComputedStyle(el);if(cs.visibility==='hidden'||cs.display==='none')continue;var fg=parseColor(cs.color);if(!fg||fg.a<0.05)continue;var bg=effBg(el);var ratio=contrast(fg,bg);if(ratio<3){offenders.push({tag:el.tagName.toLowerCase(),cls:(el.className&&el.className.toString?el.className.toString():'').slice(0,80),text:(el.textContent||'').trim().slice(0,60),ratio:Math.round(ratio*100)/100,fg:fmt(fg),bg:fmt(bg)})}}" +
1054
- "if(offenders.length){console.warn('[easel] low-contrast text detected ('+offenders.length+' element(s), threshold 3:1). The #1 cause is a hand-rolled dark code container — use <div class=\"code\"> or <div class=\"terminal\"> instead; they lock background AND ink and re-scope color:inherit to children. Offenders:',offenders.slice(0,10));try{parent.postMessage({type:'easel:contrast-warn',pushId:ID,count:offenders.length,samples:offenders.slice(0,5)},'*')}catch(e){}}}" +
1055
- "function run(){setTimeout(scan,400)}" +
1000
+ "if(offenders.length){console.warn('[easel] low-contrast text detected ('+offenders.length+' element(s), threshold 3:1). Common causes: (1) a reserved primitive class (code/terminal/window) on your own element inheriting its locked dark fill — see the reserved-class warning below; (2) a hand-rolled dark container — use <div class=\"easel-code\"> instead (locks bg AND ink). Offenders:',offenders.slice(0,10));try{parent.postMessage({type:'easel:contrast-warn',pushId:ID,count:offenders.length,samples:offenders.slice(0,5)},'*')}catch(e){}}}" +
1001
+ // Reserved-class collision guard: the primitive names code/terminal/window paint a
1002
+ // locked dark background, so finding one on an inline/table element (span/td/…) is a
1003
+ // strong signal the author reused a generic name and got an unintended dark block.
1004
+ "function scanReserved(){if(!document.body)return;var R={code:1,terminal:1,window:1};var inl={SPAN:1,TD:1,TH:1,A:1,LI:1,LABEL:1,B:1,I:1,EM:1,STRONG:1,SMALL:1};var bad=[];var all=document.body.querySelectorAll('*');for(var i=0;i<all.length;i++){var el=all[i];if(!inl[el.tagName])continue;var cl=(el.className&&el.className.toString?el.className.toString():'').split(/\\s+/);for(var j=0;j<cl.length;j++){if(R[cl[j]]){bad.push({tag:el.tagName.toLowerCase(),cls:cl[j],text:(el.textContent||'').trim().slice(0,40)});break}}}" +
1005
+ "if(bad.length){console.warn('[easel] reserved primitive class on '+bad.length+' inline/table element(s). code/terminal/window are RESERVED easel primitives that paint a locked dark background — on a <span>/<td>/etc. that yields an unintended dark block. Rename your class (e.g. mono, codecell), or use the namespaced .easel-code/.easel-terminal/.easel-window form if you DO want the primitive. Offenders:',bad.slice(0,10))}}" +
1006
+ "function run(){setTimeout(function(){scan();scanReserved()},400)}" +
1056
1007
  "if(document.fonts&&document.fonts.ready){document.fonts.ready.then(run).catch(run)}else{run()}" +
1057
1008
  "})();"
1058
1009
  );
@@ -1092,23 +1043,30 @@ ${body}
1092
1043
  <head>
1093
1044
  <meta charset="utf-8" />
1094
1045
  <base target="_blank" />
1095
- <link rel="preconnect" href="https://rsms.me/" />
1096
- <link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
1097
1046
  <script src="https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/dist/html-to-image.js"></script>
1098
1047
  <style>
1099
1048
  *, *::before, *::after { box-sizing: border-box; }
1100
- ${PRESET_TOKENS_CSS}
1101
1049
  ${SEMANTIC_CHIPS_CSS}
1102
1050
  ${STRUCTURAL_PRIMITIVES_CSS}
1051
+ /* MINIMAL FLOOR (layer 3 removed): no --ds token block, no Inter webfont, no
1052
+ presentation type scale. The wrapper only commits a base surface + ink for
1053
+ the frozen mode and a system-sans default — the agent owns everything else
1054
+ (or inlines the kit for the presentation scaffold). Primitives + chips +
1055
+ prose-width cap are retained. */
1103
1056
  html, body {
1104
1057
  margin: 0;
1105
- background: var(--ds-bg-elev);
1106
- color: var(--ds-ink);
1107
- font-family: "Inter", -apple-system, "SF Pro Text", system-ui, sans-serif;
1108
- font-feature-settings: "cv11", "ss01";
1058
+ background: ${theme === "dark" ? "#0e1116" : "#faf7f0"};
1059
+ color: ${theme === "dark" ? "#e8e8e8" : "#1a1a1a"};
1060
+ font-family: -apple-system, "SF Pro Text", system-ui, "Segoe UI", sans-serif;
1109
1061
  line-height: 1.55;
1110
1062
  -webkit-font-smoothing: antialiased;
1111
- transition: background 200ms ease, color 200ms ease;
1063
+ }
1064
+ :where(body) :where(*) { color: inherit; }
1065
+ /* keep the accent chip working without the dropped --ds-accent token */
1066
+ .chip.accent {
1067
+ background: ${theme === "dark" ? "#10241c" : "#eafaf0"};
1068
+ color: ${theme === "dark" ? "#6ee7a8" : "#15803d"};
1069
+ border-color: transparent;
1112
1070
  }
1113
1071
  body {
1114
1072
  padding: 40px clamp(28px, 4vw, 64px) 48px;
@@ -1152,104 +1110,7 @@ body > *:last-child { margin-bottom: 0 !important; }
1152
1110
  margin: 32px 0;
1153
1111
  }
1154
1112
  .wrap { display: block; }
1155
- .kicker {
1156
- display: block;
1157
- font-size: 13px;
1158
- letter-spacing: 0.14em;
1159
- text-transform: uppercase;
1160
- color: var(--ds-muted);
1161
- font-weight: 500;
1162
- margin-bottom: 14px;
1163
- }
1164
- h1 {
1165
- font-size: 40px;
1166
- font-weight: 500;
1167
- letter-spacing: -0.025em;
1168
- line-height: 1.08;
1169
- margin: 0 0 18px;
1170
- }
1171
- .deck, .lede {
1172
- font-size: 19px;
1173
- line-height: 1.55;
1174
- color: var(--ds-ink-soft);
1175
- margin: 0 0 28px;
1176
- max-width: 720px;
1177
- }
1178
- h2 {
1179
- font-size: 26px;
1180
- font-weight: 500;
1181
- letter-spacing: -0.02em;
1182
- margin: 36px 0 12px;
1183
- }
1184
- h3 {
1185
- font-size: 19px;
1186
- font-weight: 600;
1187
- letter-spacing: -0.005em;
1188
- margin: 24px 0 8px;
1189
- }
1190
- h4 { font-size: 15px; font-weight: 600; margin: 20px 0 6px; }
1191
- p {
1192
- font-size: 18px;
1193
- margin: 0 0 14px;
1194
- color: var(--ds-ink-soft);
1195
- }
1196
- a { color: var(--ds-accent); text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--ds-accent) 40%, transparent); }
1197
- a:hover { border-bottom-color: var(--ds-accent); }
1198
- ul, ol { padding-left: 22px; margin: 0 0 18px; }
1199
- li { font-size: 18px; margin-bottom: 6px; color: var(--ds-ink-soft); }
1200
- code {
1201
- font-family: ui-monospace, "SF Mono", Menlo, monospace;
1202
- font-size: 0.92em;
1203
- background: var(--ds-surface-soft);
1204
- color: var(--ds-ink);
1205
- padding: 2px 6px;
1206
- border-radius: 5px;
1207
- }
1208
- pre {
1209
- background: var(--ds-code-bg);
1210
- color: var(--ds-code-ink);
1211
- padding: 18px 22px;
1212
- border-radius: 12px;
1213
- overflow: auto;
1214
- font-family: ui-monospace, "SF Mono", Menlo, monospace;
1215
- font-size: 13.5px;
1216
- line-height: 1.7;
1217
- margin: 16px 0 24px;
1218
- }
1219
- pre code { background: transparent; padding: 0; color: inherit; font-size: inherit; }
1220
- blockquote {
1221
- border-left: 3px solid var(--ds-accent);
1222
- margin: 18px 0;
1223
- padding: 4px 0 4px 20px;
1224
- color: var(--ds-ink-soft);
1225
- font-size: 18px;
1226
- }
1227
- .card, .panel {
1228
- background: var(--ds-surface);
1229
- border: 1px solid var(--ds-line);
1230
- border-radius: 14px;
1231
- padding: 24px 28px;
1232
- margin: 0 0 20px;
1233
- box-shadow: 0 1px 2px rgba(0,0,0,0.04), 0 8px 24px rgba(0,0,0,0.04);
1234
- }
1235
- hr {
1236
- border: 0;
1237
- border-top: 1px solid var(--ds-line);
1238
- margin: 36px 0;
1239
- }
1240
- table {
1241
- width: 100%;
1242
- border-collapse: collapse;
1243
- font-size: 15px;
1244
- margin: 18px 0 24px;
1245
- }
1246
- th, td {
1247
- text-align: left;
1248
- padding: 10px 12px;
1249
- border-bottom: 1px solid var(--ds-line);
1250
- }
1251
- th { color: var(--ds-muted); font-weight: 500; font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; }
1252
- img { max-width: 100%; height: auto; border-radius: 10px; }
1113
+ img { max-width: 100%; height: auto; }
1253
1114
  :root[data-density="flat"] html,
1254
1115
  :root[data-density="flat"] body { background: transparent; }
1255
1116
 
@@ -1272,26 +1133,12 @@ img { max-width: 100%; height: auto; border-radius: 10px; }
1272
1133
  <body>
1273
1134
  ${body}
1274
1135
  <script>
1136
+ // Canvas is sealed at render (data-theme/preset/density are baked into <html>
1137
+ // above and never change). The only live message this card honours is print —
1138
+ // config/theme broadcasts are intentionally ignored so the snapshot is immutable.
1275
1139
  (function(){
1276
- function apply(cfg){
1277
- if (!cfg) return;
1278
- if (cfg.theme === "light" || cfg.theme === "dark") {
1279
- document.documentElement.setAttribute("data-theme", cfg.theme);
1280
- window.__claudeDisplayTheme = cfg.theme;
1281
- }
1282
- if (cfg.preset === "paper" || cfg.preset === "aurora" || cfg.preset === "slate") {
1283
- document.documentElement.setAttribute("data-preset", cfg.preset);
1284
- window.__claudeDisplayPreset = cfg.preset;
1285
- }
1286
- if (cfg.density === "carded" || cfg.density === "flat") {
1287
- document.documentElement.setAttribute("data-density", cfg.density);
1288
- window.__claudeDisplayDensity = cfg.density;
1289
- }
1290
- }
1291
1140
  window.addEventListener("message", function(e){
1292
1141
  if (!e || !e.data) return;
1293
- if (e.data.type === "easel:config") apply(e.data);
1294
- if (e.data.type === "easel:theme") apply({ theme: e.data.theme });
1295
1142
  if (e.data.type === "easel:print") {
1296
1143
  try { window.print(); } catch(_) {}
1297
1144
  }
@@ -1310,7 +1157,7 @@ ${body}
1310
1157
  const configScript =
1311
1158
  "<script src='https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/dist/html-to-image.js'></script><script>(function(){function a(c){if(!c)return;if(c.theme==='light'||c.theme==='dark'){document.documentElement.setAttribute('data-theme',c.theme);window.__claudeDisplayTheme=c.theme}if(c.preset==='paper'||c.preset==='aurora'||c.preset==='slate'){document.documentElement.setAttribute('data-preset',c.preset);window.__claudeDisplayPreset=c.preset}if(c.density==='carded'||c.density==='flat'){document.documentElement.setAttribute('data-density',c.density);window.__claudeDisplayDensity=c.density}}a(" +
1312
1159
  JSON.stringify({ theme, preset, density }) +
1313
- ");window.addEventListener('message',function(e){if(!e||!e.data)return;if(e.data.type==='easel:config')a(e.data);if(e.data.type==='easel:theme')a({theme:e.data.theme});if(e.data.type==='easel:print'){try{window.print()}catch(_){}}})})();</script>";
1160
+ ");window.addEventListener('message',function(e){if(!e||!e.data)return;if(e.data.type==='easel:print'){try{window.print()}catch(_){}}})})();</script>";
1314
1161
  const imageScript = "<script>" + imageExportScript() + "</script>";
1315
1162
  const guardScript = "<script>" + contrastGuardScript(pushId) + "</script>";
1316
1163
  const measureScript = "<script>" + selfMeasureScript(pushId) + "</script>";
@@ -159,7 +159,7 @@ export function startHttpServer() {
159
159
  res.json({ ok });
160
160
  });
161
161
  app.post("/api/push", async (req, res) => {
162
- const { sessionId, html, title, kind } = req.body ?? {};
162
+ const { sessionId, html, title, kind, theme } = req.body ?? {};
163
163
  if (typeof sessionId !== "string" || !sessionId.trim()) {
164
164
  res.status(400).json({ error: "sessionId required" });
165
165
  return;
@@ -185,7 +185,7 @@ export function startHttpServer() {
185
185
  console.warn("[easel] image inlining failed; storing original html:", err);
186
186
  }
187
187
  }
188
- const push = appendPush(sessionId, { html: storedHtml, title, kind });
188
+ const push = appendPush(sessionId, { html: storedHtml, title, kind, theme });
189
189
  touchSession(sessionId);
190
190
  broadcast(sessionId, "push", push);
191
191
  if (Math.random() < 0.05) {
package/dist/mcp.js CHANGED
@@ -21,6 +21,14 @@ const TOOL_PUSH = "push";
21
21
  const TOOL_OPEN = "open";
22
22
  const TOOL_CONFIG = "config";
23
23
  const TOOL_LABEL = "label";
24
+ // When EASEL_SUPPRESS_SESSION=1 (set by automated/headless consumers like the
25
+ // ammiels-bot dispatcher tick), easel loads normally but registers NO switcher
26
+ // session — every tool short-circuits to a no-op, so the MCP never contacts the
27
+ // HTTP server and the session never appears in the switcher. This keeps churny
28
+ // background sessions out of the switcher WITHOUT disabling the MCP or any OTHER
29
+ // connector. (Contrast `claude --strict-mcp-config`, which also strips the
30
+ // claude.ai account connectors — Slack etc. — and so can't be used for this.)
31
+ const SUPPRESS_SESSION = process.env.EASEL_SUPPRESS_SESSION === "1";
24
32
  // One-shot guard: only auto-open once per MCP-child lifetime. If the user
25
33
  // closes the tab afterwards, subsequent pushes won't re-open it — the user
26
34
  // closing the tab is treated as an explicit dismissal we should respect.
@@ -74,6 +82,11 @@ const inputSchema = {
74
82
  type: "string",
75
83
  description: "Freeform tag: mockup, app, diff, explanation, comparison, diagram, status, progress, etc. SPECIAL: 'mockup' and 'app' switch the iframe into APP-FIDELITY mode — the wrapper skips its PRESENTATION defaults (preset design-token CSS, semantic chips, prose width constraints, body bg/color, the Inter webfont) so the host theme can't leak in and you control every pixel. It KEEPS the self-contained structural primitives (.window/.window.dark window chrome, .code/.terminal code blocks — all fixed-colour, theme-independent) and a neutral system-sans default font, so you can still reach for <div class=\"window\"> in a mockup and it renders. Set your own font-family/colours in the pushed HTML to override the sans default. Use this kind when the push is a recreation of real UI (app screen, component instance, embedded preview). For presentation content (explanations, comparisons, status reports), omit kind or use a non-fidelity value.",
76
84
  },
85
+ theme: {
86
+ type: "string",
87
+ enum: ["light", "dark"],
88
+ description: "Canvas mode for THIS push — light or dark. SEALED at push time and never flips with the global Easel toggle: a pushed card is an immutable snapshot, like a screenshot. You OWN this canvas — set your own background + text colours in the HTML for the mode you pick here; do NOT write light-dark() / prefers-color-scheme for the page surface (the card is frozen to one mode, so adaptive CSS is dead weight). Omit to snapshot whatever the global theme is at push time (also frozen). Pass 'light' or 'dark' explicitly when you've been told which mode to present in.",
89
+ },
77
90
  },
78
91
  required: ["html"],
79
92
  additionalProperties: false,
@@ -87,6 +100,7 @@ async function pushToServer(args) {
87
100
  html: args.html,
88
101
  title: args.title,
89
102
  kind: args.kind,
103
+ theme: args.theme,
90
104
  }),
91
105
  });
92
106
  if (!r.ok) {
@@ -101,7 +115,9 @@ export async function main() {
101
115
  tools: [
102
116
  {
103
117
  name: TOOL_PUSH,
104
- description: "Push an HTML card to this session's live browser tab. Renders in a sandboxed iframe over a host-controlled canvas that can be LIGHT or DARK depending on the user's OS theme. Treat each card as a presentation slide — generous whitespace, presentation-scale type, tangible visuals. Your HTML MUST adapt to both light and dark modes.\n\n" +
118
+ description: "Push an HTML card to this session's live browser tab. Renders in a sandboxed iframe. Treat each card as a presentation slide — generous whitespace, presentation-scale type, tangible visuals.\n\n" +
119
+ "═══ YOU OWN THE CANVAS — COMMIT TO ONE MODE ═══\n" +
120
+ "A pushed card is an IMMUTABLE SNAPSHOT, like a screenshot: it's sealed at push time and NEVER reflows when the user toggles Easel's global light/dark theme. So pick ONE mode for this push and own every colour in it — set your own `background` AND `color` on `.wrap`/`body`, and choose surfaces/borders/ink for that single mode. Do NOT write `light-dark()` or `prefers-color-scheme` for the page surface; the card won't flip, so adaptive CSS is dead weight that just risks the wrong branch winning. Use the `theme` param to declare the mode ('light'/'dark') when you've been told which to present in; omit it to snapshot the current global theme (also frozen). Still scope `color: inherit` to descendants so a stray default ink can't leak in, and the locked primitives (.code/.terminal/.window) work exactly as before — the whole surface now just behaves like they already do: one committed mode, fully self-contained.\n\n" +
105
121
  "═══ FIDELITY BAR — SHIP HIGH-FIDELITY BY DEFAULT ═══\n" +
106
122
  "Default to polished, production-grade output that looks like a real screenshot of shipped software or a finished design — NOT a rough sketch, wireframe, or grey-box placeholder. This is the default for EVERY push; you do not need to be asked for quality. Only drop to low-fidelity (wireframe, ASCII-ish boxes, lorem-ipsum, unstyled) when the user EXPLICITLY says rough/lo-fi/wireframe/sketch/quick-and-dirty is fine, or asks for a thumbnail/napkin idea. When in doubt, go high-fidelity.\n" +
107
123
  "What high-fidelity means concretely:\n" +
@@ -111,18 +127,19 @@ export async function main() {
111
127
  "• Visual craft: deliberate hierarchy, aligned grids, consistent spacing scale, real iconography (inline SVG, not emoji-as-icon), proper empty/hover/active states where they matter. Avoid the generic-AI look (one purple gradient, evenly-sized boxes, centered everything).\n" +
112
128
  "• Tangible over abstract (see VISUALS): a mock should read as the actual thing, not labeled rectangles.\n" +
113
129
  "If you genuinely can't reach the bar (missing real values, ambiguous source), say so in ONE line in chat and push your best honest attempt — don't pass a rough draft off as final, and don't silently ship a grey-box.\n\n" +
114
- "═══ ADAPTIVE COLOR (gets wrong most often) ═══\n" +
115
- " Do NOT set `background` on `body` or your root wrapper. The host paints the canvas setting bg fights it and creates a wrong-shade block in the opposite mode.\n" +
116
- "• Use `light-dark()` for ALL text colors, card backgrounds, borders, and decorative shades. Add `:root { color-scheme: light dark; }` so the function resolves. Hardcoded `color: #475569` goes invisible in dark mode; hardcoded `border: 1px solid #e5e5e5` becomes a hard white line.\n" +
117
- "• After setting `.wrap { color: light-dark(...); }`, re-scope `color: inherit` to every descendant so child elements don't fall back to the host's default.\n" +
118
- "• Inverse rule: if you DO paint a fixed background on a container (a code block locked to dark, a brand-color hero), you MUST also set its text color AND re-scope `color: inherit` to its children. Background and text are a pair.\n\n" +
119
- "═══ COPY-PASTE STARTER (adaptive) ═══\n" +
120
- " :root { color-scheme: light dark; }\n" +
121
- " .wrap { color: light-dark(#111, #e8e8e8); padding: 56px 48px; font-family: -apple-system, 'Inter', system-ui, sans-serif; max-width: 820px; }\n" +
130
+ "═══ COMMITTED COLOR PICK ONE MODE, PAINT IT FULLY ═══\n" +
131
+ "The card is frozen to one mode (the `theme` param, or the global theme snapshotted at push time). So:\n" +
132
+ "• DO set `background` AND `color` explicitly on your root wrapper. You own the canvas paint it. (Old guidance said 'leave bg to the host'; that's gone the host no longer reflows your card, so an unpainted surface just inherits chrome you don't control.)\n" +
133
+ "• Pick concrete colours for the ONE mode you chose — `color: #111` for a light card, `color: #e8e8e8` for a dark one. Do NOT use `light-dark()` / `prefers-color-scheme` for the surface; the card won't flip, so the adaptive branch is dead weight and risks resolving to the wrong half.\n" +
134
+ "• Still re-scope `color: inherit` to every descendant so a stray default ink can't leak in.\n" +
135
+ " Pairing rule (unchanged): any container with a fixed background (a dark code block, a brand-color hero, a white card) MUST set its own text color and re-scope `color: inherit` to its children. Background and ink are always a pair.\n\n" +
136
+ "═══ COPY-PASTE STARTER (light card) ═══\n" +
137
+ " .wrap { background: #faf7f0; color: #1a1a1a; padding: 56px 48px; font-family: -apple-system, 'Inter', system-ui, sans-serif; max-width: 820px; }\n" +
122
138
  " .wrap *, .wrap h1, .wrap h2, .wrap h3, .wrap p, .wrap li, .wrap span { color: inherit; }\n" +
123
- " .card { background: light-dark(#fff, #161616); border: 1px solid light-dark(#e0d9c3, #2a2a2a); border-radius: 12px; padding: 24px; }\n\n" +
139
+ " .card { background: #ffffff; border: 1px solid #e0d9c3; border-radius: 12px; padding: 24px; }\n" +
140
+ " (dark card: .wrap { background:#0e1116; color:#e8e8e8 } .card { background:#161616; border-color:#2a2a2a })\n\n" +
124
141
  "═══ CODE / TERMINAL BLOCKS — USE THE BUILT-IN PRIMITIVE, DON'T HAND-ROLL ═══\n" +
125
- "The #1 recurring bug is a hand-rolled dark code container: you set `background:#0f172a` on a custom div but leave base text inheriting `.wrap`'s `light-dark(#111,…)`, which resolves to near-black in light host mode and VANISHES against the dark panel (only the explicitly-coloured syntax spans survive). Don't hand-roll it. The wrapper ships a baked-in, always-safe primitive:\n" +
142
+ "The #1 recurring bug is a hand-rolled dark code container: you set `background:#0f172a` on a custom div but leave base text inheriting `.wrap`'s ink, which on a light card is near-black and VANISHES against the dark panel (only the explicitly-coloured syntax spans survive). Don't hand-roll it. The wrapper ships a baked-in, always-safe primitive:\n" +
126
143
  " <div class=\"code\"> … </div> (alias: class=\"terminal\")\n" +
127
144
  "It locks BOTH background (#0f172a) and ink (#e6edf3), re-scopes `color:inherit` to every child, and provides verified github-dark syntax token classes you can drop onto spans — NO per-token tuning needed:\n" +
128
145
  " .kw (keywords #ff7b72) · .string (#a5d6ff) · .fn (function #d2a8ff) · .prop (identifiers #79c0ff) · .num (#ffa657) · .comment (#8b949e) · .muted (#94a3b8) · .accent (#6ee7b7)\n" +
@@ -130,10 +147,10 @@ export async function main() {
130
147
  "Plain <pre>/<code> are also already safe (bg+ink token pair). Only reach for a fully custom container when .code/.terminal genuinely don't fit — and then obey the locked-mode rule below.\n" +
131
148
  "SAFETY NET: every push self-checks for low-contrast text (WCAG <3:1) after render. If your push trips it, the card gets an amber `⚠ contrast` chip on the meta row with the offender list in the tooltip, and the iframe console.warns with sample rgb pairs. Treat the chip as a build-failure-equivalent — the fix is almost always swapping the hand-rolled container for `.code` / `.terminal` (or, for a non-code locked-bg container, applying the locked-mode rule below).\n\n" +
132
149
  "═══ COPY-PASTE STARTER (any OTHER LOCKED-MODE container — brand hero, custom panel) ═══\n" +
133
- "If a container has a FIXED background (not `light-dark()`), you MUST set its own text color AND re-scope `color: inherit` to its children. Otherwise the children inherit `light-dark(...)` from `.wrap` and the text flips to the wrong shade in one mode (e.g. dark text on a locked-dark panel in light host mode → invisible).\n" +
150
+ "If a container has a background that differs from the card surface, you MUST set its own text color AND re-scope `color: inherit` to its children. Otherwise the children inherit `.wrap`'s committed ink, which may not suit the container's bg (e.g. dark text on a dark hero on a light card → invisible).\n" +
134
151
  " .hero { background: #0f172a; color: #e6edf3; border-radius: 12px; padding: 20px 24px; }\n" +
135
152
  " .hero * { color: inherit; }\n" +
136
- "• Same pairing applies in the OPPOSITE direction — locked-LIGHT containers (e.g. a white card on the host canvas). A `.card { background: #fff }` with no `color:` inherits `.wrap`'s light-dark() text, which in dark host mode resolves to a light cream/gray → invisible titles on a white card. Commit text too AND re-scope inherit on children. This bites just as often as the dark case.\n" +
153
+ "• Same pairing applies in the OPPOSITE direction — a light panel sitting on a DARK card. A `.card { background: #fff }` with no `color:` inherits `.wrap`'s dark-card ink (a light cream/gray) → invisible titles on white. Commit text too AND re-scope inherit on children. This bites just as often as the dark case.\n" +
137
154
  " .card { background: #ffffff; color: #111111; border: 1px solid #e5e5e5; border-radius: 12px; padding: 24px 32px; }\n" +
138
155
  " .card * { color: inherit; }\n" +
139
156
  "• Syntax-highlighted code in a locked-bg block: EVERY token color must be verified readable against the bg, not just the body color. Recurring bug: locking to #0f172a then giving 'property' / 'punctuation' / 'comment' tokens something like #2c2c40 because it 'looked subtle' — against #0f172a it's nearly invisible and identifiers disappear. Either use a tested theme designed for your bg (Shiki github-dark / vitesse-dark / one-dark-pro for #0f172a-ish, github-light / vitesse-light for #f5f7fa-ish), or pick from this verified palette for #0f172a: keyword #ff7b72, string #a5d6ff, function #d2a8ff, property #79c0ff, number #ffa657, comment #8b949e, default text #e6edf3. If you can't articulate why each token reads against the bg, drop highlighting and use single-color monospace — that always works.\n\n" +
@@ -232,6 +249,16 @@ export async function main() {
232
249
  ],
233
250
  }));
234
251
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
252
+ if (SUPPRESS_SESSION) {
253
+ return {
254
+ content: [
255
+ {
256
+ type: "text",
257
+ text: "easel: session suppressed (EASEL_SUPPRESS_SESSION=1) — no switcher entry created; easel tools are no-ops in this session.",
258
+ },
259
+ ],
260
+ };
261
+ }
235
262
  const sessionId = resolveClaudeSessionId();
236
263
  const { port } = await ensureHttpServer();
237
264
  if (req.params.name === TOOL_OPEN) {
@@ -304,6 +331,7 @@ export async function main() {
304
331
  html: args.html,
305
332
  title: args.title,
306
333
  kind: args.kind,
334
+ theme: args.theme,
307
335
  port,
308
336
  });
309
337
  const url = `http://localhost:${port}/s/${sessionId}`;
@@ -63,11 +63,13 @@ export function getSessionView(id) {
63
63
  }
64
64
  export function appendPush(sessionId, input) {
65
65
  const meta = ensureSession(sessionId);
66
+ const theme = input.theme === "light" || input.theme === "dark" ? input.theme : null;
66
67
  const push = {
67
68
  id: randomUUID(),
68
69
  index: meta.nextIndex,
69
70
  title: input.title?.trim() || null,
70
71
  kind: input.kind?.trim() || null,
72
+ theme,
71
73
  html: input.html,
72
74
  createdAt: Date.now(),
73
75
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ammduncan/easel",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "A live browser tab for every Claude Code (and MCP) session. The push MCP tool appends HTML cards to a scrolling feed you keep open in split-screen.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -58,6 +58,14 @@ if (sessionId) {
58
58
  writeFileSync(join(hookDir, `cc-session-${process.ppid}.txt`), `${sessionId}\n`);
59
59
  }
60
60
 
61
+ // Suppressed sessions (e.g. the ammiels-bot dispatcher tick set
62
+ // EASEL_SUPPRESS_SESSION=1) get NO convention reminder: the MCP no-ops every
63
+ // tool, so nagging the agent to label/push would only waste a tool call. Exit
64
+ // before emitting additionalContext.
65
+ if (process.env.EASEL_SUPPRESS_SESSION === "1") {
66
+ process.exit(0);
67
+ }
68
+
61
69
  // --- staleness check (cached, every 24h) ------------------------------------
62
70
  const __dirname = dirname(fileURLToPath(import.meta.url));
63
71
  const INSTALL_DIR = resolve(__dirname, "..");