05 / Skins

Writing a new one

A skin is a single file that assigns the contract. It cannot add selectors for component internals — if a skin needs a hook the anatomy does not expose, that is a gap in the anatomy and it gets fixed there. This is the discipline that keeps the system agnostic over time.

Author what changes. Ship every name. The file below lists only what this skin decides. What gets generated carries every name — every name the skin stays silent on is filled from the contract's template value. That is what makes switching a skin a switch rather than a merge: a partial skin nested inside another would inherit the outer skin's values for anything it left unset, including --se-mix-shade, which decides the direction every hover and press mixes.

[data-se-skin="portfolio"] {

  /* type */
  --se-font-sans:   Archivo, system-ui, sans-serif;
  --se-font-code:   "Fragment Mono", ui-monospace, monospace;
  --se-font-meta:   "Archivo Narrow", system-ui, sans-serif;

  --se-text-xs:  calc(0.75rem * var(--se-font-scale));
  --se-text-sm:  calc(0.8125rem * var(--se-font-scale));
  --se-text-md:  calc(clamp(1rem, 0.97rem + 0.13cqi, 1.0625rem) * var(--se-font-scale));
  --se-text-lg:  calc(clamp(1.125rem, 1.05rem + 0.36cqi, 1.25rem) * var(--se-font-scale));
  --se-text-xl:  calc(clamp(1.25rem, 1.09rem + 0.71cqi, 1.5rem) * var(--se-font-scale));
  --se-text-2xl: calc(clamp(1.5rem, 1.11rem + 1.79cqi, 2rem) * var(--se-font-scale));
  --se-text-3xl: calc(clamp(2.125rem, 1.27rem + 3.93cqi, 3rem) * var(--se-font-scale));
  --se-text-4xl: calc(clamp(2.5rem, 0.93rem + 7.14cqi, 5rem) * var(--se-font-scale));

  --se-weight-regular:  400;
  --se-weight-medium:   500;
  --se-weight-semibold: 600;
  --se-weight-bold:     700;

  --se-leading-display: 0.98;
  --se-leading-heading: 1.12;
  --se-leading-lead:    1.45;
  --se-leading-body:    1.6;
  --se-leading-ui:      1.2;
  --se-leading-code:    1.8;

  --se-tracking-display: -0.035em;
  --se-tracking-heading: -0.02em;
  --se-tracking-body:    0;
  --se-tracking-label:   0.1em;

  /* color — surfaces */
  --se-bg:              #FBFAF8;
  --se-surface:         #FFFFFF;
  --se-surface-sunk:    #F2EFE9;   /* wells, inputs, disabled fills */
  --se-surface-raised:  #FFFFFF;   /* same as surface here; a dark skin lifts it */
  --se-border:          #E8E4DE;
  --se-border-strong:   #D6D1C9;   /* control edges */
  --se-border-hover:    #A9A29A;

  /* color — text. Two tiers on a light surface, because the AA floor
     leaves no room for a third. Hierarchy below muted comes from size,
     tracking and caps, never from a lighter grey. */
  --se-text:            #262934;   /* Sky Captain — 13.9:1 */
  --se-text-muted:      #6E6862;   /* 5.3:1 */
  --se-text-placeholder: var(--se-text-muted);
  --se-text-disabled:   #B5AFA6;   /* 2.1:1 — the WCAG exemption for inactive controls */
  --se-text-on-intent:  #FFFFFF;   /* label on a solid fill; a pale-fill skin flips this */
  --se-text-inverse:       #D9D4CC;   /* body on a dark surface */
  --se-text-muted-inverse: #9C978E;

  /* color — accent and status. Each is one value used as text OR fill;
     every softer tone is derived with color-mix, never added here. */
  --se-accent:          #2F5BE8;
  --se-accent-soft:     #EEF2FE;
  --se-accent-line:     #DBE3FC;
  --se-positive:        #1F6B4A;
  --se-negative:        #B8402A;
  --se-warning:         #8A5A0F;

  /* space */
  --se-space-1: calc(0.25rem * var(--se-density));
  --se-space-2: calc(0.5rem * var(--se-density));
  --se-space-3: calc(0.75rem * var(--se-density));
  --se-space-4: calc(1rem * var(--se-density));
  --se-space-5: calc(1.5rem * var(--se-density));
  --se-space-6: calc(2rem * var(--se-density));
  --se-space-7: calc(3rem * var(--se-density));
  --se-space-8: calc(4.5rem * var(--se-density));

  --se-section-y:   clamp(3.5rem, 8cqi, 5.5rem);
  --se-page-gutter: clamp(1.25rem, 4cqi, 2rem);
  --se-stack-gap:   clamp(1rem, 2.5cqi, 1.5rem);

  /* form */
  --se-radius-none: 0;
  --se-radius-sm:   0.3125rem;
  --se-radius-md:   0.4375rem;
  --se-radius-lg:   0.75rem;
  --se-radius-pill: 999px;
  --se-radius-nested: calc(var(--se-radius-lg) - var(--se-space-3));
  --se-border-width: 1px;

  --se-shadow-none:     none;
  --se-shadow-raised:   0 1px 2px rgba(38, 41, 52, 0.06), 0 1px 1px rgba(38, 41, 52, 0.04);
  --se-shadow-floating: 0 4px 12px -2px rgba(38, 41, 52, 0.10), 0 2px 4px -1px rgba(38, 41, 52, 0.06);
  --se-shadow-overlay:  0 16px 40px -8px rgba(38, 41, 52, 0.18), 0 4px 12px -4px rgba(38, 41, 52, 0.10);
  --se-shadow-dragging: 0 24px 48px -12px rgba(38, 41, 52, 0.24), 0 8px 16px -8px rgba(38, 41, 52, 0.12);

  --se-focus-width:  2px;
  --se-focus-offset: 2px;
  --se-focus-color:  var(--se-accent);

  /* icons */
  --se-icon-sm:     1rem;
  --se-icon-md:     1.25rem;
  --se-icon-lg:     1.5rem;
  --se-icon-weight: regular;   /* declared, not yet read */

  /* motion */
  --se-duration-instant: 80ms;
  --se-duration-fast:    160ms;
  --se-duration-medium:  240ms;
  --se-duration-slow:    400ms;
  --se-ease-standard:   cubic-bezier(0.2, 0, 0, 1);
  --se-ease-decelerate: cubic-bezier(0, 0, 0, 1);
  --se-ease-accelerate: cubic-bezier(0.3, 0, 1, 1);

  /* layout */
  --se-container-max: 72.5rem;
  --se-bp-md: 40rem;
  --se-bp-lg: 64rem;
  --se-grid-columns: 4;
  --se-grid-gutter:  var(--se-space-4);
  --se-grid-margin:  var(--se-page-gutter);
  --se-col-min:     14rem;   /* auto-fit floor for tile grids */
  --se-sidebar-min: 14rem;   /* panel floor before main gives way */
  --se-switch-at:   38rem;   /* Switcher stacks below this */

  /* dials */
  --se-density:     1;         /* multiplies every space step */
  --se-font-scale:  1;         /* multiplies every text step */
  --se-density-compact:  0.75;
  --se-density-spacious: 1.25;
  --se-target-min:  2.75rem;   /* 44px touch floor — fixed, not a proportion */

  /* control height — one source for every single-line control, each a multiple
     of the type root so the box tracks the text it wraps. Set as block-size, not
     min-block-size, so no state can change it. */
  --se-control-h-sm: calc(var(--se-text-md) * 2);
  --se-control-h:    calc(var(--se-text-md) * 2.5);
  --se-control-h-lg: calc(var(--se-text-md) * 3);

  /* interaction mixes — a component sets --se-_intent, every state derives */
  --se-mix-soft:  8%;    /* intent over surface: hover on outline and label */
  --se-mix-press: 18%;   /* intent over surface: pressed outline and label */
  --se-mix-hover: 88%;   /* intent over black: hover on solid */
  --se-mix-down:  78%;   /* intent over black: pressed solid */
  --se-mix-line:  60%;   /* intent over surface: the outline border */
  --se-mix-shade: #262934;   /* Sky Captain — what hover and press mix toward */
}

