kempo-ui 0.4.14 → 0.4.16

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/CHANGELOG.md CHANGED
@@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.16] - 2026-05-03
10
+ ### Added
11
+ - **Markdown Editor component**: Debounced change event during input
12
+ - `change` event now fires with a 300ms debounce when text is being typed
13
+ - Previously, `change` events only fired on blur or native change events
14
+ - Enables apps to use the `change` event for enable/disable logic during long typing sessions
15
+ - Useful for "Save" button state management without waiting for blur
16
+
17
+ ### Changed
18
+ - **Markdown Editor component**: Code style improvements
19
+ - Converted from hash private members (`#field`) to Symbol-keyed properties for full browser compatibility (Safari support)
20
+ - Reorganized comments to follow component-code skill pattern
21
+ - Removed unnecessary multi-line comments, keeping only documentation for properties and complex logic
22
+
9
23
  ## [0.4.13] - 2026-05-03
10
24
  ### Added
11
25
  - **Markdown Editor component**: spacing but
@@ -1,4 +1,4 @@
1
- import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./ShadowComponent.js";import l from"../utils/renderMarkdown.js";import o,{STRIP_COMPLETELY as a}from"../utils/sanitizeHtml.js";import"./Resize.js";import"./Tabs.js";export default class n extends s{static formAssociated=!0;static properties={value:{type:String},name:{type:String,reflect:!0},placeholder:{type:String,reflect:!0},disabled:{type:Boolean,reflect:!0},required:{type:Boolean,reflect:!0},readonly:{type:Boolean,reflect:!0},mode:{type:String,reflect:!0},allowedTags:{type:String,reflect:!0,attribute:"allowed-tags"},disallowedTags:{type:String,reflect:!0,attribute:"disallowed-tags"},scriptsEnabled:{type:Boolean,reflect:!0,attribute:"scripts-enabled"},controls:{type:String,reflect:!0}};#t="";constructor(){super(),this.internals=this.attachInternals(),this.value="",this.name="",this.placeholder="",this.disabled=!1,this.required=!1,this.readonly=!1,this.mode="write",this.allowedTags="",this.disallowedTags="",this.scriptsEnabled=!1,this.controls="",this.#e=!1}#e=!1;async loadControls(){if(this.#e)return;this.#e=!0;const t=new URL("./markdownEditorControls/",import.meta.url).href;await Promise.all([import(`${t}Bold.js`),import(`${t}Italic.js`),import(`${t}Strikethrough.js`),import(`${t}Heading.js`),import(`${t}Code.js`),import(`${t}Link.js`),import(`${t}Image.js`),import(`${t}Table.js`),import(`${t}BulletList.js`),import(`${t}NumberedList.js`),import(`${t}Quote.js`),import(`${t}Menu.js`),import(`${t}FormatBlock.js`),import(`${t}SpeechToText.js`)]),this.requestUpdate()}connectedCallback(){super.connectedCallback(),this.hasAttribute("value")&&(this.#t=this.getAttribute("value")),!this.value&&this.#t&&(this.value=this.#t)}updated(t){super.updated(t),t.has("value")&&this.internals.setFormValue(this.value),t.has("controls")&&this.controls&&"none"!==this.controls&&this.loadControls(),this.#i()}formResetCallback(){this.value=this.#t,this.mode="write"}formStateRestoreCallback(t){"string"==typeof t&&(this.value=t)}formDisabledCallback(t){this.disabled=t}focus(){"write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{this.shadowRoot?.querySelector("textarea")?.focus()})}blur(){this.shadowRoot?.querySelector("textarea")?.blur()}clear(){this.value=""}setMode(t){"write"!==t&&"preview"!==t||(this.mode=t)}togglePreview(){this.mode="write"===this.mode?"preview":"write"}get textarea(){return this.shadowRoot?.querySelector("textarea")||null}getSelection(){const t=this.textarea;return t?{start:t.selectionStart,end:t.selectionEnd,text:t.value.substring(t.selectionStart,t.selectionEnd)}:{start:0,end:0,text:""}}replaceSelection(t,{selectInserted:e=!0}={}){const i=this.textarea;i&&("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{i.focus();const{selectionStart:s,selectionEnd:l}=i,o=i.value.substring(0,s),a=i.value.substring(l),n=o+t+a;i.value=n;const r=s+t.length;i.selectionStart=e?s:r,i.selectionEnd=r,this.value=n,i.dispatchEvent(new Event("input",{bubbles:!0}))}))}wrapSelection(t,e=t,i=""){const s=this.textarea;s?("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{s.focus();const{selectionStart:l,selectionEnd:o,value:a}=s,n=a.substring(l,o);if(n.length>=t.length+e.length&&n.startsWith(t)&&n.endsWith(e)){const i=n.slice(t.length,n.length-e.length),r=a.substring(0,l)+i+a.substring(o);return s.value=r,s.selectionStart=l,s.selectionEnd=l+i.length,this.value=r,void s.dispatchEvent(new Event("input",{bubbles:!0}))}const r=a.substring(Math.max(0,l-t.length),l),d=a.substring(o,Math.min(a.length,o+e.length));if(r===t&&d===e){const i=a.substring(0,l-t.length)+n+a.substring(o+e.length);return s.value=i,s.selectionStart=l-t.length,s.selectionEnd=s.selectionStart+n.length,this.value=i,void s.dispatchEvent(new Event("input",{bubbles:!0}))}const m=n||i,c=a.substring(0,l)+t+m+e+a.substring(o);s.value=c;const h=l+t.length;s.selectionStart=h,s.selectionEnd=h+m.length,this.value=c,s.dispatchEvent(new Event("input",{bubbles:!0}))})):this.replaceSelection(t+i+e)}insertAtCursor(t){this.replaceSelection(t,{selectInserted:!1})}replaceInSelectedLines(t,e=""){const i=this.textarea;i&&("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{i.focus();const{selectionStart:s,selectionEnd:l,value:o}=i,a=o.lastIndexOf("\n",s-1)+1,n=o.indexOf("\n",l),r=-1===n?o.length:n,d=o.substring(a,r),m=d.split("\n").map(i=>i.replace(t,e)).join("\n");if(m===d)return;const c=o.substring(0,a)+m+o.substring(r);i.value=c,i.selectionStart=a,i.selectionEnd=a+m.length,this.value=c,i.dispatchEvent(new Event("input",{bubbles:!0}))}))}insertLinePrefix(t,e=null){const i=this.textarea;i&&("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{i.focus();const{selectionStart:s,selectionEnd:l,value:o}=i,a=o.lastIndexOf("\n",s-1)+1,n=o.indexOf("\n",l),r=-1===n?o.length:n,d=o.substring(a,r).split("\n"),m=d.filter(t=>t.length>0),c=m.length>0&&m.every(e=>e.startsWith(t)),h=d.map(i=>0===i.length?i:c?i.startsWith(t)?i.slice(t.length):i:i.startsWith(t)?i:e&&e.test(i)?i.replace(e,t):t+i).join("\n"),u=o.substring(0,a)+h+o.substring(r);i.value=u,i.selectionStart=a,i.selectionEnd=a+h.length,this.value=u,i.dispatchEvent(new Event("input",{bubbles:!0}))}))}get isEmpty(){return!(this.value||"").trim()}get renderedHtml(){const t={},e=this.#s;if(e&&(t.allowedTags=e),this.scriptsEnabled){const e=new Set(a);e.delete("SCRIPT"),t.stripCompletely=e}return o(l(this.value||""),t)}get#s(){const t=(this.allowedTags||"").trim(),e=(this.disallowedTags||"").trim();if(t&&e&&console.warn("[k-markdown-editor] `allowed-tags` and `disallowed-tags` are mutually exclusive; using `allowed-tags`."),t)return"*"===t?{has:()=>!0}:new Set(t.split(",").map(t=>t.trim().toUpperCase()).filter(Boolean));if(e){const t=new Set(e.split(",").map(t=>t.trim().toUpperCase()).filter(Boolean));return{has:e=>!t.has(e)}}return null}#i=()=>{const t=this.shadowRoot?.querySelector("textarea");this.required&&this.isEmpty?this.internals.setValidity({valueMissing:!0},"Please fill out this field.",t):this.internals.setValidity({})};handleInput=t=>{this.value=t.target.value,this.dispatchEvent(new CustomEvent("input",{detail:{value:this.value},bubbles:!0}))};handleChange=()=>{this.dispatchEvent(new CustomEvent("change",{detail:{value:this.value},bubbles:!0}))};handleTabChange=t=>{const e=t.detail?.tab;e&&e!==this.mode&&(this.mode=e,this.dispatchEvent(new CustomEvent("mode-change",{detail:{mode:e},bubbles:!0})))};render(){const e=this.constructor.controlSets[this.controls]??this.constructor.controlSets[""];return t`
1
+ import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./ShadowComponent.js";import o from"../utils/renderMarkdown.js";import l,{STRIP_COMPLETELY as a}from"../utils/sanitizeHtml.js";import n from"../utils/debounce.js";import"./Resize.js";import"./Tabs.js";const r=Symbol(),d=Symbol(),m=Symbol(),h=Symbol(),c=Symbol();export default class u extends s{static formAssociated=!0;static properties={value:{type:String},name:{type:String,reflect:!0},placeholder:{type:String,reflect:!0},disabled:{type:Boolean,reflect:!0},required:{type:Boolean,reflect:!0},readonly:{type:Boolean,reflect:!0},mode:{type:String,reflect:!0},allowedTags:{type:String,reflect:!0,attribute:"allowed-tags"},disallowedTags:{type:String,reflect:!0,attribute:"disallowed-tags"},scriptsEnabled:{type:Boolean,reflect:!0,attribute:"scripts-enabled"},controls:{type:String,reflect:!0}};constructor(){super(),this.internals=this.attachInternals(),this.value="",this.name="",this.placeholder="",this.disabled=!1,this.required=!1,this.readonly=!1,this.mode="write",this.allowedTags="",this.disallowedTags="",this.scriptsEnabled=!1,this.controls="",this[r]="",this[m]=!1,this[d]=n(()=>this.handleChange(),300)}async loadControls(){if(this[m])return;this[m]=!0;const t=new URL("./markdownEditorControls/",import.meta.url).href;await Promise.all([import(`${t}Bold.js`),import(`${t}Italic.js`),import(`${t}Strikethrough.js`),import(`${t}Heading.js`),import(`${t}Code.js`),import(`${t}Link.js`),import(`${t}Image.js`),import(`${t}Table.js`),import(`${t}BulletList.js`),import(`${t}NumberedList.js`),import(`${t}Quote.js`),import(`${t}Menu.js`),import(`${t}FormatBlock.js`),import(`${t}SpeechToText.js`)]),this.requestUpdate()}connectedCallback(){super.connectedCallback(),this.hasAttribute("value")&&(this[r]=this.getAttribute("value")),!this.value&&this[r]&&(this.value=this[r])}updated(t){super.updated(t),t.has("value")&&this.internals.setFormValue(this.value),t.has("controls")&&this.controls&&"none"!==this.controls&&this.loadControls(),this[c]()}formResetCallback(){this.value=this[r],this.mode="write"}formStateRestoreCallback(t){"string"==typeof t&&(this.value=t)}formDisabledCallback(t){this.disabled=t}focus(){"write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{this.shadowRoot?.querySelector("textarea")?.focus()})}blur(){this.shadowRoot?.querySelector("textarea")?.blur()}clear(){this.value=""}setMode(t){"write"!==t&&"preview"!==t||(this.mode=t)}togglePreview(){this.mode="write"===this.mode?"preview":"write"}get textarea(){return this.shadowRoot?.querySelector("textarea")||null}getSelection(){const t=this.textarea;return t?{start:t.selectionStart,end:t.selectionEnd,text:t.value.substring(t.selectionStart,t.selectionEnd)}:{start:0,end:0,text:""}}replaceSelection(t,{selectInserted:e=!0}={}){const i=this.textarea;i&&("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{i.focus();const{selectionStart:s,selectionEnd:o}=i,l=i.value.substring(0,s),a=i.value.substring(o),n=l+t+a;i.value=n;const r=s+t.length;i.selectionStart=e?s:r,i.selectionEnd=r,this.value=n,i.dispatchEvent(new Event("input",{bubbles:!0}))}))}wrapSelection(t,e=t,i=""){const s=this.textarea;s?("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{s.focus();const{selectionStart:o,selectionEnd:l,value:a}=s,n=a.substring(o,l);if(n.length>=t.length+e.length&&n.startsWith(t)&&n.endsWith(e)){const i=n.slice(t.length,n.length-e.length),r=a.substring(0,o)+i+a.substring(l);return s.value=r,s.selectionStart=o,s.selectionEnd=o+i.length,this.value=r,void s.dispatchEvent(new Event("input",{bubbles:!0}))}const r=a.substring(Math.max(0,o-t.length),o),d=a.substring(l,Math.min(a.length,l+e.length));if(r===t&&d===e){const i=a.substring(0,o-t.length)+n+a.substring(l+e.length);return s.value=i,s.selectionStart=o-t.length,s.selectionEnd=s.selectionStart+n.length,this.value=i,void s.dispatchEvent(new Event("input",{bubbles:!0}))}const m=n||i,h=a.substring(0,o)+t+m+e+a.substring(l);s.value=h;const c=o+t.length;s.selectionStart=c,s.selectionEnd=c+m.length,this.value=h,s.dispatchEvent(new Event("input",{bubbles:!0}))})):this.replaceSelection(t+i+e)}insertAtCursor(t){this.replaceSelection(t,{selectInserted:!1})}replaceInSelectedLines(t,e=""){const i=this.textarea;i&&("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{i.focus();const{selectionStart:s,selectionEnd:o,value:l}=i,a=l.lastIndexOf("\n",s-1)+1,n=l.indexOf("\n",o),r=-1===n?l.length:n,d=l.substring(a,r),m=d.split("\n").map(i=>i.replace(t,e)).join("\n");if(m===d)return;const h=l.substring(0,a)+m+l.substring(r);i.value=h,i.selectionStart=a,i.selectionEnd=a+m.length,this.value=h,i.dispatchEvent(new Event("input",{bubbles:!0}))}))}insertLinePrefix(t,e=null){const i=this.textarea;i&&("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{i.focus();const{selectionStart:s,selectionEnd:o,value:l}=i,a=l.lastIndexOf("\n",s-1)+1,n=l.indexOf("\n",o),r=-1===n?l.length:n,d=l.substring(a,r).split("\n"),m=d.filter(t=>t.length>0),h=m.length>0&&m.every(e=>e.startsWith(t)),c=d.map(i=>0===i.length?i:h?i.startsWith(t)?i.slice(t.length):i:i.startsWith(t)?i:e&&e.test(i)?i.replace(e,t):t+i).join("\n"),u=l.substring(0,a)+c+l.substring(r);i.value=u,i.selectionStart=a,i.selectionEnd=a+c.length,this.value=u,i.dispatchEvent(new Event("input",{bubbles:!0}))}))}get isEmpty(){return!(this.value||"").trim()}get renderedHtml(){const t={},e=this[h];if(e&&(t.allowedTags=e),this.scriptsEnabled){const e=new Set(a);e.delete("SCRIPT"),t.stripCompletely=e}return l(o(this.value||""),t)}get[h](){const t=(this.allowedTags||"").trim(),e=(this.disallowedTags||"").trim();if(t&&e&&console.warn("[k-markdown-editor] `allowed-tags` and `disallowed-tags` are mutually exclusive; using `allowed-tags`."),t)return"*"===t?{has:()=>!0}:new Set(t.split(",").map(t=>t.trim().toUpperCase()).filter(Boolean));if(e){const t=new Set(e.split(",").map(t=>t.trim().toUpperCase()).filter(Boolean));return{has:e=>!t.has(e)}}return null}[c]=()=>{const t=this.shadowRoot?.querySelector("textarea");this.required&&this.isEmpty?this.internals.setValidity({valueMissing:!0},"Please fill out this field.",t):this.internals.setValidity({})};handleInput=t=>{this.value=t.target.value,this.dispatchEvent(new CustomEvent("input",{detail:{value:this.value},bubbles:!0})),this[d]()};handleChange=()=>{this.dispatchEvent(new CustomEvent("change",{detail:{value:this.value},bubbles:!0}))};handleTabChange=t=>{const e=t.detail?.tab;e&&e!==this.mode&&(this.mode=e,this.dispatchEvent(new CustomEvent("mode-change",{detail:{mode:e},bubbles:!0})))};render(){const e=this.constructor.controlSets[this.controls]??this.constructor.controlSets[""];return t`
2
2
  <k-resize dimension="height" ?disabled=${this.disabled}>
3
3
  <div class="frame">
4
4
  <k-tabs fixed-height active=${this.mode} @tab=${this.handleTabChange}>
@@ -18,6 +18,7 @@ import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./S
18
18
  aria-label=${this.name||this.placeholder}
19
19
  @input=${this.handleInput}
20
20
  @blur=${this.handleChange}
21
+ @change=${this.handleChange}
21
22
  ></textarea>
22
23
  </k-tab-content>
23
24
  <k-tab-content name="preview">
@@ -165,4 +166,4 @@ import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./S
165
166
  <k-md-bullet-list></k-md-bullet-list>
166
167
  <k-md-numbered-list></k-md-numbered-list>
167
168
  <k-md-speech-to-text></k-md-speech-to-text>
168
- `,bottom:null}}}customElements.define("k-markdown-editor",n);
169
+ `,bottom:null}}}customElements.define("k-markdown-editor",u);
@@ -1,4 +1,4 @@
1
- import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./ShadowComponent.js";import l from"../utils/renderMarkdown.js";import o,{STRIP_COMPLETELY as a}from"../utils/sanitizeHtml.js";import"./Resize.js";import"./Tabs.js";export default class n extends s{static formAssociated=!0;static properties={value:{type:String},name:{type:String,reflect:!0},placeholder:{type:String,reflect:!0},disabled:{type:Boolean,reflect:!0},required:{type:Boolean,reflect:!0},readonly:{type:Boolean,reflect:!0},mode:{type:String,reflect:!0},allowedTags:{type:String,reflect:!0,attribute:"allowed-tags"},disallowedTags:{type:String,reflect:!0,attribute:"disallowed-tags"},scriptsEnabled:{type:Boolean,reflect:!0,attribute:"scripts-enabled"},controls:{type:String,reflect:!0}};#t="";constructor(){super(),this.internals=this.attachInternals(),this.value="",this.name="",this.placeholder="",this.disabled=!1,this.required=!1,this.readonly=!1,this.mode="write",this.allowedTags="",this.disallowedTags="",this.scriptsEnabled=!1,this.controls="",this.#e=!1}#e=!1;async loadControls(){if(this.#e)return;this.#e=!0;const t=new URL("./markdownEditorControls/",import.meta.url).href;await Promise.all([import(`${t}Bold.js`),import(`${t}Italic.js`),import(`${t}Strikethrough.js`),import(`${t}Heading.js`),import(`${t}Code.js`),import(`${t}Link.js`),import(`${t}Image.js`),import(`${t}Table.js`),import(`${t}BulletList.js`),import(`${t}NumberedList.js`),import(`${t}Quote.js`),import(`${t}Menu.js`),import(`${t}FormatBlock.js`),import(`${t}SpeechToText.js`)]),this.requestUpdate()}connectedCallback(){super.connectedCallback(),this.hasAttribute("value")&&(this.#t=this.getAttribute("value")),!this.value&&this.#t&&(this.value=this.#t)}updated(t){super.updated(t),t.has("value")&&this.internals.setFormValue(this.value),t.has("controls")&&this.controls&&"none"!==this.controls&&this.loadControls(),this.#i()}formResetCallback(){this.value=this.#t,this.mode="write"}formStateRestoreCallback(t){"string"==typeof t&&(this.value=t)}formDisabledCallback(t){this.disabled=t}focus(){"write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{this.shadowRoot?.querySelector("textarea")?.focus()})}blur(){this.shadowRoot?.querySelector("textarea")?.blur()}clear(){this.value=""}setMode(t){"write"!==t&&"preview"!==t||(this.mode=t)}togglePreview(){this.mode="write"===this.mode?"preview":"write"}get textarea(){return this.shadowRoot?.querySelector("textarea")||null}getSelection(){const t=this.textarea;return t?{start:t.selectionStart,end:t.selectionEnd,text:t.value.substring(t.selectionStart,t.selectionEnd)}:{start:0,end:0,text:""}}replaceSelection(t,{selectInserted:e=!0}={}){const i=this.textarea;i&&("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{i.focus();const{selectionStart:s,selectionEnd:l}=i,o=i.value.substring(0,s),a=i.value.substring(l),n=o+t+a;i.value=n;const r=s+t.length;i.selectionStart=e?s:r,i.selectionEnd=r,this.value=n,i.dispatchEvent(new Event("input",{bubbles:!0}))}))}wrapSelection(t,e=t,i=""){const s=this.textarea;s?("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{s.focus();const{selectionStart:l,selectionEnd:o,value:a}=s,n=a.substring(l,o);if(n.length>=t.length+e.length&&n.startsWith(t)&&n.endsWith(e)){const i=n.slice(t.length,n.length-e.length),r=a.substring(0,l)+i+a.substring(o);return s.value=r,s.selectionStart=l,s.selectionEnd=l+i.length,this.value=r,void s.dispatchEvent(new Event("input",{bubbles:!0}))}const r=a.substring(Math.max(0,l-t.length),l),d=a.substring(o,Math.min(a.length,o+e.length));if(r===t&&d===e){const i=a.substring(0,l-t.length)+n+a.substring(o+e.length);return s.value=i,s.selectionStart=l-t.length,s.selectionEnd=s.selectionStart+n.length,this.value=i,void s.dispatchEvent(new Event("input",{bubbles:!0}))}const m=n||i,c=a.substring(0,l)+t+m+e+a.substring(o);s.value=c;const h=l+t.length;s.selectionStart=h,s.selectionEnd=h+m.length,this.value=c,s.dispatchEvent(new Event("input",{bubbles:!0}))})):this.replaceSelection(t+i+e)}insertAtCursor(t){this.replaceSelection(t,{selectInserted:!1})}replaceInSelectedLines(t,e=""){const i=this.textarea;i&&("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{i.focus();const{selectionStart:s,selectionEnd:l,value:o}=i,a=o.lastIndexOf("\n",s-1)+1,n=o.indexOf("\n",l),r=-1===n?o.length:n,d=o.substring(a,r),m=d.split("\n").map(i=>i.replace(t,e)).join("\n");if(m===d)return;const c=o.substring(0,a)+m+o.substring(r);i.value=c,i.selectionStart=a,i.selectionEnd=a+m.length,this.value=c,i.dispatchEvent(new Event("input",{bubbles:!0}))}))}insertLinePrefix(t,e=null){const i=this.textarea;i&&("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{i.focus();const{selectionStart:s,selectionEnd:l,value:o}=i,a=o.lastIndexOf("\n",s-1)+1,n=o.indexOf("\n",l),r=-1===n?o.length:n,d=o.substring(a,r).split("\n"),m=d.filter(t=>t.length>0),c=m.length>0&&m.every(e=>e.startsWith(t)),h=d.map(i=>0===i.length?i:c?i.startsWith(t)?i.slice(t.length):i:i.startsWith(t)?i:e&&e.test(i)?i.replace(e,t):t+i).join("\n"),u=o.substring(0,a)+h+o.substring(r);i.value=u,i.selectionStart=a,i.selectionEnd=a+h.length,this.value=u,i.dispatchEvent(new Event("input",{bubbles:!0}))}))}get isEmpty(){return!(this.value||"").trim()}get renderedHtml(){const t={},e=this.#s;if(e&&(t.allowedTags=e),this.scriptsEnabled){const e=new Set(a);e.delete("SCRIPT"),t.stripCompletely=e}return o(l(this.value||""),t)}get#s(){const t=(this.allowedTags||"").trim(),e=(this.disallowedTags||"").trim();if(t&&e&&console.warn("[k-markdown-editor] `allowed-tags` and `disallowed-tags` are mutually exclusive; using `allowed-tags`."),t)return"*"===t?{has:()=>!0}:new Set(t.split(",").map(t=>t.trim().toUpperCase()).filter(Boolean));if(e){const t=new Set(e.split(",").map(t=>t.trim().toUpperCase()).filter(Boolean));return{has:e=>!t.has(e)}}return null}#i=()=>{const t=this.shadowRoot?.querySelector("textarea");this.required&&this.isEmpty?this.internals.setValidity({valueMissing:!0},"Please fill out this field.",t):this.internals.setValidity({})};handleInput=t=>{this.value=t.target.value,this.dispatchEvent(new CustomEvent("input",{detail:{value:this.value},bubbles:!0}))};handleChange=()=>{this.dispatchEvent(new CustomEvent("change",{detail:{value:this.value},bubbles:!0}))};handleTabChange=t=>{const e=t.detail?.tab;e&&e!==this.mode&&(this.mode=e,this.dispatchEvent(new CustomEvent("mode-change",{detail:{mode:e},bubbles:!0})))};render(){const e=this.constructor.controlSets[this.controls]??this.constructor.controlSets[""];return t`
1
+ import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./ShadowComponent.js";import o from"../utils/renderMarkdown.js";import l,{STRIP_COMPLETELY as a}from"../utils/sanitizeHtml.js";import n from"../utils/debounce.js";import"./Resize.js";import"./Tabs.js";const r=Symbol(),d=Symbol(),m=Symbol(),h=Symbol(),c=Symbol();export default class u extends s{static formAssociated=!0;static properties={value:{type:String},name:{type:String,reflect:!0},placeholder:{type:String,reflect:!0},disabled:{type:Boolean,reflect:!0},required:{type:Boolean,reflect:!0},readonly:{type:Boolean,reflect:!0},mode:{type:String,reflect:!0},allowedTags:{type:String,reflect:!0,attribute:"allowed-tags"},disallowedTags:{type:String,reflect:!0,attribute:"disallowed-tags"},scriptsEnabled:{type:Boolean,reflect:!0,attribute:"scripts-enabled"},controls:{type:String,reflect:!0}};constructor(){super(),this.internals=this.attachInternals(),this.value="",this.name="",this.placeholder="",this.disabled=!1,this.required=!1,this.readonly=!1,this.mode="write",this.allowedTags="",this.disallowedTags="",this.scriptsEnabled=!1,this.controls="",this[r]="",this[m]=!1,this[d]=n(()=>this.handleChange(),300)}async loadControls(){if(this[m])return;this[m]=!0;const t=new URL("./markdownEditorControls/",import.meta.url).href;await Promise.all([import(`${t}Bold.js`),import(`${t}Italic.js`),import(`${t}Strikethrough.js`),import(`${t}Heading.js`),import(`${t}Code.js`),import(`${t}Link.js`),import(`${t}Image.js`),import(`${t}Table.js`),import(`${t}BulletList.js`),import(`${t}NumberedList.js`),import(`${t}Quote.js`),import(`${t}Menu.js`),import(`${t}FormatBlock.js`),import(`${t}SpeechToText.js`)]),this.requestUpdate()}connectedCallback(){super.connectedCallback(),this.hasAttribute("value")&&(this[r]=this.getAttribute("value")),!this.value&&this[r]&&(this.value=this[r])}updated(t){super.updated(t),t.has("value")&&this.internals.setFormValue(this.value),t.has("controls")&&this.controls&&"none"!==this.controls&&this.loadControls(),this[c]()}formResetCallback(){this.value=this[r],this.mode="write"}formStateRestoreCallback(t){"string"==typeof t&&(this.value=t)}formDisabledCallback(t){this.disabled=t}focus(){"write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{this.shadowRoot?.querySelector("textarea")?.focus()})}blur(){this.shadowRoot?.querySelector("textarea")?.blur()}clear(){this.value=""}setMode(t){"write"!==t&&"preview"!==t||(this.mode=t)}togglePreview(){this.mode="write"===this.mode?"preview":"write"}get textarea(){return this.shadowRoot?.querySelector("textarea")||null}getSelection(){const t=this.textarea;return t?{start:t.selectionStart,end:t.selectionEnd,text:t.value.substring(t.selectionStart,t.selectionEnd)}:{start:0,end:0,text:""}}replaceSelection(t,{selectInserted:e=!0}={}){const i=this.textarea;i&&("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{i.focus();const{selectionStart:s,selectionEnd:o}=i,l=i.value.substring(0,s),a=i.value.substring(o),n=l+t+a;i.value=n;const r=s+t.length;i.selectionStart=e?s:r,i.selectionEnd=r,this.value=n,i.dispatchEvent(new Event("input",{bubbles:!0}))}))}wrapSelection(t,e=t,i=""){const s=this.textarea;s?("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{s.focus();const{selectionStart:o,selectionEnd:l,value:a}=s,n=a.substring(o,l);if(n.length>=t.length+e.length&&n.startsWith(t)&&n.endsWith(e)){const i=n.slice(t.length,n.length-e.length),r=a.substring(0,o)+i+a.substring(l);return s.value=r,s.selectionStart=o,s.selectionEnd=o+i.length,this.value=r,void s.dispatchEvent(new Event("input",{bubbles:!0}))}const r=a.substring(Math.max(0,o-t.length),o),d=a.substring(l,Math.min(a.length,l+e.length));if(r===t&&d===e){const i=a.substring(0,o-t.length)+n+a.substring(l+e.length);return s.value=i,s.selectionStart=o-t.length,s.selectionEnd=s.selectionStart+n.length,this.value=i,void s.dispatchEvent(new Event("input",{bubbles:!0}))}const m=n||i,h=a.substring(0,o)+t+m+e+a.substring(l);s.value=h;const c=o+t.length;s.selectionStart=c,s.selectionEnd=c+m.length,this.value=h,s.dispatchEvent(new Event("input",{bubbles:!0}))})):this.replaceSelection(t+i+e)}insertAtCursor(t){this.replaceSelection(t,{selectInserted:!1})}replaceInSelectedLines(t,e=""){const i=this.textarea;i&&("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{i.focus();const{selectionStart:s,selectionEnd:o,value:l}=i,a=l.lastIndexOf("\n",s-1)+1,n=l.indexOf("\n",o),r=-1===n?l.length:n,d=l.substring(a,r),m=d.split("\n").map(i=>i.replace(t,e)).join("\n");if(m===d)return;const h=l.substring(0,a)+m+l.substring(r);i.value=h,i.selectionStart=a,i.selectionEnd=a+m.length,this.value=h,i.dispatchEvent(new Event("input",{bubbles:!0}))}))}insertLinePrefix(t,e=null){const i=this.textarea;i&&("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{i.focus();const{selectionStart:s,selectionEnd:o,value:l}=i,a=l.lastIndexOf("\n",s-1)+1,n=l.indexOf("\n",o),r=-1===n?l.length:n,d=l.substring(a,r).split("\n"),m=d.filter(t=>t.length>0),h=m.length>0&&m.every(e=>e.startsWith(t)),c=d.map(i=>0===i.length?i:h?i.startsWith(t)?i.slice(t.length):i:i.startsWith(t)?i:e&&e.test(i)?i.replace(e,t):t+i).join("\n"),u=l.substring(0,a)+c+l.substring(r);i.value=u,i.selectionStart=a,i.selectionEnd=a+c.length,this.value=u,i.dispatchEvent(new Event("input",{bubbles:!0}))}))}get isEmpty(){return!(this.value||"").trim()}get renderedHtml(){const t={},e=this[h];if(e&&(t.allowedTags=e),this.scriptsEnabled){const e=new Set(a);e.delete("SCRIPT"),t.stripCompletely=e}return l(o(this.value||""),t)}get[h](){const t=(this.allowedTags||"").trim(),e=(this.disallowedTags||"").trim();if(t&&e&&console.warn("[k-markdown-editor] `allowed-tags` and `disallowed-tags` are mutually exclusive; using `allowed-tags`."),t)return"*"===t?{has:()=>!0}:new Set(t.split(",").map(t=>t.trim().toUpperCase()).filter(Boolean));if(e){const t=new Set(e.split(",").map(t=>t.trim().toUpperCase()).filter(Boolean));return{has:e=>!t.has(e)}}return null}[c]=()=>{const t=this.shadowRoot?.querySelector("textarea");this.required&&this.isEmpty?this.internals.setValidity({valueMissing:!0},"Please fill out this field.",t):this.internals.setValidity({})};handleInput=t=>{this.value=t.target.value,this.dispatchEvent(new CustomEvent("input",{detail:{value:this.value},bubbles:!0})),this[d]()};handleChange=()=>{this.dispatchEvent(new CustomEvent("change",{detail:{value:this.value},bubbles:!0}))};handleTabChange=t=>{const e=t.detail?.tab;e&&e!==this.mode&&(this.mode=e,this.dispatchEvent(new CustomEvent("mode-change",{detail:{mode:e},bubbles:!0})))};render(){const e=this.constructor.controlSets[this.controls]??this.constructor.controlSets[""];return t`
2
2
  <k-resize dimension="height" ?disabled=${this.disabled}>
3
3
  <div class="frame">
4
4
  <k-tabs fixed-height active=${this.mode} @tab=${this.handleTabChange}>
@@ -18,6 +18,7 @@ import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./S
18
18
  aria-label=${this.name||this.placeholder}
19
19
  @input=${this.handleInput}
20
20
  @blur=${this.handleChange}
21
+ @change=${this.handleChange}
21
22
  ></textarea>
22
23
  </k-tab-content>
23
24
  <k-tab-content name="preview">
@@ -165,4 +166,4 @@ import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./S
165
166
  <k-md-bullet-list></k-md-bullet-list>
166
167
  <k-md-numbered-list></k-md-numbered-list>
167
168
  <k-md-speech-to-text></k-md-speech-to-text>
168
- `,bottom:null}}}customElements.define("k-markdown-editor",n);
169
+ `,bottom:null}}}customElements.define("k-markdown-editor",u);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo-ui",
3
- "version": "0.4.14",
3
+ "version": "0.4.16",
4
4
  "type": "module",
5
5
  "description": "A Lit based web-component library",
6
6
  "main": "index.js",
@@ -42,7 +42,7 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "@inquirer/prompts": "^8.3.0",
45
- "kempo-css": "^2.2.1",
45
+ "kempo-css": "^2.2.3",
46
46
  "marked": "^18.0.2"
47
47
  }
48
48
  }
@@ -2,12 +2,17 @@ import { html, css, nothing } from '../lit-all.min.js';
2
2
  import ShadowComponent from './ShadowComponent.js';
3
3
  import renderMarkdown from '../utils/renderMarkdown.js';
4
4
  import sanitizeHtml, { STRIP_COMPLETELY } from '../utils/sanitizeHtml.js';
5
+ import debounce from '../utils/debounce.js';
5
6
  import './Resize.js';
6
7
  import './Tabs.js';
7
8
 
8
- /*
9
- k-markdown-editor
9
+ const defaultValue = Symbol();
10
+ const debouncedChange = Symbol();
11
+ const controlsLoaded = Symbol();
12
+ const resolvedAllowedTags = Symbol();
13
+ const updateValidity = Symbol();
10
14
 
15
+ /*
11
16
  Textarea that writes markdown, with a live "Preview" tab. Designed to mimic
12
17
  GitHub's comment editor — write tab + preview tab + control slots above and
13
18
  below the textarea. Markdown is parsed by snarkdown, which intentionally
@@ -17,10 +22,12 @@ import './Tabs.js';
17
22
  Controls go in the `controls-top` and `controls-bottom` slots; subclass
18
23
  MarkdownEditorControl (./MarkdownEditorControl.js) to write your own.
19
24
  */
20
-
21
25
  export default class MarkdownEditor extends ShadowComponent {
22
26
  static formAssociated = true;
23
27
 
28
+ /*
29
+ Reactive Properties / Attributes
30
+ */
24
31
  static properties = {
25
32
  value: { type: String },
26
33
  name: { type: String, reflect: true },
@@ -62,10 +69,8 @@ export default class MarkdownEditor extends ShadowComponent {
62
69
  controls: { type: String, reflect: true }
63
70
  };
64
71
 
65
- #defaultValue = '';
66
-
67
72
  /*
68
- Lifecycle Callbacks
73
+ Constructor
69
74
  */
70
75
  constructor() {
71
76
  super();
@@ -81,20 +86,17 @@ export default class MarkdownEditor extends ShadowComponent {
81
86
  this.disallowedTags = '';
82
87
  this.scriptsEnabled = false;
83
88
  this.controls = '';
84
- this.#controlsLoaded = false;
89
+ this[defaultValue] = '';
90
+ this[controlsLoaded] = false;
91
+ this[debouncedChange] = debounce(() => this.handleChange(), 300);
85
92
  }
86
93
 
87
- #controlsLoaded = false;
88
-
89
94
  /*
90
- Dynamically imports every built-in control module. Called whenever a
91
- non-empty `controls` preset is set. Mirrors the HtmlEditor pattern so
92
- consumers don't have to remember which control imports each preset
93
- needs — they just set `controls="full"` and everything is loaded.
95
+ Lifecycle
94
96
  */
95
97
  async loadControls() {
96
- if(this.#controlsLoaded) return;
97
- this.#controlsLoaded = true;
98
+ if(this[controlsLoaded]) return;
99
+ this[controlsLoaded] = true;
98
100
  const base = new URL('./markdownEditorControls/', import.meta.url).href;
99
101
  await Promise.all([
100
102
  import(/* @vite-ignore */ `${base}Bold.js`),
@@ -117,14 +119,11 @@ export default class MarkdownEditor extends ShadowComponent {
117
119
 
118
120
  connectedCallback() {
119
121
  super.connectedCallback();
120
- // Initial markdown comes from the `value` attribute only. Don't fall back
121
- // to children/textContent — slotted controls' own text (icon labels,
122
- // separators, etc.) would leak in and pollute the value.
123
122
  if(this.hasAttribute('value')){
124
- this.#defaultValue = this.getAttribute('value');
123
+ this[defaultValue] = this.getAttribute('value');
125
124
  }
126
- if(!this.value && this.#defaultValue){
127
- this.value = this.#defaultValue;
125
+ if(!this.value && this[defaultValue]){
126
+ this.value = this[defaultValue];
128
127
  }
129
128
  }
130
129
 
@@ -136,14 +135,11 @@ export default class MarkdownEditor extends ShadowComponent {
136
135
  if(changedProperties.has('controls') && this.controls && this.controls !== 'none'){
137
136
  this.loadControls();
138
137
  }
139
- this.#updateValidity();
138
+ this[updateValidity]();
140
139
  }
141
140
 
142
- /*
143
- Form-associated callbacks
144
- */
145
141
  formResetCallback() {
146
- this.value = this.#defaultValue;
142
+ this.value = this[defaultValue];
147
143
  this.mode = 'write';
148
144
  }
149
145
 
@@ -156,7 +152,7 @@ export default class MarkdownEditor extends ShadowComponent {
156
152
  }
157
153
 
158
154
  /*
159
- Public API — generic
155
+ Public Methods
160
156
  */
161
157
  focus() {
162
158
  if(this.mode !== 'write') this.mode = 'write';
@@ -181,11 +177,6 @@ export default class MarkdownEditor extends ShadowComponent {
181
177
  this.mode = this.mode === 'write' ? 'preview' : 'write';
182
178
  }
183
179
 
184
- /*
185
- Public API — selection / text manipulation, used by MarkdownEditorControl
186
- subclasses. All of these operate on the textarea (write mode); they
187
- silently no-op in preview mode.
188
- */
189
180
  get textarea() {
190
181
  return this.shadowRoot?.querySelector('textarea') || null;
191
182
  }
@@ -219,12 +210,6 @@ export default class MarkdownEditor extends ShadowComponent {
219
210
  });
220
211
  }
221
212
 
222
- /*
223
- Wrap (or unwrap) the selection. If the selection is already wrapped with
224
- the given markers — either the selection itself starts/ends with them, or
225
- the surrounding characters are the markers — the wrap is removed instead
226
- of being added again. This makes formatting buttons toggle.
227
- */
228
213
  wrapSelection(prefix, suffix = prefix, placeholder = '') {
229
214
  const ta = this.textarea;
230
215
  if(!ta){
@@ -282,19 +267,6 @@ export default class MarkdownEditor extends ShadowComponent {
282
267
  this.replaceSelection(text, { selectInserted: false });
283
268
  }
284
269
 
285
- /*
286
- Toggle a line-prefix on the selected lines (or the current line). If every
287
- non-empty line in range already starts with `prefix`, the prefix is
288
- removed; otherwise it's added. When `replacePattern` is provided, lines
289
- that match it but do NOT yet have `prefix` get their match swapped out
290
- rather than stacked — this is what lets headings switch levels (e.g.
291
- `### Hello` becomes `## Hello` instead of `## ### Hello`).
292
- */
293
- /*
294
- Run a regex `.replace(pattern, replacement)` on every selected line
295
- (or the current line if no selection). Useful for "convert to plain
296
- paragraph" — strip any heading prefix without toggling.
297
- */
298
270
  replaceInSelectedLines(pattern, replacement = '') {
299
271
  const ta = this.textarea;
300
272
  if(!ta) return;
@@ -349,7 +321,7 @@ export default class MarkdownEditor extends ShadowComponent {
349
321
  }
350
322
 
351
323
  /*
352
- Utility
324
+ Protected Members
353
325
  */
354
326
  get isEmpty() {
355
327
  return !(this.value || '').trim();
@@ -357,12 +329,9 @@ export default class MarkdownEditor extends ShadowComponent {
357
329
 
358
330
  get renderedHtml() {
359
331
  const opts = {};
360
- const resolved = this.#resolvedAllowedTags;
332
+ const resolved = this[resolvedAllowedTags];
361
333
  if(resolved) opts.allowedTags = resolved;
362
334
  if(this.scriptsEnabled){
363
- // Remove SCRIPT from the always-strip set so the allow/deny check
364
- // gets the final say. SCRIPT is the only tag scripts-enabled affects;
365
- // iframe/style/etc. stay nuked unconditionally.
366
335
  const stripCompletely = new Set(STRIP_COMPLETELY);
367
336
  stripCompletely.delete('SCRIPT');
368
337
  opts.stripCompletely = stripCompletely;
@@ -370,19 +339,7 @@ export default class MarkdownEditor extends ShadowComponent {
370
339
  return sanitizeHtml(renderMarkdown(this.value || ''), opts);
371
340
  }
372
341
 
373
- /*
374
- Resolve `allowed-tags` / `disallowed-tags` into the Set sanitizeHtml
375
- expects. The two attributes are mutually exclusive — if both are set,
376
- `allowed-tags` wins and a console warning fires.
377
-
378
- Returns:
379
- - For `allowed-tags="*"` → proxy-Set that says yes to everything.
380
- - For `allowed-tags="a,b,c"` → real Set of those uppercase names.
381
- - For `disallowed-tags="x,y"` → proxy-Set that returns true for any
382
- tag NOT in the deny list.
383
- - For neither set → null (sanitizeHtml uses DEFAULT_TAGS).
384
- */
385
- get #resolvedAllowedTags() {
342
+ get [resolvedAllowedTags]() {
386
343
  const allow = (this.allowedTags || '').trim();
387
344
  const deny = (this.disallowedTags || '').trim();
388
345
  if(allow && deny){
@@ -399,7 +356,7 @@ export default class MarkdownEditor extends ShadowComponent {
399
356
  return null;
400
357
  }
401
358
 
402
- #updateValidity = () => {
359
+ [updateValidity] = () => {
403
360
  const ta = this.shadowRoot?.querySelector('textarea');
404
361
  if(this.required && this.isEmpty){
405
362
  this.internals.setValidity(
@@ -421,6 +378,7 @@ export default class MarkdownEditor extends ShadowComponent {
421
378
  detail: { value: this.value },
422
379
  bubbles: true
423
380
  }));
381
+ this[debouncedChange]();
424
382
  };
425
383
 
426
384
  handleChange = () => {
@@ -465,6 +423,7 @@ export default class MarkdownEditor extends ShadowComponent {
465
423
  aria-label=${this.name || this.placeholder}
466
424
  @input=${this.handleInput}
467
425
  @blur=${this.handleChange}
426
+ @change=${this.handleChange}
468
427
  ></textarea>
469
428
  </k-tab-content>
470
429
  <k-tab-content name="preview">
@@ -483,9 +442,6 @@ export default class MarkdownEditor extends ShadowComponent {
483
442
  `;
484
443
  }
485
444
 
486
- /*
487
- Styles
488
- */
489
445
  static styles = css`
490
446
  :host {
491
447
  --padding: 0.5rem 0.75rem;
@@ -576,8 +532,7 @@ export default class MarkdownEditor extends ShadowComponent {
576
532
  attribute is set. Tags reference custom elements that are loaded by
577
533
  `loadControls()` — listing them here doesn't require those modules to
578
534
  be imported eagerly. Lit creates the elements as plain HTMLElements
579
- until their definitions arrive, then the browser upgrades them in
580
- place.
535
+ until their definitions arrive, then the browser upgrades them in place.
581
536
  */
582
537
  static controlSets = {
583
538
  '': { top: null, bottom: null },
@@ -1,144 +0,0 @@
1
- ---
2
- name: get-icon
3
- description: Fetches icons from the Google Material Design Icon set, saves them to this project, and formats them. Use any time an icon is needed (explicit or implicit) — buttons, controls, actions, etc. ALWAYS check the local icons/ directory first before downloading.
4
- ---
5
-
6
- # Get Icon
7
-
8
- ## When to Use
9
-
10
- Use this skill any time an icon is needed — whether the user explicitly asks for one or you determine that a UI element (button, control, action, etc.) would benefit from one. For example, if a user asks you to build a "bold" button for a WYSIWYG toolbar, you should proactively source an appropriate icon without being explicitly told to.
11
-
12
- ## Overview
13
-
14
- Icons live in the `icons/` directory as SVG files. The project uses the **Material Symbols Outlined** style from Google's CDN. The workflow has three stages:
15
-
16
- 1. **Check local icons first** — the icon may already exist
17
- 2. **Find** the correct Material Symbols name if it doesn't
18
- 3. **Fetch, format, and save** using the script (directional handling is automatic)
19
-
20
- ---
21
-
22
- ## Stage 1: Check Local Icons First
23
-
24
- Before going to the internet, list the contents of `icons/` and look for an existing icon that fits the need (use `ls icons/` via Bash, or search with Glob).
25
-
26
- - If a **clear match** exists (e.g. `icons/format_bold.svg` for a bold button), use it — no download needed.
27
- - If a **reasonable match** exists for a directional icon (e.g. `icons/arrow.svg` for a left-arrow), use it with the appropriate `direction` attribute — no download needed.
28
- - If **nothing fits**, proceed to Stage 2.
29
-
30
- ---
31
-
32
- ## Stage 2: Find the Icon Name
33
-
34
- If the user describes an icon but doesn't supply an exact name, search Google Material Symbols to find the right one.
35
-
36
- Run the search script, which fetches icon names and tags from GitHub and caches them locally in `node_modules/.cache/kempo-ui/`. On subsequent runs it only re-downloads if the icon list has changed (SHA check — ~1KB request vs ~500KB full list). Search matches both icon names **and tags**, so searching `chevron` will also surface `keyboard_arrow_right` because it has a `chevron` tag:
37
-
38
- ```bash
39
- npm run listicons -- <search_term>
40
- ```
41
-
42
- Example:
43
- ```bash
44
- npm run listicons -- bold
45
- # → format_bold
46
- ```
47
-
48
- ```bash
49
- npm run listicons -- chevron
50
- # → chevron_right
51
- # chevron_left
52
- # keyboard_arrow_right
53
- # keyboard_arrow_left
54
- # ...
55
- ```
56
-
57
- The results show **full icon names including direction suffixes** so you can see what variants are available. Pick the best match for the user's intent. If no results are returned, try different search terms (e.g. `type` instead of `font`, `text` instead of `word`).
58
-
59
- ---
60
-
61
- ## Stage 3: Fetch, Format, and Save
62
-
63
- Run the fetch script with the chosen icon name. The script automatically handles directional icons — if the name you pass is directional (has a `_left`, `_up`, `_down`, `_backward` suffix, or has no exact match but a `_right`/`_forward` variant exists), it will prompt:
64
-
65
- ```
66
- "chevron_left" is a directional icon. kempo-ui uses right direction only and applies CSS rotation.
67
- Save right-facing variant as "chevron.svg"? (Y/n)
68
- ```
69
-
70
- ```bash
71
- npm run geticon -- <icon_name> [custom_name] [-y]
72
- ```
73
-
74
- - `icon_name` — the Material Symbols name as returned by the search script
75
- - `custom_name` — optional rename (e.g. `keyboard_double_arrow_right double_chevron` saves as `double_chevron.svg`)
76
- - `-y` — auto-accept the directional prompt without user interaction
77
-
78
- Examples:
79
- ```bash
80
- # Non-directional — downloads silently
81
- npm run geticon -- format_bold
82
-
83
- # Directional — prompts to confirm saving right-facing variant as "chevron"
84
- npm run geticon -- chevron_left
85
-
86
- # Directional with rename and auto-accept
87
- npm run geticon -- keyboard_double_arrow_up double_chevron -y
88
- ```
89
-
90
- If the script exits with a non-zero code mentioning a 404, the icon name is wrong — return to Stage 2 and search for the correct name.
91
-
92
- The script produces minified output with only `xmlns` and `viewBox` on `<svg>`, and only `fill="currentColor"` and `d` on each `<path>`:
93
- ```svg
94
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="currentColor" d="..."/></svg>
95
- ```
96
-
97
- ---
98
-
99
- ## Directional Icons and `<k-icon>`
100
-
101
- When the script saves a right-facing icon (after a directional prompt), the `<k-icon>` component handles rotation via its `direction` attribute:
102
-
103
- | `direction` value | Rotation applied |
104
- |---|---|
105
- | *(omitted)* | 0° — points right |
106
- | `down` | 90° |
107
- | `left` | 180° |
108
- | `up` | 270° |
109
-
110
- **Example:** A user asking for a "left arrow" after downloading `arrow.svg`:
111
- ```html
112
- <k-icon name="arrow" direction="left"></k-icon>
113
- ```
114
-
115
- ---
116
-
117
- ## Complete Examples
118
-
119
- **User asks to "add a bold button" (implicit icon need)**
120
-
121
- 1. **Local check:** `icons/format_bold.svg` exists → use it directly, no download needed
122
- 2. **Use:** `<k-icon name="format_bold"></k-icon>`
123
-
124
- **User asks to "add a thumbs up icon" (explicit, not in local icons)**
125
-
126
- 1. **Local check:** No match in `icons/`
127
- 2. **Find:** `npm run listicons -- thumbs+up` → `thumb_up`
128
- 3. **Directional check:** Not directional, proceed normally
129
- 4. **Run:** `npm run geticon -- thumb_up`
130
- 5. **Result:** `icons/thumb_up.svg` saved
131
-
132
- **User asks for a "left arrow icon"**
133
-
134
- 1. **Local check:** `icons/arrow.svg` exists and is right-facing
135
- 2. **Directional check:** Directional — right-facing variant already present
136
- 3. **No download needed:** use `<k-icon name="arrow" direction="left"></k-icon>`
137
-
138
- **User asks for a "left arrow icon" (no arrow in local icons)**
139
-
140
- 1. **Local check:** No arrow icon found
141
- 2. **Find:** `npm run listicons -- arrow` → consider `arrow_forward` or `arrow_right`
142
- 3. **Directional check:** Directional — download the right-facing variant with a generic name
143
- 4. **Run:** `npm run geticon -- arrow_forward arrow`
144
- 5. **Use:** `<k-icon name="arrow" direction="left"></k-icon>`
@@ -1,62 +0,0 @@
1
- ---
2
- name: highlight-code
3
- description: Generates syntax-highlighted HTML for documentation code samples. USE THIS instead of hand-writing <pre><code class="hljs ..."> blocks or creating temporary scripts. Run via `npx kempo-highlightcode <lang>` with code piped on stdin.
4
- ---
5
-
6
- # Highlight Code
7
-
8
- ## When to Use
9
-
10
- Use this skill whenever you need syntax-highlighted HTML to embed in a documentation page — for example, when adding or updating a `<pre><code class="hljs ...">` block in a `docs-src/components/*.page.html` file. **Do not hand-write the highlighted markup, and do not create temporary scripts to do this.**
11
-
12
- > Source files live in `docs-src/` and are pre-rendered to `docs/` by the kempo-server templating system. Always edit the `*.page.html` source — never the generated `docs/*.html`.
13
-
14
- ## Usage
15
-
16
- Pipe code via stdin (supports multi-line):
17
- ```bash
18
- cat <<'EOF' | npx kempo-highlightcode <lang>
19
- <div class='container'>
20
- <h1>Hello</h1>
21
- </div>
22
- EOF
23
- ```
24
-
25
- Or pass minified code as an argument:
26
- ```bash
27
- npx kempo-highlightcode <lang> "<div class='container'><h1>Hello</h1></div>"
28
- ```
29
-
30
- **Supported languages:** `html`, `css`, `js` / `javascript`, `ts` / `typescript`, `json`, `md` / `markdown`, `sh` / `bash`, `xml`
31
-
32
- ## Output
33
-
34
- Prints a complete `<pre><code class="hljs {lang}">…</code></pre>` block to stdout, with `<br>` tags instead of newlines — a single line suitable for embedding directly in HTML.
35
-
36
- Input is automatically beautified before highlighting, so you can pass minified code.
37
-
38
- ## Workflow for Updating Doc Code Samples
39
-
40
- 1. Capture the highlighted block to a file or shell variable:
41
-
42
- ```bash
43
- HL=$(cat <<'EOF' | npx kempo-highlightcode xml
44
- <k-table id=myExample></k-table>
45
- <script type=module>
46
- doSomething('foo');
47
- </script>
48
- EOF
49
- )
50
- ```
51
-
52
- 2. Use the **Edit tool** to replace the existing `<pre><code class="hljs xml">…</code></pre>` block in the target page with `$HL`. Do not use `sed` or shell-based file editing — Edit handles escaping correctly.
53
-
54
- - Read the file first to find the exact `old_string` to replace.
55
- - If the page has multiple `<pre><code>` blocks, include enough surrounding context in `old_string` to make it unique.
56
-
57
- ## Important Notes
58
-
59
- - Use **single-quotes** for JS string literals inside the code. This preserves all quoting correctly through to node.
60
- - HTML attribute values can be **unquoted** in the argument (`id=foo` not `id="foo"`) — the beautifier adds consistent formatting anyway.
61
- - When piping multi-line code, use a **quoted heredoc** (`<<'EOF'`) so the shell does not interpolate `$`, backticks, or backslashes inside the code.
62
- - Output uses `<br>` instead of `\n`, keeping the block on one line in the HTML source.
@@ -1,362 +0,0 @@
1
- ---
2
- name: new-component
3
- description: Creates a new Kempo UI web component end-to-end — choosing the right base class, writing the source file, registering the custom element, adding the docs page, wiring up the nav/index/llms.txt, AND writing tests. Use any time you are asked to create a new component or add a new custom element.
4
- ---
5
-
6
- # New Component
7
-
8
- ## When to Use
9
-
10
- Use this skill any time you are asked to create a new component or add a new custom element to the project.
11
-
12
- ---
13
-
14
- ## Overview
15
-
16
- Creating a component involves six steps. Do not skip steps — especially the tests.
17
-
18
- 1. **Choose the base component** — pick the right rendering strategy
19
- 2. **Write the source file** in `src/components/` (also registers the custom element)
20
- 3. **Read the templating primer** so the docs page is structured correctly
21
- 4. **Add the documentation page** in `docs-src/components/` and wire up nav/index/llms.txt
22
- 5. **Write and run unit tests** in `tests/components/`
23
- 6. **Verify in the browser** at `http://localhost:8083`
24
-
25
- ---
26
-
27
- ## Step 1: Choose the Base Component
28
-
29
- Three base classes are available. Pick based on rendering needs:
30
-
31
- ### `ShadowComponent`
32
- Use when the component needs shadow DOM encapsulation. The base class automatically injects the kempo-css stylesheet into the shadow root. **This is the default for most components.**
33
-
34
- ```javascript
35
- import { html, css } from '../lit-all.min.js';
36
- import ShadowComponent from './ShadowComponent.js';
37
-
38
- export default class MyComponent extends ShadowComponent {
39
- render() {
40
- return html`<p>Shadow DOM content with scoped styles</p>`;
41
- }
42
- }
43
- ```
44
-
45
- ### `LightComponent`
46
- Use when the component renders to the light DOM (no encapsulation, inherits page styles). Override `renderLightDom()`.
47
-
48
- ### `HybridComponent`
49
- Use when the component needs both a shadow DOM portion and a light DOM portion (e.g. slotted children that also need managed light DOM output). Override both `render()` and `renderLightDom()`.
50
-
51
- **Important:** Always call `super.updated(...)` when overriding `updated()` in any base class.
52
-
53
- ---
54
-
55
- ## Step 2: Write the Source File
56
-
57
- Create `src/components/MyComponent.js`. Follow these conventions (also see [AGENTS.md](../../AGENTS.md)):
58
-
59
- - Use multi-line comments to separate logical sections: `Lifecycle Callbacks`, `Event Handlers`, `Public Methods`, `Utility`, `Rendering`, `Styles`.
60
- - Declare reactive properties with `static properties = { ... }` and initialize defaults in the constructor.
61
- - Use `static styles = css\`...\`` for component-scoped CSS (shadow DOM only).
62
- - Use **arrow functions** for class methods to avoid `.bind(this)`.
63
- - For private fields use native JS private fields (`#field`) — never underscore-prefixed.
64
- - For form-associated components: set `static formAssociated = true`, call `this.attachInternals()` in the constructor, and use `internals.setFormValue(...)` / `internals.setValidity(...)`.
65
- - **Buttons inside the shadow DOM should have `class="no-btn"`** to opt out of kempo-css's default button styling. Add explicit `display: inline-flex; align-items: center; justify-content: center;` in the component CSS to keep content centered after stripping. The same applies to `class="no-style"` on selects/inputs if you need to fully opt out, but most form controls render fine with kempo-css defaults.
66
-
67
- Example skeleton:
68
-
69
- ```javascript
70
- import { html, css } from '../lit-all.min.js';
71
- import ShadowComponent from './ShadowComponent.js';
72
-
73
- export default class MyComponent extends ShadowComponent {
74
- static properties = {
75
- value: { type: String, reflect: true },
76
- disabled: { type: Boolean, reflect: true }
77
- };
78
-
79
- /*
80
- Lifecycle Callbacks
81
- */
82
- constructor() {
83
- super();
84
- this.value = '';
85
- this.disabled = false;
86
- }
87
-
88
- /*
89
- Event Handlers
90
- */
91
- handleClick = () => {
92
- if(this.disabled) return;
93
- this.dispatchEvent(new CustomEvent('change', {
94
- detail: { value: this.value },
95
- bubbles: true
96
- }));
97
- };
98
-
99
- /*
100
- Rendering
101
- */
102
- render() {
103
- return html`
104
- <button class="no-btn btn" ?disabled=${this.disabled} @click=${this.handleClick}>
105
- ${this.value}
106
- </button>
107
- `;
108
- }
109
-
110
- /*
111
- Styles
112
- */
113
- static styles = css`
114
- :host { display: inline-block; }
115
- .btn {
116
- display: inline-flex;
117
- align-items: center;
118
- justify-content: center;
119
- padding: var(--spacer_h) var(--spacer);
120
- border: 1px solid var(--c_border);
121
- border-radius: var(--radius);
122
- background: var(--c_bg);
123
- color: var(--tc);
124
- cursor: pointer;
125
- }
126
- `;
127
- }
128
-
129
- customElements.define('k-my-component', MyComponent);
130
- ```
131
-
132
- ### Custom element naming
133
- - All elements use the `k-` prefix (e.g. `k-spinner`, `k-color-picker`).
134
- - Use kebab-case for multi-word names.
135
- - The `customElements.define(...)` call goes at the **bottom** of the file, after the class.
136
-
137
- ---
138
-
139
- ## Step 3: kempo-server Templating Primer
140
-
141
- The docs site is built with the **kempo-server v3 templating system**. Source files live in `docs-src/`; production pages are pre-rendered into `docs/` at build time. **Do not edit anything inside `docs/components/` directly — those files are generated.**
142
-
143
- ### File types
144
-
145
- | Suffix | Purpose | Example |
146
- |---|---|---|
147
- | `*.page.html` | An individual page. Wraps content in `<page>` and fills template slots with `<content>` blocks. | `docs-src/components/slider.page.html` |
148
- | `*.template.html` | Shared layout. Defines named slots with `<location>` and pulls in fragments with `<fragment name="..." />`. | `docs-src/default.template.html` |
149
- | `*.fragment.html` | Reusable HTML partial. Included via `<fragment name="..." />` from templates or pages. | `docs-src/nav.fragment.html` |
150
- | `*.global.html` | Site-wide content auto-injected into matching `<location>` tags across every page. | (none currently) |
151
-
152
- ### How a page renders
153
-
154
- 1. The `<page>` tag chooses a template (defaults to `default`, looked up upward from the page directory). For component docs that's `docs-src/default.template.html`.
155
- 2. Each `<content>` block in the page fills a `<location>` slot in the template:
156
- - `<content>` (no `location`) fills `<location />` (the unnamed default slot — body content).
157
- - `<content location="scripts">` fills `<location name="scripts" />` (`<script type="module">` tags at the end of `<body>`).
158
- - `<content location="header">` fills `<location name="header">` (overrides the default `<h1>` heading).
159
- 3. Page tag attributes become template variables: `<page pageName="Slider" title="...">` makes `{{pageName}}` and `{{title}}` available throughout the template.
160
- 4. The `<fragment name="nav" />` call in the template pulls in `docs-src/nav.fragment.html`, which renders the navbar and side menu.
161
- 5. Path variables: **`{{pathToRoot}}`** is the relative path back to `docs-src/` — use it for every asset reference. The renderer substitutes the correct number of `../` segments based on the page's depth. Other built-ins include `{{year}}`, `{{date}}`, `{{datetime}}`, `{{timestamp}}`, `{{version}}`, `{{env}}`.
162
- 6. Templates and fragments resolve **upward** from the referencing page directory (nearest match wins). Pages and globals scan **downward** (recursively).
163
-
164
- ### Conditionals and loops
165
-
166
- The templating engine supports:
167
- - `<if condition="..."> … </if>` with `===`, `!==`, `>`, `<`, `>=`, `<=`, `&&`, `||`, `!`
168
- - `<foreach in="arrayName" as="item"> {{item.name}} </foreach>` with dot-path access
169
-
170
- ### Dev server
171
-
172
- A dev server is already running on `http://localhost:8083` (started via `npm run dev`). It uses SSR for `docs-src/`, so your changes appear on refresh without a build. Static assets in `docs/` (CSS, media, manifest) are served via customRoutes. **Do not start another server.**
173
-
174
- For production, `npm run build` minifies the JS, copies icons, and pre-renders all pages from `docs-src/` into `docs/`.
175
-
176
- ---
177
-
178
- ## Step 4: Add the Documentation Page
179
-
180
- Create `docs-src/components/my-component.page.html`. Use an existing page (e.g. [`slider.page.html`](../../docs-src/components/slider.page.html), [`time.page.html`](../../docs-src/components/time.page.html)) as a structural reference. Recommended sections:
181
-
182
- - A Table of Contents accordion at the top
183
- - Examples (Basic Usage, Default Value, mode-specific examples, etc.)
184
- - A `<h2 id="jsRef">JavaScript Reference</h2>` section covering: Constructor, Requirements, Properties, Methods, CSS Variables, Events
185
- - Module script tags inside `<content location="scripts">`
186
-
187
- ```html
188
- <page pageName="My Component" title="My Component - Components - Kempo Docs - A Web Components Solution">
189
- <content>
190
- <k-accordion persistent-id="toc" class="b r mb">
191
- <k-accordion-header for-panel="toc-panel">Table of Contents</k-accordion-header>
192
- <k-accordion-panel name="toc-panel">
193
- <!-- TOC links -->
194
- </k-accordion-panel>
195
- </k-accordion>
196
-
197
- <h3 id="basicUsage"><a href="#basicUsage" class="no-link">Basic Usage</a></h3>
198
- <!-- examples -->
199
-
200
- <h2 id="jsRef"><a href="#jsRef" class="no-link">JavaScript Reference</a></h2>
201
- <!-- properties / methods / events -->
202
- </content>
203
- <content location="scripts">
204
- <script type="module" src="{{pathToRoot}}src/components/MyComponent.js"></script>
205
- <script type="module" src="{{pathToRoot}}src/components/Accordion.js"></script>
206
- <script type="module" src="{{pathToRoot}}src/components/Card.js"></script>
207
- </content>
208
- </page>
209
- ```
210
-
211
- Use `{{pathToRoot}}` for any relative path (the templating system substitutes the correct `../` count per page depth).
212
-
213
- ### Code Samples in the Docs
214
-
215
- For every `<pre><code class="hljs ...">` block, **use the highlight-code skill** to generate the markup. Do not hand-write the highlighted HTML — it is fragile and error-prone.
216
-
217
- ```bash
218
- cat <<'EOF' | npx kempo-highlightcode xml
219
- <k-my-component value="hello"></k-my-component>
220
- EOF
221
- ```
222
-
223
- Then paste the result into the page (Edit tool).
224
-
225
- ### Wire Up Nav, Index, and llms.txt
226
-
227
- After creating the page, add the component in **four** places, all in alphabetical order:
228
-
229
- #### 1. Search filter dropdown — [`docs-src/nav.fragment.html`](../../docs-src/nav.fragment.html)
230
-
231
- Inside `<k-filter-list id="navSearchList">`, add a `<k-filter-item>`:
232
-
233
- ```html
234
- <k-filter-item filter-keywords="my component mycomponent keywords here components"><a
235
- href="{{pathToRoot}}components/my-component.html"
236
- >My Component<br><small>Component</small></a></k-filter-item>
237
- ```
238
-
239
- **WATCH OUT:** When using the Edit tool to insert a new filter-item, do **not** truncate the `<a` of the surrounding filter-item. The Edit tool's `old_string`/`new_string` boundaries must end on whole lines. Verify with Read after each edit.
240
-
241
- #### 2. Sidebar menu link — same `nav.fragment.html`
242
-
243
- Inside the `<menu>` block under `<h3>Components</h3>`, add an `<a>` tag in alphabetical order:
244
-
245
- ```html
246
- <a href="{{pathToRoot}}components/my-component.html">My Component</a>
247
- ```
248
-
249
- #### 3. Homepage card — [`docs-src/index.page.html`](../../docs-src/index.page.html)
250
-
251
- Inside the `<div class="row -mx">` under `<h2>Components</h2>`, add a card in alphabetical order:
252
-
253
- ```html
254
- <div class="span-12 t-span-6 d-span-4 px">
255
- <a href="{{pathToRoot}}components/my-component.html" class="card mb no-link d-b">
256
- <h3 class="tc-primary">My Component</h3>
257
- <p class="tc-muted">One-sentence description of what the component does.</p>
258
- </a>
259
- </div>
260
- ```
261
-
262
- #### 4. LLM reference — [`llms.txt`](../../llms.txt)
263
-
264
- Add a row to the **Components** table (alphabetical by element name):
265
-
266
- ```markdown
267
- | `<k-my-component>` | `MyComponent.js` | One-sentence description with key attributes and events | [my-component.html](https://dustinpoissant.github.io/kempo-ui/components/my-component.html) |
268
- ```
269
-
270
- If the component registers multiple elements (e.g. parent + child), list all element names in the first column separated by spaces.
271
-
272
- ---
273
-
274
- ## Step 5: Write and Run Unit Tests
275
-
276
- **This step is required.** Skipping tests is a common mistake — the AGENTS.md says "ALL tests must pass — ZERO failures are acceptable."
277
-
278
- Create `tests/components/MyComponent.browser-test.js`. Use [Toggle.browser-test.js](../../tests/components/Toggle.browser-test.js) or [Slider.browser-test.js](../../tests/components/Slider.browser-test.js) as a reference.
279
-
280
- Conventions:
281
- - Import the component class at the top.
282
- - Define an async `createMyComponent(attrs = {})` helper that builds the DOM, appends to `document.body`, awaits `el.updateComplete`, and returns `{ container, el }`.
283
- - Define a `cleanup(container)` helper that removes the container from the DOM.
284
- - Export a default plain object where each key is a test description and each value is an `async ({pass, fail}) => {}` function.
285
- - Always call `cleanup(container)` before every `pass()` or `fail()` call.
286
- - Use multi-line comments to group related tests (`/* Element Creation */`, `/* Properties */`, etc.).
287
-
288
- Tests to include at minimum:
289
-
290
- - Element is created and is an instance of the component class
291
- - Element has a shadow root (when applicable)
292
- - Default property values are correct
293
- - Attribute reflection works (when `reflect: true`)
294
- - Public methods behave correctly
295
- - Events are dispatched correctly with the right `detail` shape
296
- - For form-associated components: form submission produces the expected value
297
-
298
- Run the tests after writing them:
299
-
300
- ```bash
301
- npm run test -- MyComponent
302
- ```
303
-
304
- The partial string `MyComponent` matches any test file path containing it. Fix any failures before considering the component complete. Zero failures.
305
-
306
- ---
307
-
308
- ## Step 6: Verify in the Browser
309
-
310
- A dev server is already running on `http://localhost:8083` — **do not start another**. Source pages render via SSR so changes appear on refresh without rebuilding.
311
-
312
- - Navigate to `http://localhost:8083/components/my-component.html`
313
- - Use chrome-devtools-mcp to interact with the rendered output
314
- - Test the golden path AND edge cases
315
- - Confirm form-associated behavior by submitting an actual `<form>`
316
- - Watch for console errors that aren't pre-existing
317
-
318
- If you cannot test the UI for some reason, say so explicitly rather than claiming success.
319
-
320
- ---
321
-
322
- ## Component Architecture and Communication
323
-
324
- - Use **methods** to trigger actions. Events notify that something already happened; they should not be used to trigger logic.
325
- - Prefer `el.closest('k-parent')?.doSomething()` over dispatching an event and listening for it.
326
- - Child components should locate their parent via `closest('k-parent-element')` and call its methods directly.
327
- - Avoid `window` globals and global custom events for coordination. Scope events to the relevant element; reserve `window` events for global, non-visual concerns (e.g. settings changes).
328
-
329
- ---
330
-
331
- ## Elevation (Z-Index)
332
-
333
- Any component using `position: fixed` must follow the kempo-css elevation system where `z-index = level × 10`.
334
-
335
- | Level | z-index | Kempo UI Components | Notes |
336
- |-------|---------|---------------------|-------|
337
- | 2 | 20 | (page default) | No z-index needed for flow-position components |
338
- | 3 | 30 | Aside (push) | Fixed panels that sit **below** a fixed navbar |
339
- | 5 | 50 | *(navbar — user-defined)* | Reserved buffer for user-defined navbars |
340
- | 6 | 60 | Aside (overlay) | Overlay drawers that sit **above** a fixed navbar |
341
- | 7 | 70 | Dropdown | Floating menus; above navbar and overlay |
342
- | 8 | 80 | Dialog, PhotoViewer | Full-screen modals and lightboxes |
343
- | 9 | 90 | Toast | Notification toasts; always topmost |
344
-
345
- Levels 1, 4 are intentional buffer zones for user customization.
346
-
347
- When deciding a new component's elevation:
348
- - If it is a panel or drawer in `push` mode (shifts page content), use level 3.
349
- - If it is a panel or drawer in `overlay` mode (floats over content), use level 6.
350
- - If it covers the entire viewport (modal/dialog), use level 8.
351
- - If it is a temporary notification, use level 9.
352
-
353
- ---
354
-
355
- ## Common Mistakes To Avoid
356
-
357
- - **Hand-writing the syntax-highlighted HTML** in code samples — use the `highlight-code` skill instead.
358
- - **Skipping tests** — every component needs a `*.browser-test.js` file with the minimum coverage above.
359
- - **Editing `docs/`** — that directory is generated by the build. Edit `docs-src/`.
360
- - **Forgetting `class="no-btn"` on shadow-DOM `<button>` elements** — kempo-css aggressively styles native buttons; opt out and re-center with flex.
361
- - **Editing only the search filter and forgetting the sidebar menu, index page, or llms.txt** — all four locations must be updated for a new component.
362
- - **Truncating adjacent elements when editing `nav.fragment.html`** — Edit tool boundaries must include whole lines; always Read after editing to verify nothing was clipped.