Noodle

Docs / Skins guide

Noodle skins

A skin changes how Noodle looks: colours, type, corners, spacing, and optionally the layout and page effects it suggests. A skin is a file ending in .skin. Open one from your file manager, drop it on the Noodle window, or use Settings > Appearance > Install skin. Noodle shows it for 15 seconds with Keep and Revert before anything is saved.

This page covers the whole format. To start right away, copy examples/skins/starter and go to Building a skin from scratch.

The built-in skins in resources/skins/ are laid out exactly like a .skin, unzipped, so they double as examples: noodle (light and dark), black, high-contrast, paper, classic-98, phosphor, phosphor-amber, glass-2000 and blueprint.

Contents

The file

A .skin is a zip with:

Path Required What it holds
skin.json yes Name, tokens and suggestions
skin.css no Extra CSS for the Noodle window
assets/ no PNG, JPEG, WebP, AVIF and SVG pictures, WOFF2 fonts
LICENSE, *.txt, *.md no Licence and notes; never served

Anything else, a path that leaves the zip's root, or more than 10 MB in total makes Noodle refuse the skin. A zip that wraps everything in one top folder is fine. You don't need to make the zip by hand: npm run skin -- pack <folder> does it, and so does Save as .skin in the skin editor.

skin.json

A full example:

{
  "name": "Night Shift",
  "author": "You",
  "version": "1.0.0",
  "description": "Warm and dim for late reading.",
  "license": "CC0-1.0",
  "noodle": "0.0.1",
  "mode": "dark",
  "tokens": {
    "bg": "#16120e",
    "surface": "#1f1a14",
    "text": "#f1e6d4",
    "muted": "#bba98f",
    "accent": "#f0a35e",
    "radius": "6px"
  },
  "fonts": [{ "family": "My Font", "src": "assets/my-font.woff2", "weight": "400" }],
  "wallpaper": "assets/wall.webp",
  "layout": { "sidebarSide": "right", "density": "compact" },
  "pageEffects": { "crt": "light", "scanlines": 0.4 },
  "features": ["sounds"]
}

Every field:

Field Type Default Notes
name string required Up to 60 characters. Also gives the installed skin its id
author string empty Up to 60 characters
version string 1.0.0 Up to 20 characters, shown in Settings
description string empty Up to 300 characters, shown when you hover the skin
license string empty An SPDX id like CC0-1.0 or MIT is the clearest
noodle string none The oldest Noodle version the skin works with. Newer requirements refuse it
mode string dark dark, light or both
tokens object {} Token values, see Tokens. With both, two sets
fonts list of objects [] Up to 8 WOFF2 fonts from assets/
wallpaper string none A picture in assets/, shown behind the sidebar and top bar
layout object none Layout the skin suggests, see Layout suggestions
pageEffects object none Page effects the skin suggests, see Page effects
features list of strings [] Extras Noodle draws itself: sounds, window-buttons

Some details:

  • With "mode": "both", tokens holds two sets, { "dark": { ... }, "light": { ... } }, and Noodle picks one from the theme setting (system, dark or light). A dark or light skin ignores the theme setting.
  • Token names are written without the leading dashes: "bg", not "--bg". Unknown names are ignored.
  • A font entry looks like { "family": "My Font", "src": "/assets/my-font.woff2", "weight": "400", "style": "normal" }. family may hold letters, digits, spaces, _ and -. weight is one weight (700) or a range (100 900). style is normal or italic. Use the family in the font-ui or font-mono token.
  • layout and pageEffects are suggestions. Settings > Appearance offers "Use its layout" and "Use its page effects"; nothing changes until the user accepts.
  • sounds gives the skin short synthesized interface clicks, only when the user has turned sounds on in Settings.
  • window-buttons (Windows and Linux) makes Noodle draw the minimize, maximize and close buttons itself instead of the system's, so skin.css can restyle them (see Window buttons). A window can't swap its buttons while it is open, so this takes effect the next time Noodle starts; Settings > Appearance offers a Restart button. Without it, the system's buttons stay and only take the titlebar and titlebar-symbol colours, which keeps the Windows 11 snap layouts on the maximize button. Ask for it only when colours aren't enough. The choice follows the skin picked in Settings, not a space's own skin. On macOS the system's buttons always stay. The window is created before the profile is unlocked, so the choice (and, for these skins only, the bg colour shown before the first paint) is kept in window-frame.json in the Noodle data folder, outside the locked profile.
  • Keys Noodle doesn't know are ignored. npm run skin -- validate and skin dev mode warn about them, which catches typos.