Reference skins

Maya Okonjo

Art director, Lagos → Berlin. Available for freelance from October.

Hire me Résumé
portfolio · default

Maya Okonjo

Art director, Lagos → Berlin. Available for freelance from October.

Hire me Résumé
studio-edit-dark

Maya Okonjo

Art director, Lagos → Berlin. Available for freelance from October.

Hire me Résumé
editorial · creative-supplied

Maya Okonjo

Art director, Lagos → Berlin. Available for freelance from October.

Hire me Résumé
high-contrast

The connection with the contract

Values fall down. A version points up. A skin and the contract are authored apart — different files, different lifecycles, and adding a skin never touches the contract. What holds them together is two things travelling in opposite directions, and neither is optional.

Down · the contract fills the silences

A skin names twenty-odd values. The rest arrive from the contract's template when the CSS is generated, which is why every emitted skin carries every name rather than the handful it declared.

This is what makes growth free. A skin written against an older contract does not break when a token is added — the new name was never in the skin, so it resolves to its template and the component renders.

Up · the skin records which contract

Every skin carries the contract version it was authored against. Nothing reads it at runtime and no component ever asks for it.

It exists so a rename can be answered. Which skins still hold the old name? Ask the pins. Without it that question has no answer, and a deprecated alias can never be proven unused — so every alias would live forever.

{
  // the contract this skin is an instance of
  "contract": "0.2.0",
  "selector": "[data-se-skin=\"editorial\"]",

  "values": {
    "font-sans": "Georgia, serif",
    "accent":    "#241F1A",
    "radius-md": "999px"
  }
}

Renaming a token

A rename is the dangerous edit, and it is worse than a break. A skin setting the old name does not error — the declaration is still perfectly valid CSS. It is never read again, the value reverts to the template, and nothing anywhere reports it. So a rename is declared rather than just done, and two mechanisms carry it to two different audiences.

The alias reaches everyone. Declare the rename on the new token and the contract emits the old name as a fallback. Anyone still setting it keeps working, and nobody has to touch a skin for that to be true.

:root {
  --se-<new>: var(--se-<old>, <template value>);
}

The migration reaches skins you store. A skin holds names as keys, and a key goes stale. Stored values are carried to the new name when the skin is read, and rewritten the next time it is saved — lazily, on a write the author was making anyway. Bulk-rewriting someone's stored values is the riskiest operation in the system, and it buys nothing the alias has not already bought.

The alias is dropped in a later major, once no skin pins below the rename. That is the pin earning its place.

What makes a skin valid

A skin gives a value to some or all of the names on the list, sets values and nothing else, and never touches structure, keyboard, ARIA or words. It need not be complete to author — completeness is a property of emission.

npm run check:skins is the gate. It fails a skin with no pin, a pin claiming a contract that does not exist yet, a name the contract does not declare, or anything that is not a value. A skin below a declared rename is reported rather than failed — the emitted CSS is already correct, so there is nothing to fix in a hurry.