Merge fix/editor-parity into release/editor-parity (on top of captcha + Font Awesome)

This commit is contained in:
shadowdao committed 2026-11-01 09:26:21 -08:00
commit ab7a10073b
30 files changed
+1067 -31

No files matched your search

+2
View File
@@ -11,6 +11,8 @@
"test:headed": "playwright test tests/site-builder.spec.ts --reporter=list --headed",
"test:e2e:sitesmith": "playwright test tests/sitesmith.spec.ts --reporter=list",
"test:unit": "vitest run",
"test:browser:bg": "node scripts/e2e-background.mjs",
"test:browser:canvas": "node scripts/e2e-canvas-parity.mjs",
"test:unit:watch": "vitest"
},
"dependencies": {
+24
View File
@@ -0,0 +1,24 @@
// Bundled by scripts/e2e-background.mjs and mounted in real Chromium.
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Editor, Frame } from '@craftjs/core';
import { HeroSimple } from '../src/components/sections/HeroSimple';
import { BackgroundSection } from '../src/components/layout/BackgroundSection';
import { CTASection } from '../src/components/sections/CTASection';
import { CallToAction } from '../src/components/sections/CallToAction';
import { Container } from '../src/components/layout/Container';
const resolver = { HeroSimple, BackgroundSection, CTASection, CallToAction, Container };
(window as any).mountNode = (id: string, type: string, props: Record<string, unknown>) => {
const data = JSON.stringify({
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: {}, displayName: 'Container', custom: {}, hidden: false, nodes: ['n'], linkedNodes: {} },
n: { type: { resolvedName: type }, isCanvas: false, props, displayName: type, custom: {}, parent: 'ROOT', hidden: false, nodes: [], linkedNodes: {} },
});
const el = document.createElement('div');
el.id = id;
document.body.appendChild(el);
createRoot(el).render(
<Editor resolver={resolver} enabled={false}><Frame data={data} /></Editor>,
);
};
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env node
/**
* Real-browser regression test: a `background` shorthand followed by an
* undefined/'' background longhand wipes the gradient in Chromium (React
* assigns '' for undefined style values). jsdom cannot reproduce this, so
* mount the real components via react-dom in headless Chromium and assert the
* COMPUTED style. Run: npm run test:browser:bg (needs a Playwright chromium)
*/
import { build } from 'esbuild';
import { chromium, firefox } from '@playwright/test';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const out = await build({
entryPoints: [path.join(root, 'scripts/e2e-background-entry.tsx')],
bundle: true, write: false, format: 'iife', platform: 'browser',
define: { 'process.env.NODE_ENV': '"production"' }, jsx: 'automatic', logLevel: 'error',
});
const js = out.outputFiles[0].text;
const cases = [
['hero gradient', 'HeroSimple', { bgType: 'gradient', bgGradientFrom: '#0f172a', bgGradientTo: '#334155' }, 'backgroundImage', /linear-gradient/],
['hero color', 'HeroSimple', { bgType: 'color', bgColor: '#123456' }, 'backgroundColor', /rgb\(18, 52, 86\)/],
['hero image', 'HeroSimple', { bgType: 'image', bgImage: 'https://example.invalid/a.jpg' }, 'backgroundImage', /url\(.*a\.jpg/],
['hero video (fallback color)', 'HeroSimple', { bgType: 'video', bgVideo: 'v.mp4', bgColor: '#123456' }, 'backgroundColor', /rgb\(18, 52, 86\)/],
['BackgroundSection image', 'BackgroundSection', { bgImage: 'https://example.invalid/b.jpg', bgColor: '#123456' }, 'backgroundImage', /url\(.*b\.jpg/],
['BackgroundSection color', 'BackgroundSection', { bgColor: '#123456' }, 'backgroundColor', /rgb\(18, 52, 86\)/],
['CTASection gradient', 'CTASection', { gradient: 'linear-gradient(135deg, #111111, #222222)' }, 'backgroundImage', /linear-gradient/],
['CallToAction gradient', 'CallToAction', { bgType: 'gradient', bgValue: 'linear-gradient(135deg, #111111, #222222)' }, 'backgroundImage', /linear-gradient/],
];
// Override binaries with CHROMIUM_PATH / FIREFOX_PATH when the Playwright-pinned builds are not installed.
async function run(name, launcher, exe) {
const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) });
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(String(e)));
await page.setContent('<!doctype html><html><body></body></html>');
await page.addScriptTag({ content: js });
let failed = 0;
for (const [i, [label, type, props, prop, re]] of cases.entries()) {
const id = `c${i}`;
await page.evaluate(([id, type, props]) => window.mountNode(id, type, props), [id, type, props]);
await page.waitForSelector(`#${id} section`, { timeout: 5000 }).catch(() => {});
const value = await page.evaluate(([id, prop]) => {
const s = document.querySelector(`#${id} section`);
return s ? getComputedStyle(s)[prop] : '<<no section>>';
}, [id, prop]);
const ok = re.test(value);
if (!ok) failed++;
console.log(`[${name}] ${ok ? 'PASS' : 'FAIL'} ${label}: computed ${prop} = ${value}`);
}
if (errors.length) console.log('page errors:', errors.join('\n'));
await browser.close();
return failed;
}
let total = 0;
total += await run('chromium', chromium, process.env.CHROMIUM_PATH);
total += await run('firefox', firefox, process.env.FIREFOX_PATH);
process.exit(total ? 1 : 0);
+15
View File
@@ -0,0 +1,15 @@
// Bundled (esbuild) by scripts/e2e-canvas-parity.mjs and run in Node: renders what a publish
// of the CURRENT stored state would emit, composed like TopBar's Preview
// (header + body + footer under the real document head). Used for specs whose
// checked-in published HTML is stale relative to the stored state.
import { exportBodyHtml, exportComposedPreviewHtml } from '../src/utils/html-export';
export function renderPublished(project: any, pageIdx: number): string {
const page = project.pages_craft_state[pageIdx];
const hdr = project.header_craft_state ? exportBodyHtml(project.header_craft_state, 'header').html : '';
const ftr = project.footer_craft_state ? exportBodyHtml(project.footer_craft_state, 'footer').html : '';
const body = exportBodyHtml(page.craftState).html;
return exportComposedPreviewHtml(page.craftState, hdr + body + ftr, {
title: page.name, includeFonts: true, headCode: project.design?.headCode, design: project.design,
});
}
+317
View File
@@ -0,0 +1,317 @@
#!/usr/bin/env node
/**
* Real-browser regression test: the editor CANVAS must lay page content out
* exactly like the PUBLISHED page ("the site looks different in the builder
* than published"). jsdom has no layout, so this mounts the REAL editor (a
* vite build served by a tiny mock API) in headless Firefox AND Chromium,
* loads a stored project, and compares -- element by element -- against the
* same page's published HTML rendered in a plain page whose viewport equals
* the canvas content width.
*
* Stored state is loaded VERBATIM (blank style values included). Allowed editor-only
* differences (empty-container drop zone, empty-image placeholder) are neutralised in the
* canvas before measuring; see the ALLOWED block in run().
*
* Compared per matched element: bounding box (x/y/w/h, within 1px) and the
* computed font-size / line-height / margin / padding / colour / font-family
* / font-weight / display. Editor-only chrome (the dashed header/footer zone
* separators) is compensated for, not ignored.
*
* npm run test:browser:canvas
*
* Env:
* CHROMIUM_PATH / FIREFOX_PATH override the Playwright-pinned binaries
* WHP_CANVAS_SPECS extra comma-separated spec.json paths
* (e.g. real customer sites kept out of git)
* CANVAS_SHOTS=<dir> write canvas + published PNGs there
* CANVAS_CHROME_DUMP=<file> write a computed-style snapshot of the
* editor chrome (everything OUTSIDE the
* canvas frame) to diff before/after a change
* CANVAS_ONLY=chromium|firefox run a single browser
*
* A spec is {name, project: <project.json>, published: {<slug>: <full html>}}
* with paths relative to the spec file. Only pages that have a published
* file are compared.
*/
import { chromium, firefox } from '@playwright/test';
import { build as esbuild } from 'esbuild';
import { execFileSync } from 'node:child_process';
import http from 'node:http';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const VIEW_H = 1000;
const EDITOR_W = 1500;
const TOL = 1; // px
// ---------------------------------------------------------------- specs
const specPaths = [
path.join(root, 'tests/fixtures/canvas-parity/ols.spec.json'),
...(process.env.WHP_CANVAS_SPECS || '').split(',').map((s) => s.trim()).filter(Boolean),
];
// `published` is either {slug: html file} or "generate" = render what a publish of the stored
// state emits (scripts/e2e-canvas-export-entry.ts) -- for sites whose saved HTML is stale.
let renderPublished = null;
async function loadExporter() {
if (renderPublished) return renderPublished;
const out = await esbuild({
entryPoints: [path.join(root, 'scripts/e2e-canvas-export-entry.ts')],
bundle: true, write: false, format: 'esm', platform: 'node', jsx: 'automatic', logLevel: 'error',
define: { 'process.env.NODE_ENV': '"production"', __EDITOR_BUILD__: '"test"' },
banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" },
});
const f = path.join(os.tmpdir(), `wsb-export-${process.pid}.mjs`);
fs.writeFileSync(f, out.outputFiles[0].text);
renderPublished = (await import(f)).renderPublished;
fs.rmSync(f, { force: true });
return renderPublished;
}
const specs = [];
for (const p of specPaths) {
const dir = path.dirname(p);
const s = JSON.parse(fs.readFileSync(p, 'utf8'));
const project = JSON.parse(fs.readFileSync(path.resolve(dir, s.project), 'utf8'));
let published;
if (s.published === 'generate') {
const render = await loadExporter();
const only = s.pages || project.pages_craft_state.map((pg) => pg.slug);
published = {};
project.pages_craft_state.forEach((pg, i) => { if (only.includes(pg.slug)) published[pg.slug] = render(project, i); });
} else {
published = Object.fromEntries(Object.entries(s.published).map(([k, v]) => [k, fs.readFileSync(path.resolve(dir, v), 'utf8')]));
}
specs.push({ name: s.name, project, published });
}
// NOTE: stored state is loaded VERBATIM, blank style values included (e.g.
// {padding:'20px', paddingTop:''} left by the style panel). The harness must test what
// the editor really renders; an earlier version stripped blanks and thereby hid a wipe bug.
// ---------------------------------------------------------------- build + serve the real editor
const buildDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wsb-canvas-'));
execFileSync(path.join(root, 'node_modules/.bin/vite'), ['build', '--outDir', buildDir, '--emptyOutDir', '--logLevel', 'error'], { cwd: root, stdio: 'inherit' });
let currentProject = null;
const mime = { '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.png': 'image/png', '.svg': 'image/svg+xml' };
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
if (u.pathname === '/api/site-builder.php') {
res.setHeader('content-type', 'application/json');
if (u.searchParams.get('action') === 'load') {
return res.end(JSON.stringify({ success: true, rev: 'r1', limits: {}, project: currentProject }));
}
return res.end(JSON.stringify({ success: true }));
}
const p = u.pathname === '/' ? '/index.html' : u.pathname;
if (p === '/index.html') {
const cfg = { user: 't', apiUrl: '/api/site-builder.php', csrfToken: 'x', siteId: 1, siteDomain: 'example.test', siteName: 'Parity', backUrl: '/', isRoot: false };
const h = fs.readFileSync(path.join(buildDir, 'index.html'), 'utf8')
.replace('</head>', () => `<script>window.WHP_CONFIG=${JSON.stringify(cfg)};</script></head>`);
res.setHeader('content-type', 'text/html');
return res.end(h);
}
const f = path.join(buildDir, p);
if (f.startsWith(buildDir) && fs.existsSync(f) && fs.statSync(f).isFile()) {
res.setHeader('content-type', mime[path.extname(f)] || 'application/octet-stream');
return res.end(fs.readFileSync(f));
}
res.statusCode = 404;
res.end('nf');
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
// ---------------------------------------------------------------- in-page measurer (runs in BOTH documents)
function measure({ rootSel, isCanvas, skipNav }) {
const rootEl = document.querySelector(rootSel);
const rr = rootEl.getBoundingClientRect();
const bl = isCanvas ? parseFloat(getComputedStyle(rootEl).borderLeftWidth) || 0 : 0;
const bt = isCanvas ? parseFloat(getComputedStyle(rootEl).borderTopWidth) || 0 : 0;
const ox = rr.left + bl;
const oy = rr.top + bt;
// Editor-only dashed zone separators shift everything after them by their width.
const zones = isCanvas ? [...rootEl.querySelectorAll('[data-zone-preview]')] : [];
const adjustY = (el) => {
let adj = 0;
for (const z of zones) {
const cs = getComputedStyle(z);
const zone = z.getAttribute('data-zone-preview');
const bw = parseFloat(zone === 'header' ? cs.borderBottomWidth : cs.borderTopWidth) || 0;
if (zone === 'header' && !z.contains(el) && (z.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)) adj += bw;
if (zone === 'footer' && (z === el || z.contains(el))) adj += bw;
}
return adj;
};
const SKIP = new Set(['STYLE', 'SCRIPT', 'NOSCRIPT', 'LINK', 'META', 'BR', 'HEAD', 'TITLE', 'PATH', 'CIRCLE', 'RECT', 'LINE', 'POLYLINE', 'POLYGON', 'G', 'USE', 'DEFS']);
const norm = (s) => s.replace(/\s+/g, '');
// Visible text only (never <style>/<script> bodies), whitespace-insensitive:
// React renders no inter-element whitespace, the stored HTML does.
const textOf = (el) => {
let t = '';
const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
for (let n = w.nextNode(); n; n = w.nextNode()) {
const pt = n.parentElement && n.parentElement.tagName;
if (pt !== 'STYLE' && pt !== 'SCRIPT') t += n.nodeValue;
}
return norm(t);
};
const normStyle = (s) => norm((s || '').replace(/\s*([:;])\s*/g, '$1').replace(/;$/, ''));
// Order-insensitive identity of an element's inline style (React and the exporter
// serialise the same declarations in different orders); editor-only props dropped.
const EDITOR_PROPS = /^(cursor|outline|user-select|pointer-events|position)/;
const styleKey = (el) => Array.from(el.style).filter((p) => !EDITOR_PROPS.test(p)).sort()
.map((p) => `${p}:${el.style.getPropertyValue(p)}`).join(';').replace(/\s+/g, '').slice(0, 120);
const counts = new Map();
const out = {};
const walk = (el) => {
for (const c of el.children) {
if (SKIP.has(c.tagName.toUpperCase())) continue;
const tag = c.tagName.toLowerCase();
// Navbar link ORDER follows page order, which the harness rotates to open page N: skip links.
if (skipNav && tag === 'a' && c.closest('nav')) continue;
// Editor-only wrapper boxes (Craft frame div, RenderNode `position:relative` host, header/footer zone-preview host) have no
// counterpart in the published DOM: descend through them without measuring.
const bare = tag === 'div' && (c.attributes.length === 0
|| normStyle(c.getAttribute('style')) === 'max-width:1200px;margin:0auto'
|| (isCanvas && c.attributes.length === 1 && c.hasAttribute('style') && normStyle(c.getAttribute('style')) === 'position:relative'));
if (c.hasAttribute('data-zone-preview') || bare) { walk(c); continue; }
const text = textOf(c).slice(0, 40);
const ident = tag === 'img' ? `${c.getAttribute('alt') || ''}`
: text || styleKey(c);
const k0 = `${tag}|${ident}`;
const n = (counts.get(k0) || 0) + 1;
counts.set(k0, n);
const key = `${k0}#${n}`;
const r = c.getBoundingClientRect();
const cs = getComputedStyle(c);
out[key] = {
tag,
x: r.left - ox, y: r.top - oy - adjustY(c), w: r.width, h: r.height,
fontSize: cs.fontSize, lineHeight: cs.lineHeight, fontFamily: cs.fontFamily, fontWeight: cs.fontWeight,
color: cs.color, display: cs.display, textAlign: cs.textAlign,
margin: `${cs.marginTop} ${cs.marginRight} ${cs.marginBottom} ${cs.marginLeft}`,
padding: `${cs.paddingTop} ${cs.paddingRight} ${cs.paddingBottom} ${cs.paddingLeft}`,
};
walk(c);
}
};
walk(rootEl);
return out;
}
const PROPS = ['fontSize', 'lineHeight', 'fontFamily', 'fontWeight', 'color', 'display', 'textAlign', 'margin', 'padding'];
function chromeSnapshot() {
const frame = document.querySelector('.canvas-device-frame');
const rows = {};
let i = 0;
for (const el of document.body.querySelectorAll('*')) {
if (frame && (frame === el || frame.contains(el))) continue;
if (el.closest('[data-zone-preview]')) continue;
const cs = getComputedStyle(el);
const k = `${i++}:${el.tagName.toLowerCase()}.${String(el.className && el.className.baseVal !== undefined ? el.className.baseVal : el.className).slice(0, 40)}`;
rows[k] = [cs.margin, cs.padding, cs.fontSize, cs.lineHeight, cs.color, cs.display, cs.fontFamily].join('|');
}
return rows;
}
// ---------------------------------------------------------------- driver
async function run(name, launcher, exe) {
const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) });
let failed = 0;
const offline = (route) => route.abort();
const shotDir = process.env.CANVAS_SHOTS;
if (shotDir) fs.mkdirSync(shotDir, { recursive: true });
for (const spec of specs) {
const pages = spec.project.pages_craft_state || [];
for (const [idx, pg] of pages.entries()) {
const pubHtml = spec.published[pg.slug];
if (!pubHtml) continue;
const label = `${spec.name}/${pg.slug}`;
// The editor opens pages_craft_state[0]; rotate so page `idx` is first.
currentProject = { ...spec.project, pages_craft_state: [pg, ...pages.filter((_, j) => j !== idx)] };
const ctx = await browser.newContext({ viewport: { width: EDITOR_W, height: VIEW_H } });
await ctx.route(/^https?:\/\/(?!127\.0\.0\.1)/, offline); // no web fonts / icon CDN: identical fallbacks both sides
const ed = await ctx.newPage();
const errors = [];
ed.on('pageerror', (e) => errors.push(String(e)));
await ed.goto(base + '/');
await ed.waitForSelector('.canvas-device-frame [data-craft-container]', { timeout: 20000 });
await ed.waitForTimeout(1500); // header/footer zone previews + layout settle
const canvasW = await ed.evaluate(() => document.querySelector('.canvas-device-frame').clientWidth);
// ALLOWED editor-only differences (deliberate UX with no published counterpart), neutralised
// before measuring so they don't mask real diffs:
// - the 40px drop zone on EMPTY containers (Container sets data-wsb-dropzone only then)
// - the placeholder <img> shown for an image node with no src (toHtml emits nothing)
await ed.evaluate(() => {
const f = document.querySelector('.canvas-device-frame');
f.querySelectorAll('[data-wsb-dropzone]').forEach((el) => el.style.setProperty('min-height', '0', 'important'));
f.querySelectorAll('img[src^="data:image/svg"]').forEach((el) => el.style.setProperty('display', 'none', 'important'));
});
const canvas = await ed.evaluate(measure, { rootSel: '.canvas-device-frame', isCanvas: true, skipNav: idx > 0 });
if (shotDir) await ed.screenshot({ path: path.join(shotDir, `${label.replace('/', '_')}.${name}.canvas.png`) });
if (process.env.CANVAS_CHROME_DUMP && idx === 0 && spec === specs[0]) {
fs.writeFileSync(`${process.env.CANVAS_CHROME_DUMP}.${name}.json`, JSON.stringify(await ed.evaluate(chromeSnapshot), null, 1));
}
const chromeBody = await ed.evaluate(() => getComputedStyle(document.body).fontSize);
// Published: viewport so that the layout width (excluding any scrollbar) equals the canvas content width.
let w = canvasW;
const pub = await ctx.newPage();
let published;
for (let attempt = 0; attempt < 3; attempt++) {
await pub.setViewportSize({ width: w, height: VIEW_H });
await pub.setContent(pubHtml, { waitUntil: 'domcontentloaded' });
await pub.waitForTimeout(300);
const cw = await pub.evaluate(() => document.documentElement.clientWidth);
if (cw === canvasW) break;
w += canvasW - cw;
}
published = await pub.evaluate(measure, { rootSel: 'body', isCanvas: false, skipNav: idx > 0 });
if (shotDir) await pub.screenshot({ path: path.join(shotDir, `${label.replace('/', '_')}.${name}.published.png`) });
const pk = Object.keys(published);
const matched = pk.filter((k) => k in canvas);
const diffs = [];
for (const k of matched) {
const a = canvas[k], b = published[k];
for (const f of ['x', 'y', 'w', 'h']) if (Math.abs(a[f] - b[f]) > TOL) diffs.push([k, f, a[f].toFixed(1), b[f].toFixed(1)]);
for (const f of PROPS) if (a[f] !== b[f]) diffs.push([k, f, a[f], b[f]]);
}
const ratio = matched.length / pk.length;
const bad = new Set(diffs.map((d) => d[0]));
const ok = diffs.length === 0 && ratio >= 0.6 && errors.length === 0 && chromeBody === '13px';
if (!ok) failed++;
console.log(`[${name}] ${ok ? 'PASS' : 'FAIL'} ${label}: ${matched.length}/${pk.length} published elements matched, ${bad.size} elements differ (${diffs.length} property diffs), canvas width ${canvasW}, editor body font ${chromeBody}`);
if (diffs.length) {
// Summarise: property -> count + first examples
const byProp = {};
for (const [k, f, a, b] of diffs) (byProp[f] ||= []).push(`${k.split('#')[0].slice(0, 44)} canvas=${a} published=${b}`);
for (const [f, rows] of Object.entries(byProp)) console.log(` ${f}: ${rows.length}x e.g. ${rows.slice(0, 3).join(' ;; ')}`);
}
if (errors.length) console.log(' page errors:', errors.join('\n'));
if (process.env.CANVAS_JSON) {
fs.writeFileSync(`${process.env.CANVAS_JSON}.${name}.${label.replace('/', '_')}.json`, JSON.stringify({ canvas, published, diffs }, null, 1));
}
await ctx.close();
}
}
await browser.close();
return failed;
}
let total = 0;
try {
if (process.env.CANVAS_ONLY !== 'firefox') total += await run('chromium', chromium, process.env.CHROMIUM_PATH);
if (process.env.CANVAS_ONLY !== 'chromium') total += await run('firefox', firefox, process.env.FIREFOX_PATH);
} finally {
server.close();
fs.rmSync(buildDir, { recursive: true, force: true });
}
console.log(total ? `FAILED: ${total} page comparison(s) differ` : 'OK: canvas content matches published');
process.exit(total ? 1 : 0);
@@ -154,3 +154,10 @@ describe('Heading.toHtml inline rich text (#46)', () => {
test('Heading default colour is the text token', () => {
expect((Heading as any).craft.props.style.color).toBe('var(--wsb-text)');
});
describe('Heading.toHtml newlines (canvas parity)', () => {
test('plain text newlines become <br>, like the editor canvas', () => {
const { html } = (Heading as any).toHtml({ text: 'a\nb', level: 'h2' }, '');
expect(html).toContain('a<br>b');
});
});
+2 -3
View File
@@ -1,8 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml } from '../../utils/escape';
import { resolveInlineHtml } from '../../utils/inline-html';
import { resolveInlineHtml, textToInlineHtml } from '../../utils/inline-html';
import { tokenRef } from '../../utils/tokens';
import { useInlineEditable } from './useInlineEditable';
@@ -102,7 +101,7 @@ Heading.craft = {
(Heading as any).toHtml = (props: HeadingProps, _childrenHtml: string) => {
const tag = sanitizeHeadingLevel(props.level);
const safeText = resolveInlineHtml(props.text, props.html) ?? escapeHtml(props.text || '');
const safeText = resolveInlineHtml(props.text, props.html) ?? textToInlineHtml(props.text || '');
const styleStr = cssPropsToString(props.style);
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${safeText}</${tag}>` };
};
@@ -117,3 +117,10 @@ describe('TextBlock.toHtml design tokens (#44)', () => {
for (const hex of ["#3f3f46"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
describe('TextBlock.toHtml newlines (canvas parity)', () => {
test('plain text newlines become <br>, like the editor canvas (textToInlineHtml)', () => {
const { html } = toHtml({ text: 'a\n\nb <i>' }, '');
expect(html).toBe('<p>a<br><br>b &lt;i&gt;</p>');
});
});
+2 -3
View File
@@ -2,8 +2,7 @@ import React, { CSSProperties } from 'react';
import { tokenRef } from '../../utils/tokens';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml } from '../../utils/escape';
import { resolveInlineHtml } from '../../utils/inline-html';
import { resolveInlineHtml, textToInlineHtml } from '../../utils/inline-html';
import { useInlineEditable } from './useInlineEditable';
interface TextBlockProps {
@@ -94,6 +93,6 @@ TextBlock.craft = {
(TextBlock as any).toHtml = (props: TextBlockProps, _childrenHtml: string) => {
const styleStr = cssPropsToString(props.style);
const escapedText = resolveInlineHtml(props.text, props.html) ?? escapeHtml(props.text || '');
const escapedText = resolveInlineHtml(props.text, props.html) ?? textToInlineHtml(props.text || '');
return { html: `<p${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</p>` };
};
@@ -0,0 +1,41 @@
import { describe, test, expect, afterEach } from 'vitest';
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
// The 40px min-height is an editor-only DROP ZONE for EMPTY containers. A container that has
// content must lay out like the published page (no injected min-height).
const node = (type: string, props: Record<string, unknown>, nodes: string[] = [], extra: Record<string, unknown> = {}) => ({
type: { resolvedName: type }, isCanvas: type === 'Container' || type === 'Text', props, displayName: type, custom: {}, hidden: false, nodes, linkedNodes: {}, ...extra,
});
let h: EditorHarness | null = null;
afterEach(() => { h?.unmount(); h = null; });
const mount = (inner: Record<string, any>, withChild: boolean) => {
h = renderEditorHarness({
initialState: JSON.stringify({
ROOT: node('Container', { tag: 'div', style: {} }, ['c1']),
c1: node('Container', { tag: 'section', ...inner }, withChild ? ['t1'] : [], { parent: 'ROOT' }),
...(withChild ? { t1: node('TextBlock', { text: 'hi', style: {} }, [], { parent: 'c1' }) } : {}),
}),
});
return h.container.querySelector('section') as HTMLElement;
};
describe('Container empty-only drop zone', () => {
test('empty container keeps the 40px drop zone', () => {
expect(mount({ style: {} }, false).style.minHeight).toBe('40px');
});
test('non-empty container does NOT get an injected min-height', () => {
expect(mount({ style: {} }, true).style.minHeight).toBe('');
});
test('an explicit style.minHeight always wins', () => {
expect(mount({ style: { minHeight: '100px' } }, true).style.minHeight).toBe('100px');
expect(mount({ style: { minHeight: '100px' } }, false).style.minHeight).toBe('100px');
});
});
describe('data-wsb-dropzone marker (harness hook)', () => {
test('set only while the drop-zone min-height is the one applied', () => {
expect(mount({ style: {} }, false).hasAttribute('data-wsb-dropzone')).toBe(true);
expect(mount({ style: {} }, true).hasAttribute('data-wsb-dropzone')).toBe(false);
expect(mount({ style: { minHeight: '100px' } }, false).hasAttribute('data-wsb-dropzone')).toBe(false);
});
});
+5 -1
View File
@@ -95,8 +95,11 @@ export const Container: UserComponent<ContainerProps> = ({
// follows the current device's Alignment / Vertical alignment.
const layout = containerLayout(style, needsBoxedWrapper);
// 40px min-height = editor-only DROP ZONE, so only an EMPTY container gets it. A container with
// content must size like the published page (toHtml adds no min-height).
const isEmpty = React.Children.count(children) === 0;
const outerStyle: CSSProperties = {
minHeight: '40px',
...(isEmpty ? { minHeight: '40px' } : {}),
...style,
...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}),
...layout.outer,
@@ -114,6 +117,7 @@ export const Container: UserComponent<ContainerProps> = ({
style: outerStyle,
'data-craft-container': 'true',
'data-fullwidth': fullWidth ? '' : undefined,
'data-wsb-dropzone': isEmpty && !style.minHeight ? '' : undefined,
id: idValue,
className: cssClass || undefined,
},
@@ -0,0 +1,21 @@
import { describe, test, expect } from 'vitest';
import { HeroSimple } from './HeroSimple';
// Exported section tag captured BEFORE the shorthand/longhand fix; must stay byte-identical.
const toHtml = (HeroSimple as any).toHtml;
const cases: Array<[any, string]> = [
[{"bgType":"gradient"}, "<section style=\"background:linear-gradient(135deg, var(--wsb-primary), var(--wsb-secondary));min-height:500px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px\">"],
[{"bgType":"color","bgColor":"#123456"}, "<section style=\"background:#123456;min-height:500px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px\">"],
[{"bgType":"image","bgImage":"a.jpg"}, "<section style=\"background-image:url('a.jpg');background-size:cover;background-position:center;min-height:500px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px\">"],
[{"bgType":"image","bgImage":"a.jpg","bgPosition":"top"}, "<section style=\"background-image:url('a.jpg');background-size:cover;background-position:top;min-height:500px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px\">"],
[{"bgType":"image"}, "<section style=\"background-size:cover;background-position:center;min-height:500px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px\">"],
[{"bgType":"video","bgVideo":"v.mp4"}, "<section style=\"background:var(--wsb-text);min-height:500px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px\">"],
[{"bgType":"gradient","style":{"backgroundColor":"red"}}, "<section style=\"background:linear-gradient(135deg, var(--wsb-primary), var(--wsb-secondary));min-height:500px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px;background-color:red\">"],
];
describe('HeroSimple.toHtml background output is unchanged by the longhand fix', () => {
test.each(cases)('%j', (props, expected) => {
const tag = toHtml(props, '').html.match(/<section[^>]*>/)[0];
expect(tag).toBe(expected);
});
});
@@ -0,0 +1,36 @@
import { describe, test, expect, afterEach } from 'vitest';
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
import { HeroSimple } from './HeroSimple';
// The canvas render must lay the <section> out exactly like toHtml (published is the source of
// truth): same declarations, `style` LAST so style.minHeight / padding win over the minHeight prop.
const node = (type: string, props: Record<string, unknown>, nodes: string[] = [], extra: Record<string, unknown> = {}) => ({
type: { resolvedName: type }, isCanvas: type === 'Container', props, displayName: type, custom: {}, hidden: false, nodes, linkedNodes: {}, ...extra,
});
let h: EditorHarness | null = null;
afterEach(() => { h?.unmount(); h = null; });
const cases: Record<string, any>[] = [
{ minHeight: '600px', style: { minHeight: '480px' } },
{ minHeight: '300px', style: {} },
{ minHeight: '600px', style: { padding: '10px 5px', display: 'block' } },
];
describe('HeroSimple canvas render == export', () => {
test.each(cases)('%j', (props) => {
h = renderEditorHarness({
initialState: JSON.stringify({
ROOT: node('Container', { tag: 'div', style: {} }, ['hero']),
hero: node('HeroSimple', props, [], { parent: 'ROOT' }),
}),
});
const sec = h.container.querySelector('section') as HTMLElement;
const html = (HeroSimple as any).toHtml(props, '').html.match(/<section[^>]*>/)[0];
const exp = document.createElement('div');
exp.innerHTML = html + '</section>';
const e = (exp.firstElementChild as HTMLElement).style;
for (const p of ['minHeight', 'padding', 'display', 'alignItems', 'overflow', 'position'] as const) {
expect(sec.style[p], p).toBe(e[p]);
}
});
});
+25 -15
View File
@@ -49,6 +49,24 @@ function buildBackground(props: HeroProps): string {
}
}
/**
* Background style keys for the hero section. Emits EITHER the `background`
* shorthand (color/gradient/video) OR the image longhands -- never the
* shorthand together with a longhand that may be undefined. React assigns ''
* for undefined style values on the initial render, and in Chromium assigning
* a background longhand '' AFTER the `background` shorthand wipes the
* shorthand's gradient (background-image -> none), leaving the editor canvas
* hero blank. jsdom does not reproduce this; see scripts/e2e-background.mjs.
*/
function heroBackgroundStyle(props: HeroProps): React.CSSProperties {
if (props.bgType !== 'image') return { background: buildBackground(props) };
return {
...(props.bgImage ? { backgroundImage: `url('${props.bgImage}')` } : {}),
backgroundSize: 'cover',
backgroundPosition: cssValue(props.bgPosition) || 'center',
};
}
export const HeroSimple: UserComponent<HeroProps> = ({
heading = 'Build Something Amazing',
subtitle = 'Create beautiful websites without writing a single line of code.',
@@ -78,10 +96,6 @@ export const HeroSimple: UserComponent<HeroProps> = ({
}) => {
const { connectors: { connect, drag } } = useNode();
const bg = buildBackground({
bgType, bgColor, bgGradientFrom, bgGradientTo, bgGradientAngle, bgImage,
} as HeroProps);
const justifyMap = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
const effectiveCtas = normalizeCtas({ ctas, buttonText, buttonHref, secondaryButtonText, secondaryButtonHref });
@@ -95,19 +109,19 @@ export const HeroSimple: UserComponent<HeroProps> = ({
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
// Same declaration ORDER as toHtml (published output is the source of truth):
// computed defaults first, the node's own `style` last so style.minHeight /
// padding / display ... win over the `minHeight` prop and the built-ins.
style={{
...style,
background: bgType !== 'image' ? bg : undefined,
backgroundImage: bgType === 'image' && bgImage ? `url('${bgImage}')` : undefined,
backgroundSize: bgType === 'image' ? 'cover' : undefined,
backgroundPosition: bgType === 'image' ? (cssValue(bgPosition) || 'center') : undefined,
minHeight: minHeight === '100vh' ? '100vh' : minHeight,
...heroBackgroundStyle({ bgType, bgColor, bgGradientFrom, bgGradientTo, bgGradientAngle, bgImage, bgPosition } as HeroProps),
minHeight: minHeight || '500px',
display: 'flex',
alignItems: justifyMap[verticalAlign] || 'center',
justifyContent: 'center',
position: 'relative',
overflow: 'hidden',
padding: '60px 20px',
...style,
}}
>
{/* Video background */}
@@ -205,14 +219,10 @@ HeroSimple.craft = {
/* ---------- HTML export ---------- */
(HeroSimple as any).toHtml = (props: HeroProps, _childrenHtml: string) => {
const bg = buildBackground(props);
const justifyMap: Record<string, string> = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
const sectionStyle = cssPropsToString({
background: props.bgType !== 'image' ? bg : undefined,
backgroundImage: props.bgType === 'image' && props.bgImage ? `url('${props.bgImage}')` : undefined,
backgroundSize: props.bgType === 'image' ? 'cover' : undefined,
backgroundPosition: props.bgType === 'image' ? (cssValue(props.bgPosition) || 'center') : undefined,
...heroBackgroundStyle(props),
minHeight: props.minHeight || '500px',
display: 'flex',
alignItems: justifyMap[props.verticalAlign || 'center'],
@@ -128,3 +128,36 @@ describe('RenderNode per-device style resolution (real editor)', () => {
expect(h2().hasAttribute('data-wsb-overridden')).toBe(false);
});
});
describe('RenderNode drops blank style values like the exporter (canvas/published parity)', () => {
test('{padding, paddingTop:""} keeps the shorthand (React would wipe it)', () => {
mount(
JSON.stringify({
ROOT: node('Container', { tag: 'div', style: {} }, ['sec1']),
sec1: node('Container', { tag: 'section', style: { padding: '20px', paddingTop: '', paddingLeft: undefined, margin: null } }, [], { parent: 'ROOT' }),
}),
'desktop',
);
const sec = h!.container.querySelector('section') as HTMLElement;
expect(sec.style.padding).toBe('20px');
expect(sec.style.paddingTop).toBe('20px');
});
test('a real longhand after the shorthand still wins', () => {
mount(
JSON.stringify({
ROOT: node('Container', { tag: 'div', style: {} }, ['sec1']),
sec1: node('Container', { tag: 'section', style: { padding: '20px', paddingTop: '5px' } }, [], { parent: 'ROOT' }),
}),
'desktop',
);
const sec = h!.container.querySelector('section') as HTMLElement;
expect(sec.style.paddingTop).toBe('5px');
expect(sec.style.paddingBottom).toBe('20px');
});
test('also holds on a non-desktop device with overrides', () => {
mount(state({ style: { margin: '8px', marginTop: '' }, styleMobile: { textAlign: 'center' } }), 'mobile');
expect(h2().style.marginBottom).toBe('8px');
});
});
+7 -3
View File
@@ -2,7 +2,7 @@ import React, { useCallback, useContext, useEffect, useMemo, useRef } from 'reac
import { createPortal } from 'react-dom';
import { NodeElement, useEditor, useNode } from '@craftjs/core';
import { useDevice } from '../state/DeviceContext';
import { baseStacksOnMobile, hasAnyOverride, hasActiveOverride, resolveStyleForDevice, type ResponsiveProps } from '../utils/responsive-style';
import { dropBlankStyle, baseStacksOnMobile, hasAnyOverride, hasActiveOverride, resolveStyleForDevice, type ResponsiveProps } from '../utils/responsive-style';
import type { DeviceMode } from '../types';
import { GlobalInstanceContext } from '../state/GlobalInstanceContext';
@@ -87,8 +87,12 @@ const ResolvedRender: React.FC<{ device: DeviceMode }> = ({ device }) => {
) : (
props.children
);
const resolved =
styleDevice !== 'desktop' ? { ...props, style: resolveStyleForDevice(props as ResponsiveProps, styleDevice) } : props;
// The ONE place node style reaches React: drop blank values like the exporter does
// (see dropBlankStyle) so the canvas can't wipe a shorthand with a blank longhand.
const style = dropBlankStyle(
styleDevice !== 'desktop' ? resolveStyleForDevice(props as ResponsiveProps, styleDevice) : (props as ResponsiveProps).style,
);
const resolved = styleDevice !== 'desktop' || style !== (props as ResponsiveProps).style ? { ...props, style } : props;
return React.createElement(type as React.ElementType, resolved, children);
}, [type, props, childIds, hydrationTimestamp, styleDevice]);
};
+44
View File
@@ -391,6 +391,50 @@ body {
min-height: 100%;
}
/* ==========================================================================
CANVAS = PUBLISHED BASE RESET
Published pages and Preview ship a reset in the document head
(utils/css-reset.ts CSS_RESET: `*{margin:0;padding:0}`, img{display:block},
a{color:inherit}, body line-height 1.6 / colour #1f2937 / 16px). The editor
canvas lives inside the editor document, whose own body is 13px editor
chrome with no such reset, so content looked different (default h1-h6/p/ul
margins, `normal` line-height, 13px inherited text). This block gives the
canvas FRAME -- the <body> stand-in -- and everything inside it the same
defaults.
Rules:
- Every selector is ZERO specificity (:where). The published reset is `*`
(specificity 0), so component inline styles and class rules beat it; a
plain `.canvas-device-frame *` (0,1,0) would instead beat component
<style> class rules and editor chrome rules. Do NOT remove the :where().
- Scoped to the frame, never body/:root, so panels, toolbars and Craft
overlays (all rendered outside the frame) are untouched. Editor-only
elements inside the frame style themselves with classes / inline styles,
which outrank this block.
- Declarations come from utils/css-reset.ts; css-reset.test.ts fails if
this block and that module drift apart. The font-family / colour tokens
are layered on top by CanvasTokenStyle exactly as the published head
layers them over the reset.
========================================================================== */
.canvas-device-frame {
font-size: 16px; /* UA default a published <body> gets; editor body is 13px */
font-family: 'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
line-height:1.6;
color:#1f2937;
-webkit-font-smoothing:antialiased;
}
:where(.canvas-device-frame) :where(*),
:where(.canvas-device-frame) ::before,
:where(.canvas-device-frame) ::after {
box-sizing:border-box;margin:0;padding:0;
}
:where(.canvas-device-frame) :where(img) {
max-width:100%;height:auto;display:block;
}
:where(.canvas-device-frame) :where(a) {
color:inherit;
}
/* END CANVAS = PUBLISHED BASE RESET */
/* --------------------------------------------------------------------------
No Selection Message
-------------------------------------------------------------------------- */
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, test } from 'vitest';
// @ts-ignore -- node builtin (vitest runs in node); the project has no @types/node
import fs from 'node:fs';
import {
CSS_RESET, RESET_ALL_DECLS, BODY_BASE_DECLS, IMG_DECLS, LINK_DECLS,
} from './css-reset';
import { buildCanvasTokenCss } from './tokens';
import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
// (not `?raw`: vitest's css pipeline strips/empties .css imports, and the block markers are comments)
const css: string = fs.readFileSync('src/styles/editor.css' /* vitest cwd = craft/ */, 'utf8');
const compact = (s: string) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, '').replace(/;}/g, '}');
const blockStart = css.lastIndexOf('/*', css.indexOf('CANVAS = PUBLISHED BASE RESET'));
const block = compact(css.slice(blockStart, css.indexOf('END CANVAS = PUBLISHED BASE RESET')));
describe('published reset text', () => {
test('CSS_RESET is byte-identical to the backend head text (site-builder.php $reset)', () => {
expect(CSS_RESET).toBe(
"*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}" +
"body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}" +
"img{max-width:100%;height:auto;display:block}a{color:inherit}",
);
});
});
describe('editor.css canvas reset block', () => {
test('block exists', () => expect(blockStart).toBeGreaterThan(-1));
test.each([
['reset-all', RESET_ALL_DECLS],
['body base', BODY_BASE_DECLS],
['img', IMG_DECLS],
['a', LINK_DECLS],
])('carries the shared %s declarations', (_n, decls) => {
expect(block).toContain(compact(decls));
});
test('base font-size matches the default 16px a published body gets', () => {
expect(block).toContain('font-size:16px');
});
test('every selector in the block is zero-specificity (:where) below the frame', () => {
const rules = css.slice(blockStart, css.indexOf('/* END CANVAS = PUBLISHED BASE RESET'))
.replace(/\/\*[\s\S]*?\*\//g, '');
const selectors = [...rules.matchAll(/([^{}]+)\{/g)].map((m) => m[1].trim());
expect(selectors.length).toBeGreaterThan(3);
for (const sel of selectors) for (const part of sel.split(',')) {
const p = part.trim();
if (p === '.canvas-device-frame') continue; // the frame itself is the <body> stand-in
expect(p.startsWith(':where(.canvas-device-frame)')).toBe(true);
// nothing after the :where() group may add specificity (classes, ids, attrs, :not...)
expect(p.replace(/:where\([^)]*\)/g, '').replace(/::(before|after)/g, '')).not.toMatch(/[.#[:]/);
}
});
});
describe('canvas token base css keeps published (zero) specificity', () => {
test('link/heading rules are :where-wrapped, only the frame rule carries a class', () => {
const out = buildCanvasTokenCss(DEFAULT_SITE_DESIGN);
expect(out).toContain(':where(.canvas-device-frame) :where(a){color:var(--wsb-link)}');
expect(out).toContain(':where(.canvas-device-frame) :where(h1,h2,h3,h4,h5,h6){font-family:var(--wsb-heading-font)}');
});
});
+21
View File
@@ -0,0 +1,21 @@
/**
* The page-level base reset shared by the PUBLISHED/Preview document head
* (html-export.ts `wrapInDocument`, mirrored by the backend's
* `generateCompiledHTML` in web-files/api/site-builder.php) and the editor
* CANVAS (the "canvas = published reset" block in styles/editor.css).
*
* The declaration strings live here once so the two cannot drift;
* css-reset.test.ts asserts html-export's CSS_RESET is byte-identical to the
* published head text and that the editor.css block carries every one of
* these declarations.
*/
export const RESET_ALL_DECLS = 'box-sizing:border-box;margin:0;padding:0';
export const BODY_BASE_DECLS =
"font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased";
export const IMG_DECLS = 'max-width:100%;height:auto;display:block';
export const LINK_DECLS = 'color:inherit';
/** Minified published reset (document head). Must stay byte-identical to the backend's `$reset`. */
export const CSS_RESET =
`*,*::before,*::after{${RESET_ALL_DECLS}}html,body{overflow-x:clip}` +
`body{${BODY_BASE_DECLS}}img{${IMG_DECLS}}a{${LINK_DECLS}}`;
+2 -1
View File
@@ -1,3 +1,4 @@
import { CSS_RESET } from './css-reset';
import { componentResolver } from '../components/resolver';
import type { CSSProperties } from 'react';
import { cssPropsToString } from './style-helpers';
@@ -214,7 +215,7 @@ function renderNode(
return { html: allChildrenHtml };
}
const CSS_RESET = `*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}`;
// CSS_RESET (minified published reset) lives in ./css-reset, shared with the editor canvas.
const CSS_RESET_PRETTY = `*, *::before, *::after {
box-sizing: border-box;
+12
View File
@@ -202,3 +202,15 @@ describe('responsiveCssForNode', () => {
expect(responsiveCssForNode('"]', { styleMobile: { fontSize: '2px' } })).toBe('');
});
});
import { dropBlankStyle as _dropBlankStyle } from './responsive-style';
describe('dropBlankStyle (canvas == exporter: blanks are not declarations)', () => {
test('drops "", null, undefined; keeps 0 and real values', () => {
expect(_dropBlankStyle({ padding: '20px', paddingTop: '', a: null, b: undefined, opacity: 0, zIndex: '0' } as any)).toEqual({ padding: '20px', opacity: 0, zIndex: '0' });
});
test('returns the same object when nothing is blank, and passes non-objects through', () => {
const s = { color: 'red' };
expect(_dropBlankStyle(s)).toBe(s);
expect(_dropBlankStyle(undefined)).toBeUndefined();
});
});
+13
View File
@@ -70,6 +70,19 @@ export function resolveStyleForDevice(props: ResponsiveProps, device: DeviceMode
return { ...base, ...tablet, ...setEntries(props.styleMobile) } as CSSProperties;
}
/**
* The style as the CANVAS must render it: blank ('' / null / undefined) entries
* dropped, exactly like the exporter (`cssPropsToString`). React assigns '' for a
* blank value, and a blank longhand after a shorthand ({padding:'20px',
* paddingTop:''}) wipes the shorthand in the browser -- published output keeps it.
* Returns the SAME object when nothing is blank (no needless re-render).
*/
export function dropBlankStyle<T>(style: T): T {
if (!style || typeof style !== 'object' || Array.isArray(style)) return style;
const rec = style as unknown as StyleRecord;
return Object.values(rec).every(isSet) ? style : (setEntries(rec) as unknown as T);
}
/** The raw partial stored for `device` (`{}` for desktop or when absent). */
export function overrideFor(props: ResponsiveProps, device: DeviceMode): Partial<CSSProperties> {
if (device === 'desktop') return {};
+3 -2
View File
@@ -110,8 +110,9 @@ describe('buildCanvasTokenCss', () => {
const css = buildCanvasTokenCss(DEFAULT_SITE_DESIGN);
expect(css).toContain(
'.canvas-device-frame{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}' +
'.canvas-device-frame h1,.canvas-device-frame h2,.canvas-device-frame h3,.canvas-device-frame h4,.canvas-device-frame h5,.canvas-device-frame h6{font-family:var(--wsb-heading-font)}' +
'.canvas-device-frame a{color:var(--wsb-link)}',
// descendant rules are zero-specificity (:where) like the published `h1..h6{}` / `a{}`
':where(.canvas-device-frame) :where(h1,h2,h3,h4,h5,h6){font-family:var(--wsb-heading-font)}' +
':where(.canvas-device-frame) :where(a){color:var(--wsb-link)}',
);
// No unscoped selectors leak into the editor chrome.
expect(css).not.toMatch(/(^|\})(body|h1|a)\{/);
+7 -3
View File
@@ -99,11 +99,15 @@ export function resolveTokenValue(v: string, design: SiteDesign): string {
// device frame (not :root) so the editor chrome never inherits site
// fonts/colours.
const CANVAS_SCOPE = '.canvas-device-frame';
// Descendant rules are `:where()`-wrapped (zero specificity) to match the
// published `h1..h6{}` / `a{}` rules: with a plain `.canvas-device-frame a`
// (0,1,1) a component's own class rule (0,1,0) would lose in the editor but
// win when published.
const CANVAS_ZERO = `:where(${CANVAS_SCOPE})`;
const CANVAS_TOKEN_BASE_CSS =
`${CANVAS_SCOPE}{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}` +
['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].map((h) => `${CANVAS_SCOPE} ${h}`).join(',') +
`{font-family:var(--wsb-heading-font)}` +
`${CANVAS_SCOPE} a{color:var(--wsb-link)}`;
`${CANVAS_ZERO} :where(h1,h2,h3,h4,h5,h6){font-family:var(--wsb-heading-font)}` +
`${CANVAS_ZERO} :where(a){color:var(--wsb-link)}`;
/**
* Defines the 16 `--wsb-*` variables on the editor canvas frame, plus the
+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>About</title>
<meta property="og:title" content="About">
<meta property="og:type" content="website">
<link rel="canonical" href="https://olsdigital.com/about">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}@media(max-width:767px){[style*="display: flex"][style*="flex-direction: row"],[style*="display:flex"][style*="flex-direction:row"]{flex-direction:column!important}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>
</head>
<body>
<style>
.nav_yk1fl5 .navbar-link:hover { color: #3b82f6 !important; }
.nav_yk1fl5 .navbar-cta:hover { filter: brightness(1.1); }
</style>
<nav class="nav_yk1fl5" style="display:flex;align-items:center;justify-content:space-between;padding:16px 24px;background-color:#0f172a;border-bottom:1px solid #e4e4e7">
<a href="/" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><img src="assets/1789158005_2592c5cf_Minimalist_OLS_Digital_Logo_with_Rounded_Elements__400_x_135_px_.webp" alt="Logo" style="width:120px;height:auto;display:block" /></a>
<div class="navbar-links" id="nav_yk1fl5_links" style="display:flex;align-items:center;gap:24px">
<a href="/" class="navbar-link" style="text-decoration:none;font-size:14px;font-weight:400;color:#ffffff;background-color:transparent;padding:0;border-radius:0;transition:color 0.15s, background-color 0.15s">Home</a>
<a href="/brands" class="navbar-link" style="text-decoration:none;font-size:14px;font-weight:400;color:#ffffff;background-color:transparent;padding:0;border-radius:0;transition:color 0.15s, background-color 0.15s">Our Brands</a>
<a href="/about" class="navbar-link" style="text-decoration:none;font-size:14px;font-weight:400;color:#ffffff;background-color:transparent;padding:0;border-radius:0;transition:color 0.15s, background-color 0.15s">About</a>
<a href="/contact" class="navbar-link" style="text-decoration:none;font-size:14px;font-weight:400;color:#ffffff;background-color:transparent;padding:0;border-radius:0;transition:color 0.15s, background-color 0.15s">Contact</a>
<a href="/contact" class="navbar-cta" style="text-decoration:none;font-size:14px;font-weight:600;color:#ffffff;background-color:#3b82f6;padding:8px 20px;border-radius:6px;transition:color 0.15s, background-color 0.15s">Contact</a>
</div>
</nav>
<div style="padding:20px;min-height:100px;border:none;box-shadow:none;opacity:1"><section id="about" style="background:#1e293b;min-height:35vh;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px">
<div style="max-width:800px;width:100%;position:relative;z-index:2;text-align:center">
<h1 style="font-size:48px;font-weight:700;color:#ffffff;margin-bottom:16px;line-height:1.2">About OLS Digital</h1>
<p style="font-size:20px;color:#ffffff;opacity:0.85;margin-bottom:32px;line-height:1.6;white-space:pre-line">Who we are and what we believe in.</p>
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap"></div>
</div>
</section><section id="our-story" style="width:100%;padding-top:60px;padding-bottom:20px"><div style="max-width:1200px;margin:0 auto"><div style="padding:20px;min-height:100px;border:none;box-shadow:none;opacity:1"><h2 style="font-size:36px;font-weight:700;font-family:Inter, sans-serif;color:#1f2937;margin-bottom:16px;border:none;box-shadow:none;opacity:1">Our Story</h2><p style="font-size:16px;line-height:1.6;color:#3f3f46;border:none;box-shadow:none;opacity:1">OLS Digital was founded to bring structure, resources, and long-term support to promising digital brands. Rather than absorbing the businesses we work with, we help them keep their independent identity while benefiting from shared operational support.</p></div></div></section><section id="our-mission" style="width:100%;padding-top:20px;padding-bottom:60px"><div style="max-width:1200px;margin:0 auto"><div style="padding:20px;min-height:100px;border:none;box-shadow:none;opacity:1"><h2 style="font-size:36px;font-weight:700;font-family:Inter, sans-serif;color:#1f2937;margin-bottom:16px;border:none;box-shadow:none;opacity:1">Our Mission</h2><p style="font-size:16px;line-height:1.6;color:#3f3f46;border:none;box-shadow:none;opacity:1">We believe in honesty, quality, and putting people first. Every brand under the OLS Digital name is expected to uphold these same values in how they serve their customers.</p></div></div></section><section id="about-contact" style="background:linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);padding:80px 20px;text-align:center">
<div style="max-width:700px;margin:0 auto">
<h2 style="font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px">Have Questions?</h2>
<p style="font-size:18px;color:rgba(255,255,255,0.85);margin-bottom:28px;line-height:1.6">We'd love to hear from you.</p>
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap"><a href="mailto:info@olsdigital.com" style="display:inline-block;padding:14px 36px;background-color:var(--wsb-bg);color:var(--wsb-text);text-decoration:none;border-radius:8px;font-weight:600;font-size:16px">Email info@olsdigital.com</a></div>
</div>
</section></div>
<footer style="padding:24px 20px;text-align:center">© 2026 OLS Digital. All rights reserved. Questions? Email info@olsdigital.com</footer>
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Brands</title>
<meta property="og:title" content="Brands">
<meta property="og:type" content="website">
<link rel="canonical" href="https://olsdigital.com/brands">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}@media(max-width:767px){[style*="display: flex"][style*="flex-direction: row"],[style*="display:flex"][style*="flex-direction:row"]{flex-direction:column!important}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>
</head>
<body>
<style>
.nav_yk1fl5 .navbar-link:hover { color: #3b82f6 !important; }
.nav_yk1fl5 .navbar-cta:hover { filter: brightness(1.1); }
</style>
<nav class="nav_yk1fl5" style="display:flex;align-items:center;justify-content:space-between;padding:16px 24px;background-color:#0f172a;border-bottom:1px solid #e4e4e7">
<a href="/" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><img src="assets/1789158005_2592c5cf_Minimalist_OLS_Digital_Logo_with_Rounded_Elements__400_x_135_px_.webp" alt="Logo" style="width:120px;height:auto;display:block" /></a>
<div class="navbar-links" id="nav_yk1fl5_links" style="display:flex;align-items:center;gap:24px">
<a href="/" class="navbar-link" style="text-decoration:none;font-size:14px;font-weight:400;color:#ffffff;background-color:transparent;padding:0;border-radius:0;transition:color 0.15s, background-color 0.15s">Home</a>
<a href="/brands" class="navbar-link" style="text-decoration:none;font-size:14px;font-weight:400;color:#ffffff;background-color:transparent;padding:0;border-radius:0;transition:color 0.15s, background-color 0.15s">Our Brands</a>
<a href="/about" class="navbar-link" style="text-decoration:none;font-size:14px;font-weight:400;color:#ffffff;background-color:transparent;padding:0;border-radius:0;transition:color 0.15s, background-color 0.15s">About</a>
<a href="/contact" class="navbar-link" style="text-decoration:none;font-size:14px;font-weight:400;color:#ffffff;background-color:transparent;padding:0;border-radius:0;transition:color 0.15s, background-color 0.15s">Contact</a>
<a href="/contact" class="navbar-cta" style="text-decoration:none;font-size:14px;font-weight:600;color:#ffffff;background-color:#3b82f6;padding:8px 20px;border-radius:6px;transition:color 0.15s, background-color 0.15s">Contact</a>
</div>
</nav>
<div style="padding:20px;min-height:100px;border:none;box-shadow:none;opacity:1"><section id="brands" style="background:#1e293b;min-height:35vh;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px">
<div style="max-width:800px;width:100%;position:relative;z-index:2;text-align:center">
<h1 style="font-size:48px;font-weight:700;color:#ffffff;margin-bottom:16px;line-height:1.2">Our Brands</h1>
<p style="font-size:20px;color:#ffffff;opacity:0.85;margin-bottom:32px;line-height:1.6;white-space:pre-line">A closer look at the companies under the OLS Digital umbrella.</p>
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap"></div>
</div>
</section><style>
.cols_67iy9y > :nth-child(1) { flex: 0 0 calc(40% - 40px) !important; }
.cols_67iy9y > :nth-child(2) { flex: 0 0 calc(60% - 40px) !important; }
</style>
<div class="cols_67iy9y" id="anhonesthost" style="display:flex;flex-wrap:wrap;gap:40px;width:100%;padding-top:60px;padding-bottom:20px"><div style="flex:0 0 calc(40% - 40px);min-height:60px;padding:8px"></div><div style="padding:20px;min-height:100px;border:none;box-shadow:none;opacity:1"><h2 style="font-size:36px;font-weight:700;font-family:Inter, sans-serif;color:#1f2937;margin-bottom:16px;border:none;box-shadow:none;opacity:1">AnHonestHost</h2><p style="font-size:16px;line-height:1.6;color:#3f3f46;border:none;box-shadow:none;opacity:1">AnHonestHost provides transparent, no-nonsense web hosting for individuals and businesses. Straightforward pricing, honest support, and reliable infrastructure — no surprises.</p></div></div><hr style="border:none;border-top:1px solid #e2e8f0;margin:16px 0" /><style>
.cols_1ctfb24 > :nth-child(1) { flex: 0 0 calc(60% - 40px) !important; }
.cols_1ctfb24 > :nth-child(2) { flex: 0 0 calc(40% - 40px) !important; }
</style>
<div class="cols_1ctfb24" id="streamertools" style="display:flex;flex-wrap:wrap;gap:40px;width:100%;padding-top:60px;padding-bottom:60px"><div style="padding:20px;min-height:100px;border:none;box-shadow:none;opacity:1"><h2 style="font-size:36px;font-weight:700;font-family:Inter, sans-serif;color:#1f2937;margin-bottom:16px;border:none;box-shadow:none;opacity:1">StreamerTools</h2><p style="font-size:16px;line-height:1.6;color:#3f3f46;border:none;box-shadow:none;opacity:1">StreamerTools builds practical tools and resources for streamers and content creators, helping them grow their channels and streamline their workflow.</p></div><div style="flex:0 0 calc(40% - 40px);min-height:60px;padding:8px"></div></div><section id="brands-contact" style="background:linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);padding:80px 20px;text-align:center">
<div style="max-width:700px;margin:0 auto">
<h2 style="font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px">Interested in One of Our Brands?</h2>
<p style="font-size:18px;color:rgba(255,255,255,0.85);margin-bottom:28px;line-height:1.6">Reach out and we'll point you in the right direction.</p>
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap"><a href="mailto:info@olsdigital.com" style="display:inline-block;padding:14px 36px;background-color:var(--wsb-bg);color:var(--wsb-text);text-decoration:none;border-radius:8px;font-weight:600;font-size:16px">Email info@olsdigital.com</a></div>
</div>
</section></div>
<footer style="padding:24px 20px;text-align:center">© 2026 OLS Digital. All rights reserved. Questions? Email info@olsdigital.com</footer>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contact</title>
<meta property="og:title" content="Contact">
<meta property="og:type" content="website">
<link rel="canonical" href="https://olsdigital.com/contact">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}@media(max-width:767px){[style*="display: flex"][style*="flex-direction: row"],[style*="display:flex"][style*="flex-direction:row"]{flex-direction:column!important}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>
</head>
<body>
<style>
.nav_yk1fl5 .navbar-link:hover { color: #3b82f6 !important; }
.nav_yk1fl5 .navbar-cta:hover { filter: brightness(1.1); }
</style>
<nav class="nav_yk1fl5" style="display:flex;align-items:center;justify-content:space-between;padding:16px 24px;background-color:#0f172a;border-bottom:1px solid #e4e4e7">
<a href="/" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><img src="assets/1789158005_2592c5cf_Minimalist_OLS_Digital_Logo_with_Rounded_Elements__400_x_135_px_.webp" alt="Logo" style="width:120px;height:auto;display:block" /></a>
<div class="navbar-links" id="nav_yk1fl5_links" style="display:flex;align-items:center;gap:24px">
<a href="/" class="navbar-link" style="text-decoration:none;font-size:14px;font-weight:400;color:#ffffff;background-color:transparent;padding:0;border-radius:0;transition:color 0.15s, background-color 0.15s">Home</a>
<a href="/brands" class="navbar-link" style="text-decoration:none;font-size:14px;font-weight:400;color:#ffffff;background-color:transparent;padding:0;border-radius:0;transition:color 0.15s, background-color 0.15s">Our Brands</a>
<a href="/about" class="navbar-link" style="text-decoration:none;font-size:14px;font-weight:400;color:#ffffff;background-color:transparent;padding:0;border-radius:0;transition:color 0.15s, background-color 0.15s">About</a>
<a href="/contact" class="navbar-link" style="text-decoration:none;font-size:14px;font-weight:400;color:#ffffff;background-color:transparent;padding:0;border-radius:0;transition:color 0.15s, background-color 0.15s">Contact</a>
<a href="/contact" class="navbar-cta" style="text-decoration:none;font-size:14px;font-weight:600;color:#ffffff;background-color:#3b82f6;padding:8px 20px;border-radius:6px;transition:color 0.15s, background-color 0.15s">Contact</a>
</div>
</nav>
<div style="padding:20px;min-height:100px;border:none;box-shadow:none;opacity:1"><section id="contact" style="background:linear-gradient(135deg, #0f172a, #334155);min-height:40vh;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px">
<div style="max-width:800px;width:100%;position:relative;z-index:2;text-align:center">
<h1 style="font-size:48px;font-weight:700;color:#ffffff;margin-bottom:16px;line-height:1.2">Contact Us</h1>
<p style="font-size:20px;color:#ffffff;opacity:0.85;margin-bottom:32px;line-height:1.6;white-space:pre-line">Have a question about OLS Digital or one of our brands? We're happy to help.</p>
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap"></div>
</div>
</section><section id="contact-info" style="width:100%;padding-top:60px;padding-bottom:60px;text-align:center"><div style="max-width:1200px;margin:0 auto"><div style="padding:20px;min-height:100px;border:none;box-shadow:none;opacity:1"><h2 style="font-size:36px;font-weight:700;font-family:Inter, sans-serif;color:#1f2937;margin-bottom:16px;border:none;box-shadow:none;opacity:1">Get In Touch</h2><p style="font-size:16px;line-height:1.6;color:#3f3f46;border:none;box-shadow:none;opacity:1">For all inquiries regarding OLS Digital, AnHonestHost, StreamerTools, or any of our other brands, please reach out via email and our team will respond as soon as possible.</p><div style="height:20px"></div><a href="mailto:info@olsdigital.com" style="display:inline-block;text-decoration:none;background-color:#3b82f6;color:#ffffff;padding:12px 24px;border-radius:8px;font-weight:600;font-size:16px;border:none;box-shadow:none;opacity:1">info@olsdigital.com</a></div></div></section></div>
<footer style="padding:24px 20px;text-align:center">© 2026 OLS Digital. All rights reserved. Questions? Email info@olsdigital.com</footer>
</body>
</html>
+64
View File
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Home</title>
<meta property="og:title" content="Home">
<meta property="og:type" content="website">
<link rel="canonical" href="https://olsdigital.com/">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}@media(max-width:767px){[style*="display: flex"][style*="flex-direction: row"],[style*="display:flex"][style*="flex-direction:row"]{flex-direction:column!important}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>
</head>
<body>
<style>
.nav_yk1fl5 .navbar-link:hover { color: #3b82f6 !important; }
.nav_yk1fl5 .navbar-cta:hover { filter: brightness(1.1); }
</style>
<nav class="nav_yk1fl5" style="display:flex;align-items:center;justify-content:space-between;padding:16px 24px;background-color:#0f172a;border-bottom:1px solid #e4e4e7">
<a href="/" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><img src="assets/1789158005_2592c5cf_Minimalist_OLS_Digital_Logo_with_Rounded_Elements__400_x_135_px_.webp" alt="Logo" style="width:120px;height:auto;display:block" /></a>
<div class="navbar-links" id="nav_yk1fl5_links" style="display:flex;align-items:center;gap:24px">
<a href="/" class="navbar-link" style="text-decoration:none;font-size:14px;font-weight:400;color:#ffffff;background-color:transparent;padding:0;border-radius:0;transition:color 0.15s, background-color 0.15s">Home</a>
<a href="/brands" class="navbar-link" style="text-decoration:none;font-size:14px;font-weight:400;color:#ffffff;background-color:transparent;padding:0;border-radius:0;transition:color 0.15s, background-color 0.15s">Our Brands</a>
<a href="/about" class="navbar-link" style="text-decoration:none;font-size:14px;font-weight:400;color:#ffffff;background-color:transparent;padding:0;border-radius:0;transition:color 0.15s, background-color 0.15s">About</a>
<a href="/contact" class="navbar-link" style="text-decoration:none;font-size:14px;font-weight:400;color:#ffffff;background-color:transparent;padding:0;border-radius:0;transition:color 0.15s, background-color 0.15s">Contact</a>
<a href="/contact" class="navbar-cta" style="text-decoration:none;font-size:14px;font-weight:600;color:#ffffff;background-color:#3b82f6;padding:8px 20px;border-radius:6px;transition:color 0.15s, background-color 0.15s">Contact</a>
</div>
</nav>
<div style="padding:20px;min-height:100px;border:none;box-shadow:none;opacity:1"><section id="home" style="background:linear-gradient(135deg, #0f172a, #334155);min-height:60vh;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px">
<div style="max-width:800px;width:100%;position:relative;z-index:2;text-align:center">
<h1 style="font-size:48px;font-weight:700;color:#ffffff;margin-bottom:16px;line-height:1.2">OLS Digital</h1>
<p style="font-size:20px;color:#ffffff;opacity:0.85;margin-bottom:32px;line-height:1.6;white-space:pre-line">The parent company behind a growing family of trusted online brands, including AnHonestHost and StreamerTools.</p>
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap"><a href="/brands" style="display:inline-block;padding:14px 36px;background-color:#3b82f6;color:#ffffff;text-decoration:none;border-radius:8px;font-weight:600;font-size:16px">Explore Our Brands</a><a href="/contact" style="display:inline-block;padding:14px 36px;background-color:transparent;color:#ffffff;text-decoration:none;border-radius:8px;font-weight:600;font-size:16px;border:2px solid #ffffff">Contact Us</a></div>
</div>
</section><section id="what-we-do" style="padding:80px 20px;background-color:#ffffff">
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px">
<div style="text-align:center;padding:32px 24px;border-radius:12px;background-color:var(--wsb-bg);border:1px solid var(--wsb-border)">
<div style="font-size:36px;margin-bottom:16px">🏢</div>
<h3 style="font-size:20px;font-weight:600;color:var(--wsb-text);margin-bottom:8px">Multi-Brand Portfolio</h3>
<p style="font-size:14px;color:var(--wsb-muted);line-height:1.6">We own and support a growing collection of independent, high-quality brands.</p>
</div>
<div style="text-align:center;padding:32px 24px;border-radius:12px;background-color:var(--wsb-bg);border:1px solid var(--wsb-border)">
<div style="font-size:36px;margin-bottom:16px">🌐</div>
<h3 style="font-size:20px;font-weight:600;color:var(--wsb-text);margin-bottom:8px">AnHonestHost</h3>
<p style="font-size:14px;color:var(--wsb-muted);line-height:1.6">Transparent, reliable web hosting built on trust.</p>
</div>
<div style="text-align:center;padding:32px 24px;border-radius:12px;background-color:var(--wsb-bg);border:1px solid var(--wsb-border)">
<div style="font-size:36px;margin-bottom:16px">🎮</div>
<h3 style="font-size:20px;font-weight:600;color:var(--wsb-text);margin-bottom:8px">StreamerTools</h3>
<p style="font-size:14px;color:var(--wsb-muted);line-height:1.6">Tools and resources built for content creators and streamers.</p>
</div>
</div>
</section><section id="about-preview" style="width:100%;padding-top:60px;padding-bottom:60px"><div style="max-width:1200px;margin:0 auto"><div style="padding:20px;min-height:100px;border:none;box-shadow:none;opacity:1"><h2 style="font-size:36px;font-weight:700;font-family:Inter, sans-serif;color:#1f2937;margin-bottom:16px;border:none;box-shadow:none;opacity:1">Building Brands That Matter</h2><p style="font-size:16px;line-height:1.6;color:#3f3f46;border:none;box-shadow:none;opacity:1">OLS Digital is a privately held parent company focused on acquiring and growing digital-first businesses. Each brand under our umbrella operates independently, guided by a shared commitment to honesty, quality, and great customer experiences.</p></div></div></section><section id="contact-cta" style="background:linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);padding:80px 20px;text-align:center">
<div style="max-width:700px;margin:0 auto">
<h2 style="font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px">Want to Learn More?</h2>
<p style="font-size:18px;color:rgba(255,255,255,0.85);margin-bottom:28px;line-height:1.6">For inquiries about OLS Digital or any of our brands, reach out to our team directly.</p>
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap"><a href="mailto:info@olsdigital.com" style="display:inline-block;padding:14px 36px;background-color:var(--wsb-bg);color:var(--wsb-text);text-decoration:none;border-radius:8px;font-weight:600;font-size:16px">Email info@olsdigital.com</a></div>
</div>
</section></div>
<footer style="padding:24px 20px;text-align:center">© 2026 OLS Digital. All rights reserved. Questions? Email info@olsdigital.com</footer>
</body>
</html>
+88
View File
@@ -0,0 +1,88 @@
{
"site_id": 100,
"name": "OneLastShot Digital",
"html": "<div style=\"padding:20px;min-height:100px;border:none;box-shadow:none;opacity:1\"><section id=\"home\" style=\"background:linear-gradient(135deg, #0f172a, #334155);min-height:60vh;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px\">\n\n<div style=\"max-width:800px;width:100%;position:relative;z-index:2;text-align:center\">\n <h1 style=\"font-size:48px;font-weight:700;color:#ffffff;margin-bottom:16px;line-height:1.2\">OLS Digital</h1>\n <p style=\"font-size:20px;color:#ffffff;opacity:0.85;margin-bottom:32px;line-height:1.6;white-space:pre-line\">The parent company behind a growing family of trusted online brands, including AnHonestHost and StreamerTools.</p>\n <div style=\"display:flex;gap:12px;justify-content:center;flex-wrap:wrap\"><a href=\"/brands\" style=\"display:inline-block;padding:14px 36px;background-color:#3b82f6;color:#ffffff;text-decoration:none;border-radius:8px;font-weight:600;font-size:16px\">Explore Our Brands</a><a href=\"/contact\" style=\"display:inline-block;padding:14px 36px;background-color:transparent;color:#ffffff;text-decoration:none;border-radius:8px;font-weight:600;font-size:16px;border:2px solid #ffffff\">Contact Us</a></div>\n</div>\n</section><section id=\"what-we-do\" style=\"padding:80px 20px;background-color:#ffffff\">\n <div style=\"max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px\">\n <div style=\"text-align:center;padding:32px 24px;border-radius:12px;background-color:var(--wsb-bg);border:1px solid var(--wsb-border)\">\n <div style=\"font-size:36px;margin-bottom:16px\">\ud83c\udfe2</div>\n <h3 style=\"font-size:20px;font-weight:600;color:var(--wsb-text);margin-bottom:8px\">Multi-Brand Portfolio</h3>\n <p style=\"font-size:14px;color:var(--wsb-muted);line-height:1.6\">We own and support a growing collection of independent, high-quality brands.</p>\n </div>\n <div style=\"text-align:center;padding:32px 24px;border-radius:12px;background-color:var(--wsb-bg);border:1px solid var(--wsb-border)\">\n <div style=\"font-size:36px;margin-bottom:16px\">\ud83c\udf10</div>\n <h3 style=\"font-size:20px;font-weight:600;color:var(--wsb-text);margin-bottom:8px\">AnHonestHost</h3>\n <p style=\"font-size:14px;color:var(--wsb-muted);line-height:1.6\">Transparent, reliable web hosting built on trust.</p>\n </div>\n <div style=\"text-align:center;padding:32px 24px;border-radius:12px;background-color:var(--wsb-bg);border:1px solid var(--wsb-border)\">\n <div style=\"font-size:36px;margin-bottom:16px\">\ud83c\udfae</div>\n <h3 style=\"font-size:20px;font-weight:600;color:var(--wsb-text);margin-bottom:8px\">StreamerTools</h3>\n <p style=\"font-size:14px;color:var(--wsb-muted);line-height:1.6\">Tools and resources built for content creators and streamers.</p>\n </div>\n </div>\n</section><section id=\"about-preview\" style=\"width:100%;padding-top:60px;padding-bottom:60px\"><div style=\"max-width:1200px;margin:0 auto\"><div style=\"padding:20px;min-height:100px;border:none;box-shadow:none;opacity:1\"><h2 style=\"font-size:36px;font-weight:700;font-family:Inter, sans-serif;color:#1f2937;margin-bottom:16px;border:none;box-shadow:none;opacity:1\">Building Brands That Matter</h2><p style=\"font-size:16px;line-height:1.6;color:#3f3f46;border:none;box-shadow:none;opacity:1\">OLS Digital is a privately held parent company focused on acquiring and growing digital-first businesses. Each brand under our umbrella operates independently, guided by a shared commitment to honesty, quality, and great customer experiences.</p></div></div></section><section id=\"contact-cta\" style=\"background:linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);padding:80px 20px;text-align:center\">\n <div style=\"max-width:700px;margin:0 auto\">\n <h2 style=\"font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px\">Want to Learn More?</h2>\n <p style=\"font-size:18px;color:rgba(255,255,255,0.85);margin-bottom:28px;line-height:1.6\">For inquiries about OLS Digital or any of our brands, reach out to our team directly.</p>\n <div style=\"display:flex;gap:12px;justify-content:center;flex-wrap:wrap\"><a href=\"mailto:info@olsdigital.com\" style=\"display:inline-block;padding:14px 36px;background-color:var(--wsb-bg);color:var(--wsb-text);text-decoration:none;border-radius:8px;font-weight:600;font-size:16px\">Email info@olsdigital.com</a></div>\n </div>\n</section></div>",
"css": "",
"pages": [
{
"filename": "index.html",
"title": "Home",
"html": "<div style=\"padding:20px;min-height:100px;border:none;box-shadow:none;opacity:1\"><section id=\"home\" style=\"background:linear-gradient(135deg, #0f172a, #334155);min-height:60vh;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px\">\n\n<div style=\"max-width:800px;width:100%;position:relative;z-index:2;text-align:center\">\n <h1 style=\"font-size:48px;font-weight:700;color:#ffffff;margin-bottom:16px;line-height:1.2\">OLS Digital</h1>\n <p style=\"font-size:20px;color:#ffffff;opacity:0.85;margin-bottom:32px;line-height:1.6;white-space:pre-line\">The parent company behind a growing family of trusted online brands, including AnHonestHost and StreamerTools.</p>\n <div style=\"display:flex;gap:12px;justify-content:center;flex-wrap:wrap\"><a href=\"/brands\" style=\"display:inline-block;padding:14px 36px;background-color:#3b82f6;color:#ffffff;text-decoration:none;border-radius:8px;font-weight:600;font-size:16px\">Explore Our Brands</a><a href=\"/contact\" style=\"display:inline-block;padding:14px 36px;background-color:transparent;color:#ffffff;text-decoration:none;border-radius:8px;font-weight:600;font-size:16px;border:2px solid #ffffff\">Contact Us</a></div>\n</div>\n</section><section id=\"what-we-do\" style=\"padding:80px 20px;background-color:#ffffff\">\n <div style=\"max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px\">\n <div style=\"text-align:center;padding:32px 24px;border-radius:12px;background-color:var(--wsb-bg);border:1px solid var(--wsb-border)\">\n <div style=\"font-size:36px;margin-bottom:16px\">\ud83c\udfe2</div>\n <h3 style=\"font-size:20px;font-weight:600;color:var(--wsb-text);margin-bottom:8px\">Multi-Brand Portfolio</h3>\n <p style=\"font-size:14px;color:var(--wsb-muted);line-height:1.6\">We own and support a growing collection of independent, high-quality brands.</p>\n </div>\n <div style=\"text-align:center;padding:32px 24px;border-radius:12px;background-color:var(--wsb-bg);border:1px solid var(--wsb-border)\">\n <div style=\"font-size:36px;margin-bottom:16px\">\ud83c\udf10</div>\n <h3 style=\"font-size:20px;font-weight:600;color:var(--wsb-text);margin-bottom:8px\">AnHonestHost</h3>\n <p style=\"font-size:14px;color:var(--wsb-muted);line-height:1.6\">Transparent, reliable web hosting built on trust.</p>\n </div>\n <div style=\"text-align:center;padding:32px 24px;border-radius:12px;background-color:var(--wsb-bg);border:1px solid var(--wsb-border)\">\n <div style=\"font-size:36px;margin-bottom:16px\">\ud83c\udfae</div>\n <h3 style=\"font-size:20px;font-weight:600;color:var(--wsb-text);margin-bottom:8px\">StreamerTools</h3>\n <p style=\"font-size:14px;color:var(--wsb-muted);line-height:1.6\">Tools and resources built for content creators and streamers.</p>\n </div>\n </div>\n</section><section id=\"about-preview\" style=\"width:100%;padding-top:60px;padding-bottom:60px\"><div style=\"max-width:1200px;margin:0 auto\"><div style=\"padding:20px;min-height:100px;border:none;box-shadow:none;opacity:1\"><h2 style=\"font-size:36px;font-weight:700;font-family:Inter, sans-serif;color:#1f2937;margin-bottom:16px;border:none;box-shadow:none;opacity:1\">Building Brands That Matter</h2><p style=\"font-size:16px;line-height:1.6;color:#3f3f46;border:none;box-shadow:none;opacity:1\">OLS Digital is a privately held parent company focused on acquiring and growing digital-first businesses. Each brand under our umbrella operates independently, guided by a shared commitment to honesty, quality, and great customer experiences.</p></div></div></section><section id=\"contact-cta\" style=\"background:linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);padding:80px 20px;text-align:center\">\n <div style=\"max-width:700px;margin:0 auto\">\n <h2 style=\"font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px\">Want to Learn More?</h2>\n <p style=\"font-size:18px;color:rgba(255,255,255,0.85);margin-bottom:28px;line-height:1.6\">For inquiries about OLS Digital or any of our brands, reach out to our team directly.</p>\n <div style=\"display:flex;gap:12px;justify-content:center;flex-wrap:wrap\"><a href=\"mailto:info@olsdigital.com\" style=\"display:inline-block;padding:14px 36px;background-color:var(--wsb-bg);color:var(--wsb-text);text-decoration:none;border-radius:8px;font-weight:600;font-size:16px\">Email info@olsdigital.com</a></div>\n </div>\n</section></div>"
},
{
"filename": "brands.html",
"title": "Brands",
"html": "<div style=\"padding:20px;min-height:100px;border:none;box-shadow:none;opacity:1\"><section id=\"brands\" style=\"background:#1e293b;min-height:35vh;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px\">\n\n<div style=\"max-width:800px;width:100%;position:relative;z-index:2;text-align:center\">\n <h1 style=\"font-size:48px;font-weight:700;color:#ffffff;margin-bottom:16px;line-height:1.2\">Our Brands</h1>\n <p style=\"font-size:20px;color:#ffffff;opacity:0.85;margin-bottom:32px;line-height:1.6;white-space:pre-line\">A closer look at the companies under the OLS Digital umbrella.</p>\n <div style=\"display:flex;gap:12px;justify-content:center;flex-wrap:wrap\"></div>\n</div>\n</section><style>\n .cols_67iy9y > :nth-child(1) { flex: 0 0 calc(40% - 40px) !important; }\n .cols_67iy9y > :nth-child(2) { flex: 0 0 calc(60% - 40px) !important; }\n</style>\n<div class=\"cols_67iy9y\" id=\"anhonesthost\" style=\"display:flex;flex-wrap:wrap;gap:40px;width:100%;padding-top:60px;padding-bottom:20px\"><div style=\"flex:0 0 calc(40% - 40px);min-height:60px;padding:8px\"></div><div style=\"padding:20px;min-height:100px;border:none;box-shadow:none;opacity:1\"><h2 style=\"font-size:36px;font-weight:700;font-family:Inter, sans-serif;color:#1f2937;margin-bottom:16px;border:none;box-shadow:none;opacity:1\">AnHonestHost</h2><p style=\"font-size:16px;line-height:1.6;color:#3f3f46;border:none;box-shadow:none;opacity:1\">AnHonestHost provides transparent, no-nonsense web hosting for individuals and businesses. Straightforward pricing, honest support, and reliable infrastructure \u2014 no surprises.</p></div></div><hr style=\"border:none;border-top:1px solid #e2e8f0;margin:16px 0\" /><style>\n .cols_1ctfb24 > :nth-child(1) { flex: 0 0 calc(60% - 40px) !important; }\n .cols_1ctfb24 > :nth-child(2) { flex: 0 0 calc(40% - 40px) !important; }\n</style>\n<div class=\"cols_1ctfb24\" id=\"streamertools\" style=\"display:flex;flex-wrap:wrap;gap:40px;width:100%;padding-top:60px;padding-bottom:60px\"><div style=\"padding:20px;min-height:100px;border:none;box-shadow:none;opacity:1\"><h2 style=\"font-size:36px;font-weight:700;font-family:Inter, sans-serif;color:#1f2937;margin-bottom:16px;border:none;box-shadow:none;opacity:1\">StreamerTools</h2><p style=\"font-size:16px;line-height:1.6;color:#3f3f46;border:none;box-shadow:none;opacity:1\">StreamerTools builds practical tools and resources for streamers and content creators, helping them grow their channels and streamline their workflow.</p></div><div style=\"flex:0 0 calc(40% - 40px);min-height:60px;padding:8px\"></div></div><section id=\"brands-contact\" style=\"background:linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);padding:80px 20px;text-align:center\">\n <div style=\"max-width:700px;margin:0 auto\">\n <h2 style=\"font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px\">Interested in One of Our Brands?</h2>\n <p style=\"font-size:18px;color:rgba(255,255,255,0.85);margin-bottom:28px;line-height:1.6\">Reach out and we'll point you in the right direction.</p>\n <div style=\"display:flex;gap:12px;justify-content:center;flex-wrap:wrap\"><a href=\"mailto:info@olsdigital.com\" style=\"display:inline-block;padding:14px 36px;background-color:var(--wsb-bg);color:var(--wsb-text);text-decoration:none;border-radius:8px;font-weight:600;font-size:16px\">Email info@olsdigital.com</a></div>\n </div>\n</section></div>"
},
{
"filename": "about.html",
"title": "About",
"html": "<div style=\"padding:20px;min-height:100px;border:none;box-shadow:none;opacity:1\"><section id=\"about\" style=\"background:#1e293b;min-height:35vh;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px\">\n\n<div style=\"max-width:800px;width:100%;position:relative;z-index:2;text-align:center\">\n <h1 style=\"font-size:48px;font-weight:700;color:#ffffff;margin-bottom:16px;line-height:1.2\">About OLS Digital</h1>\n <p style=\"font-size:20px;color:#ffffff;opacity:0.85;margin-bottom:32px;line-height:1.6;white-space:pre-line\">Who we are and what we believe in.</p>\n <div style=\"display:flex;gap:12px;justify-content:center;flex-wrap:wrap\"></div>\n</div>\n</section><section id=\"our-story\" style=\"width:100%;padding-top:60px;padding-bottom:20px\"><div style=\"max-width:1200px;margin:0 auto\"><div style=\"padding:20px;min-height:100px;border:none;box-shadow:none;opacity:1\"><h2 style=\"font-size:36px;font-weight:700;font-family:Inter, sans-serif;color:#1f2937;margin-bottom:16px;border:none;box-shadow:none;opacity:1\">Our Story</h2><p style=\"font-size:16px;line-height:1.6;color:#3f3f46;border:none;box-shadow:none;opacity:1\">OLS Digital was founded to bring structure, resources, and long-term support to promising digital brands. Rather than absorbing the businesses we work with, we help them keep their independent identity while benefiting from shared operational support.</p></div></div></section><section id=\"our-mission\" style=\"width:100%;padding-top:20px;padding-bottom:60px\"><div style=\"max-width:1200px;margin:0 auto\"><div style=\"padding:20px;min-height:100px;border:none;box-shadow:none;opacity:1\"><h2 style=\"font-size:36px;font-weight:700;font-family:Inter, sans-serif;color:#1f2937;margin-bottom:16px;border:none;box-shadow:none;opacity:1\">Our Mission</h2><p style=\"font-size:16px;line-height:1.6;color:#3f3f46;border:none;box-shadow:none;opacity:1\">We believe in honesty, quality, and putting people first. Every brand under the OLS Digital name is expected to uphold these same values in how they serve their customers.</p></div></div></section><section id=\"about-contact\" style=\"background:linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);padding:80px 20px;text-align:center\">\n <div style=\"max-width:700px;margin:0 auto\">\n <h2 style=\"font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px\">Have Questions?</h2>\n <p style=\"font-size:18px;color:rgba(255,255,255,0.85);margin-bottom:28px;line-height:1.6\">We'd love to hear from you.</p>\n <div style=\"display:flex;gap:12px;justify-content:center;flex-wrap:wrap\"><a href=\"mailto:info@olsdigital.com\" style=\"display:inline-block;padding:14px 36px;background-color:var(--wsb-bg);color:var(--wsb-text);text-decoration:none;border-radius:8px;font-weight:600;font-size:16px\">Email info@olsdigital.com</a></div>\n </div>\n</section></div>"
},
{
"filename": "contact.html",
"title": "Contact",
"html": "<div style=\"padding:20px;min-height:100px;border:none;box-shadow:none;opacity:1\"><section id=\"contact\" style=\"background:linear-gradient(135deg, #0f172a, #334155);min-height:40vh;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px\">\n\n<div style=\"max-width:800px;width:100%;position:relative;z-index:2;text-align:center\">\n <h1 style=\"font-size:48px;font-weight:700;color:#ffffff;margin-bottom:16px;line-height:1.2\">Contact Us</h1>\n <p style=\"font-size:20px;color:#ffffff;opacity:0.85;margin-bottom:32px;line-height:1.6;white-space:pre-line\">Have a question about OLS Digital or one of our brands? We're happy to help.</p>\n <div style=\"display:flex;gap:12px;justify-content:center;flex-wrap:wrap\"></div>\n</div>\n</section><section id=\"contact-info\" style=\"width:100%;padding-top:60px;padding-bottom:60px;text-align:center\"><div style=\"max-width:1200px;margin:0 auto\"><div style=\"padding:20px;min-height:100px;border:none;box-shadow:none;opacity:1\"><h2 style=\"font-size:36px;font-weight:700;font-family:Inter, sans-serif;color:#1f2937;margin-bottom:16px;border:none;box-shadow:none;opacity:1\">Get In Touch</h2><p style=\"font-size:16px;line-height:1.6;color:#3f3f46;border:none;box-shadow:none;opacity:1\">For all inquiries regarding OLS Digital, AnHonestHost, StreamerTools, or any of our other brands, please reach out via email and our team will respond as soon as possible.</p><div style=\"height:20px\"></div><a href=\"mailto:info@olsdigital.com\" style=\"display:inline-block;text-decoration:none;background-color:#3b82f6;color:#ffffff;padding:12px 24px;border-radius:8px;font-weight:600;font-size:16px;border:none;box-shadow:none;opacity:1\">info@olsdigital.com</a></div></div></section></div>"
}
],
"header_html": "<style>\n .nav_yk1fl5 .navbar-link:hover { color: #3b82f6 !important; }\n .nav_yk1fl5 .navbar-cta:hover { filter: brightness(1.1); }\n</style>\n<nav class=\"nav_yk1fl5\" style=\"display:flex;align-items:center;justify-content:space-between;padding:16px 24px;background-color:#0f172a;border-bottom:1px solid #e4e4e7\">\n <a href=\"/\" style=\"text-decoration:none;display:flex;align-items:center;flex-shrink:0\"><img src=\"/api/assets-proxy/?site_id=100&amp;filename=1789158005_2592c5cf_Minimalist_OLS_Digital_Logo_with_Rounded_Elements__400_x_135_px_.webp\" alt=\"Logo\" style=\"width:120px;height:auto;display:block\" /></a>\n <div class=\"navbar-links\" id=\"nav_yk1fl5_links\" style=\"display:flex;align-items:center;gap:24px\">\n <a href=\"/\" class=\"navbar-link\" style=\"text-decoration:none;font-size:14px;font-weight:400;color:#ffffff;background-color:transparent;padding:0;border-radius:0;transition:color 0.15s, background-color 0.15s\">Home</a>\n <a href=\"/brands\" class=\"navbar-link\" style=\"text-decoration:none;font-size:14px;font-weight:400;color:#ffffff;background-color:transparent;padding:0;border-radius:0;transition:color 0.15s, background-color 0.15s\">Our Brands</a>\n <a href=\"/about\" class=\"navbar-link\" style=\"text-decoration:none;font-size:14px;font-weight:400;color:#ffffff;background-color:transparent;padding:0;border-radius:0;transition:color 0.15s, background-color 0.15s\">About</a>\n <a href=\"/contact\" class=\"navbar-link\" style=\"text-decoration:none;font-size:14px;font-weight:400;color:#ffffff;background-color:transparent;padding:0;border-radius:0;transition:color 0.15s, background-color 0.15s\">Contact</a>\n <a href=\"/contact\" class=\"navbar-cta\" style=\"text-decoration:none;font-size:14px;font-weight:600;color:#ffffff;background-color:#3b82f6;padding:8px 20px;border-radius:6px;transition:color 0.15s, background-color 0.15s\">Contact</a>\n </div>\n</nav>",
"footer_html": "<footer style=\"padding:24px 20px;text-align:center\">\u00a9 2026 OLS Digital. All rights reserved. Questions? Email info@olsdigital.com</footer>",
"craft_state": "{\"ai-hero-1\":{\"type\":{\"resolvedName\":\"HeroSimple\"},\"isCanvas\":false,\"props\":{\"heading\":\"OLS Digital\",\"subtitle\":\"The parent company behind a growing family of trusted online brands, including AnHonestHost and StreamerTools.\",\"ctas\":[{\"text\":\"Explore Our Brands\",\"href\":\"/brands\",\"variant\":\"primary\"},{\"text\":\"Contact Us\",\"href\":\"/contact\",\"variant\":\"outline\"}],\"bgType\":\"gradient\",\"bgColor\":\"#1e293b\",\"bgGradientFrom\":\"#0f172a\",\"bgGradientTo\":\"#334155\",\"bgGradientAngle\":135,\"bgImage\":\"\",\"bgPosition\":\"\",\"bgVideo\":\"\",\"overlayColor\":\"#000000\",\"overlayOpacity\":0,\"textColor\":\"#ffffff\",\"buttonBgColor\":\"#3b82f6\",\"buttonTextColor\":\"#ffffff\",\"minHeight\":\"60vh\",\"verticalAlign\":\"center\",\"textAlign\":\"center\",\"anchorId\":\"home\",\"style\":{},\"aiName\":\"Hero\",\"node_id\":\"ai-hero-1\"},\"displayName\":\"Hero\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-features-1\":{\"type\":{\"resolvedName\":\"FeaturesGrid\"},\"isCanvas\":false,\"props\":{\"features\":[{\"icon\":\"\ud83c\udfe2\",\"title\":\"Multi-Brand Portfolio\",\"description\":\"We own and support a growing collection of independent, high-quality brands.\"},{\"icon\":\"\ud83c\udf10\",\"title\":\"AnHonestHost\",\"description\":\"Transparent, reliable web hosting built on trust.\"},{\"icon\":\"\ud83c\udfae\",\"title\":\"StreamerTools\",\"description\":\"Tools and resources built for content creators and streamers.\"}],\"style\":{\"backgroundColor\":\"#ffffff\"},\"anchorId\":\"what-we-do\",\"animation\":\"\",\"animationDelay\":\"\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"What We Do\",\"node_id\":\"ai-features-1\"},\"displayName\":\"Features Grid\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-section-intro-1\":{\"type\":{\"resolvedName\":\"Section\"},\"isCanvas\":false,\"props\":{\"style\":{\"paddingTop\":\"60px\",\"paddingBottom\":\"60px\"},\"innerMaxWidth\":\"1200px\",\"topDivider\":\"none\",\"topDividerColor\":\"#ffffff\",\"topDividerHeight\":\"50px\",\"bottomDivider\":\"none\",\"bottomDividerColor\":\"#ffffff\",\"bottomDividerHeight\":\"50px\",\"anchorId\":\"about-preview\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Intro Section\",\"node_id\":\"ai-section-intro-1\"},\"displayName\":\"Section\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{\"section-inner\":\"ai-section-intro-1__section-inner\"}},\"ai-heading-intro-1\":{\"type\":{\"resolvedName\":\"Heading\"},\"isCanvas\":false,\"props\":{\"text\":\"Building Brands That Matter\",\"html\":\"\",\"level\":\"h2\",\"style\":{\"fontSize\":\"36px\",\"fontWeight\":\"700\",\"fontFamily\":\"Inter, sans-serif\",\"color\":\"#1f2937\",\"marginBottom\":\"16px\",\"lineHeight\":\"\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Intro Heading\",\"node_id\":\"ai-heading-intro-1\"},\"displayName\":\"Heading\",\"custom\":{},\"parent\":\"ai-section-intro-1__section-inner\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-text-intro-1\":{\"type\":{\"resolvedName\":\"TextBlock\"},\"isCanvas\":false,\"props\":{\"text\":\"OLS Digital is a privately held parent company focused on acquiring and growing digital-first businesses. Each brand under our umbrella operates independently, guided by a shared commitment to honesty, quality, and great customer experiences.\",\"html\":\"\",\"style\":{\"fontSize\":\"16px\",\"lineHeight\":\"1.6\",\"color\":\"#3f3f46\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Intro Text\",\"node_id\":\"ai-text-intro-1\"},\"displayName\":\"Text\",\"custom\":{},\"parent\":\"ai-section-intro-1__section-inner\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-section-intro-1__section-inner\":{\"type\":{\"resolvedName\":\"Container\"},\"isCanvas\":true,\"props\":{\"style\":{\"padding\":\"20px\",\"minHeight\":\"100px\",\"justifyContent\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottoLine truncated
"header_craft_state": "{\"ROOT\":{\"type\":{\"resolvedName\":\"Navbar\"},\"isCanvas\":true,\"props\":{\"logoType\":\"image\",\"logoText\":\"\",\"logoImage\":\"/api/assets-proxy/?site_id=100&filename=1789158005_2592c5cf_Minimalist_OLS_Digital_Logo_with_Rounded_Elements__400_x_135_px_.webp\",\"logoWidth\":\"120px\",\"logoUrl\":\"/\",\"logoFontFamily\":\"Inter, sans-serif\",\"logoFontSize\":\"20px\",\"links\":[{\"text\":\"Home\",\"href\":\"/\",\"source\":\"page\",\"pageId\":\"home\"},{\"text\":\"Our Brands\",\"href\":\"/brands\",\"source\":\"page\",\"pageId\":\"page_mtxec41h_1\"},{\"text\":\"About\",\"href\":\"/about\",\"source\":\"page\",\"pageId\":\"page_mtxec41h_2\"},{\"text\":\"Contact\",\"href\":\"/contact\",\"source\":\"page\",\"pageId\":\"page_mtxec41h_3\"},{\"text\":\"Contact\",\"href\":\"/contact\",\"isCta\":true}],\"followPages\":true,\"backgroundColor\":\"#0f172a\",\"textColor\":\"#ffffff\",\"hoverColor\":\"#3b82f6\",\"ctaColor\":\"#3b82f6\",\"ctaTextColor\":\"#ffffff\",\"padding\":\"16px 24px\",\"navAlignment\":\"space-between\",\"isSticky\":false,\"showMobileMenu\":false,\"style\":{\"borderBottom\":\"1px solid #e4e4e7\"},\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"animation\":\"none\",\"animationDelay\":\"0\",\"aiName\":\"Site Nav\",\"node_id\":\"ai-nav-1\"},\"displayName\":\"Navbar\",\"custom\":{},\"parent\":null,\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}}}",
"footer_craft_state": "{\"ROOT\":{\"type\":{\"resolvedName\":\"Footer\"},\"isCanvas\":true,\"props\":{\"aiName\":\"Site Footer\",\"node_id\":\"ai-footer-1\",\"text\":\"\u00a9 2026 OLS Digital. All rights reserved. Questions? Email info@olsdigital.com\"},\"displayName\":\"Footer\",\"custom\":{},\"hidden\":false,\"parent\":null,\"nodes\":[],\"linkedNodes\":{}}}",
"pages_craft_state": [
{
"id": "home",
"name": "Home",
"slug": "index",
"craftState": "{\"ai-hero-1\":{\"type\":{\"resolvedName\":\"HeroSimple\"},\"isCanvas\":false,\"props\":{\"heading\":\"OLS Digital\",\"subtitle\":\"The parent company behind a growing family of trusted online brands, including AnHonestHost and StreamerTools.\",\"ctas\":[{\"text\":\"Explore Our Brands\",\"href\":\"/brands\",\"variant\":\"primary\"},{\"text\":\"Contact Us\",\"href\":\"/contact\",\"variant\":\"outline\"}],\"bgType\":\"gradient\",\"bgColor\":\"#1e293b\",\"bgGradientFrom\":\"#0f172a\",\"bgGradientTo\":\"#334155\",\"bgGradientAngle\":135,\"bgImage\":\"\",\"bgPosition\":\"\",\"bgVideo\":\"\",\"overlayColor\":\"#000000\",\"overlayOpacity\":0,\"textColor\":\"#ffffff\",\"buttonBgColor\":\"#3b82f6\",\"buttonTextColor\":\"#ffffff\",\"minHeight\":\"60vh\",\"verticalAlign\":\"center\",\"textAlign\":\"center\",\"anchorId\":\"home\",\"style\":{},\"aiName\":\"Hero\",\"node_id\":\"ai-hero-1\"},\"displayName\":\"Hero\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-features-1\":{\"type\":{\"resolvedName\":\"FeaturesGrid\"},\"isCanvas\":false,\"props\":{\"features\":[{\"icon\":\"\ud83c\udfe2\",\"title\":\"Multi-Brand Portfolio\",\"description\":\"We own and support a growing collection of independent, high-quality brands.\"},{\"icon\":\"\ud83c\udf10\",\"title\":\"AnHonestHost\",\"description\":\"Transparent, reliable web hosting built on trust.\"},{\"icon\":\"\ud83c\udfae\",\"title\":\"StreamerTools\",\"description\":\"Tools and resources built for content creators and streamers.\"}],\"style\":{\"backgroundColor\":\"#ffffff\"},\"anchorId\":\"what-we-do\",\"animation\":\"\",\"animationDelay\":\"\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"What We Do\",\"node_id\":\"ai-features-1\"},\"displayName\":\"Features Grid\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-section-intro-1\":{\"type\":{\"resolvedName\":\"Section\"},\"isCanvas\":false,\"props\":{\"style\":{\"paddingTop\":\"60px\",\"paddingBottom\":\"60px\"},\"innerMaxWidth\":\"1200px\",\"topDivider\":\"none\",\"topDividerColor\":\"#ffffff\",\"topDividerHeight\":\"50px\",\"bottomDivider\":\"none\",\"bottomDividerColor\":\"#ffffff\",\"bottomDividerHeight\":\"50px\",\"anchorId\":\"about-preview\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Intro Section\",\"node_id\":\"ai-section-intro-1\"},\"displayName\":\"Section\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{\"section-inner\":\"ai-section-intro-1__section-inner\"}},\"ai-heading-intro-1\":{\"type\":{\"resolvedName\":\"Heading\"},\"isCanvas\":false,\"props\":{\"text\":\"Building Brands That Matter\",\"html\":\"\",\"level\":\"h2\",\"style\":{\"fontSize\":\"36px\",\"fontWeight\":\"700\",\"fontFamily\":\"Inter, sans-serif\",\"color\":\"#1f2937\",\"marginBottom\":\"16px\",\"lineHeight\":\"\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Intro Heading\",\"node_id\":\"ai-heading-intro-1\"},\"displayName\":\"Heading\",\"custom\":{},\"parent\":\"ai-section-intro-1__section-inner\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-text-intro-1\":{\"type\":{\"resolvedName\":\"TextBlock\"},\"isCanvas\":false,\"props\":{\"text\":\"OLS Digital is a privately held parent company focused on acquiring and growing digital-first businesses. Each brand under our umbrella operates independently, guided by a shared commitment to honesty, quality, and great customer experiences.\",\"html\":\"\",\"style\":{\"fontSize\":\"16px\",\"lineHeight\":\"1.6\",\"color\":\"#3f3f46\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Intro Text\",\"node_id\":\"ai-text-intro-1\"},\"displayName\":\"Text\",\"custom\":{},\"parent\":\"ai-section-intro-1__section-inner\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-section-intro-1__section-inner\":{\"type\":{\"resolvedName\":\"Container\"},\"isCanvas\":true,\"props\":{\"style\":{\"padding\":\"20px\",\"minHeight\":\"100px\",\"justifyContent\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddiLine truncated
},
{
"id": "page_mtxec41h_1",
"name": "Brands",
"slug": "brands",
"craftState": "{\"ai-hero-brands-1\":{\"type\":{\"resolvedName\":\"HeroSimple\"},\"isCanvas\":false,\"props\":{\"heading\":\"Our Brands\",\"subtitle\":\"A closer look at the companies under the OLS Digital umbrella.\",\"ctas\":[],\"bgType\":\"color\",\"bgColor\":\"#1e293b\",\"bgGradientFrom\":\"#667eea\",\"bgGradientTo\":\"#764ba2\",\"bgGradientAngle\":135,\"bgImage\":\"\",\"bgPosition\":\"\",\"bgVideo\":\"\",\"overlayColor\":\"#000000\",\"overlayOpacity\":0,\"textColor\":\"#ffffff\",\"buttonBgColor\":\"#3b82f6\",\"buttonTextColor\":\"#ffffff\",\"minHeight\":\"35vh\",\"verticalAlign\":\"center\",\"textAlign\":\"center\",\"anchorId\":\"brands\",\"style\":{},\"aiName\":\"Brands Hero\",\"node_id\":\"ai-hero-brands-1\"},\"displayName\":\"Hero\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-columns-anhonesthost-1\":{\"type\":{\"resolvedName\":\"ColumnLayout\"},\"isCanvas\":false,\"props\":{\"columns\":2,\"split\":\"40-60\",\"gap\":\"40px\",\"style\":{\"paddingTop\":\"60px\",\"paddingBottom\":\"20px\"},\"anchorId\":\"anhonesthost\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Brand: AnHonestHost\",\"node_id\":\"ai-columns-anhonesthost-1\"},\"displayName\":\"Columns\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{\"col-0\":\"K8SJDt59Ct\",\"col-1\":\"ai-container-anhonesthost-1\"}},\"ai-container-anhonesthost-1\":{\"type\":{\"resolvedName\":\"Container\"},\"isCanvas\":true,\"props\":{\"style\":{\"padding\":\"20px\",\"minHeight\":\"100px\",\"justifyContent\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"tag\":\"div\",\"fullWidth\":false,\"contentWidth\":\"full\",\"anchorId\":\"\",\"cssId\":\"\",\"cssClass\":\"\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"AnHonestHost Text\",\"node_id\":\"ai-container-anhonesthost-1\"},\"displayName\":\"Container\",\"custom\":{},\"parent\":\"ai-columns-anhonesthost-1\",\"hidden\":false,\"nodes\":[\"ai-heading-anhonesthost-1\",\"ai-text-anhonesthost-1\"],\"linkedNodes\":{}},\"ai-heading-anhonesthost-1\":{\"type\":{\"resolvedName\":\"Heading\"},\"isCanvas\":false,\"props\":{\"text\":\"AnHonestHost\",\"html\":\"\",\"level\":\"h2\",\"style\":{\"fontSize\":\"36px\",\"fontWeight\":\"700\",\"fontFamily\":\"Inter, sans-serif\",\"color\":\"#1f2937\",\"marginBottom\":\"16px\",\"lineHeight\":\"\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"AnHonestHost Heading\",\"node_id\":\"ai-heading-anhonesthost-1\"},\"displayName\":\"Heading\",\"custom\":{},\"parent\":\"ai-container-anhonesthost-1\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-text-anhonesthost-1\":{\"type\":{\"resolvedName\":\"TextBlock\"},\"isCanvas\":false,\"props\":{\"text\":\"AnHonestHost provides transparent, no-nonsense web hosting for individuals and businesses. Straightforward pricing, honest support, and reliable infrastructure \u2014 no surprises.\",\"html\":\"\",\"style\":{\"fontSize\":\"16px\",\"lineHeight\":\"1.6\",\"color\":\"#3f3f46\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"AnHonestHost Description\",\"node_id\":\"ai-text-anhonesthost-1\"},\"displayName\":\"Text\",\"custom\":{},\"parent\":\"ai-container-anhonesthost-1\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-divider-1\":{\"type\":{\"resolvedName\":\"Divider\"},\"isCanvas\":false,\"props\":{\"color\":\"#e2e8f0\",\"thickness\":\"1px\",\"style\":{},\"aiName\":\"Divider 1\",\"node_id\":\"ai-divider-1\"},\"displayName\":\"Divider\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-columns-streamertools-1\":{\"type\":{\"resolvedName\":\"ColumnLayout\"},\"isCanvas\":false,\"props\":{\"columns\":2,\"split\":\"60-40\",\"gap\":\"40px\",\"style\":{\"paddingTop\":\"60px\",\"paddingBottom\":\"60px\"},\"anchorId\":\"streamertools\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\Line truncated
"nav": {
"label": "Our Brands"
}
},
{
"id": "page_mtxec41h_2",
"name": "About",
"slug": "about",
"craftState": "{\"ai-hero-about-1\":{\"type\":{\"resolvedName\":\"HeroSimple\"},\"isCanvas\":false,\"props\":{\"heading\":\"About OLS Digital\",\"subtitle\":\"Who we are and what we believe in.\",\"ctas\":[],\"bgType\":\"color\",\"bgColor\":\"#1e293b\",\"bgGradientFrom\":\"#667eea\",\"bgGradientTo\":\"#764ba2\",\"bgGradientAngle\":135,\"bgImage\":\"\",\"bgVideo\":\"\",\"overlayColor\":\"#000000\",\"overlayOpacity\":0,\"textColor\":\"#ffffff\",\"buttonBgColor\":\"#3b82f6\",\"buttonTextColor\":\"#ffffff\",\"minHeight\":\"35vh\",\"verticalAlign\":\"center\",\"textAlign\":\"center\",\"anchorId\":\"about\",\"style\":{},\"aiName\":\"About Hero\",\"node_id\":\"ai-hero-about-1\"},\"displayName\":\"Hero\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-section-about-1\":{\"type\":{\"resolvedName\":\"Section\"},\"isCanvas\":false,\"props\":{\"style\":{\"paddingTop\":\"60px\",\"paddingBottom\":\"20px\"},\"innerMaxWidth\":\"1200px\",\"topDivider\":\"none\",\"topDividerColor\":\"#ffffff\",\"topDividerHeight\":\"50px\",\"bottomDivider\":\"none\",\"bottomDividerColor\":\"#ffffff\",\"bottomDividerHeight\":\"50px\",\"anchorId\":\"our-story\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"About Content\",\"node_id\":\"ai-section-about-1\"},\"displayName\":\"Section\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{\"section-inner\":\"ai-section-about-1__section-inner\"}},\"ai-heading-about-1\":{\"type\":{\"resolvedName\":\"Heading\"},\"isCanvas\":false,\"props\":{\"text\":\"Our Story\",\"level\":\"h2\",\"style\":{\"fontSize\":\"36px\",\"fontWeight\":\"700\",\"fontFamily\":\"Inter, sans-serif\",\"color\":\"#1f2937\",\"marginBottom\":\"16px\",\"lineHeight\":\"\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Our Story Heading\",\"node_id\":\"ai-heading-about-1\"},\"displayName\":\"Heading\",\"custom\":{},\"parent\":\"ai-section-about-1__section-inner\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-text-about-1\":{\"type\":{\"resolvedName\":\"TextBlock\"},\"isCanvas\":false,\"props\":{\"text\":\"OLS Digital was founded to bring structure, resources, and long-term support to promising digital brands. Rather than absorbing the businesses we work with, we help them keep their independent identity while benefiting from shared operational support.\",\"style\":{\"fontSize\":\"16px\",\"lineHeight\":\"1.6\",\"color\":\"#3f3f46\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Our Story Text\",\"node_id\":\"ai-text-about-1\"},\"displayName\":\"Text\",\"custom\":{},\"parent\":\"ai-section-about-1__section-inner\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-section-about-1__section-inner\":{\"type\":{\"resolvedName\":\"Container\"},\"isCanvas\":true,\"props\":{\"style\":{\"padding\":\"20px\",\"minHeight\":\"100px\",\"justifyContent\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"tag\":\"div\",\"fullWidth\":false,\"contentWidth\":\"full\",\"anchorId\":\"\",\"cssId\":\"\",\"cssClass\":\"\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false},\"displayName\":\"Container\",\"custom\":{},\"parent\":\"ai-section-about-1\",\"hidden\":false,\"nodes\":[\"ai-heading-about-1\",\"ai-text-about-1\"],\"linkedNodes\":{}},\"ai-section-mission-1\":{\"type\":{\"resolvedName\":\"Section\"},\"isCanvas\":false,\"props\":{\"style\":{\"paddingTop\":\"20px\",\"paddingBottom\":\"60px\"},\"innerMaxWidth\":\"1200px\",\"topDivider\":\"none\",\"topDividerColor\":\"#ffffff\",\"topDividerHeight\":\"50px\",\"bottomDivider\":\"none\",\"bottomDividerColor\":\"#ffffff\",\"bottomDividerHeight\":\"50px\",\"anchorId\":\"our-mission\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Our Mission\",\"node_id\":\"ai-section-mission-1\"},\"displayName\":\"Section\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":falLine truncated
},
{
"id": "page_mtxec41h_3",
"name": "Contact",
"slug": "contact",
"craftState": "{\"ai-hero-contact-1\":{\"type\":{\"resolvedName\":\"HeroSimple\"},\"isCanvas\":false,\"props\":{\"heading\":\"Contact Us\",\"subtitle\":\"Have a question about OLS Digital or one of our brands? We're happy to help.\",\"ctas\":[],\"bgType\":\"gradient\",\"bgColor\":\"#1e293b\",\"bgGradientFrom\":\"#0f172a\",\"bgGradientTo\":\"#334155\",\"bgGradientAngle\":135,\"bgImage\":\"\",\"bgVideo\":\"\",\"overlayColor\":\"#000000\",\"overlayOpacity\":0,\"textColor\":\"#ffffff\",\"buttonBgColor\":\"#3b82f6\",\"buttonTextColor\":\"#ffffff\",\"minHeight\":\"40vh\",\"verticalAlign\":\"center\",\"textAlign\":\"center\",\"anchorId\":\"contact\",\"style\":{},\"aiName\":\"Contact Hero\",\"node_id\":\"ai-hero-contact-1\"},\"displayName\":\"Hero\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-section-contact-1\":{\"type\":{\"resolvedName\":\"Section\"},\"isCanvas\":false,\"props\":{\"style\":{\"paddingTop\":\"60px\",\"paddingBottom\":\"60px\",\"textAlign\":\"center\"},\"innerMaxWidth\":\"1200px\",\"topDivider\":\"none\",\"topDividerColor\":\"#ffffff\",\"topDividerHeight\":\"50px\",\"bottomDivider\":\"none\",\"bottomDividerColor\":\"#ffffff\",\"bottomDividerHeight\":\"50px\",\"anchorId\":\"contact-info\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Contact Info\",\"node_id\":\"ai-section-contact-1\"},\"displayName\":\"Section\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{\"section-inner\":\"ai-section-contact-1__section-inner\"}},\"ai-heading-contact-1\":{\"type\":{\"resolvedName\":\"Heading\"},\"isCanvas\":false,\"props\":{\"text\":\"Get In Touch\",\"level\":\"h2\",\"style\":{\"fontSize\":\"36px\",\"fontWeight\":\"700\",\"fontFamily\":\"Inter, sans-serif\",\"color\":\"#1f2937\",\"marginBottom\":\"16px\",\"lineHeight\":\"\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Contact Heading\",\"node_id\":\"ai-heading-contact-1\"},\"displayName\":\"Heading\",\"custom\":{},\"parent\":\"ai-section-contact-1__section-inner\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-text-contact-1\":{\"type\":{\"resolvedName\":\"TextBlock\"},\"isCanvas\":false,\"props\":{\"text\":\"For all inquiries regarding OLS Digital, AnHonestHost, StreamerTools, or any of our other brands, please reach out via email and our team will respond as soon as possible.\",\"style\":{\"fontSize\":\"16px\",\"lineHeight\":\"1.6\",\"color\":\"#3f3f46\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Contact Text\",\"node_id\":\"ai-text-contact-1\"},\"displayName\":\"Text\",\"custom\":{},\"parent\":\"ai-section-contact-1__section-inner\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-spacer-contact-1\":{\"type\":{\"resolvedName\":\"Spacer\"},\"isCanvas\":false,\"props\":{\"height\":\"20px\",\"style\":{},\"aiName\":\"Spacer\",\"node_id\":\"ai-spacer-contact-1\"},\"displayName\":\"Spacer\",\"custom\":{},\"parent\":\"ai-section-contact-1__section-inner\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-button-contact-1\":{\"type\":{\"resolvedName\":\"ButtonLink\"},\"isCanvas\":false,\"props\":{\"text\":\"info@olsdigital.com\",\"href\":\"mailto:info@olsdigital.com\",\"target\":\"_self\",\"hoverBg\":\"\",\"hoverColor\":\"\",\"style\":{\"backgroundColor\":\"#3b82f6\",\"color\":\"#ffffff\",\"padding\":\"12px 24px\",\"borderRadius\":\"8px\",\"fontWeight\":\"600\",\"fontSize\":\"16px\",\"border\":\"none\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Email Button\",\"node_id\":\"ai-button-contact-1\"},\"displayName\":\"Button\",\"custom\":{},\"parent\":\"ai-section-contact-1__section-inner\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-section-contact-1__section-inner\":{\"type\":{\"resolvedName\":\"Container\"},\"isCanvas\":true,\"props\":{\"style\":{\"padding\":\"20px\",\"minHeight\":\"100px\",\"justifyContent\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paLine truncated
}
],
"head_code": "",
"design": {
"primaryColor": "#3b82f6",
"secondaryColor": "#8b5cf6",
"accentColor": "#10b981",
"headingFont": "Inter, sans-serif",
"bodyFont": "Inter, sans-serif",
"linkColor": "#3b82f6",
"successColor": "#10b981",
"warningColor": "#f59e0b",
"errorColor": "#ef4444",
"backgroundColor": "#ffffff",
"textColor": "#1f2937",
"mutedTextColor": "#6b7280",
"borderColor": "#e5e7eb",
"borderRadius": "8px",
"buttonFont": "Inter, sans-serif",
"buttonRadius": "8px",
"navStyle": "light",
"headCode": "",
"favicon": ""
},
"extras": [],
"_rev": "0af66cc775393317",
"_base_rev": "3d18b0567d28ea29",
"_client_id": "e4633813-f672-4fbb-9618-d16fb97ff90f"
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "ols",
"project": "ols.project.json",
"published": {"index": "ols.index.html", "about": "ols.about.html", "contact": "ols.contact.html"},
"_note": "brands is deliberately not listed: its ColumnLayout row renders differently in the canvas than the export (padding longhand / wrapping) -- a component bug unrelated to the base reset"
}