scandinavian-design
Apply a refined Scandinavian visual system with a neutral black-and-white foundation, restrained sans-serif typography, purposeful product imagery, generous spacing, clear page chapters, and minimal decoration. Use when the user invokes /scandinavian-design or requests a Nordic, monochrome, calm, spacious, restrained, or minimalist interface.
Install
npx skills add https://github.com/ericzakariasson/scandinavian-design --skill scandinavian-designWebsites Without Source Access
The Scandinavian design skill is written for codebases where markup, styles, scripts, and components can all change; structural redesign assumes that control. When the target is a shipped or third-party site — a before/after demonstration, a quick restyle — deliver an injected override stylesheet as a constrained fallback and say plainly that this mode has a lower ceiling: composition can only be bent as far as the existing DOM allows, and a source-level redesign would go further.
Rules for the fallback mode
- Decolorizing is not redesigning. Recolored tokens on an unchanged composition produce a grayscale copy of the same site; the recognition test in SKILL.md Verification still applies.
- Structure remains partially in scope through grid, flex, spacing, size, and alignment overrides: establish a real type scale, rebuild chapter spacing, left-align text, and frame a restrained measure. Preserve the existing primary action if there is one, and add none if there isn't — a reference page, an index, or an operational tool may legitimately have no call to action, and promoting a control into a filled primary changes what the page asks the user to do.
- Query
document.fontsduring recon. A site frequently loads a full product typeface while setting most of its chrome in a system fallback, and nothing in the visible CSS reveals it. One line tells you which families and weights are actually available, which decides whether the skill's "preserve a well-made product typeface" applies before you reach for a new one. - A neutralizing pass reaches less far than a clean tint report suggests. On image-led commerce the merchandising colour has usually migrated into the photography — studio backdrops baked into the JPEGs — which "expressive media keeps its colour" protects entirely. Removing every CSS colour on such a page still leaves it looking colourful. That is the correct outcome, but say so rather than letting a zero-tint result imply the page went monochrome.
- Probe the live DOM for real selectors and computed styles before writing rules. Clickable elements may be buttons rather than anchors,
!importanton an element selector loses to!importanton a class selector, and utility class names are often reused across unrelated regions. !importantdoes not break a tie. Two overrides of the same property with equal specificity resolve by source order, so a broad rule written after a specific one silently reverses it — a card-wide colour rule placed below a button rule will repaint that button's label, and both selectors look correct in isolation. Order rules broad to specific, and give the specific one an element qualifier so it wins on weight rather than on position.- A state rule needs at least the specificity of the resting rule it modifies, regardless of order. A
:hoverwritten at (0,2,1) after a rest rule at (0,3,0) loses on weight and simply never fires, which no screenshot will show — this is the mirror of the ordering trap above and is easy to miss because the rule reads as correct and sits in the right place. - When an override appears to be ignored, look for a constraint before adding more declarations.
heightloses tomin-height,widthloses to a parent'salign-items: stretch, and a dimension set on an element whosedisplayyou did not check is a guess —width: 100%never reaches the cells of a table that site CSS has switched todisplay: flex. - When retargeting design-system custom properties, check the selector each token is defined on rather than just its name. Tokens are often declared on something like
[data-color-mode="light"]rather than:root, so a:rootoverride loses silently. Override the specific token the component reads, which also leaves the semantic token intact for the states that still need it. Watch for families that redeclare the same tokens further down the tree — a themed subtree will not inherit your:rootoverride — and remember that a:rootoverride only holds while nothing nested redeclares it. - Overriding a
--x-rgbinput does nothing when the--x-colorthat consumes it is declared further up the tree. Substitution happens once, where the consuming property is declared, and descendants inherit the finished colour — so:rootcan report your new value while every component still paints the old one. Override the property the component actually reads. - A themed subtree can re-emit an entire resolved theme as literal values on itself, which makes your
:rootoverride invisible for everything inside it. To find them, walk the tree with your override applied and compare each element's computed token value against its parent's; the redeclarations are otherwise undetectable, because they carry the same values as:root. :not()carries the specificity of its argument.*:not(.foo)scores 0,1,0, not 0,0,0, so a broad reset written that way silently outranks the per-element rules you write afterwards.- The no-emoji rule assumes you can edit markup. In override mode you often cannot, and sometimes the emoji is the label — a reaction count encodes its type in the glyph, so silhouetting them to one ink collapses heart, thumb and laugh into identical blobs. Greyscale is then the honest fallback: it holds the distinction while taking the colour off the page. Say that you did it and why.
- Cross-origin stylesheets make
document.styleSheetsunreadable, so the token audit above cannot be done from the DOM on many sites. Intercepttext/cssnetwork responses and parse them instead, and scrape inline<style>tags too, since CSS-in-JS writes there rather than to a file. Do this early: on a hashed-class site it is usually the highest-value thing you can do, because it is the only way to see which selector really carries a value. - A design system can be a decoy. A site may publish a full set of typography tokens that its components do not read, having hard-coded size, weight and tracking directly — so a token-only pass changes the font family and silently fails on everything else, while looking like it worked. Confirm a token is live by finding a component that reads it, not by finding it declared.
- Prefer the site's own semantic hooks over class names of any kind. Many sites ship
data-*vocabularies or unhashed structural classes alongside their hashed ones, and those survive deploys. A class-frequency digest will not surface them; dump the markup of a representative component and read it. - On a site built from micro-frontends, the same component ships several times under different build prefixes. Match the shared BEM tail (
[class*="btn__inner"]) rather than enumerating prefixes. The hazard is that broad tail-matching also catches state variants, so a rule aimed at secondary buttons can silently empty a selected filter chip; keep an exception list for states. - A card's fill often lives on its
::beforerather than on the card, sobackground: transparenton the element you can see does nothing. Check the pseudo-elements before concluding a surface is unreachable. - A
<canvas>is opaque to every rule exceptfilter, which is a blunt instrument: it desaturates whatever is drawn. It is safe only when nothing in the drawing is encoded by hue — verify that lightness or shape carries the distinctions first, then say in the comment why the filter was acceptable. - Product images sometimes ship
mix-blend-mode: multiply, which is what lets a tinted panel sit behind a shot without showing a rectangle. Check for it before placing a surface behind media. - Removing an icon means removing the box reserved for it. Hidden glyphs leave behind fixed heights, min-widths, and flex columns that hold empty space and strand neighboring labels off the shared baseline.
- Margins apply to empty blocks. A suspicious gap is usually inherited structure rather than a value that needs tuning, so measure it before adjusting it; any rule that sets spacing across a group of selectors should account for the members that render empty.
- Centering can come from
text-align, from flexalign-itemsorjustify-contenton containers, from flex layout inside the buttons themselves, or from auto margins. Identify the mechanism actually in use instead of stacking blind overrides. - Set the type family on
bodyand form controls only and let inheritance carry it, so icon fonts keep rendering. - Verify with the standard browser loop at desktop and mobile widths, scrolling the full page.
Harness scripts
Execute these with Node. They require playwright-core (npm i in the skill's scripts/) and a locally installed Google Chrome; they launch Chrome with CSP bypass so injected styles and web fonts load on strict sites.
Every script takes a URL, which is the normal way to use them and works on any page including a local dev server. Inside this repo they also accept an id from demos/sites.json, which resolves to that demo's URL and injects its theme.css and the shared consent stylesheet before measuring — that mode exists for the eval and is not needed elsewhere.
node scripts/run-eval.js recon|snap|all [id]— batch recon or before/after snaps for the sites indemos/sites.json. Writes intodemos/<id>/. A site entry may carry"scrollY", either a number or{ "desktop": n, "mobile": n }, which moves the fold capture down past a leaderboard ad or other opening chrome that has nothing to do with the design. It applies to the before and after equally, so the pair stays comparable, and the full-page captures are unaffected.node scripts/snap.js <url> <cssFile> <outPrefix> [width] [height]— capture before/after screenshots of a URL with the override stylesheet injected. Defaults to 1440×900 at 2x; use390 844for the mobile pass.node scripts/recon.js <url> <outPrefix>— run before writing any rules: captures a screenshot and prints a DOM digest (ids, class frequency, body font and colors, heading sample, and a layout container tree with sizes).node scripts/probe.js <url> "text one,text two"— run when rules do not take effect: prints computed color, background, font, border, and shadow plus the ancestor chain for every element containing the given text, innermost first. Matching collapses whitespace and works on substrings, because the element carrying the styling is usually a wrapper two or three levels above the text node.node scripts/density.js <url|siteId> [desktop|mobile] [screens]— run whenever the skill asks you to preserve density: counts the distinct links and headings intersecting each screen, before and after, in one run. Judging this from screenshots does not work on a long page, and a change that adds height while removing nothing looks fine in every individual shot. It also reports whether any link present before is missing after, which catches a dropped control.node scripts/bands.js <url|siteId> [desktop|mobile] [maxBands]— slice a long page into viewport-height captures you can actually review. A 15,000px full-page PNG scales down past the point where type and alignment can be judged, and cropping it afterwards is worse, since image tools crop from the centre and will hand you a mid-page slice labelled as the top.node scripts/lines.js <url|siteId> [yMin] [yMax] [width]— run after any pass that removes rules: lists every line drawn in a region of the themed page, horizontal and vertical, and names the mechanism painting it. Sites draw lines with a border edge,box-shadow, a::before/::afterbar, or a real element collapsed to 1px, and overriding one of those does nothing about the other four.node scripts/tints.js <url|siteId> [minSpread] [maxSpread] [width] [--exclude=sel]— run to prove a neutralizing pass is actually complete: reports every colour in the page chrome whose channel spread marks it as a tinted neutral rather than a real colour, grouped by value and count, ignoring anything inside product media. It catches the things a CSS read cannot — literal hex buried in component CSS, token aliases you never overrode, and casts hiding in gradient stops. It measures the painted colour rather than the declared one, which matters most on dark canvases: a tint there cannot be built from low-alpha black, so it arrives as a saturated colour at low alpha instead, andrgba(179,218,255,0.08)that looks like a real colour composites to a spread of 7. Only real media elements are detected automatically, so pass--excludewith the roots of any product mockups rebuilt in DOM; without it the count is dominated by the inside of screenshots you were right not to touch.
Inline SVG icons are chrome and are audited as such. Only SVG inside a real media container is exempt, so an icon-heavy design system will report its icon colours — which is the point. Name genuine illustration roots with --exclude rather than assuming the tool should skip the element type.
A clean tints.js run at the default threshold is not by itself evidence of anything. Some design systems bake their whole neutral ramp two or three points off true grey, which is below the threshold worth correcting, so the unstyled site already scans clean. When that is the case, re-run at 2 40 and read the result as "which of the site's own ramp values did my pass fail to reach" rather than "is there a cast" — a different question that the same tool happens to answer.
Node resolves playwright-core relative to the script, so a scratch script written outside the repo fails with MODULE_NOT_FOUND. Run it with NODE_PATH=<repo>/node_modules, or keep scratch scripts inside scripts/ and delete them afterwards.
Open index.html or run npm run site to review before/after shots.