Overview#
Tessel is a multi-page marketing website for a SaaS form and survey builder, styled in a neo-brutalist way: warm
cream paper with visible grid lines, thick 2.5px black borders, hard offset shadows with no blur, flat tomato,
butter, mint, periwinkle and pink blocks, sticker-like badges and chunky buttons that press down when clicked.
The fictional company, Tessel Forms, Inc., is based at 1110 Harney Street in Omaha, Nebraska, so the copy uses US
spelling, prices are in US dollars and everything time-based runs in Central Time (America/Chicago). It is
built with plain HTML, Tailwind CSS v4 and vanilla JavaScript, has a light and a dark theme, and is version
1.0.0. See it on the template page, which links to the live demo.
Quick start#
-
Unzip the download and open
index.htmlin a browser. Everything works from the file system. -
Or serve the folder so URLs look like a real site:
shnpx serve . -
To change styles you need Node.js 24 or newer:
shnpm install npm run watch
assets/css/style.css is the compiled Tailwind output and is already included, so you only need Node when you
add or change classes.
Pages#
| File | Purpose | Notable sections and features |
|---|---|---|
index.html |
Home | Live mini form builder with a validating preview, customer wordmarks, four feature blocks, how it works, stats, testimonial, three featured templates, call to action |
features.html |
Features | Sticky section nav, conditional-logic demo with a running total, payments receipt, integration strip, drop-off analytics card with a 7/30 day switch, smaller features |
templates.html |
Templates gallery | Search, category chips, Saved filter, result count, empty state, preview dialog, save to shortlist |
pricing.html |
Pricing | Response-volume slider, monthly / annual switch, four plans with a "Best fit" sticker, comparison table, FAQ accordion |
integrations.html |
Integrations | Search and category filter over 20 integrations, "What syncs" details, empty state, webhook sample |
changelog.html |
Changelog | Releases with type filter, dates that stay current, "Coming next" roadmap, monthly roundup form |
customers.html |
Customer stories | Featured Bramblecourt story, six stories with before/after charts and an industry filter |
contact.html |
Talk to sales | Sales form with routing and a reference, live sales hours, next weekly walkthrough, team, map |
signup.html |
Sign up and sign in | Tabs, password strength, plan and template hand-off, sign-in and password reset |
404.html |
Not found | Links back to home and templates |
Project structure#
tessel/
index.html ... 404.html the ten pages (header and footer repeated in each)
assets/
css/style.css compiled Tailwind CSS (generated by npm run build)
js/data.js content: field types, templates, plans, integrations, changelog, stories
js/main.js theme, menu, reveals and every interactive feature
img/favicon.svg the four-tile logo
img/og-image.svg source of the social image
img/og-image.png 1200×630 social image used by og:image
src/input.css Tailwind source: @theme tokens, dark values, a few components
package.json build and watch scripts for the CSS
README.md short overview
DOCS.md this guide
CHANGELOG.md the template's version history
LICENSE.txt MIT license (in the download)Customizing#
Brand and colors#
All tokens live in the @theme block of src/input.css. Theme tokens change between light and dark; brand
colors stay the same in both.
| Token | Light | Used for |
|---|---|---|
--color-bg |
#fbf3e4 |
Page background (bg-bg) |
--color-bg-2 |
#f3e5c9 |
Alternate bands (bg-bg-2) |
--color-card |
#fffaf1 |
Cards, inputs |
--color-ink |
#16130f |
Text (text-ink) |
--color-ink-soft |
#4a4238 |
Secondary text |
--color-line |
#16130f |
Borders and hard shadows (border-line) |
--color-grid |
9% ink | The grid paper behind heroes |
--color-tomato, --color-butter, --color-mint, --color-peri, --color-bubble |
fixed | Accent blocks, stickers, chart bars |
--color-coal, --color-cream |
fixed | Text on accent blocks (always dark) and on the dark stats band |
The hard shadows are tokens too: --shadow-brut-sm, --shadow-brut and --shadow-brut-lg (utilities
shadow-brut-sm, shadow-brut, shadow-brut-lg). To change the main accent, edit one value and rebuild:
@theme {
--color-tomato: #ff4f8b; /* hot pink instead of tomato */
}Text on an accent block always uses text-coal, so it stays readable in dark mode. A few SVGs use the hex values
directly (for example #ff6b42); search for them if you change a brand color.
The small component layer in src/input.css holds the pieces used on every page: .btn (with .btn-tomato,
.btn-mint, .btn-plain, .btn-sm, .btn-lg), .icon-btn, .card, .sticker, .eyebrow, .chip, .field,
.label, .error, .switch, .range, .grid-paper, .marker and .reveal. Everything else is Tailwind
utilities in the markup.
Typography#
Three Google Fonts, loaded with one <link> in each page's <head>:
- Bowlby One for display headings (
font-display) - Ubuntu Sans for body text (
font-sans, weights 400–800) - Ubuntu Sans Mono for labels, stickers and numbers (
font-mono)
To swap a font, change the Google Fonts URL in every page and the matching --font-* token in src/input.css,
then rebuild the CSS.
Content#
Most page copy is plain HTML. The lists that drive interactive parts live in assets/js/data.js as
window.TESSEL:
| Constant | What it drives |
|---|---|
FIELD_TYPES, FIELD_ORDER, MAX_FIELDS, BUILDER_DEFAULT |
The home page form builder |
TICKETS, ADDON_TSHIRT |
Prices in the logic demo |
TEMPLATE_CATEGORIES, TEMPLATES |
Templates gallery, featured templates, sign-up hand-off |
VOLUME_STEPS, DEFAULT_VOLUME, ANNUAL_DISCOUNT, PLANS |
Pricing calculator |
INTEGRATION_CATEGORIES, INTEGRATIONS |
Integrations directory and the strip on the features page |
CHANGES, CHANGE_TAGS |
Changelog |
INDUSTRIES, STORIES |
Customer stories |
Add a template by appending an object to TEMPLATES. A bracketed hint in a field label sets its kind in the
thumbnail and the preview (choice, rating, payment, upload, checkbox, date, logic):
{ id: "potluck-signup", name: "Potluck sign-up", cat: "events", uses: 1280, accent: "butter",
desc: "Who's bringing what, with allergy notes.",
fields: ["Name", "Dish (choice)", "Contains nuts? (checkbox)", "Arrival time (date)"] },The category chips on templates.html are written in the HTML; add a chip there if you add a category.
Integrations take a glyph name from GLYPHS in main.js (wave, leaf, coin, bird, grid, bolt and
so on) and an accent (tomato, butter, mint, peri, bubble).
Settings that aren't content are at the top of main.js: TIME_ZONE, KEEP_DEMO_CURRENT, DEMO_ANCHOR,
STORAGE_KEYS, SALES_HOURS, WALKTHROUGH, TRIAL_DAYS and ANALYTICS (the numbers in the analytics card).
Images and artwork#
All artwork is inline SVG drawn for the template: the feature block illustrations, the hero doodles, portraits,
the school bus, the receipt, the map and the logo. Template thumbnails, integration logos and story charts are
generated by main.js (templateArt(), glyph(), storyChart(), portrait()), so they follow the data. If you
replace an illustration with a photo, keep a similar aspect ratio (the feature block art is about 2.7:1, the
testimonial portrait is square) and add a 2.5px border to match the style.
The social image is assets/img/og-image.png (1200×630), made from og-image.svg. Edit the SVG, then export it
to PNG with any SVG tool. The favicon is assets/img/favicon.svg.
Navigation, header and footer#
The header and footer are repeated in every HTML file. To add a page, copy an existing page, change its
<title>, description, Open Graph tags and data-page, and add a link to the <nav aria-label="Main"> list and
the mobile menu list (#mobile-menu) in every file. Mark the current page with aria-current="page". The desktop
nav shows from 1280px (xl:); below that the menu button opens the mobile menu.
Dark mode#
The moon button in the header ([data-theme-toggle]) switches themes. The choice is saved as tessel:theme
("light" or "dark"). With no saved choice the site follows the visitor's system setting. A small script in
each page's <head> adds the dark class to <html> before the page paints; src/input.css redefines the theme
tokens under html.dark, keeping the thick borders and shadows (cream on near-black). To ship light only, delete
the toggle button and the prefers-color-scheme check in the head script.
Features#
Live form builder#
initBuilder() in main.js, markup in index.html ([data-builder]). Visitors rename the form, add up to
MAX_FIELDS (8) fields from FIELD_ORDER, reword each question, toggle Required, move fields up and down and
remove them. The right-hand preview renders a real form: required questions show an asterisk, errors appear as
you type once a question has been touched (empty required answers, an email without @ and a domain, a phone
number with fewer than 10 digits), a progress bar counts required answers, and Submit shows a thank-you screen.
- On a first visit there are 3 fields (
BUILDER_DEFAULT), so the badge reads "3 fields" and the progress line "0 of 2 required answered". Adding Rating makes it "4 fields" with "How did we do?" in the preview. - The editor state is saved as
tessel:builder:v1; answers are not saved. Reset restores the default. - Accessibility: every control is a real button or input with a label; changes are announced in a polite live region ("Rating added. 4 fields."); focus stays on the moved or toggled control.
Templates gallery and shortlist#
initTemplates(), templates.html. Search matches name, description, category and field names; chips filter
by category or by Saved. The URL keeps ?cat= and ?q= so a filtered view can be shared, and ?preview=<id>
opens a template's preview (the home page cards link there). The preview is a native <dialog> that closes with
Esc, the close button or a click on the backdrop, and returns focus. The bookmark button saves a template to the
shortlist (tessel:saved-templates:v1), and "Use this template" links to signup.html?template=<id>.
Logic demo#
initLogicDemo(), features.html. Four rules show and hide questions: "In person" shows Ticket type and
Dietary needs, "Student" shows School email, "Online stream" shows the recording checkbox. Rules that fire turn
mint, and the badge reads "2 of 4 rules on" by default. The total is the ticket price from TICKETS when
attending in person plus ADDON_TSHIRT ($18): $40.00 by default, $15.00 for Student, $93.00 for Supporter with
a T-shirt, $18.00 online with a T-shirt.
Analytics card#
initAnalytics(), features.html. The 7 and 30 day buttons (aria-pressed) swap the numbers in ANALYTICS.
Completion is finishes ÷ starts (30 days: 2,288 ÷ 2,966 = 77%). The question with the biggest fall from the one
before it is drawn in tomato with a note (30 days: Dietary needs, 14 points).
Pricing calculator#
initPricing(), pricing.html. The slider steps through VOLUME_STEPS (100 to 100,000 responses a month).
For each plan, monthly price = base + extra × the number of blocks needed above included; a plan whose
max is below the volume shows "—". Annual billing takes ANNUAL_DISCOUNT (20%) off, rounded to whole dollars.
The cheapest plan that fits gets the "Best fit" sticker; from 50,000 responses Business is recommended.
| Responses | Billing | Free | Maker | Team |
|---|---|---|---|---|
| 1,000 (default) | annual | — | $19 | $47 |
| 1,000 | monthly | — | $24 | $59 |
| 2,500 | monthly | — | $40 | $59 |
| 25,000 | annual | — | — | $83 |
The choice is saved as tessel:pricing:v1; ?billing=monthly|annual and ?responses=<a value from VOLUME_STEPS> override it.
Integrations directory#
initIntegrations(), integrations.html. Search covers names, descriptions, categories and the "What syncs"
lists; chips filter by category. ?cat= and ?q= are kept in the URL (the features page strip links to
integrations.html?q=<name>).
Changelog#
initChangelog(), changelog.html. Releases from CHANGES, newest first, filterable by New, Improved and
Fixed. The "Coming next" months are filled in from today (data-months-ahead="1" is next month).
Customer stories#
initCustomers(), customers.html. Six stories from STORIES with an industry filter; each has a before and
after bar chart (with a text alternative) and an illustrated portrait. Links like customers.html#hobnail scroll
to a story.
Sales hours and walkthrough#
initContact(), contact.html. The badge says "Open now · until 5:30 p.m. CT" during SALES_HOURS
(Monday–Friday, 8:00–17:30 Central) and otherwise when sales opens next. The walkthrough card shows the first
Thursday at least one day after today at noon Central (on Tuesday, October 27, 2026: "Thursday, October 29 ·
12:00 p.m. CT"). Public holidays are not taken into account; add a check in salesStatus() if you need them.
Keeping the demo current#
Demos stay online for years, so nothing dated is allowed to go stale:
- Changelog dates: while
KEEP_DEMO_CURRENTistrue, each date inCHANGESmoves forward by the whole weeks elapsed sinceDEMO_ANCHOR(2026-10-27), so weekdays stay right and the newest release always reads as recent. On the anchor date the page shows exactly the dates indata.js. For a real site, setKEEP_DEMO_CURRENT = falseand write your real release dates. - Roadmap months, walkthrough date, trial end date and the footer year are worked out in the browser from today in Central Time.
?now=YYYY-MM-DDTHH:MMpreviews any page at another Central time, for examplecontact.html?now=2027-03-06T10:00.
Menu and motion#
The mobile menu button toggles aria-expanded, and Esc closes it. Sections fade in as they scroll into view
(.reveal, an IntersectionObserver); with reduced motion everything shows at once and button presses don't
animate.
Forms#
None of the forms send anything until you connect them. Each one validates on the client, shows inline errors
(linked with aria-describedby), and then shows its success state.
| Form | Where | Validates | Success state |
|---|---|---|---|
| Sales form | contact.html, [data-sales-form] |
Name, work email, organization, optional 10-digit phone, team size, volume | Thanks, the rep who has it (Ysolde Ebbesen for 50,000+ responses or 251+ people, otherwise Leopold Rasmussen), a reference like TS-7K2Q |
| Create account | signup.html, [data-signup-form] |
Name, email, password of 8+ characters with a number, terms | Welcome message, the plan or trial end date, the chosen template, workspace address |
| Sign in | signup.html, [data-signin-form] |
Email, password | "Signed in." |
| Password reset | signup.html, [data-reset-form] |
Reset link note | |
| Monthly roundup | changelog.html, [data-subscribe] |
Confirmation note | |
| Builder preview | index.html |
Per field, as described above | "Response #… recorded" (demo of the product) |
signup.html reads ?plan=free|maker|team (from the pricing buttons) and ?template=<id>; #signin opens the
Sign in tab.
To connect the sales form to a service such as Formspree, replace the comment in the sales form's submit handler
in main.js with a request, and show the success state only when it answers OK:
const response = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
if (!response.ok) {
setError(form, "email", "Something went wrong. Please try again or call (402) 555-0134.");
return;
}Make the handler async when you add await. Sign-up and sign-in need a real authentication provider; the
places to call it are marked with // To go live: comments.
Saved data (localStorage)#
| Key | Shape | Validated on read |
|---|---|---|
tessel:theme |
"light" or "dark" |
Any other value is ignored |
tessel:builder:v1 |
{ title, fields: [{ type, label, required }] } |
Unknown types dropped, strings trimmed to length, at most 8 fields; broken JSON is removed |
tessel:saved-templates:v1 |
["event-rsvp", ...] |
Only ids that exist in TEMPLATES, no duplicates |
tessel:pricing:v1 |
{ billing, responses } |
billing must be monthly or annual, responses a value in VOLUME_STEPS |
All access goes through safeStorage in main.js, which falls back to memory when the browser blocks storage
(private modes, sandboxed iframes), so features keep working for the page view. To reset, clear the site's data
in the browser or run localStorage.removeItem("tessel:builder:v1") in the console. URL values are checked with
own-key lookups, so values like ?cat=constructor are ignored.
SEO and sharing#
Each page has its own <title>, meta description, Open Graph tags, theme-color and og:locale in its
<head>. og:image points at assets/img/og-image.png; most social sites need an absolute URL, so change it to
https://your-domain.example/assets/img/og-image.png when you deploy. Add a sitemap.xml listing the pages and a
robots.txt that points to it. 404.html is picked up automatically by most static hosts.
Accessibility#
- Skip link, landmarks, one
<h1>per page and headings in order (the sign-up page has a visually hidden<h1>above the tabs). - Visible 3px focus outlines; chips show focus around the whole chip.
- Tabs on
signup.htmlfollow the ARIA tabs pattern (arrow keys move between tabs). - The preview dialog is a native modal
<dialog>; FAQ items are<details>. - Toggle buttons use
aria-pressed; result counts and the builder use polite live regions. - Charts have text alternatives; decorative SVGs are
aria-hidden. - Text meets WCAG AA in both themes; on accent blocks keep
text-coal. - Motion is reduced when the visitor asks for it.
When you edit, keep one <h1> per page, give every new input a <label>, and check that new color pairs still
pass contrast in dark mode.
Building and deploying#
npm install
npm run build # one-off, minified
npm run watch # rebuild on saveTailwind scans the HTML and JavaScript files for class names, so classes used only in main.js strings are
included as long as they are written out in full. Upload the folder (without node_modules, src and
package.json if you like) to any static host: Netlify Drop, GitHub Pages, Cloudflare Pages or your own server.
Troubleshooting#
- A new class has no effect. Rebuild the CSS with
npm run build; only classes found in the files are generated. Don't build class names from pieces like"bg-" + color; write the full name. - The builder shows old fields. It restores
tessel:builder:v1. Click Reset or clear the key. - Dark mode turns on by itself. With no saved choice the site follows the system setting; click the toggle once to save a choice.
- Changelog dates differ from
data.js.KEEP_DEMO_CURRENTis moving them forward; set it tofalse. - The sales badge says closed during business hours. Hours are Central Time; change
TIME_ZONEandSALES_HOURSinmain.jsfor another office. - Social previews show no image. Use an absolute URL in
og:image. - Fonts look like Arial. The Google Fonts link was removed or blocked; restore it in each page's
<head>.
License#
MIT. The code and all included artwork are free for personal and commercial use under LICENSE.txt.
Bowlby One (SIL Open Font License) and Ubuntu Sans and Ubuntu Sans Mono (Ubuntu Font Licence) are served by Google Fonts.
Tessel, its people, customers, integrations, contact details and figures are fictional.