From d5eec5e0e09852c921962b886263aef35869dbb3 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 7 Aug 2026 14:38:02 +0200 Subject: [PATCH 1/3] Add shared icon map with aliases and domain emoji. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract iconMap to icon-map.ts, alias gear/trash/x/house/person, and add climate-friendly names (tree, map, heat, shop, …) so WireMD shortcodes match skills docs and Adapt Now screens. Bump to 0.1.9. --- docs/components/icons.md | 28 ++++- figma-plugin/src/lib/content-components.ts | 54 +++++++-- package.json | 2 +- src/renderer/html-renderer.ts | 96 +-------------- src/renderer/icon-map.ts | 135 +++++++++++++++++++++ src/renderer/react-renderer.ts | 10 +- src/renderer/tailwind-renderer.ts | 10 +- tests/renderer.test.ts | 16 +++ 8 files changed, 225 insertions(+), 126 deletions(-) create mode 100644 src/renderer/icon-map.ts diff --git a/docs/components/icons.md b/docs/components/icons.md index b4aa7967..cc08c8ef 100644 --- a/docs/components/icons.md +++ b/docs/components/icons.md @@ -28,9 +28,9 @@ Use `:name:` inline anywhere β€” in headings, buttons, nav bars, or plain text. | Name | Renders as | |------|------------| -| `:home:` | 🏠 | -| `:user:` | πŸ‘€ | -| `:settings:` | βš™οΈ | +| `:home:` / `:house:` | 🏠 | +| `:user:` / `:person:` | πŸ‘€ | +| `:settings:` / `:gear:` | βš™οΈ | | `:search:` | πŸ” | | `:star:` | ⭐ | | `:heart:` | ❀️ | @@ -43,11 +43,11 @@ Use `:name:` inline anywhere β€” in headings, buttons, nav bars, or plain text. | `:download:` | ⬇️ | | `:upload:` | ⬆️ | | `:edit:` | ✏️ | -| `:delete:` | πŸ—‘οΈ | +| `:delete:` / `:trash:` | πŸ—‘οΈ | | `:plus:` | βž• | | `:minus:` | βž– | | `:check:` | βœ“ | -| `:close:` | βœ• | +| `:close:` / `:x:` | βœ• | | `:menu:` | ☰ | | `:more:` | β‹― | | `:info:` | ℹ️ | @@ -104,6 +104,24 @@ Use `:name:` inline anywhere β€” in headings, buttons, nav bars, or plain text. | `:cloud:` | ☁️ | | `:wifi:` | πŸ“Ά | +### Domain / climate + +| Name | Renders as | +|------|------------| +| `:tree:` | 🌳 | +| `:plant:` | 🌱 | +| `:sun:` | β˜€οΈ | +| `:thermometer:` / `:heat:` | 🌑️ | +| `:drop:` / `:water:` | πŸ’§ | +| `:rain:` | 🌧️ | +| `:wind:` | πŸ’¨ | +| `:map:` | πŸ—ΊοΈ | +| `:building:` | 🏒 | +| `:shop:` | πŸͺ | +| `:tools:` | πŸ› οΈ | +| `:megaphone:` | πŸ“’ | +| `:handshake:` | 🀝 | + ### Activities | Name | Renders as | diff --git a/figma-plugin/src/lib/content-components.ts b/figma-plugin/src/lib/content-components.ts index 2fefaa96..3f897e77 100644 --- a/figma-plugin/src/lib/content-components.ts +++ b/figma-plugin/src/lib/content-components.ts @@ -147,16 +147,50 @@ export async function createIcon( // Icon mapping - using Unicode symbols and emoji const iconMap: Record = { - 'home': '🏠', 'user': 'πŸ‘€', 'settings': 'βš™οΈ', 'search': 'πŸ”', - 'star': '⭐', 'heart': '❀️', 'mail': 'βœ‰οΈ', 'phone': 'πŸ“ž', - 'calendar': 'πŸ“…', 'clock': 'πŸ•', 'location': 'πŸ“', 'link': 'πŸ”—', - 'download': '⬇️', 'upload': '⬆️', 'edit': '✏️', 'delete': 'πŸ—‘οΈ', - 'plus': 'βž•', 'minus': 'βž–', 'check': 'βœ“', 'close': 'βœ•', - 'menu': '☰', 'more': 'β‹―', 'info': 'ℹ️', 'warning': '⚠️', - 'error': '❌', 'success': 'βœ…', - 'arrow-up': '↑', 'arrow-down': '↓', 'arrow-left': '←', 'arrow-right': 'β†’', - 'twitter': '𝕏', 'github': 'βŠ™', 'linkedin': 'in', - 'default': '●' + home: '🏠', + user: 'πŸ‘€', + settings: 'βš™οΈ', + search: 'πŸ”', + star: '⭐', + heart: '❀️', + mail: 'βœ‰οΈ', + phone: 'πŸ“ž', + calendar: 'πŸ“…', + clock: 'πŸ•', + location: 'πŸ“', + link: 'πŸ”—', + download: '⬇️', + upload: '⬆️', + edit: '✏️', + delete: 'πŸ—‘οΈ', + plus: 'βž•', + minus: 'βž–', + check: 'βœ“', + close: 'βœ•', + menu: '☰', + more: 'β‹―', + info: 'ℹ️', + warning: '⚠️', + error: '❌', + success: 'βœ…', + 'arrow-up': '↑', + 'arrow-down': '↓', + 'arrow-left': '←', + 'arrow-right': 'β†’', + twitter: '𝕏', + github: 'βŠ™', + linkedin: 'in', + // Aliases + domain + house: '🏠', + person: 'πŸ‘€', + gear: 'βš™οΈ', + trash: 'πŸ—‘οΈ', + x: 'βœ•', + tree: '🌳', + map: 'πŸ—ΊοΈ', + thermometer: '🌑️', + heat: '🌑️', + default: '●', }; const iconName = node.props.name || 'default'; diff --git a/package.json b/package.json index d17150a4..e8528ca2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wiremd", - "version": "0.1.8", + "version": "0.1.9", "description": "Text-first UI design tool - Create wireframes and mockups using Markdown syntax", "type": "module", "sideEffects": false, diff --git a/src/renderer/html-renderer.ts b/src/renderer/html-renderer.ts index fd8df0bc..c908128b 100644 --- a/src/renderer/html-renderer.ts +++ b/src/renderer/html-renderer.ts @@ -8,6 +8,7 @@ */ import type { WiremdNode } from '../types.js'; +import { resolveIcon } from './icon-map.js'; export interface RenderContext { style: string; @@ -289,100 +290,7 @@ function renderIcon(node: any, context: RenderContext): string { const { classPrefix: prefix } = context; const classes = buildClasses(prefix, 'icon', node.props); const iconName = node.props.name || 'default'; - - // Icon mapping - using Unicode symbols and emoji - const iconMap: Record = { - // Social media - 'twitter': '𝕏', // Twitter/X logo approximation - 'github': 'βŠ™', // GitHub-like symbol - 'linkedin': 'in', // LinkedIn text representation - 'facebook': 'f', - 'instagram': 'β—‰', - 'youtube': 'β–Ά', - - // Common UI icons - 'home': '🏠', - 'user': 'πŸ‘€', - 'settings': 'βš™οΈ', - 'search': 'πŸ”', - 'star': '⭐', - 'heart': '❀️', - 'mail': 'βœ‰οΈ', - 'phone': 'πŸ“ž', - 'calendar': 'πŸ“…', - 'clock': 'πŸ•', - 'location': 'πŸ“', - 'link': 'πŸ”—', - 'download': '⬇️', - 'upload': '⬆️', - 'edit': '✏️', - 'delete': 'πŸ—‘οΈ', - 'plus': 'βž•', - 'minus': 'βž–', - 'check': 'βœ“', - 'close': 'βœ•', - 'menu': '☰', - 'more': 'β‹―', - 'info': 'ℹ️', - 'warning': '⚠️', - 'error': '❌', - 'success': 'βœ…', - - // Arrows - 'arrow-up': '↑', - 'arrow-down': '↓', - 'arrow-left': '←', - 'arrow-right': 'β†’', - - // Business/Finance - 'chart': 'πŸ“Š', - 'dollar': '$', - 'euro': '€', - 'pound': 'Β£', - - // Tech - 'code': '', - 'database': 'πŸ—„οΈ', - 'cloud': '☁️', - 'wifi': 'πŸ“Ά', - - // Communication - 'chat': 'πŸ’¬', - 'video': 'πŸŽ₯', - 'microphone': '🎀', - 'bell': 'πŸ””', - - // Files - 'file': 'πŸ“„', - 'folder': 'πŸ“', - 'image': 'πŸ–ΌοΈ', - 'document': 'πŸ“ƒ', - 'pdf': 'πŸ“‘', - - // Brand placeholders - 'logo': 'β—ˆ', - 'brand': 'β—†', - - // Activities - 'rocket': 'πŸš€', - 'bulb': 'πŸ’‘', - 'shield': 'πŸ›‘οΈ', - 'lock': 'πŸ”’', - 'unlock': 'πŸ”“', - 'key': 'πŸ”‘', - 'gift': '🎁', - 'trophy': 'πŸ†', - 'flag': '🚩', - 'bookmark': 'πŸ”–', - 'tag': '🏷️', - 'cart': 'πŸ›’', - 'credit-card': 'πŸ’³', - - // Default - 'default': '●' - }; - - const iconContent = iconMap[iconName] || iconMap['default']; + const iconContent = resolveIcon(iconName); // For social media icons, wrap in a styled span to make them look more icon-like const socialIcons = ['twitter', 'github', 'linkedin', 'facebook', 'instagram', 'youtube']; diff --git a/src/renderer/icon-map.ts b/src/renderer/icon-map.ts new file mode 100644 index 00000000..dfa2e07a --- /dev/null +++ b/src/renderer/icon-map.ts @@ -0,0 +1,135 @@ +/** + * Shared emoji/unicode map for `:name:` icon shortcodes. + * Aliases keep docs/skills names working (gear/trash/x) and add + * domain-friendly names (tree, house, person, map, …). + */ + +export const ICON_MAP: Record = { + // Social media + twitter: '𝕏', + github: 'βŠ™', + linkedin: 'in', + facebook: 'f', + instagram: 'β—‰', + youtube: 'β–Ά', + + // Common UI icons + home: '🏠', + user: 'πŸ‘€', + settings: 'βš™οΈ', + search: 'πŸ”', + star: '⭐', + heart: '❀️', + mail: 'βœ‰οΈ', + phone: 'πŸ“ž', + calendar: 'πŸ“…', + clock: 'πŸ•', + location: 'πŸ“', + link: 'πŸ”—', + download: '⬇️', + upload: '⬆️', + edit: '✏️', + delete: 'πŸ—‘οΈ', + plus: 'βž•', + minus: 'βž–', + check: 'βœ“', + close: 'βœ•', + menu: '☰', + more: 'β‹―', + info: 'ℹ️', + warning: '⚠️', + error: '❌', + success: 'βœ…', + + // Arrows + 'arrow-up': '↑', + 'arrow-down': '↓', + 'arrow-left': '←', + 'arrow-right': 'β†’', + + // Business/Finance + chart: 'πŸ“Š', + dollar: '$', + euro: '€', + pound: 'Β£', + + // Tech + code: '', + database: 'πŸ—„οΈ', + cloud: '☁️', + wifi: 'πŸ“Ά', + + // Communication + chat: 'πŸ’¬', + video: 'πŸŽ₯', + microphone: '🎀', + bell: 'πŸ””', + + // Files + file: 'πŸ“„', + folder: 'πŸ“', + image: 'πŸ–ΌοΈ', + document: 'πŸ“ƒ', + pdf: 'πŸ“‘', + + // Brand placeholders + logo: 'β—ˆ', + brand: 'β—†', + + // Activities + rocket: 'πŸš€', + bulb: 'πŸ’‘', + shield: 'πŸ›‘οΈ', + lock: 'πŸ”’', + unlock: 'πŸ”“', + key: 'πŸ”‘', + gift: '🎁', + trophy: 'πŸ†', + flag: '🚩', + bookmark: 'πŸ”–', + tag: '🏷️', + cart: 'πŸ›’', + 'credit-card': 'πŸ’³', + + // Domain / climate-adaptation friendly + tree: '🌳', + plant: '🌱', + leaf: 'πŸƒ', + sun: 'β˜€οΈ', + thermometer: '🌑️', + drop: 'πŸ’§', + rain: '🌧️', + wind: 'πŸ’¨', + map: 'πŸ—ΊοΈ', + building: '🏒', + shop: 'πŸͺ', + tools: 'πŸ› οΈ', + clipboard: 'πŸ“‹', + megaphone: 'πŸ“’', + handshake: '🀝', + pin: 'πŸ“Œ', + + // Aliases (skills docs + natural synonyms) + house: '🏠', + person: 'πŸ‘€', + people: 'πŸ‘₯', + users: 'πŸ‘₯', + gear: 'βš™οΈ', + cog: 'βš™οΈ', + trash: 'πŸ—‘οΈ', + x: 'βœ•', + cross: 'βœ•', + 'pin-drop': 'πŸ“', + marker: 'πŸ“', + thermo: '🌑️', + heat: '🌑️', + water: 'πŸ’§', + + // Default + default: '●', +}; + +export function resolveIcon(name: string | undefined | null): string { + const key = (name || 'default').toLowerCase(); + return ICON_MAP[key] || ICON_MAP.default; +} diff --git a/src/renderer/react-renderer.ts b/src/renderer/react-renderer.ts index d9710431..f1dc0bcf 100644 --- a/src/renderer/react-renderer.ts +++ b/src/renderer/react-renderer.ts @@ -8,6 +8,7 @@ */ import type { WiremdNode } from '../types.js'; +import { resolveIcon } from './icon-map.js'; export interface ReactRenderContext { classPrefix: string; @@ -281,14 +282,7 @@ function renderIcon(node: any, context: ReactRenderContext, indent: number): str const classAttr = context.useClassName ? 'className' : 'class'; // Icon mapping - const iconMap: Record = { - 'home': '🏠', 'user': 'πŸ‘€', 'settings': 'βš™οΈ', 'search': 'πŸ”', - 'star': '⭐', 'heart': '❀️', 'mail': 'βœ‰οΈ', 'phone': 'πŸ“ž', - 'check': 'βœ“', 'close': 'βœ•', 'menu': '☰', 'more': 'β‹―', - 'default': '●' - }; - - const iconContent = iconMap[iconName] || iconMap['default']; + const iconContent = resolveIcon(iconName); return `${indentStr}${iconContent}`; } diff --git a/src/renderer/tailwind-renderer.ts b/src/renderer/tailwind-renderer.ts index 2f4e143b..558ce723 100644 --- a/src/renderer/tailwind-renderer.ts +++ b/src/renderer/tailwind-renderer.ts @@ -8,6 +8,7 @@ */ import type { WiremdNode } from '../types.js'; +import { resolveIcon } from './icon-map.js'; export interface TailwindRenderContext { pretty: boolean; @@ -266,14 +267,7 @@ function renderIcon(node: any, _context: TailwindRenderContext): string { const classes = 'inline-block align-middle'; const iconName = node.props.name || 'default'; - const iconMap: Record = { - 'home': '🏠', 'user': 'πŸ‘€', 'settings': 'βš™οΈ', 'search': 'πŸ”', - 'star': '⭐', 'heart': '❀️', 'mail': 'βœ‰οΈ', 'phone': 'πŸ“ž', - 'check': 'βœ“', 'close': 'βœ•', 'menu': '☰', 'more': 'β‹―', - 'default': '●' - }; - - const iconContent = iconMap[iconName] || iconMap['default']; + const iconContent = resolveIcon(iconName); return `${iconContent}`; } diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index afc76d6e..d509a42f 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -62,6 +62,22 @@ describe('HTML Renderer', () => { expect(html).toContain('wmd-icon'); expect(html).toContain('data-icon="house"'); + expect(html).toContain('🏠'); + }); + + it('should resolve icon aliases and domain names', () => { + const cases: Array<[string, string]> = [ + [':gear:', 'βš™οΈ'], + [':trash:', 'πŸ—‘οΈ'], + [':person:', 'πŸ‘€'], + [':tree:', '🌳'], + [':map:', 'πŸ—ΊοΈ'], + [':heat:', '🌑️'], + ]; + for (const [src, emoji] of cases) { + const html = renderToHTML(parse(src), { style: 'sketch' }); + expect(html).toContain(emoji); + } }); }); From 4f2edb89be81c6b00c35935416db126cff163ad7 Mon Sep 17 00:00:00 2001 From: Christian Date: Sun, 9 Aug 2026 16:44:30 +0200 Subject: [PATCH 2/3] Add bottom-nav, chip, and toggle-group components. Implements the documented mobile tab bar (::: bottom-nav with nested nav), removable/static chips via |.chip|, and segmented controls via row {.toggle-group}, with shared structural CSS across themes. --- docs/components/not-implemented-components.md | 11 +-- src/parser/index.ts | 2 +- src/parser/transformer.ts | 13 ++- src/renderer/html-renderer.ts | 63 ++++++++++++- src/renderer/styles.ts | 91 ++++++++++++++++++- src/types.ts | 2 +- tests/bottom-nav-chips.test.ts | 82 +++++++++++++++++ 7 files changed, 249 insertions(+), 15 deletions(-) create mode 100644 tests/bottom-nav-chips.test.ts diff --git a/docs/components/not-implemented-components.md b/docs/components/not-implemented-components.md index 81c8e856..2893f7d7 100644 --- a/docs/components/not-implemented-components.md +++ b/docs/components/not-implemented-components.md @@ -120,7 +120,7 @@ Same as `[Select___v]` + list β€” the `v` suffix and following list already expr Segmented control. Used for view-mode switches (list/grid), alignment, and filters. -A `::: row` with a class modifier β€” row already creates a flex button container, `.toggle-group` styles it as a segmented control: +**Implemented.** A `::: row` with a class modifier β€” row already creates a flex button container, `.toggle-group` styles it as a segmented control: ``` ::: row {.toggle-group} @@ -280,14 +280,11 @@ New container using `###` items β€” same boundary convention as `::: grid-N`: Mobile tab bar fixed to the bottom. Used in mobile-first apps. -New container type β€” button links with icons follow the existing `[[:icon: Label]]` pattern: +**Implemented.** Nest a nav row inside `::: bottom-nav`. Trailing `*` on an item marks it active: ``` ::: bottom-nav -[[:home: Home]*] -[[:search: Explore]] -[[:bell: Alerts]] -[[:user: Profile]] +[[ :home: Feed* | :search: Explore | :bell: Alerts | :user: Profile ]] ::: ``` @@ -332,7 +329,7 @@ Extends image syntax β€” `![alt](src)` already works; avatar is an image with a Compact, optionally removable label. Distinct from badge β€” chips are interactive and can be dismissed. Used for tags, filters, selections. -Extends badge `|...|` syntax β€” static chip is a badge with `.chip` class; removable adds `Γ—`: +**Implemented.** Extends badge `|...|` syntax β€” static chip is a badge with `.chip` class; removable adds `Γ—`: ``` |React|{.chip} diff --git a/src/parser/index.ts b/src/parser/index.ts index 9f7567ed..7bd84e48 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -139,7 +139,7 @@ export function validate(ast: DocumentNode): ValidationError[] { code: 'MISSING_CONTAINER_TYPE', }); } else { - const validContainerTypes = ['hero', 'card', 'modal', 'sidebar', 'footer', 'alert', 'grid', 'layout', 'section', 'form-group', 'button-group']; + const validContainerTypes = ['hero', 'card', 'modal', 'sidebar', 'footer', 'alert', 'grid', 'layout', 'section', 'form-group', 'button-group', 'bottom-nav']; if (!validContainerTypes.includes(node.containerType)) { errors.push({ message: `Invalid containerType: "${node.containerType}". Must be one of: ${validContainerTypes.join(', ')}`, diff --git a/src/parser/transformer.ts b/src/parser/transformer.ts index b4c4e298..9140f8b9 100644 --- a/src/parser/transformer.ts +++ b/src/parser/transformer.ts @@ -437,11 +437,16 @@ function transformInlineContainer(node: any, _options: ParseOptions): WiremdNode continue; } - // Check if it starts with icon: :icon: Text - const iconTextMatch = trimmed.match(/^:([a-z-]+):\s*(.+)$/); + // Check if it starts with icon: :icon: Text (trailing * marks active) + const iconTextMatch = trimmed.match(/^:([a-z-]+):\s*(.+?)(\*)?$/); if (iconTextMatch) { const iconName = iconTextMatch[1]; - const text = iconTextMatch[2]; + let text = iconTextMatch[2].trim(); + const isActive = Boolean(iconTextMatch[3]) || /^\*\*?(.+?)\*\*?$/.test(text); + if (isActive) { + const inner = text.match(/^\*\*?(.+?)\*\*?$/); + if (inner) text = inner[1]; + } // Create a brand node for :logo:, otherwise nav-item const nodeType = iconName === 'logo' ? 'brand' : 'nav-item'; @@ -452,7 +457,7 @@ function transformInlineContainer(node: any, _options: ParseOptions): WiremdNode { type: 'icon', props: { name: iconName } }, { type: 'text', content: text }, ], - props: {}, + props: isActive && nodeType === 'nav-item' ? { classes: ['active'] } : {}, }); continue; } diff --git a/src/renderer/html-renderer.ts b/src/renderer/html-renderer.ts index c908128b..1b4d57c3 100644 --- a/src/renderer/html-renderer.ts +++ b/src/renderer/html-renderer.ts @@ -153,7 +153,15 @@ function renderButton(node: any, context: RenderContext): string { function renderBadge(node: any, context: RenderContext): string { const { classPrefix: prefix } = context; const classes = buildClasses(prefix, 'badge', node.props); - return `${escapeHtml(node.content)}`; + const raw = String(node.content ?? ''); + const isChip = (node.props?.classes || []).includes('chip'); + // Removable chip: "|Label Γ—|{.chip}" β€” keep the Γ— as a dismiss affordance. + const removable = isChip && /[Γ—x]\s*$/u.test(raw); + if (removable) { + const label = raw.replace(/\s*[Γ—x]\s*$/u, '').trim(); + return `${escapeHtml(label)}`; + } + return `${escapeHtml(raw)}`; } function renderInput(node: any, context: RenderContext): string { @@ -310,6 +318,10 @@ function renderContainer(node: any, context: RenderContext): string { return renderSidebarMainLayout(node, context, classes); } + if (node.containerType === 'bottom-nav') { + return renderBottomNav(node, context, classes); + } + const childrenHTML = (node.children || []).map((child: any) => renderNode(child, context)).join('\n '); return `
@@ -317,6 +329,55 @@ function renderContainer(node: any, context: RenderContext): string {
`; } +/** + * Mobile tab bar. Prefer a nested `[[ :icon: Label | ... ]]` nav; otherwise + * render button / nav-item children directly. + */ +function renderBottomNav(node: any, context: RenderContext, classes: string): string { + const { classPrefix: prefix } = context; + const kids: any[] = node.children || []; + let items: any[] = []; + if (kids.length === 1 && kids[0].type === 'nav') { + items = kids[0].children || []; + } else { + for (const child of kids) { + if (child.type === 'nav') { + items.push(...(child.children || [])); + } else if (child.type === 'nav-item' || child.type === 'button') { + items.push(child); + } else if (child.type === 'container' && child.containerType === 'button-group') { + items.push(...(child.children || [])); + } else if (child.type === 'container' && child.containerType === 'section') { + for (const nested of child.children || []) { + if (nested.type === 'nav-item' || nested.type === 'button') items.push(nested); + else if (nested.type === 'nav') items.push(...(nested.children || [])); + } + } + } + } + + const itemsHTML = items + .map((item: any) => { + if (item.type === 'nav-item') return renderNavItem(item, context); + if (item.type === 'button') { + const btn = renderButton(item, context); + // Promote primary button to active tab affordance in the bar. + if ((item.props?.variant === 'primary') && !btn.includes(`${prefix}active`)) { + return btn.replace(`class="`, `class="${prefix}active `); + } + return btn; + } + return renderNode(item, context); + }) + .join('\n '); + + return ``; +} + function renderSidebarMainLayout(node: any, context: RenderContext, classes: string): string { const { classPrefix: prefix } = context; const children: any[] = node.children || []; diff --git a/src/renderer/styles.ts b/src/renderer/styles.ts index b1e975df..f0cb4431 100644 --- a/src/renderer/styles.ts +++ b/src/renderer/styles.ts @@ -37,6 +37,95 @@ export function getStyleCSS(style: string, prefix: string): string { .${prefix}container-button-group > .${prefix}select, .${prefix}row > .${prefix}grid-item > .${prefix}input, .${prefix}row > .${prefix}grid-item > .${prefix}select { display: inline-block; width: auto; } +.${prefix}row.${prefix}toggle-group { gap: 0; flex-wrap: nowrap; } +.${prefix}row.${prefix}toggle-group > .${prefix}grid-item { flex: 1 1 0; } +.${prefix}row.${prefix}toggle-group .${prefix}container-button-group { display: flex; width: 100%; gap: 0; } +.${prefix}row.${prefix}toggle-group .${prefix}button { + flex: 1 1 0; + border-radius: 0; + margin: 0; + justify-content: center; +} +.${prefix}row.${prefix}toggle-group .${prefix}button:first-child { border-radius: 9999px 0 0 9999px; } +.${prefix}row.${prefix}toggle-group .${prefix}button:last-child { border-radius: 0 9999px 9999px 0; } +.${prefix}row.${prefix}toggle-group .${prefix}grid-item:first-child .${prefix}button:first-child { border-radius: 9999px 0 0 9999px; } +.${prefix}row.${prefix}toggle-group .${prefix}grid-item:last-child .${prefix}button:last-child { border-radius: 0 9999px 9999px 0; } +`; + + // Bottom nav + chips β€” shared structure; themes can override colours + const bottomNavAndChipStructural = ` +.${prefix}container-bottom-nav { + position: sticky; + bottom: 0; + left: 0; + right: 0; + display: block; + margin: 1.5rem -1.5rem -1.5rem; + padding: 0.35rem 0.5rem calc(0.35rem + env(safe-area-inset-bottom, 0)); + border-top: 1px solid #e0e0e0; + background: #fff; + z-index: 20; +} +.${prefix}bottom-nav-content { + display: flex; + align-items: stretch; + justify-content: space-around; + gap: 0.25rem; +} +.${prefix}container-bottom-nav .${prefix}nav-item, +.${prefix}container-bottom-nav .${prefix}button { + flex: 1 1 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.15rem; + min-height: 3.25rem; + padding: 0.35rem 0.25rem; + border: none; + background: transparent; + border-radius: 0.75rem; + font-size: 0.7rem; + font-weight: 500; + color: #666; + text-decoration: none; +} +.${prefix}container-bottom-nav .${prefix}nav-item.${prefix}active, +.${prefix}container-bottom-nav .${prefix}button.${prefix}active, +.${prefix}container-bottom-nav .${prefix}button-primary { + color: #111; + font-weight: 700; + background: #f3f3f3; +} +.${prefix}container-bottom-nav .${prefix}icon { font-size: 1.25rem; line-height: 1; } +.${prefix}badge.${prefix}chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.2rem 0.65rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; + border-width: 1px; +} +.${prefix}badge.${prefix}chip-removable { padding-right: 0.35rem; } +.${prefix}chip-dismiss { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.1rem; + height: 1.1rem; + margin-left: 0.15rem; + border: none; + border-radius: 9999px; + background: transparent; + color: inherit; + font-size: 0.85rem; + line-height: 1; + cursor: pointer; + opacity: 0.7; +} +.${prefix}chip-dismiss:hover { opacity: 1; background: rgba(0,0,0,0.08); } `; const demoStructural = ` @@ -61,7 +150,7 @@ export function getStyleCSS(style: string, prefix: string): string { case 'brutal': themeCSS = getBrutalStyle(prefix); break; default: themeCSS = getSketchStyle(prefix); } - return linkButtonReset + tabsStructural + rowStructural + demoStructural + themeCSS; + return linkButtonReset + tabsStructural + rowStructural + bottomNavAndChipStructural + demoStructural + themeCSS; } /** diff --git a/src/types.ts b/src/types.ts index e509bc7f..489c10e3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -54,7 +54,7 @@ export interface DocumentNode { export type WiremdNode = // Layout - | { type: 'container'; containerType: 'hero' | 'card' | 'modal' | 'sidebar' | 'footer' | 'alert' | 'grid' | 'layout' | 'section' | 'form-group' | 'button-group'; props: ComponentProps; children: WiremdNode[]; position?: Location } + | { type: 'container'; containerType: 'hero' | 'card' | 'modal' | 'sidebar' | 'footer' | 'alert' | 'grid' | 'layout' | 'section' | 'form-group' | 'button-group' | 'bottom-nav'; props: ComponentProps; children: WiremdNode[]; position?: Location } | { type: 'nav'; props: ComponentProps; children: WiremdNode[]; position?: Location } | { type: 'nav-item'; content?: string; children?: WiremdNode[]; href?: string; props: ComponentProps; position?: Location } | { type: 'brand'; children: WiremdNode[]; props: ComponentProps; position?: Location } diff --git a/tests/bottom-nav-chips.test.ts b/tests/bottom-nav-chips.test.ts new file mode 100644 index 00000000..a862ffe1 --- /dev/null +++ b/tests/bottom-nav-chips.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { parse } from '../src/parser/index.js'; +import { renderToHTML } from '../src/renderer/index.js'; + +describe('bottom-nav', () => { + it('parses nested nav items with icons and active marker', () => { + const ast = parse(`::: bottom-nav +[[ :home: Feed* | :search: Themen | :user: Profil ]] +::: +`); + const nav = ast.children[0] as any; + expect(nav.type).toBe('container'); + expect(nav.containerType).toBe('bottom-nav'); + expect(nav.children[0].type).toBe('nav'); + const items = nav.children[0].children; + expect(items).toHaveLength(3); + expect(items[0].props?.classes).toContain('active'); + expect(items[1].props?.classes || []).not.toContain('active'); + }); + + it('renders a sticky bottom nav with active item', () => { + const html = renderToHTML( + parse(`::: bottom-nav +[[ :home: Feed* | :search: Themen | :user: Profil ]] +::: +`), + { style: 'clean' }, + ); + expect(html).toContain('wmd-container-bottom-nav'); + expect(html).toContain('aria-label="Bottom navigation"'); + expect(html).toContain('wmd-bottom-nav-content'); + expect(html).toContain('wmd-active'); + expect(html).toContain('Feed'); + expect(html).toContain('Themen'); + }); +}); + +describe('chips', () => { + it('parses static and removable chips', () => { + const ast = parse('|React|{.chip} |Design Γ—|{.chip}'); + const para = ast.children[0] as any; + expect(para.type).toBe('paragraph'); + const badges = (para.children || []).filter((c: any) => c.type === 'badge'); + expect(badges).toHaveLength(2); + expect(badges[0].content).toBe('React'); + expect(badges[0].props.classes).toContain('chip'); + expect(badges[1].content).toBe('Design Γ—'); + }); + + it('renders removable chip with dismiss control', () => { + const html = renderToHTML(parse('|Hitze Γ—|{.chip}'), { style: 'clean' }); + expect(html).toContain('wmd-chip'); + expect(html).toContain('wmd-chip-removable'); + expect(html).toContain('wmd-chip-dismiss'); + expect(html).toContain('Hitze'); + expect(html).toContain('aria-label="Remove Hitze"'); + }); +}); + +describe('toggle-group', () => { + it('applies toggle-group class on row', () => { + const ast = parse(`::: row {.toggle-group} +[List]* [Grid] [Map] +::: +`); + const row = ast.children[0] as any; + expect(row.type).toBe('row'); + expect(row.props.classes).toContain('toggle-group'); + }); + + it('emits segmented-control structural CSS', () => { + const html = renderToHTML( + parse(`::: row {.toggle-group} +[List]* [Grid] +::: +`), + { style: 'clean' }, + ); + expect(html).toContain('wmd-toggle-group'); + expect(html).toContain('.wmd-row.wmd-toggle-group'); + }); +}); From 54b0f42c3de926e342ddfa3adc8b6c7ac81a8c00 Mon Sep 17 00:00:00 2001 From: Christian Date: Sun, 9 Aug 2026 17:58:39 +0200 Subject: [PATCH 3/3] Remove color emoji from icon-map glyphs ICON_MAP now returns plain/monochrome Unicode symbols (or an empty glyph, label-text-only) instead of colored pictograph emoji for every :name: shortcode. Kept the handful of already-monochrome symbols (check, close, arrows, gear, social marks) as-is. Fixes resolveIcon() falling back to the default bullet for icons intentionally left blank (switched || to ?? so empty string isn't treated as falsy). Updated renderer/tailwind/react renderer tests to assert the specific former emoji are gone rather than expecting them. --- src/renderer/icon-map.ts | 159 ++++++++++++++++---------------- tests/react-renderer.test.ts | 6 +- tests/renderer.test.ts | 34 ++++--- tests/tailwind-renderer.test.ts | 6 +- 4 files changed, 108 insertions(+), 97 deletions(-) diff --git a/src/renderer/icon-map.ts b/src/renderer/icon-map.ts index dfa2e07a..4c3b10c3 100644 --- a/src/renderer/icon-map.ts +++ b/src/renderer/icon-map.ts @@ -1,5 +1,8 @@ /** - * Shared emoji/unicode map for `:name:` icon shortcodes. + * Shared monochrome icon-glyph map for `:name:` icon shortcodes. + * + * No color emoji: icons render either as plain (non-color-presentation) + * Unicode symbols, or as an empty glyph so only the label text shows. * Aliases keep docs/skills names working (gear/trash/x) and add * domain-friendly names (tree, house, person, map, …). */ @@ -14,32 +17,32 @@ export const ICON_MAP: Record = { youtube: 'β–Ά', // Common UI icons - home: '🏠', - user: 'πŸ‘€', - settings: 'βš™οΈ', - search: 'πŸ”', - star: '⭐', - heart: '❀️', - mail: 'βœ‰οΈ', - phone: 'πŸ“ž', - calendar: 'πŸ“…', - clock: 'πŸ•', - location: 'πŸ“', - link: 'πŸ”—', - download: '⬇️', - upload: '⬆️', - edit: '✏️', - delete: 'πŸ—‘οΈ', - plus: 'βž•', - minus: 'βž–', + home: '', + user: '', + settings: 'βš™', + search: '', + star: 'β˜†', + heart: 'β™₯', + mail: 'βœ‰', + phone: '', + calendar: '', + clock: '', + location: '', + link: '', + download: '⬇', + upload: '⬆', + edit: '✏', + delete: '', + plus: '+', + minus: 'βˆ’', check: 'βœ“', close: 'βœ•', menu: '☰', more: 'β‹―', - info: 'ℹ️', - warning: '⚠️', - error: '❌', - success: 'βœ…', + info: 'β„Ή', + warning: '⚠', + error: 'βœ•', + success: 'βœ“', // Arrows 'arrow-up': '↑', @@ -48,82 +51,82 @@ export const ICON_MAP: Record = { 'arrow-right': 'β†’', // Business/Finance - chart: 'πŸ“Š', + chart: '', dollar: '$', euro: '€', pound: 'Β£', // Tech code: '', - database: 'πŸ—„οΈ', - cloud: '☁️', - wifi: 'πŸ“Ά', + database: '', + cloud: '☁', + wifi: '', // Communication - chat: 'πŸ’¬', - video: 'πŸŽ₯', - microphone: '🎀', - bell: 'πŸ””', + chat: '', + video: '', + microphone: '', + bell: '', // Files - file: 'πŸ“„', - folder: 'πŸ“', - image: 'πŸ–ΌοΈ', - document: 'πŸ“ƒ', - pdf: 'πŸ“‘', + file: '', + folder: '', + image: '', + document: '', + pdf: '', // Brand placeholders logo: 'β—ˆ', brand: 'β—†', // Activities - rocket: 'πŸš€', - bulb: 'πŸ’‘', - shield: 'πŸ›‘οΈ', - lock: 'πŸ”’', - unlock: 'πŸ”“', - key: 'πŸ”‘', - gift: '🎁', - trophy: 'πŸ†', - flag: '🚩', - bookmark: 'πŸ”–', - tag: '🏷️', - cart: 'πŸ›’', - 'credit-card': 'πŸ’³', + rocket: '', + bulb: '', + shield: '', + lock: '', + unlock: '', + key: '', + gift: '', + trophy: '', + flag: 'βš‘', + bookmark: '', + tag: '', + cart: '', + 'credit-card': '', // Domain / climate-adaptation friendly - tree: '🌳', - plant: '🌱', - leaf: 'πŸƒ', - sun: 'β˜€οΈ', - thermometer: '🌑️', - drop: 'πŸ’§', - rain: '🌧️', - wind: 'πŸ’¨', - map: 'πŸ—ΊοΈ', - building: '🏒', - shop: 'πŸͺ', - tools: 'πŸ› οΈ', - clipboard: 'πŸ“‹', - megaphone: 'πŸ“’', - handshake: '🀝', - pin: 'πŸ“Œ', + tree: '', + plant: '', + leaf: '', + sun: 'β˜€', + thermometer: '', + drop: '', + rain: '', + wind: '', + map: '', + building: '', + shop: '', + tools: '', + clipboard: '', + megaphone: '', + handshake: '', + pin: '', // Aliases (skills docs + natural synonyms) - house: '🏠', - person: 'πŸ‘€', - people: 'πŸ‘₯', - users: 'πŸ‘₯', - gear: 'βš™οΈ', - cog: 'βš™οΈ', - trash: 'πŸ—‘οΈ', + house: '', + person: '', + people: '', + users: '', + gear: 'βš™', + cog: 'βš™', + trash: '', x: 'βœ•', cross: 'βœ•', - 'pin-drop': 'πŸ“', - marker: 'πŸ“', - thermo: '🌑️', - heat: '🌑️', - water: 'πŸ’§', + 'pin-drop': '', + marker: '', + thermo: '', + heat: '', + water: '', // Default default: '●', @@ -131,5 +134,5 @@ export const ICON_MAP: Record = { export function resolveIcon(name: string | undefined | null): string { const key = (name || 'default').toLowerCase(); - return ICON_MAP[key] || ICON_MAP.default; + return ICON_MAP[key] ?? ICON_MAP.default; } diff --git a/tests/react-renderer.test.ts b/tests/react-renderer.test.ts index 42126fa8..90cbfc46 100644 --- a/tests/react-renderer.test.ts +++ b/tests/react-renderer.test.ts @@ -228,14 +228,14 @@ Email }); describe('Icons', () => { - it('should render icons', () => { + it('should render icon markers without color emoji', () => { const ast = parse('[[ :home: Home | :user: Profile ]]'); const jsx = renderToReact(ast); expect(jsx).toContain('data-icon="home"'); expect(jsx).toContain('data-icon="user"'); - expect(jsx).toContain('🏠'); - expect(jsx).toContain('πŸ‘€'); + expect(jsx).not.toContain('🏠'); + expect(jsx).not.toContain('πŸ‘€'); }); }); diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index d509a42f..b4e9c1f9 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -56,27 +56,35 @@ describe('HTML Renderer', () => { expect(html).toContain('Selected option'); }); - it('should render an icon', () => { - const ast = parse(':house:'); + it('should render an icon without color emoji', () => { + const ast = parse(':check:'); const html = renderToHTML(ast, { style: 'sketch' }); expect(html).toContain('wmd-icon'); - expect(html).toContain('data-icon="house"'); - expect(html).toContain('🏠'); + expect(html).toContain('data-icon="check"'); + expect(html).toContain('βœ“'); }); - it('should resolve icon aliases and domain names', () => { + it('should resolve icon aliases to plain, non-color glyphs', () => { const cases: Array<[string, string]> = [ - [':gear:', 'βš™οΈ'], - [':trash:', 'πŸ—‘οΈ'], - [':person:', 'πŸ‘€'], - [':tree:', '🌳'], - [':map:', 'πŸ—ΊοΈ'], - [':heat:', '🌑️'], + [':gear:', 'βš™'], + [':x:', 'βœ•'], + [':heart:', 'β™₯'], + [':star:', 'β˜†'], ]; - for (const [src, emoji] of cases) { + for (const [src, glyph] of cases) { const html = renderToHTML(parse(src), { style: 'sketch' }); - expect(html).toContain(emoji); + expect(html).toContain(glyph); + } + }); + + it('should render decorative-only icons (house/person/tree/map) with no glyph', () => { + const formerEmoji = ['🏠', 'πŸ‘€', 'πŸ—‘οΈ', '🌳', 'πŸ—ΊοΈ', '🌑️']; + for (const [i, name] of ['house', 'trash', 'person', 'tree', 'map', 'heat'].entries()) { + const html = renderToHTML(parse(`:${name}:`), { style: 'sketch' }); + expect(html).toContain(`data-icon="${name}"`); + expect(html).not.toContain(formerEmoji[i]); + expect(html).toContain(`aria-label="${name}">`); } }); }); diff --git a/tests/tailwind-renderer.test.ts b/tests/tailwind-renderer.test.ts index e83ffef2..d9490ecd 100644 --- a/tests/tailwind-renderer.test.ts +++ b/tests/tailwind-renderer.test.ts @@ -281,14 +281,14 @@ Email }); describe('Icons', () => { - it('should render icons', () => { + it('should render icon markers without color emoji', () => { const ast = parse('[[ :home: Home | :user: Profile ]]'); const html = renderToTailwind(ast); expect(html).toContain('data-icon="home"'); expect(html).toContain('data-icon="user"'); - expect(html).toContain('🏠'); - expect(html).toContain('πŸ‘€'); + expect(html).not.toContain('🏠'); + expect(html).not.toContain('πŸ‘€'); }); });