From 1bf91f9ee589102894bab16f3c0e860b1c2b4aa5 Mon Sep 17 00:00:00 2001 From: devcluna Date: Thu, 9 Jul 2026 19:13:12 -0500 Subject: [PATCH] feat(style): visual Style editor with live preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dedicated Style mode (Fields | Style toggle) for theming the public form, gated behind a "Custom styles" setting. - feat: theme model + `resolveThemeVars` (single source) → `--sfb-*` variables shared by the admin live preview and the public embed/page via one stylesheet (`form-css.ts`), so there is no duplicated styling logic. - feat: Style mode sidebar — accent / font / card + page background, field corners / style / label weight, button style / width, form width / spacing / card shadow / card border / card corners / placement (top | center), with a Desktop | Mobile live preview. - feat: editable, persisted colour palettes (add via picker, remove per swatch) per colour group; "Reset to default theme" restores them. - feat: enabling "Custom styles" in Settings reveals the Style tab immediately (no save required) with a link into the editor. - feat: style changes follow the existing draft / publish flow (theme lives in settings, snapshotted on publish); toasts announce the state on save/publish. - fix: public page applies theme vars via a -
+

${title}

${description ? `

${description}

` : ''}
@@ -92,6 +102,15 @@ function embedScript(): string { // the browser's native required bubble formEl.noValidate = true; + // apply the form's resolved theme (variables + the field-style variant attribute) + if (form.settings && form.settings.themeVars) { + var tv = form.settings.themeVars; + for (var k in tv) { if (Object.prototype.hasOwnProperty.call(tv, k)) formEl.style.setProperty(k, tv[k]); } + } + if (form.settings && form.settings.theme && form.settings.theme.fieldStyle) { + formEl.setAttribute('data-sfb-fields', form.settings.theme.fieldStyle); + } + var captchaCfg = (form.settings && form.settings.captcha) || null; var captchaOn = !!(captchaCfg && captchaCfg.provider && captchaCfg.provider !== 'none' && captchaCfg.siteKey); @@ -428,32 +447,7 @@ function embedScript(): string { if (document.getElementById('sfb-css')) return; var s = document.createElement('style'); s.id = 'sfb-css'; - s.textContent = [ - '.sfb-form { font-family: inherit; text-align: left; color: #32324d; line-height: 1.5; }', - '.sfb-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }', - '.sfb-field { display: flex; flex-direction: column; gap: 4px; }', - '.sfb-label { font-size: 13px; font-weight: 600; color: #32324d; }', - '.sfb-required { color: #ee5e52; }', - '.sfb-input { width: 100%; height: 40px; padding: 0 12px; border: 1px solid #dcdce4; border-radius: var(--sfb-radius, 4px); font-size: 14px; font-family: inherit; color: #32324d; background: #fff; box-sizing: border-box; outline: none; transition: border-color .15s; }', - '.sfb-input:focus { border-color: var(--sfb-accent, #4945ff); box-shadow: 0 0 0 2px rgba(73,69,255,.15); }', - 'textarea.sfb-input { height: auto; padding: 8px 12px; resize: vertical; }', - 'select.sfb-input { appearance: none; cursor: pointer; }', - '.sfb-radio-group { display: flex; flex-direction: column; gap: 8px; }', - '.sfb-radio-label, .sfb-checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 14px; color: #32324d; cursor: pointer; }', - '.sfb-radio-label input, .sfb-checkbox-label input { accent-color: var(--sfb-accent, #4945ff); width: 16px; height: 16px; cursor: pointer; }', - '.sfb-help { font-size: 12px; color: #666687; margin: 0; }', - '.sfb-btn { background: var(--sfb-accent, #4945ff); color: #fff; border: none; border-radius: var(--sfb-radius, 4px); padding: 10px 24px; font-size: 14px; font-weight: 600; font-family: inherit; cursor: pointer; transition: opacity .15s; }', - '.sfb-btn:hover { opacity: .88; }', - '.sfb-btn:disabled { opacity: .6; cursor: not-allowed; }', - '.sfb-heading { font-size: 18px; font-weight: 700; color: #32324d; margin: 4px 0; }', - '.sfb-paragraph { font-size: 14px; color: #666687; margin: 4px 0; line-height: 1.6; }', - '.sfb-divider { border: none; border-top: 1px solid #dcdce4; margin: 4px 0; }', - '.sfb-success { font-size: 15px; color: #27ae60; font-weight: 600; }', - '.sfb-error { font-size: 13px; color: #ee5e52; margin-bottom: 12px; }', - '.sfb-field-error { font-size: 12px; color: #ee5e52; margin: 4px 0 0; }', - '.sfb-field--error .sfb-input, .sfb-field--error .sfb-radio-group { border-color: #ee5e52 !important; }', - '.sfb-field--error > .sfb-label { color: #ee5e52; }', - ].join('\\n'); + s.textContent = ${JSON.stringify(FORM_CSS)}; document.head.appendChild(s); } diff --git a/tests/theme.test.ts b/tests/theme.test.ts new file mode 100644 index 0000000..c5a3abc --- /dev/null +++ b/tests/theme.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { resolveThemeVars, DEFAULT_THEME, CORNERS, CARD_RADIUS, __selfCheck } from '../admin/src/theme'; + +describe('resolveThemeVars', () => { + it('built-in self-check passes', () => { + expect(__selfCheck()).toBe(true); + }); + + it('uses light text on a dark card, dark text on a light card', () => { + expect(resolveThemeVars({ ...DEFAULT_THEME, cardBg: '#0f0f1a' })['--sfb-text']).toBe('#f4f4f8'); + expect(resolveThemeVars({ ...DEFAULT_THEME, cardBg: '#ffffff' })['--sfb-text']).toBe('#1f1f33'); + }); + + it('maps field corners to a radius', () => { + expect(resolveThemeVars({ ...DEFAULT_THEME, corners: 'pill' })['--sfb-radius']).toBe(CORNERS.pill); + expect(resolveThemeVars({ ...DEFAULT_THEME, corners: 'sharp' })['--sfb-radius']).toBe('0px'); + }); + + it('maps card corners to a card radius', () => { + expect(resolveThemeVars({ ...DEFAULT_THEME, cardCorners: 'round' })['--sfb-card-radius']).toBe(CARD_RADIUS.round); + expect(resolveThemeVars({ ...DEFAULT_THEME, cardCorners: 'sharp' })['--sfb-card-radius']).toBe('0px'); + }); + + it('hides the card border when cardBorder is none', () => { + expect(resolveThemeVars({ ...DEFAULT_THEME, cardBorder: 'none' })['--sfb-card-border']).toBe('none'); + expect(resolveThemeVars({ ...DEFAULT_THEME, cardBorder: 'bold' })['--sfb-card-border']).toContain('2px'); + }); + + it('maps placement to the page alignment', () => { + expect(resolveThemeVars({ ...DEFAULT_THEME, placement: 'center' })['--sfb-page-align']).toBe('center'); + expect(resolveThemeVars({ ...DEFAULT_THEME, placement: 'top' })['--sfb-page-align']).toBe('flex-start'); + }); + + it('underline fields use a transparent input background', () => { + expect(resolveThemeVars({ ...DEFAULT_THEME, fieldStyle: 'underline' })['--sfb-input-bg']).toBe('transparent'); + }); + + it('outline button is transparent with an accent border', () => { + const v = resolveThemeVars({ ...DEFAULT_THEME, buttonStyle: 'outline', accent: '#ff0000' }); + expect(v['--sfb-btn-bg']).toBe('transparent'); + expect(v['--sfb-btn-border']).toContain('#ff0000'); + }); + + it('editable palettes are editor-only — they never leak into the resolved CSS variables', () => { + const v = resolveThemeVars({ ...DEFAULT_THEME, palettes: { accent: ['#111111'], cardBg: ['#222222'], pageBg: ['#333333'] } }); + expect(Object.keys(v).every((k) => k.startsWith('--sfb-'))).toBe(true); + expect(JSON.stringify(v)).not.toContain('#111111'); // a palette entry not selected must not appear + }); +});