Internal ui is the shared interface of Landings Admin and LP Constructor: one set of tokens and components for both products, their design canvases and, later, their code. Internal tools for marketing: calm, dense, desktop-first (1440, checked at 1024, 1280, 1920), light and dark.

Code: GitLab · package: npm (npm i @mrktg-common/internal-ui) · this system as a page: S3.

Content

  • Interface copy is English, sentence case: “Make v4 live”, “Publish to production”.
  • Buttons name the action, verb first. Never “OK” or “Yes”.
  • People appear by email; the name goes in the tooltip. Your own rows are marked “you”.
  • Say what happens and what stays: “Published landings stay on Aurora.” “The site may still show the previous version.”
  • Time always with its zone: “Tue, Oct 13, 10:00 · Europe/Moscow · UTC+3 · in 5 days”. Relative times carry the absolute one in a tooltip.
  • Request IDs and addresses are mono with a copy button.
  • No emoji, no exclamation marks.

Colour

Tokens live in tokens.json; both themes switch with data-theme="light" or "dark" on <html> (default: the system's). Never write a hex in a component: name a token.

Roles, the same in both products:

  • Green (success*): Live, nothing else, in every environment. Where it is live is said by the environment — the Dev pill in the header, the PROD / TEST chip in the row — not by the colour, just as buttons don't change colour on dev. Not “saved”, not “copied”, not “available”.
  • Yellow (warning*): unsaved, missing, drift from the site, Unpublished, consequences in a form, and a non-production environment (Dev pill, TEST chip, dev stripe), always with the word.
  • Red (danger*): errors and irreversible actions (Unpublish, Delete).
  • Accent: a process running, selection, focus, links, info.
  • Neutral (bg-subtle, text-muted): Test, Draft, Previous, saved states, counts. Outline: exists but not current (Never published, Archived).

Every status carries a word and a mark (dot, hollow dot, spinner, icon), so it reads without colour.

Text: text for content, text-muted for hints and secondary data, text-faint only for placeholders and separators on surface (it fails on bg, bg-subtle and danger-soft in light). Text on an accent fill is accent-contrast, never white. Contrast for every pair is in the “Colour and contrast” section.

Type, space, shape

  • Inter 400/500/600/700 from Google Fonts, falling back to system-ui; mono for addresses, slugs, keys. Base size 13 (text-base); scale 11 · 12 · 13 · 14 · 16 · 18 · 22.
  • 4 px spacing step; page gutter 32 (space-8).
  • Radius 4 for sm controls and menu rows, 6 for md controls, 10 for cards, popovers, modals, toasts; pills for badges.
  • Controls 28 (control-height-sm: toolbars, panels, rows) and 34 (control-height-md: forms, pages). Header 56, editor toolbar 44, table head 36, rows 52.
  • Shadows only for things that float: shadow-md menus and tooltips, shadow-lg modals and toasts.

States

Every interactive part has hover, keyboard focus (focus-ring, a 3 px accent halo), off, and error where it takes input.

  • Off, with the reason. Don't hide or silently disable what people expect. Use aria-disabled="true", keep focus, show why and how to get it (tooltip or a hint line, plus aria-describedby). Native disabled only where the reason is obvious (Redo, Previous on page 1).
  • Error under the field that caused it, with circle-x; general errors in an alert with Retry and the request ID.
  • Canvases show states statically with .is-hover, .is-focus, .is-open.

Iconography

Lucide only, inline SVG with class ic (16), ic-sm (14), ic-xs (12), ic-lg (28), ic-xl (40), stroke 2, currentColor. In code: lucide-react with className="ic". Product tiles: Landings Admin globe, LP Constructor blocks on an accent tile. Icon-only buttons get aria-label and a tooltip.

Using it on a design canvas

  1. Install this system on the canvas (Theme menu, or ask Claude to install Internal ui). It copies tokens.json, tokens.css, components/bundle.css and components/canvas.css into project/ds/internal-ui/ and records the version.
  2. In every artboard, right after the support.js line, link in this order: ds/internal-ui/tokens.css, ds/internal-ui/components/bundle.css, ds/internal-ui/components/canvas.css, then the product's own stylesheet (on the constructor canvas, lpc.css). In <helmet>: the Inter link from Google Fonts.
  3. Build with the classes from the component cards. Product stylesheets add only what the product alone has and never restyle a system class.

canvas.css holds spec-sheet helpers (sheet, panel, kv, doc, qa, swatches) and is for canvases only. Put .is-still on an animated part (or any ancestor) to show it as a still frame: spinners stop, a countdown or indeterminate bar holds at --still (60% by default) instead of ending empty.

Using it in code

Planned package @mrktg-common/internal-ui, same version as this system: tokens.css and components.css (this bundle.css) imported once at the app root, plus thin React components on these classes. Overlays and the toast stack are absolute inside artboards; the app renders them in a portal with .is-fixed. Product screens keep their own CSS modules and use the package's components.

Update rule

  • The version is meta.release in tokens.json, and every change gets a line in the Changelog section: patch for fixes that change no markup, minor for additions, major when a class, token or markup changes.
  • Product canvases get the system only by installing it with a version. After a change here, each product canvas reinstalls it; nobody edits the copy under project/ds/ on a product canvas.
  • A change that both products need is made here first, then reinstalled. Something only one product has stays on that product's canvas and links to this system.
  • Until the code package exists, this system is the source; after its first release the repository is the source and this system is re-synced from it at the same version.

Colour and contrast

WCAG 2 AA: text 4.5:1, text 24 px+ and UI marks (control edges, focus, icons that carry meaning) 3:1. Dark *-soft fills are translucent; their ratios are measured over surface.

Text pairs

Text Ground Light Dark
text surface 16.5:1 13.0:1
text-muted surface 6.0:1 6.2:1
text-muted bg-subtle 5.1:1 6.6:1
text-muted any *-soft 5.2:1 and up 4.6:1 and up
text-faint surface 4.7:1 4.8:1
text-faint bg 4.4:1 — fails 5.4:1
text-faint bg-subtle 4.1:1 — fails 5.1:1
text-faint danger-soft 4.1:1 — fails 3.8:1 — fails
accent-contrast accent 4.6:1 5.9:1
accent-text surface 7.3:1 8.3:1
accent-text accent-soft 6.3:1 6.6:1
success-text success-soft 4.8:1 6.9:1
warning-text warning-soft 5.0:1 6.9:1
danger-text danger-soft 5.4:1 6.0:1
danger-text surface 6.1:1 7.5:1
surface (tooltip text) text 16.5:1 13.0:1
surface (Dev tag text) warning-text 5.5:1 9.3:1

So: text-faint goes on surface only. Inside an invalid field the placeholder switches to text-muted. Disabled fields (bg-subtle) show their placeholder in text-muted.

Not for text

  • danger on surface is 4.4:1 in light: borders and icons only; the required mark uses danger-text.
  • success and warning are icon colours (3.5:1 and 3.2:1 on surface in light).
  • White on danger is 4.4:1 light, 3.1:1 dark: the danger button is outlined, not filled.
  • White on the dark accent is 3.1:1: text on accent is always accent-contrast.

UI marks

  • Switch track off: text-faint, 4.7:1. border-strong would be 1.5:1.
  • Accent selection outline on a white page in dark theme: 3.1:1 (page content isn't themed).
  • border (hairlines) is decorative; control edges use border-strong.

Patterns

Page or modal

Long or losable work (a new landing with an upload) is a page. Short decisions are modals: sm 440, md 560, lg 800.

Confirmations

  • Changes the live site (Publish, Make live): md modal with the change block (v7 → v4) and the facts (Address, Environment). The primary button names the action.
  • Irreversible now (Unpublish on production): danger alert and the slug typed to confirm; the danger button stays aria-disabled until it matches. On Dev only the yellow alert, no typing.
  • Doesn't touch the site (Archive, Restore): sm, one paragraph.
  • Reversible and small (delete a block, clear the canvas): no confirm. Do it and offer Undo in a toast for 10 s.
  • Consequences inside a form (changing the brand) are a warning alert above the button, never a confirm(). No browser dialogs anywhere, except the browser's own leave-page prompt.

Long operations

Progress modal with steps and no close button; “Run in background” moves it to a banner. The result closes by itself with a countdown, then a toast with a link.

Toast, banner or alert

  • Toast: what just happened. Goes away (6 s, Undo 10 s, errors stay).
  • Banner: the state of the whole page or editor. Stays until the state changes. One at a time.
  • Alert: a message tied to a place: a form, a modal, an empty table.

Errors

  • Field errors under the field, after blur or submit.
  • A failed load: an alert or empty state with Retry and the request ID (mono, copy button). No red when the live site isn't affected.
  • After a failed publish: the step that failed and “The site may still show the previous version”.

Off, with the reason

Options people can't use stay visible: aria-disabled, a lock where it's about rights, the reason and the way to get access. Example: “Production needs the landings-publishers group. Ask Dana Fox or Sam Taylor to add you.”

Lists to choose from

Group headings with menu-label; the state of a whole group in one line under its heading; a value that isn't in the list pinned on top as “Selected”; an empty search says what didn't match and gives a hint.

People, ownership, View only

By email with the name in the tooltip; “you” on your own rows. Someone else's object: a View only plaque with a lock in place of the actions.

Empty states

Icon, title, one sentence, one action (two at most).

Adopting on the product canvases

What each product canvas changes when it installs a release. Each product thread does this on its own canvas, newest release first.

1.11

LP Constructor canvas

  1. Reinstall Internal ui 1.11.0.
  2. Nothing to redraw: the SourceDetailed board already shows the rules grouped (the form’s language, then “Other languages”). In code the list now uses groups instead of putting the form’s language first by hand.

Landings Admin: no change.

1.10

LP Constructor canvas

  1. Reinstall Internal ui 1.10.0.
  2. ABM Section Form, Source Detailed: a Combobox over the confirmation-email rules with ok / warn / hint lines under it (MIFD-2289). See the “Source Detailed” section in Handoff.

Landings Admin: no change.

1.9

LP Constructor canvas

  1. Reinstall Internal ui 1.9.0.
  2. Editor and Vibe (VbParFailed, VbParReady): with the properties panel open, the toast stack sits 24 px left of the panel (--toast-inset-end: 320px on .toasts), not over Generate and the fields. Matches the toasts item in Handoff, “Параллельная генерация Vibe HTML”.

Landings Admin: no change, the stack keeps the window edge.

1.8

Landings Admin canvas

  1. Reinstall Internal ui 1.8.0.
  2. Dev headers (hdr-dev): the logo tile is warning, as on the dev favicon. Nothing to change in the markup.

LP Constructor: no change, its own mark isn't recoloured.

1.7

LP Constructor canvas

  1. Reinstall Internal ui 1.7.0.
  2. Projects and Templates: the “Long list · only the table scrolls…” board drops its inline styles: main.page.is-fill with page-head, toolbar, content and the tbl-box inside. Matches the “Прокрутка списков” row in Handoff.

Landings Admin canvas

  • Optional: the Landings list as page is-fill, the same way.

1.6

LP Constructor canvas

  1. Reinstall Internal ui 1.6.0.
  2. Header and the Shell sheet (page 2): the logo-mark tile with blocks gives way to the constructor's favicon in logo-img, 26 px, no tile; the same in the editor's compact header. Matches the “Logo” row in Handoff.

Landings Admin: no change, the globe tile stays.

1.5

Both canvases

  1. Reinstall Internal ui 1.5.0.
  2. Live is green in every environment. Live on Test and in the dev admin: b-neutral → b-success, the dot stays. Live-version rows lose is-nonprod. “vN is live on Test” alerts and toasts use alert-success / t-success. Where it is live is said by the Dev pill and the PROD / TEST chips, as before.

LP Constructor canvas

  • Projects, CompBadges, CompSite, Publish (page 11), Toolbar: Live on TEST rows and “Live on test” go green.

Landings Admin canvas

  • Dev environment (ListRuDev, landing page on dev, Make live on dev): Live green, the live-version row without is-nonprod.
  • Page 8: Published list TEST rows show Live green.

1.4

LP Constructor

  1. Reinstall Internal ui 1.4.0.
  2. Projects → Brand: INT and RU groups get the whole-group row (wholeGroup): “All INT brands” / “All RU brands” first under the heading, mixed while part of the market is ticked. The button reads “Brand: All INT” when exactly one market is picked.

1.2

Both canvases

  1. Reinstall Internal ui 1.2.0.
  2. Breadcrumbs: nav.crumbs with aria-label="Breadcrumbs", links, chevron-right separators (ic, aria-hidden), the last item span.here with aria-current="page".
  3. Animated parts that a static artboard shows (countdown, indeterminate bar, spinners): add .is-still instead of inline animation-delay / animation-play-state.

LP Constructor canvas

  • Editor header: replace doc-title / crumb-back / crumb-sep / doc-name with nav.crumbs.crumbs-lg, the project name as .here; the type badge and the save state follow the nav as before. Remove those four classes from lpc.css.

Landings Admin canvas

  • Remove .crumbs and its rules from la.css; the landing page markup already matches (add aria-hidden to the chevrons, aria-current to the slug).
  • PublishProgress (done): the countdown's inline animation-delay and animation-play-state become .is-still.

Both canvases

  1. Reinstall Internal ui 1.1.0 (same links as 1.0).
  2. Remove from the product stylesheet what moved here: drop*, file*, progress*, report*, the report row class (rep-check in la.css, div.check in lpc.css), c-ok, c-info, c-warn, c-err, paths, steps, step*, .tbl tr.grp.
  3. Markup:
    • report rows become report-row, the text in report-text;
    • the file row's text goes in file-body, the size or what was found in file-meta; actions in file-actions;
    • the dropzone's second line is drop-hint, buttons in drop-actions; a dropzone in a panel or modal can use drop-sm;
    • progress bars get role="progressbar" and their values; the bytes and percent under a bar go in progress-label;
    • a result that closes by itself uses progress-countdown (neutral) with its duration in --countdown;
    • done steps turn neutral by themselves; a failed step gets its error in step-err.

LP Constructor canvas

  • How translations work: numbered steps use step is-num instead of the inline accent styles.

Landings Admin canvas

  • Keep in la.css: .tbl tr.is-past, and .hdr-center / .project-name for the constructor mockups on pages 8–9.
  • Delete event… in the event window: btn-danger in the footer's .start, without the inline red on a ghost button.

1.0

What each product canvas replaced when it installed Internal ui 1.0.0.

Both canvases

  1. Install Internal ui 1.0.0. In every artboard replace <link rel="stylesheet" href="./admin.css"> with, in order: ds/internal-ui/tokens.css, ds/internal-ui/components/bundle.css, ds/internal-ui/components/canvas.css, then the product stylesheet.
  2. Remove project/admin.css.
  3. Markup the new classes need:
    • clickable table rows get is-link (the old rule made every row a pointer); first / last cell classes are no longer needed;
    • sortable headers become th-sort buttons with aria-sort on the th;
    • Live on Test or in the dev admin: b-neutral with a dot; the live-version row on Test or Dev adds is-nonprod;
    • Draft gets the hollow dot, Unpublished eye-off, Error and Publishing failed circle-x;
    • selected option: check after the label (.check), not before it;
    • toast description toast-desc (was muted or hint), its action a btn-secondary btn-sm inside toast-act, a close button on every toast; saving and copying toasts use t-neutral;
    • banner text in banner-text, buttons in banner-actions; editor banners add banner-tight;
    • change block sides in change-side; the new side change-v is-target (was an inline accent colour); dates and names change-v-sm;
    • Cancel in modals is btn-secondary; a note or third action in the footer is .start;
    • native disabled with a reason becomes aria-disabled="true" plus the reason (tooltip or hint);
    • the Copied state and positive field checks are neutral by themselves (no markup change).

LP Constructor canvas

  • lpc.css keeps only constructor parts: --panel-left-width, --panel-right-width, --rail-width, --canvas-*; crumbs and save state, kbd, toolbar (tb*, lang-btn, split, zoom), editor layout, palette, outline tree, properties panel, canvas layer, the site popover (pubcell, more-pill, site-*), favicon and browser tab. Remove from it what moved here: --toolbar-height, --duration-base, nav, theme-row, seg-sm, seg-icon, env-chip, label-row, and the spec helpers (swatch-grid, sw*, ratio, diff-old, new-mark, fix-mark, anno, stage) now in canvas.css.
  • Page 1 Tokens: keep only the constructor's own tokens (panels, rail, canvas layer) and link to this system for the rest.
  • Page 2 Components: Header, user menu, buttons, fields, segmented, select and multi-select, badges, toasts, banners, modals now come from here. Keep on the page: Toolbar, type and translation badges, saved versions, date range, radio cards, filter bar layout, left panel, properties panel, canvas layer, On the site.
  • Status changes on Projects, CompBadges, CompSite, Publish (page 11) and the Toolbar: TEST rows show Live neutral; Unpublished and Publishing failed get their icons.
  • Brand filter (ProjBrand): group headings not clickable; Apply goes, “Applies as you tick” instead.
  • Toasts: “Project saved” and “Version saved” use the neutral icon; 6 s.

Landings Admin canvas

  • Move admin-only classes out of admin.css into an admin stylesheet: breadcrumbs, landing page layout (lp-*, note-text), activity feed, dropzone, file row, progress, archive report, publish steps, schedule (sched-*, tl*, ev-*, next-ev, when-grid, utc-line, ver-opt), and the constructor mockups of pages 8–9 (pub-*, result*, section-title, static-val, ed-*, project-name, c-user*, hdr-center). is-later is in canvas.css.
  • Page 6: Header, Badges, Controls and Feedback cards come from here. Keep on the page: Zip upload, slug field, note inline edit, publish steps, activity events.
  • Confirm modals (Archive, Make live, Restore, Unpublish): Cancel ghost → secondary.
  • Sort menu on the list goes; sort by the column headers.
  • Dev environment (ListRuDev, landing page on dev, Make live on dev): Live neutral, live-version row is-nonprod.
  • Page 8: Published list TEST rows show Live neutral; toolbar badge Unpublished and Publishing failed get their icons.
  • User menu: add the Theme row (System, Light, Dark).
  • Row action “Not on the site”: aria-disabled with a tooltip instead of disabled.

Changelog

1.11.0 · Oct 20, 2026

  • Combobox: groups of suggestions, a menu-label over each group, as in Select menu. A group with no match hides with its label, an empty group never shows, arrows skip labels, Enter takes the first match in group order, “No … match” is one line for the whole list, the chevron opens every group with the current value checked in its own. React: groups on Combobox.

1.10.0 · Oct 20, 2026

  • Text field: warning state, .warn with triangle-alert in warning-text — the value is allowed but probably wrong, saving and publishing go on. The control keeps its border; only one line shows, in the order error, warning, check passed, hint. Field and TextField take warning.
  • Combobox: a text field with suggestions that keeps any typed value (combo, combo-btn, mark in menu-item). Filters while typing, checks the value on blur or pick; loading, nothing matched and couldn’t load. For closed lists Select menu stays. React: Combobox.

1.9.0 · Oct 14, 2026

  • Toast: useToastInsetEnd(width) moves the stack off a side panel; --toast-inset-end on .toasts.

1.8.0 · Oct 14, 2026

  • Header: on dev the logo tile is warning, matching the dev favicon; an own mark isn't recoloured.

1.7.0 · Oct 14, 2026

  • Page: is-fill — a list page takes the window, only the table scrolls; the table head sticks and the footer stays.

1.6.0 · Oct 14, 2026

  • Header: productMark — the product's own mark (26 px, no tile) instead of the icon tile.

1.5.1 · Oct 13, 2026

  • Table: the minimum width is never less than the fixed columns plus 120 px for each column without a width (minWidth on the column overrides it). Before, a hand-set minWidth could leave the flexible column (Comment, Note, Name) zero pixels when the window narrowed.

1.5.0 · Oct 13, 2026

  • Live is green in every environment: b-success with a dot on Test and in the dev admin too; where it is live is said by the Dev pill and the PROD / TEST chips. StatusBadge ignores production (deprecated, goes in the next major), the live-version row has no is-nonprod, “vN is live on Test” uses alert-success / t-success.

1.4.0 · Oct 13, 2026

  • Multi-select: whole-group row (All INT brands) with a mixed state; the button names one whole group.

1.3.2 · Oct 13, 2026

  • Library favicon on the design system page (design/assets).

1.3.1 · Oct 13, 2026

  • Examples: cards, tests and token samples use only made-up brands (Aurora, Borealis, Cirrus and the like), example.com addresses and name.surname@example.com people. A test fails on the company’s brands, domains and real names.

1.3.0 · Oct 12, 2026

  • Change block: a side can carry several meta lines (date and who), as the card always allowed.
  • Button: buttonClass lives in a module without 'use client', so server components (Next layouts, sign-in screens) can style links as buttons.

1.2.1 · Oct 12, 2026

  • Search: the browser's own clear button no longer shows next to the component's; a ref from the product reaches the input without breaking Clear.

1.2.0 · Oct 12, 2026

  • Breadcrumbs: one component for the landing page in Landings Admin and the editor header in LP Constructor. Separator is a chevron-right icon in text-faint (decorative), current item text 500; crumbs-lg in the header (14 px, the document title at 600). Replaces .crumbs in la.css and crumb-back / crumb-sep / doc-name in lpc.css.
  • canvas.css: .is-still freezes animations on an artboard on a fixed, non-empty frame.

Not added: the result state (big icon, title, actions instead of the form). Only LP Constructor's Publish window has it; the copy on the Landings Admin canvas (pages 8–9) is a mockup of that window. It stays a constructor part; when another window in either product needs it, it comes here.

1.1.0 · Oct 12, 2026

Added what both products use for files and long operations, proposed by the Landings Admin designer:

  • Dropzone: empty, hover, focus, dragging over, wrong type or too big (checked before upload), off with the reason, compact drop-sm.
  • File row: icon, name, size or what was found, Replace / Change / Remove; uploading with progress inside; failed with Retry.
  • Progress: determinate with bytes and percent, determinate with a count, indeterminate, countdown before a result closes by itself (neutral, a timer).
  • Result report: rows ok / info / warn / error, mono paths with the bad part marked, optional head with a count. A passed check is neutral.
  • Steps: waiting, running, done, failed with the step number and the error; numbered instructions (is-num). Done is neutral: the green comes with the result, and only for production.
  • Table: group heading row tr.grp.

Not added, because only one product has them: the muted “past” table row and the centred project title in the header (both stay on the Landings Admin canvas), and a red-at-rest ghost button. A destructive third action in a modal footer uses btn-danger in .start.

1.0.0 · Oct 12, 2026

First release, named Internal ui (namespace and canvas folder internal-ui, code package @mrktg-common/internal-ui), merged from the Landings Admin canvas (page 6, admin.css), the LP Constructor canvas (pages 1–2, admin.css + lpc.css), lp-constructor/src/styles/tokens.css and both products' UI code. Agreed screens (the brief, admin pages 8–9) are kept; where they differed from the rest, the agreed version won.

Decisions in this release:

  • Green only for live on production. Live on Test and in the dev admin is neutral with a dot; Copied, Available and Saved are neutral; the live-version row is tinted only on production (is-nonprod otherwise).
  • Every status has a mark: Draft hollow dot, Unpublished eye-off, Error and Publishing failed circle-x, Archived archive.
  • Selected option: check on the right. Group headings aren't clickable. Multi-select filters apply as you tick, no Apply button.
  • Cancel in modals is secondary. Off states use aria-disabled with the reason.
  • Logo hover: accent-text, no underline. Header tile icon 14 px. Dev pill icon triangle-alert.
  • User menu has the theme switch in both products.
  • Switch track off is text-faint. Required mark and invalid-field placeholder fixed for contrast.
  • Toast: 6 s with pause on hover, Undo 10 s, errors stay; description 12 px; one action button; close always.
  • Banner: neutral, accent, warning, danger; inset 32 on pages, 16 in the editor (banner-tight).
  • Change block: change-v-sm for dates and names, is-target for the new side.
  • Tables: sticky head, header sorting (th-sort, aria-sort), clickable rows marked is-link, footer with count and optional pager.
  • Copy field: md 34 and sm 28, Open optional, Copied 2 s.
  • Select lists in code are fixed above modals with a 320 px scroll.
  • Tokens: toolbar-height and duration-base join the shared set; tone borders stored as their rgba values (same colours as the color-mix they replace).

Alert

A message inside the content: in a form, a modal, an empty table. Icon, optional title, text, optional actions.

  • alert-info (info): context, what stays as it is.
  • alert-success (circle-check): only for “vN is live”, in any environment; the title names it (“v9 is live on Test”).
  • alert-warning (triangle-alert): consequences people must see before they press the button (“Changing the brand…”). Inside the form, above the button, never a confirm().
  • alert-danger (circle-x): a failure, with Retry; request IDs in code with a copy action.
  • Errors get role="alert", the rest role="status".
  • For the state of the whole page, use Banner; for a reaction to what just happened, Toast.

Badge

Short status or label on a pill: 11 / 500, radius-full. Every badge carries a word, and a status badge also a mark, so meaning never rests on colour alone.

Tones and what they mean

  • b-neutral: Test, Draft, Previous, saved, counts that need no action.
  • b-outline: exists but not current: Never published, Archived.
  • b-accent: a process running (with mini-spin), Beta, selection.
  • b-success: Live only, in every environment; the environment is said by the Dev pill or the PROD / TEST chip.
  • b-warning: unsaved, missing, drift, Unpublished.
  • b-danger: errors and failed publishes.
  • b-solid: the Admin role.

Contrast

Each *-text on its *-soft fill passes AA in both themes (4.8:1 and up light, 6.0:1 and up dark).

Don't use a badge for the source of a landing (Constructor, Zip): that is an icon, see People and source.

Brand name

A brand is its 16 px favicon and its name (title.en from the brands package).

  • The favicon comes from the brands package; while it loads or if it fails, the lettered tile (.fav: first letter on bg-subtle) stands in.
  • The name truncates with an ellipsis and gets a tooltip in narrow cells.
  • fav-lg (20 px) where the brand is a field value.

Button

One button for every action: primary, secondary, ghost, danger; md 34 for forms and pages, sm 28 for toolbars, panels and table rows.

Variants

  • Primary (btn-primary): the action the view is for, once per view. Label names the action: “Make v4 live”, not “OK”.
  • Secondary (btn-secondary): Cancel in modals, Retry, filter buttons. is-active for a filter with a value.
  • Ghost (btn-ghost): quiet actions, icon buttons in rows and toolbars. aria-pressed="true" for a toggle that is on (Show keys, device). danger-hover turns red on hover for a destructive icon (Delete block).
  • Danger (btn-danger): outlined, danger-text; filled red would be 4.4:1 in light and 3:1 in dark. For Unpublish, Delete.

States

Hover, focus (focus-ring), busy (spinner, label changes, width stays), off with the reason.

Off with the reason: aria-disabled="true", not disabled, so focus and the tooltip still work, plus aria-describedby on the reason. The click handler ignores the press. Native disabled only where the reason is obvious: Redo with nothing to redo, Previous on page 1.

Content

Sentence case, verb first. Icon-only buttons carry aria-label and a tooltip. Lucide 16 in md, 14 in sm text buttons.

Card

A surface for a group of facts or one piece of content: Live version, Note, Details.

  • card-head (title 14 / 600, one ghost action on the right), card-body 16 px.
  • Facts as kv (muted terms, 96 px column).
  • Cards don't nest and don't get coloured borders.

Centered card

One card in the middle of the screen: sign-in, No access, a fatal error.

  • Product tile icon in a 56 px accent-soft disc (cc-icon-danger for errors), title 22 / 700, one muted subtitle line.
  • One primary action, full width; a hint under it says where it goes.
  • Errors as an Alert above the button. Busy: spinner and “Signing in…”.

Change block

“Was → will be” in confirm modals: what the action changes, before people press it.

  • Two sides with a arrow-right between: label (12 / 500 muted), value, optional meta lines.
  • Value change-v 18 / 700 for versions; change-v-sm 14 / 600 for dates and names. The target side is is-target (accent-text).
  • Removal: change-off in danger-text (“Not on the site”).
  • Under it, facts: Address (mono), Environment (env pill), Brand.

Checkbox

Native checkbox in accent, label to the right; the whole row is the hit area (check-row).

  • Use for a yes/no that is applied with a form (Apply, Save). For a setting that applies at once, use Switch.
  • A hint goes under the label inside the row.
  • Off with the reason: aria-disabled, row at 0.55 opacity, the reason as the hint and in aria-describedby.
  • Focus: focus-ring on the box.

Combobox

A text field with suggestions from a list. Any typed value is kept: the list helps to type a known value, it doesn't limit the field. For a closed list (brand, language, time zone) use Select menu.

Anatomy

  • Input: .control inside .combo, role="combobox", aria-autocomplete="list", aria-expanded, aria-controls on the list, aria-activedescendant on the focused option. mono for codes and keys.
  • Chevron: combo-btn (btn btn-ghost btn-sm btn-icon), tabindex="-1", aria-label="Show suggestions"; opens the whole list.
  • List: .pop under the input, as wide as the input, role="listbox"; options are menu-item with role="option", an optional trail (language, count) and the check on the current value, as in Select menu. pop-scroll from 8 options.
  • The typed part of each option is bold: <mark> inside menu-item, no fill.
  • Groups (optional): menu-label above the group's options, as in Select menu and Multi-select. Each group is role="group" with aria-labelledby on its label; the label is not an option and never takes focus. A group with no label is the plain list: one group without a label behaves exactly like the ungrouped Combobox. An option belongs to one group only.

Behaviour

  • Typing opens the list and filters it (substring, case-insensitive). The first match gets keyboard focus; Enter takes it.
  • ↓ / ↑ move through options, Enter takes the focused one, Esc closes the list and keeps the text, Tab leaves with the text as typed.
  • No matches: one menu-empty block — what didn't match and “You can keep it as typed.” Enter keeps the text.

With groups

  • Order: groups in the order given, options in their group's order. Put the most likely group first (the form's language, then “Other languages”).
  • Filtering: a group with no matching option hides with its label; the groups that keep matches keep their labels, even if only one group is left, so it stays clear which group a match is from.
  • An empty group is never shown, with or without filtering.
  • ↓ / ↑ go through options across groups and skip the labels. The first match in group order gets keyboard focus; Enter takes it.
  • Nothing matches in any group: one menu-empty for the whole list, not one per group.
  • The chevron opens every group; the current value has its check inside its own group, and the list scrolls to it.
  • Loading: menu-empty with a small spinner and “Loading …”; typing works meanwhile.
  • The list couldn't load: no list, the chevron is hidden; the field works as a plain input and a hint line says what isn't checked.
  • Checking the value (found, not found) happens on blur or on picking an option, never on every keystroke. The result is one line under the field: ok when found, warn when the value is allowed but unknown, err only when the value can't be used. See Text field for the order of lines.

Behaviour in code

The list is positioned like Select menu's: fixed to the input, at z-modal + 1, so it works inside modals and isn't cut by overflow.

Classes: combo, combo-btn, control, pop, pop-scroll, menu-item, menu-label, trail, check, menu-empty, plus mark inside menu-item.

Copy field

A value people copy: a landing address, a request ID, error details. Mono text, optional Open, then Copy.

  • md 34 by default, copy-sm 28 in rows and error cards.
  • Copy: “Copy” → “Copied” with a check for 2 s, neutral (bg-subtle); aria-label changes to “Address copied” so screen readers hear it.
  • Open (external-link, new tab) only for addresses. Not on the site: the address is struck through in text-muted, Open is aria-disabled with the reason in title; Copy still works.
  • Long values truncate; the full value is in title.

Dropzone

Where people drop or choose a file: a zip of a landing, a translations table, an image.

What the product provides

The title (what to drop), one hint line (type and limit), the Choose file button, the accepted types and the size limit.

States

  • Empty: dashed border-strong. Hover: accent border. Keyboard focus: focus-ring (the zone is focusable and opens the file picker on Enter or Space).
  • Dragging over: 2 px dashed accent on accent-soft, title says “Drop to upload”.
  • Wrong type, too big: checked in the browser before anything is sent; is-error border and an .err line under the zone naming the file and the rule. The zone stays ready for another file.
  • Off with the reason: aria-disabled, the reason in the hint.
  • drop-sm: one row, for panels and modals.

After a file is chosen, the zone gives way to a File row.

Classes: drop, drop-sm, drop-title, drop-hint, drop-actions, is-over, is-error.

Empty state

What a list or tab shows when it has nothing: an icon, a title, one sentence, one action (two at most).

  • First use: what goes here and the primary action (“New landing”).
  • Nothing matches: what didn't match and the way out (Clear filters, Include archived).
  • Can't load: no red when the site isn't affected; Retry and the request ID with a copy button.
  • Icon 40 px in text-faint (decorative, on surface).

Env tag

Where something lives: the contour (INT, RU) and the environment. Always a word, never colour alone.

  • Pill (.env): contour + tag. Production: border-strong, tag on bg-subtle. Dev: warning border, warning-soft fill, tag warning-text with triangle-alert. Optional host (env-host) on the sign-in screen; the header shows the host in title.
  • Chip (.env-chip): PROD (tag-prod, neutral) or TEST (tag-test, warning) before a published landing.
  • Yellow here means “not production”. The words differ by product on purpose: the admin runs as Dev, the constructor publishes to Test (the brands' dev stands).

File row

One chosen file: icon, name, size or what was found in it, and what you can do with it.

  • file-ic: package for archives, file for tables and images.
  • file-name truncates; the full name in title. file-meta (12, muted): size, rows, columns found.
  • Actions on the right, ghost sm: Replace or Change, Remove (x, aria-label="Remove file"), Cancel while uploading.
  • Uploading: a Progress bar with its label inside the row.
  • Failed: is-error, the message under the name, Retry.

Multi-select filter

Filter a list by several values: brands, type, status. A filter button opens a list of checkboxes.

  • Button: btn-secondary. With one value it names it (“Status: Error”), with one whole group it names the group (“Brand: All RU”), otherwise a count-pill; all three is-active.
  • List: aria-multiselectable, each row a label.menu-item with a checkbox, optional favicon, and a count on the right.
  • Applies as you tick: no Apply button. Footer: Clear on the left, “Applies as you tick” on the right. The selection goes to the URL.
  • Groups with menu-label; group state or a caveat in one menu-hint line (“Counts are at least…”).

Whole group

Headings stay labels and are never clickable. A group that people pick as a whole (brands of one market) gets a whole-group row first under its heading, the same pattern as “All INT brands” in Select menu.

  • Row: menu-item with a checkbox, layers icon in the favicon column (only when the group's options have favicons), “All INT brands”, and the sum of the group's counts (“≥” if any count is “at least”).
  • Checkbox: empty when nothing in the group is ticked, mixed (native indeterminate, aria-checked="mixed") when part is, checked when all are. aria-selected="true" only when all are.
  • Click or Space: from empty or mixed ticks every option of the group, from checked clears them. Options still tick one by one; the row follows them.
  • Button: exactly one whole group and nothing else → “Brand: All INT”. Whole group plus anything else → the count.
  • Opt-in per group. Groups without a heading (“No brand”) and filters where nobody picks a whole group don't get the row.

Page

Layout of a list page under the header: page head, toolbar, content. Both products' lists (Landings, Projects, Templates) use it.

  • .page-head: page-title (22 / 700) with an optional page-count (muted, tabular), one primary action on the right.
  • .toolbar: search (with-icon, 280 px), filter buttons (see Multi-select), switches, then a ghost Clear filters. Wraps at narrow widths.
  • .content: the table or empty state.
  • Gutter 32 px (space-8) everywhere on the page, banners included.

Filters, sort and page go to the URL.

Fill

page is-fill for a list that can be long (Projects, Templates, Landings). The page takes the window under the header and banners, and only the table scrolls: the page head, toolbar and table head stay, the footer with the count and pager stays under the rows.

  • .page.is-fill is a direct child of .app; the header and page banners are its siblings and keep their height.
  • .app is the window's height (100dvh) and doesn't scroll. Below 560 px of window it keeps 560 px and the window scrolls as usual.
  • .content is a column; the tbl-box is as tall as its rows but never taller than the room left, 32 px (space-8) above the window's bottom. Only tbl-scroll scrolls; the table head sticks to its top.
  • Empty states, errors and the first load keep their own height, as on a plain page.
  • Pages that aren't lists (a landing, settings) don't use it: they scroll as a whole.

Classes: page, is-fill, page-head, page-title, page-count, toolbar, content.

People and source

Who owns what, and where a landing came from.

  • People by email, the name in title. Your own rows get .you.
  • Avatar: initials on accent-soft, or the photo; 32 px, avatar-sm 24.
  • Role: Admin b-solid, Publisher b-neutral, No publishing group b-outline. Only in the user menu, never next to other people.
  • View only (.readonly): lock, “View only · owned by ”, in place of the action buttons. Neutral on purpose.
  • Owner note: one line with a lock under a row that someone else owns.
  • Source: not a status, so no pill. src-mark (icon with aria-label) after the slug in lists; src (icon + word) in versions and activity: Constructor (blocks), Zip (package).

Progress

A thin bar for work whose length is known, or for waiting on the server.

  • Determinate: progress with the bar's width; role="progressbar" with aria-valuenow, aria-valuemin, aria-valuemax. A progress-label under it: bytes on the left, percent on the right; or a count in the line above (“Loading hotel 3 of 5…”).
  • Indeterminate (progress-ind): the length is unknown; say what is happening under it.
  • Countdown (progress-countdown, duration in --countdown): before a result closes by itself; neutral text-faint, because it is a timer, not a status. Offer “Keep open”.
  • The bar is accent; the track bg-subtle (border in dark).
  • On a design canvas, add .is-still (from canvas.css) to freeze the animation on a fixed frame (--still), so a countdown never shows an empty bar.

Result report

What a check or an import found: an archive before publishing, a translations table, a hotels list. One row per finding, the worst first.

Rows

  • c-ok (check, text-muted): passed. Neutral, not green: green is for Live.
  • c-info (info, accent): done for you (“Unwrapped folder …”, “Row 5 skipped”).
  • c-warn (triangle-alert, warning): worth a look, doesn't block.
  • c-err (circle-x, danger): blocks the next step; say what to do and repeat that it blocks in an .err line under the report.

Parts

  • report-head (optional): what was checked and a count on the right (“1 warning”). In a modal the summary can be a line above the report instead.
  • report-row with report-text; a bold first phrase says the finding, the rest explains.
  • paths: file locations in mono, the location muted, the bad part in mark.

Segmented

The one way to pick one of two to four short options: Environment, Visibility, Theme, alignment, device.

  • Each option is a button with aria-pressed; the group has role="group" and a label.
  • Sizes: md 30 inside a 34 frame, seg-sm 26, seg-icon square (each button needs aria-label).
  • A locked option stays visible: lock icon, aria-disabled, the reason in a tooltip and aria-describedby. The default selection never lands on a locked option.
  • Options that need a sentence each (Create project: Landing or Email) use radio cards in the product, not this.

Select menu

Pick one value from a list: brand, language, time zone, sort. Replaces native <select> everywhere.

Anatomy

  • Trigger: .control.select-btn with the value and a chevron; aria-haspopup="listbox", aria-expanded.
  • List: role="listbox", options role="option" with aria-selected. The selected option has a check on the right (.check, accent-text), as on the agreed Publish window.
  • Groups: menu-label headings, not clickable. To pick a whole group, add an “All INT brands” option at the top of the group. In Multi-select it is the whole-group row with a mixed state.
  • Group state: one menu-note line under the heading (“Landings Admin RU isn’t responding”), never a note on every option.
  • An option that can't be chosen stays visible: aria-disabled, the reason as trailing text.
  • A value that isn't in the list (old time zone, removed brand) is pinned on top under “Selected”.
  • Long lists get a search field (menu-search, focus starts there) and pop-scroll (320 px). No results: one line saying what didn't match and a hint.

Behaviour in code

The list is positioned fixed to the trigger and sits at z-modal + 1, so it works inside modals and isn't cut by overflow. Arrows move, typing jumps, Enter picks, Esc closes.

Skeleton

Grey shapes in place of content while the first load runs, so the layout doesn't jump.

  • Same row height as the real rows (table-row-height 52), shapes at the real widths of text, badges and buttons.
  • Pulses (1.4 s); slower with reduced motion.
  • aria-hidden on the shapes and one role="status" line, “Loading landings”, for screen readers.
  • On bg-subtle use sk-on-subtle.
  • Reloads keep the old content with a small spinner instead of flashing a skeleton.

Spinner

Something is running and its length is unknown.

  • spinner sm 16 (in buttons and hints), md 24 (inline loading), lg 40 (whole screen).
  • On an accent fill: spinner-inverse. In badges: mini-spin. In banners and step icons: spin16 (takes the text colour).
  • Pair it with words: “Saving…”, “Loading options…”. A button keeps its width while busy.

Status sets

The agreed status vocabulary, built from Badge. Each status has its own mark, so the set reads in greyscale.

Landing (Landings Admin)

Live — b-success + dot (any environment) · Draft — b-neutral + hollow dot · Publishing — b-accent + spinner · Error — b-danger + circle-x · Unpublished — b-warning + eye-off · Archived — b-outline + archive, next to the status, not instead of it.

Live is green in every environment. The Dev pill in the header or the PROD / TEST chip in the row says where it is live; the badge doesn't repeat it.

Version

Live · Previous (neutral) · Never published (outline) · Publishing (accent, on the version being switched to).

Publish button (LP Constructor toolbar)

No badge while loading or when not published · Live on test (success) · Live on production (success; wins when both) · Publishing · Unpublished (warning + eye-off) · Publishing failed (danger + circle-x, same as Error in the admin).

Published list

env-chip (PROD neutral, TEST warning), brand favicon and slug, status with the live version: “Live · v3”.

Steps

The stages of a long operation (publish, unpublish), or numbered instructions.

Operation

  • is-wait: empty ring, muted title.
  • is-run: accent-soft with a spinner, a live timer; aria-current="step".
  • is-done: check on bg-subtle. Neutral: the green comes with the result, and only when it is Live.
  • is-fail: danger-soft with x, title in danger-text, the error under it (step-err) and what it means for the site. The number of the failed step goes in its meta (“Step 3 of 3”) and in the banner or toast that follows.
  • Time on the right, tabular.

The steps sit in a progress modal without a close button; “Run in background” moves them to a banner.

Numbered

is-num: the number on accent-soft, for instructions (“How translations work”).

Switch

On/off that applies at once: Show archived, Only mine, Show on mobile. role="switch" on a native checkbox.

  • Track off text-faint (4.7:1 on surface), on accent; thumb text-inverse.
  • Label to the right, 13 / 500, in the same label so the whole row toggles.
  • Focus: focus-ring on the track.
  • Off with the reason: aria-disabled, 0.55 opacity, a tooltip with the reason.
  • For values applied with a form, use Checkbox.

Table

Lists of landings, projects, templates, versions. Head 36 (table-head-height), rows 52 (table-row-height), in a tbl-box card.

Head

Sticky to the top of tbl-scroll, so it only sticks when the table scrolls inside: on a page is-fill (see Page). On a plain page the whole page scrolls and the head goes with it. Sortable columns are buttons in the header (th-sort) with aria-sort on the th and an arrow; no separate sort menu. Sorted column in text, others text-muted.

Rows

  • A row that opens something is a link (is-link, the main cell is a real <a>); hover surface-hover.
  • Main cell: cell-main (500) and cell-sub (12, muted), both truncate with a tooltip; the table never wraps.
  • Archived: is-archived, bg-subtle, slug muted.
  • Live version: is-current, green tint in every environment; the version cell or the environment chip says where.
  • Group heading: tr.grp with one cell spanning all columns (Upcoming, Past 30 days); 32 px, bg, not clickable.
  • Row actions: ghost sm icon buttons, right; an action that doesn't apply stays visible with aria-disabled and a tooltip.
  • First load: skeleton rows of the same height.

Footer

On a page is-fill the footer stays under the visible rows; only the rows scroll.

Count on the left (“51–100 of 128”), pager on the right when the list pages (50 per page). Page, sort and filters go to the URL.

Narrow screens

Below the table's minimum width it scrolls inside tbl-scroll; the page doesn't. The minimum is never less than the fixed columns plus 120 px for each column without a width (minWidth on the column changes it), so a flexible column such as Comment or Note never collapses to nothing.

Tabs

Switch between views of one object: Versions, Activity, Schedule on a landing.

  • role="tablist", each tab role="tab" with aria-selected; arrows move between tabs.
  • Selected: text with a 2 px accent line. Counts in a neutral badge; a count of upcoming things in accent.
  • A tab that doesn't apply yet stays, aria-disabled, with the reason in a tooltip.
  • The tab goes to the URL.

Text field

Label, control, then one line under it: a hint, an error or a check. Inputs, textareas and select buttons share .control.

What the consumer provides

label (always; never placeholder-only), optional hint, required or optional mark, the error text, mono for slugs, URLs and keys.

States

  • Default: border-strong on surface; placeholder text-faint (only on surface).
  • Focus: accent border + focus-ring.
  • Error: invalid (danger border, danger-soft fill), aria-invalid, message under the field in .err with circle-x. The placeholder turns text-muted inside an invalid field: text-faint on danger-soft is 4.1:1. Focus of an invalid field uses focus-ring-danger.
  • Off with the reason: aria-disabled="true" + readonly, bg-subtle, and a hint that says why and where to change it.
  • Check passed: .ok, neutral with a check. Green is for Live only.
  • Warning: .warn, warning-text with triangle-alert. The value is allowed but probably wrong (no such campaign, an unusual value); saving and publishing go on. The control keeps its normal border and fill: red edge and fill mean “can't go on”, and a yellow edge would read as the same thing. Not aria-invalid; the line is linked with aria-describedby. Contrast: warning-text on surface 5.5:1 light, 9.3:1 dark; on bg-subtle 4.7:1 light.
  • Checking: hint with a small spinner.

One line under the control

Only one line shows, in this order: error, warning, check passed, hint. An error replaces a warning; a warning replaces the check and the hint. If the hint says something people need while they fix the value, put it into the error or warning text.

Rules

Required mark is danger-text (the danger hue is 4.4:1 in light). Errors appear after blur or submit, not while typing, except format errors in slug fields, which react on the fly. Counter right of the label in .label-row.

Warnings follow the same timing as errors: after blur, after picking a suggestion, or when the form opens with a saved value.

Classes: field, label, opt, required, control, control-sm, mono, textarea, select-btn, invalid, hint, err, warn, ok, label-row, counter.

Toast

Confirms what just happened, bottom right (off a side panel when one is open), then goes. 380 px, stack of up to three, newest at the bottom.

Timing

  • Status toasts close after 6 s; the timer pauses while the pointer is over the stack or focus is inside it.
  • A toast with Undo stays 10 s.
  • Errors stay until closed.

Anatomy

Icon · title (600) · optional description (12, text-muted) · optional one action (btn-secondary btn-sm) · the close button, always.

Tones (icon colour)

  • t-success: only “vN is live on Production” or “… on Test”.
  • t-neutral: saved, deleted, copied.
  • t-info: background work finished (cache purged).
  • t-warning, t-danger: problems; danger toasts get role="alert", the rest role="status" (polite).

Position

Bottom right, 24 px (space-6) from the window's edges. When a side panel covers the right edge (the editor's properties panel, 320 px), the product moves the stack off it with useToastInsetEnd(width) while the panel is open: the stack keeps its 24 px from the panel, not from the window. The hook resets to the window edge when the panel closes or unmounts.

  • The stack slides over duration-base; no slide with reduced motion. Toasts don't re-mount, their timers keep running.
  • In CSS it is --toast-inset-end on .toasts (the panel's width; 0 by default).

A lasting state is a Banner, not a toast.

Tooltip

Short text on hover and on keyboard focus: the name of an icon button, the full text of a truncated cell, the absolute time behind “2 h ago”, why something is off.

  • 400 ms delay, shows on focus too, hides on Esc. role="tooltip" linked with aria-describedby (or the button's aria-label for icon names).
  • text ground, surface text, 12 px, up to 320 px wide.
  • Under the element by default, above near the bottom edge.
  • Never the only place for something people must read to finish a task.

User menu

The menu under the user button: who you are, your role and where it comes from, the theme, product items, Sign out.

Order

  1. User card: avatar, email (strong), name (hint).
  2. Role box: role badge, its source, one sentence on what the role allows. Dev adds one warning-text line.
  3. Theme: segmented System · Light · Dark (.seg.seg-sm), applied to the whole app and kept in the browser; System follows prefers-color-scheme live.
  4. Product items (constructor: Keyboard shortcuts with its key on the right).
  5. Sign out: goes straight to the Keycloak logout, no confirm.

Roles

  • Admin: b-solid, “landings-admin group”.
  • Publisher: b-neutral, “landings-publishers group” (on dev: “everyone on dev”).
  • No publishing group (constructor): b-outline plus how to get access.

Roles are shown only here, never next to other people.

Classes: pop, user-card, role-box, theme-row, seg seg-sm, menu-item, menu-sep.