Tokens

Tokens are CSS custom properties on the Noodle window. Noodle's own styles read them everywhere, so setting a handful changes the whole window. A skin's values come after Noodle's defaults and win. The user's own choices in Settings (gap, corner radius, font, text size, a space's accent colour and wallpaper) win over the skin.

A token value may be a colour, a length, a shadow, a gradient or a font list. url(), image-set() and similar functions, ;, braces, comments, backslashes and unbalanced quotes are refused, so a value can't end its declaration or load anything. Pictures belong in skin.css or wallpaper.

The tables list every token with Noodle's own values. "same as dark" means the light palette keeps the dark value, and var(--bg) and similar mean the token follows another one unless you set it.

Colours

Token Dark Light Use
bg #111113 #ececef Window background, behind the sidebar and top bar
surface #1a1a1d #ffffff The page area, fields and panels
elevated #1c1c1f #ffffff Command palette, menus and dialogs
card #202024 #ffffff Cards on the canvas
sidebar-bg transparent same as dark Behind the sidebar, over bg
topbar-bg transparent same as dark Behind the top bar, over bg
titlebar var(--bg) #ececef Strip behind the window buttons on Windows and Linux (solid colour)
titlebar-symbol #d4d4d8 #3f3f46 The window button symbols on Windows and Linux (solid colour)
hover rgba(255, 255, 255, 0.06) rgba(0, 0, 0, 0.05) Rows and buttons under the pointer
active rgba(255, 255, 255, 0.1) rgba(0, 0, 0, 0.085) Pressed buttons and selected rows
border rgba(255, 255, 255, 0.08) rgba(0, 0, 0, 0.1) Lines between parts
border-strong rgba(255, 255, 255, 0.16) rgba(0, 0, 0, 0.2) Lines that need to show, like a focused field
text #e4e4e7 #18181b Main text
muted #a1a1aa #52525b Secondary text
faint #71717a #71717a Hints, counts and the weakest text
accent #a78bfa #7c3aed Highlights, focus rings and the selected thing
accent-hover #b9a3fc #6d28d9 The accent under the pointer
accent-text #18181b #ffffff Text on an accent background
agent #c084fc #9333ea Anything an agent is doing
warn #fbbf24 #b45309 Warnings
danger #f87171 #dc2626 Errors and buttons that delete
danger-text #fca5a5 #b91c1c Error text
good #4ade80 #16a34a Success
good-text #86efac #15803d Success text
link #c4b5fd #6d28d9 Links, including links in the reader
scrim rgba(9, 9, 11, 0.55) rgba(24, 24, 27, 0.25) Behind the palette and dialogs
selection rgba(167, 139, 250, 0.35) rgba(124, 58, 237, 0.22) Selected text
input-bg var(--surface) same as dark The address field and other inputs
tab-active-bg var(--active) same as dark The selected tab
tab-active-text var(--text) same as dark Text of the selected tab
scrollbar rgba(255, 255, 255, 0.14) rgba(0, 0, 0, 0.2) Scrollbar thumbs
code-bg rgba(255, 255, 255, 0.08) rgba(0, 0, 0, 0.05) Code in the reader and in notes
mark rgba(250, 204, 21, 0.28) rgba(250, 204, 21, 0.4) Highlighted text in the reader and in notes

Shape and spacing

Token Dark Light Use
radius 8px same as dark Corner radius of panels; the page views follow it
radius-sm 6px same as dark Smaller corners: buttons, rows, fields
gap 8px same as dark Space around the page and between large parts
row-height 34px same as dark Height of tabs, bookmarks and similar rows
shadow 0 6px 20px rgba(0, 0, 0, 0.28) 0 6px 20px rgba(0, 0, 0, 0.08) Small shadow: menus, the address suggestions
shadow-lg 0 24px 64px rgba(0, 0, 0, 0.5) 0 24px 64px rgba(0, 0, 0, 0.18) Large shadow: the palette and dialogs

Type

Token Dark Light Use
font-ui system-ui, -apple-system, 'Segoe UI', sans-serif same as dark Font of the whole window
font-mono ui-monospace, 'Cascadia Code', Consolas, monospace same as dark Font of code, the userscript editor and addresses
font-size 13px same as dark Base text size; the rest scales from it

Motion

Token Dark Light Use
duration 120ms same as dark Length of small transitions; 0ms turns them off
ease ease-out same as dark Easing of those transitions

The reader view's "Noodle" theme takes bg, surface, text, muted, border, hover, active, accent, link, code-bg, mark and danger from the active skin.

If you work on Noodle itself: npm run docs:tokens writes the tables above from SKIN_TOKENS in src/shared/skins.ts and the :root blocks in styles.css and theme.css. npm run build runs the same script with --check and stops when the tables are out of date or a token has no description, so a new token needs a line here first.

Layout suggestions

All keys are optional. A key with a value Noodle doesn't accept is dropped, and the validator says so.

Key Values What it does
sidebarSide left, right Which side the sidebar sits on
sidebarMode shown, autohide, compact, hidden Always there, slides in at the edge, icons only, or gone
tabsOnTop true, false Tabs in a strip along the top instead of the sidebar
topBar shown, merged, hidden The address bar row, folded into the sidebar, or gone
density comfortable, compact Row heights and padding
gap whole number from 0 to 24 Pixels around the page
radius whole number from 0 to 24 Corner radius in pixels, page views included
motion full, reduced, none How much the window animates
toolbar list of top bar button names, see below Which top bar buttons show, in order; each name at most once

Toolbar button names: split, reader, video, scripts, shield, fast, star, devtools and effects.

gap and radius here become the user's settings. Use the gap and radius tokens for the look itself, and the layout keys only when the skin needs those numbers to stick. Prompts, agent waits and find bring a hidden top bar back while they are up, whatever the skin suggests.

Page effects

Page effects change how web pages look, not the Noodle window. They are Noodle's own code: a skin can only suggest which ones to switch on and with which numbers, and the user turns them on (Use its page effects, or Settings > Appearance > Page effects).

{
  "pageEffects": {
    "crt": "light",
    "curvature": 0.35,
    "scanlines": 0.35,
    "glow": 0.25,
    "flicker": 0.1,
    "mask": 0.3,
    "tint": "#33ff66",
    "pixelFont": true
  }
}
Key Values Default Light tier Full tier
crt off, light, full off light turns on the overlay below full draws the page through the CRT shader
curvature 0 to 1 0.5 Strength of the dark vignette How far the tube bulges, plus vignette and fringe
scanlines 0 to 1 0.5 Darkness of the 3 px lines Depth of scanlines that follow the curve
glow 0 to 1 0.4 A little more brightness and contrast Bloom: bright areas bleed into their neighbours
flicker 0 to 1 0.2 A slight brightness flicker A slight brightness flicker
mask 0 to 1 0.4 Faint red, green and blue columns Aperture grille: each column favours one colour
grayscale 0 to 1 0 Page in gray Not used
sepia 0 to 1 0 Page in brown tones Not used
tint #rrggbb or empty empty One-colour screen: inverted, then tinted One phosphor colour; light pages become lit text
dither true, false false A 2 px checker pattern over the page Not used
pixelFont true, false false Page text in a pixel font (VT323) Not used

Flicker stops when the system asks for reduced motion or the user sets motion to reduced or none.

The light tier

The light tier is CSS that Noodle puts into each page: a filter on the page for grayscale, sepia, tint and glow, and one click-through overlay for scanlines, the mask, the vignette and dither. It costs close to nothing and works on every machine. With pixelFont on, icon fonts, code and preformatted text keep their own font.

The full CRT tier

With "crt": "full", Noodle renders the active tab offscreen and draws each frame through a WebGPU shader on the Noodle window: barrel curvature, an aperture grille, bloom, scanlines that follow the curve, slight colour fringing towards the edges, flicker, a rounded tube edge and the phosphor tint. Clicks, scrolling and typing are mapped back through the same curve, so the page stays usable. It draws at most 60 frames a second. The real tab is hidden and muted while the CRT copy shows, and moves to the address you ended up on when the CRT stops.

Noodle falls back to the light tier when WebGPU or shared GPU textures are missing, when the GPU device is lost, or when drawing stays under 50 frames a second for 3 seconds. Split view, reader view, the canvas and fullscreen use the light tier, and the CRT never starts on a tab an agent is working in. Since a skin can only suggest full, nothing heavy runs until the user turns it on.

skin.css

skin.css styles the Noodle window, after Noodle's own styles. Use the class names you see in the window's developer tools (.sidebar, .tab.active, .topbar, .address-input, .palette, .button and so on). Reach for tokens first: they are shorter, they keep working when Noodle's markup changes, and they cover parts you might forget. Use skin.css for what tokens can't say, like bevels, patterns or a different shape for one control.

/* The selected tab gets a bar in the accent colour on its left edge. */
.tab.active {
  box-shadow: inset 3px 0 0 var(--accent);
}

@media (prefers-reduced-motion: no-preference) {
  .tab {
    transition: background var(--duration) var(--ease);
  }
}

Window buttons

With the window-buttons feature, Noodle draws the window buttons as plain HTML in the top right corner, where the system's would be:

<div class="window-controls maximized inactive">
  <button class="wc-button wc-min"><span class="wc-glyph"></span></button>
  <button class="wc-button wc-max"><span class="wc-glyph"></span></button>
  <button class="wc-button wc-close"><span class="wc-glyph"></span></button>
</div>

maximized is there while the window is maximized (the middle button then restores) and inactive while another window has focus. The glyphs are drawn with borders and the ::before and ::after pseudo-elements of .wc-glyph, so a skin can redraw them without pictures. The rest of the top strip still moves the window: drag it, double-click it to maximize, or drag it to a screen edge to snap. Windows' snap layouts menu only appears on the system's maximize button; Win+Z still opens it.

New tab page

Blank tabs show the new tab page (Settings > Appearance > New tab page): a clock, a search field and the user's shortcuts. Noodle draws it in the window, so it uses the skin's tokens (surface behind it, elevated and border for the icon tiles, hover, accent for focus) and the skin's or space's wallpaper. The user can pick their own colour or picture instead. Its classes, for skin.css:

Class What it is
.ntp The page; on-dark, on-light or on-picture with the user's own background
.ntp-clock, .ntp-time, .ntp-date The clock
.ntp-search-input The search field
.ntp-tile, .ntp-link, .ntp-add A shortcut, its link, the Add shortcut tile
.ntp-icon, .ntp-name A shortcut's icon box and name
.ntp-remove The remove button that shows on hover

Cleaning

Noodle cleans the file before use:

  • @media, @supports, @keyframes, @font-face and @container stay. @import, @layer and every other at-rule are removed.
  • url() works for files in the skin's own assets/ folder (url('assets/dots.svg') or url(./assets/dots.svg)) and for data: pictures and WOFF2 fonts. Anything else becomes none.
  • image-set(), src(), image(), cross-fade(), element(), attr(), env() and paint() are removed.
  • z-index is capped at 9999.
  • A backslash escape outside a string makes Noodle refuse the skin, since an escape could spell one of the blocked names.

The install preview, skin dev mode and the validator all list what was removed.

Pictures and fonts

Put them in assets/ (subfolders are fine, up to five levels). File and folder names may use letters, digits, ., _ and - and must start with a letter or digit.

Extension Served as Use it for
.png image/png Pictures, patterns
.jpg, .jpeg image/jpeg Photos
.webp, .avif as named Photos and pictures, smaller than PNG or JPEG
.svg image/svg+xml Patterns and icons; scripts in SVG never run
.woff2 font/woff2 Fonts, through fonts in skin.json or @font-face
  • The wallpaper is a picture from assets/ named in wallpaper. It sits behind the sidebar and the top bar, under bg. A user can also pick a wallpaper per space, and that one wins.
  • Fonts in fonts get font-display: swap, so text shows in the next font in your list until the file has loaded. End font-ui with a system font.
  • Check the licence of every font and picture you ship and put it next to the file (assets/OFL.txt, say). Classic 98 and Phosphor do this for their Open Font License fonts.
  • Pictures and fonts are served on noodle-skin://<skin>/assets/... to the Noodle window only. Web pages can't load them.

Limits

What Limit
Whole skin (all files) 10 MB, and the .skin file itself 10 MB
skin.json 64 KB
skin.css 256 KB
A token value 240 characters
Fonts in fonts 8
A data: URL in skin.css 200,000 characters
z-index in skin.css -9999 to 9999
Folders under assets/ 5 levels, names up to 81 characters
A folder in dev mode or pack 500 files; hidden files (starting with .) are skipped

What Noodle refuses, and why

Skins come from strangers, so a skin is data and never code.

  • No JavaScript or any other code. A skin has nowhere to put it, SVG pictures are shown as images (where scripts never run), and Noodle serves skin files with a policy that blocks scripts even if one is opened as a document.
  • No remote addresses. Fonts and pictures come from the skin itself, so a skin can't tell anyone when or where you use it. The window's content security policy only allows pictures and fonts from Noodle itself and from noodle-skin://.
  • No files outside the skin's folder, and no file types beyond the list above. The zip reader refuses paths with .., backslashes or a leading /.
  • No @import (it could load other files), no @layer (see below) and no functions that fetch by name.
  • No backslash escapes outside strings, since they could spell a blocked name in a way a simple check would miss.
  • No skins that need a newer Noodle than yours, since they might rely on tokens or classes that don't exist yet.

Some parts of the window are protected. Permission prompts, agent approval cards and waits, the agent bypass flag, the address field, download warnings and the Keep and Revert bar have their key styles in a cascade layer that skins can't override or join. A skin can't hide them, make them transparent, move them off screen, reword them or cover them, and the address field always uses the system's own field colours. Web pages are native views above the Noodle window, so nothing in a skin can draw over a page either. When the profile locks, the skin comes off and the unlock screen shows Noodle's own look.

Skin dev mode

Settings > Appearance > Skin development > Load skin folder picks an unpacked skin folder (the one holding skin.json) and shows it right away. Noodle watches the folder and reloads the skin about 150 ms after any file in it changes, pictures and fonts included, so you can keep your editor and Noodle side by side.

  • Errors and warnings show under the button, worded the same as the command line checker. An error (broken JSON, a backslash escape, a file type Noodle refuses) stops the reload, and the last version that loaded stays on screen until you fix it.
  • Nothing is installed or saved. Stop puts your own skin back, and so does restarting Noodle.
  • Reload reads the folder again by hand, for editors that save in ways the watcher misses.
  • Files and folders starting with . are skipped, so a .git folder or editor swap files don't get in the way.

Checking and packing from the command line

Run these from inside a checkout of the Noodle source (Node 22.18 or newer). Relative paths count from the folder you type the command in; a skin folder outside the checkout needs its full path, like C:/skins/harbor.

npm run skin -- validate path/to/my-skin
npm run skin -- validate my-skin.skin other-skin
npm run skin -- validate C:/skins/harbor
npm run skin -- pack path/to/my-skin
npm run skin -- pack path/to/my-skin --out dist/my-skin.skin

validate takes folders and .skin files, prints each skin's problems and exits with 1 if any skin would be refused. pack checks the folder first and writes <folder name>.skin in the folder you ran it from (or the --out path) only when there are no errors. Both use src/main/skins/validate.ts, the same code Noodle runs when it installs a skin and in skin dev mode, so the messages match. A run looks like this:

ok   Starter 1.0.0 (1.6 KB, 3 files)
FAIL Night Shift 1.0.0 (2.1 KB, 2 files)
     warning: skin.json: tokens has "acent", which isn't a Noodle token.
     error: skin.css uses a backslash escape outside a string, which skins may not.

Building a skin from scratch

This builds a small dark and light skin called Harbor. The result is close to examples/skins/starter, so you can compare.

  1. Make a folder named harbor anywhere (this guide uses C:/skins/harbor), and in it a file named skin.json:

    {
      "name": "Harbor",
      "author": "Your name",
      "mode": "dark",
      "tokens": {
        "bg": "#12161a",
        "surface": "#1a2026",
        "accent": "#5fc490"
      }
    }
  2. In Noodle, open Settings > Appearance, scroll to Skin development and choose Load skin folder. Pick harbor. The window turns blue-gray with a green accent. Everything you didn't set keeps Noodle's value.

  3. Change "accent" to "#e0b252" and save the file. Noodle reloads the skin as soon as the file is written. Add "text", "muted" and "accent-text" (the text on accent buttons) to suit. Tokens says what each name colours; most skins need ten or so.

  4. Give it a light version. Change "mode" to "both" and split tokens in two:

    {
      "name": "Harbor",
      "author": "Your name",
      "mode": "both",
      "tokens": {
        "dark": { "bg": "#12161a", "surface": "#1a2026", "accent": "#5fc490" },
        "light": { "bg": "#e9eef0", "surface": "#ffffff", "accent": "#1f8a57" }
      }
    }

    Switch Settings > Appearance > Theme between Dark and Light to check both.

  5. Round the corners a bit more: add "radius": "12px" and "radius-sm": "8px" to both sets. The page views follow radius too.

  6. Now some CSS. Make skin.css next to skin.json:

    .tab.active {
      box-shadow: inset 3px 0 0 var(--accent);
    }

    With var(--accent) the bar follows the dark or light accent by itself.

  7. Add a picture. Make a folder assets and save this as assets/dots.svg:

    <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12">
      <circle cx="6" cy="6" r="0.9" fill="#808080" fill-opacity="0.35" />
    </svg>

    Then use it in skin.css:

    .sidebar {
      background-image: url('assets/dots.svg');
      background-size: 12px 12px;
    }

    If the path is wrong, dev mode warns that the url() was removed.

  8. A font, if you want one: put a WOFF2 file in assets/, list it in skin.json, and use it in font-ui with a system font after it:

    {
      "fonts": [{ "family": "Harbor Sans", "src": "assets/harbor-sans.woff2", "weight": "400" }]
    }

    and in each token set, "font-ui": "'Harbor Sans', system-ui, sans-serif".

  9. Suggest a layout or page effects if the skin is meant for them, for example "layout": { "gap": 10 }. People get a button to accept the suggestion; nothing changes on its own.

  10. Fill in version, description and license, then check and pack it. Run these from inside your Noodle checkout and give the skin folder's full path; pack writes harbor.skin in the folder you run it from.

    npm run skin -- validate C:/skins/harbor
    npm run skin -- pack C:/skins/harbor

    Without the Noodle source, use the skin editor's Save as .skin instead, or zip the folder's contents yourself and rename the zip to harbor.skin.

  11. Choose Stop under Skin development, then install harbor.skin the way anyone else would (Install skin, or drop it on the window) and press Keep. Now you can send the file to others.

How Classic 98 is made

Classic 98 (resources/skins/classic-98) is the most detailed built-in skin, and almost all of it is plain CSS.

Tokens do most of the work

skin.json sets "mode": "light" and about forty tokens. The gray #c0c0c0 goes in bg, elevated, card, active, sidebar-bg and tab-active-bg; fields are white through surface and input-bg. accent and selection are navy (#000080), as on the real thing. The corners are square, nothing animates, and the shadows are hard offsets with no blur:

{
  "tokens": {
    "radius": "0px",
    "radius-sm": "0px",
    "shadow": "2px 2px 0 #000000",
    "shadow-lg": "3px 3px 0 #000000",
    "duration": "0ms"
  }
}

titlebar is #1084d0 and titlebar-symbol is white for the moment before the skin's own buttons take over: Classic 98 asks for window-buttons, so after a restart Noodle draws gray beveled minimize, maximize and close keys on the blue strip, with close set a little apart. They gray out the strip when the window loses focus.

.wc-button {
  width: 18px;
  height: 16px;
  color: #000000;
  background: #c0c0c0;
  box-shadow:
    inset -1px -1px #0a0a0a,
    inset 1px 1px #ffffff,
    inset -2px -2px #808080,
    inset 2px 2px #dfdfdf;
}

A pixel font

The skin ships Pixelify Sans (Open Font License, with assets/OFL.txt next to it) in two weights and points font-ui at it, with Segoe UI as the fallback:

{
  "fonts": [
    { "family": "Pixelify Sans", "src": "assets/pixelify-sans-400.woff2", "weight": "400" },
    { "family": "Pixelify Sans", "src": "assets/pixelify-sans-700.woff2", "weight": "700" }
  ]
}

skin.css turns off font smoothing on .app so the pixels stay sharp.

The bevel

Every raised control uses the same four inset shadows: white and light gray on the top and left edges, dark gray and near black on the bottom and right. A pressed control swaps them. No pictures needed:

.button {
  border: 0;
  border-radius: 0;
  background: #c0c0c0;
  box-shadow:
    inset -1px -1px #0a0a0a,
    inset 1px 1px #ffffff,
    inset -2px -2px #808080,
    inset 2px 2px #dfdfdf;
}

.button:active:not(:disabled) {
  box-shadow:
    inset -1px -1px #ffffff,
    inset 1px 1px #0a0a0a,
    inset -2px -2px #dfdfdf,
    inset 2px 2px #808080;
}

Fields (.address-input, .palette-input) use the pressed order, which makes them look sunken. Tabs and bookmarks are raised buttons, and the active tab is pressed in. The page area (.content) gets the sunken bevel and 2 px of padding, so the page sits in a frame.

The title strip and the dither

The top bar, the tab strip, the sidebar header and the settings header get the navy to blue gradient, linear-gradient(90deg, #000080, #1084d0), with white text. The checkered fill of a pressed tab and of scrollbar tracks is a 2 px conic gradient, again with no picture:

.tab.active {
  background: #c0c0c0 repeating-conic-gradient(#ffffff 0 25%, #c0c0c0 0 50%) 0 0 / 2px 2px;
}

The start menu palette

The command palette gets a strip down its left side with "Noodle 98" written upwards. It is a ::before element with writing-mode: vertical-rl, turned 180 degrees, over the same gradient running bottom to top. The palette gets 30 px of left padding to make room.

Scrollbars

The scrollbars are 16 px wide with raised thumbs and buttons and dithered tracks, drawn with the ::-webkit-scrollbar pseudo-elements and the same bevel.

Suggestions

The skin suggests "layout": { "radius": 0, "gap": 6, "motion": "none" }, so square corners stick even if the user had set their own radius. It suggests a light CRT for pages (curvature 0.35, scanlines 0.35, glow 0.25, flicker 0.1, mask 0.3) and asks for sounds and window-buttons. The suggestions and sounds wait until the user accepts them; the window buttons follow the skin from the next start.

To take it apart yourself, copy resources/skins/classic-98 somewhere, load the copy in skin dev mode and start changing things.

The skin editor

Settings > Appearance > Skin editor starts from the skin you use, with colour pickers and sliders for every token and a box for extra CSS, all applied live. "Add to my skins" installs it; "Save as .skin" writes the file to share. A skin made in the editor keeps the CSS, fonts and pictures of the skin it started from. For anything beyond colours, skin dev mode with your own editor is quicker.