KC Design System — Sketch / Periwinkle
Rough it out.
Rough it out.
One system, every component.
The sketch style redraws the system freehand in
design-system-sketch.css:
handwriting type, wobble borders, sticky-note labels and marker highlights,
everything a degree or two off square. Same components, same markup —
the notebook the classic skin was ruled up from.
Design style
Specimen
One card, re-inked and re-shaped by whatever the two columns say.
Primary
chip
Set — Sketch / Periwinkle
Chrome
Brand marks
Four lockups the name can wear, all one block. The base
.ds-brand
is what the site ships — the bitmap logo beside the full name — and the
three modifiers are the alternatives.
--monogram
sets the initials in the signature 2px box beside the name;
--plate
is the postmark, the name stacked over a mono role kicker on a filled slab; and
--dot
is the compact mark, lowercase mono initials closed by the single full stop that
carries the accent. None of them fork for dark: every colour resolves through a
--ds-brand-*
property, so an ink surface declares
.ds-on-dark
once and the marks follow — the same eight tokens the scrolled header already
flips, which is why it is named in that block rather than restating them.
The base lockup
(.ds-brand, no modifier).
The mark the site actually wears: the 40px bitmap logo and the full name, set at
--ds-text-md bold with a
touch of tracking. It is the only lockup whose glyph is an image rather than type, so on ink
it cannot recolour through a token — it inverts, exactly as the scrolled band inverts it.
The name is an unclassed span, which is why the dark rule has to come from the surface
rather than from the element.
The monogram
(.ds-brand--monogram).
Initials in the signature 2px box, name alongside — the smallest lockup that still
says both things. The box stays an outline on either surface and only its stroke
colour moves, so the mark never turns into a different shape in the dark; hover fills it
periwinkle, the one move it borrows from the button. Square corners: it is a surface,
not a control.
The plate
(.ds-brand--plate).
The postmark: the name over a mono role kicker in a drawn 2px frame — the one place
the brand is allowed two lines. It used to be a filled slab, which made it the
only lockup that inverted against its surface rather than with it. It no longer
does: the frame stays an outline on both surfaces and only its stroke colour moves,
exactly as the monogram's box does, so the lockup never becomes a different shape in the
dark. That costs one token
(--ds-brand-plate-edge)
and buys back a whole category of surprise.
The frame is a pseudo-element, not a border on the box, and that is not a flourish: a real
border occupies layout, so it could not be absent from a folded plate without shifting
every row by 2px. Being a pseudo lets it be drawn — which is the last beat
of the fold below. The three rows are three elements, each leading with a
.ds-brand__seed:
the glyph of the dot mark that row grew from.
The dot
(.ds-brand--dot).
Lowercase mono initials closed by a full stop — the compact mark, for the places a
full name will not fit. The stop is the only place the accent is spent, and it is the
only thing that moves in the dark: periwinkle on paper, violet on ink, where the stronger
tone would vibrate.
--ds-brand-dot-size
carries the same mark from this header scale to the 3rem loader splash without a second
rule. It reads as initials, not a word, so the link carries the name in its
aria-label.
Plate to dot, and back
(.ds-brand-fold).
The last two lockups are not two marks — the plate is the dot, unfolded.
Each of the plate's three rows carries the glyph it grew from, so folding does not swap
one lockup for another, it stands the same three glyphs back down into a row. That is what
lets a column narrow to 84px without the identity at the top of it blinking into something
else.
Four beats, in order: kc. stands up from a
row into a column and shrinks to the plate's own size as it goes; the frame is drawn round
the column it now encloses; each glyph hands off; each word writes out, one row after
another. The glyph is gone before its word arrives — lowercase mono cannot
be interpolated into bold sans, and two typefaces caught overlapping read as a glitch
rather than as a handoff.
A fold only means anything at two widths, so each frame below is a column that narrows,
built from the rail's own tokens. Hover one, or tab
into it. On the live rail the same two states are the collapse and the breather.
Both surfaces, as everywhere else on this page — and nothing in the pair is restated
for the dark one. The frame, the glyphs and the kicker all read the plate's own tokens, so
the fold inverts by standing on a different surface rather than by being told to.
Chrome
Icons
One canvas, one stroke, one place they live
(_Icon): 24×24,
bare geometry carrying nothing but coordinates, and a 2.4 stroke supplied by whatever
surface the mark lands on. Nothing here names a colour, which is the only reason the
same twenty marks can sit on paper in one row and inside an ink plate in the next
without a second copy of themselves — the two panels below are that claim,
run twice on identical markup. Square caps and miter joins because there is not a
rounded corner anywhere in this system; angles of 0, 45 and 90 and nothing else.
The set is also honest about itself. The fifteen destination marks and the
five chrome marks meet the contract; the twelve below the fold do not, and each one
says which rule it breaks and where it ships. They are drawn at the weight they
actually render, not corrected on the way to the page. The full contract —
counter minimums, the element budget, the five-question review test — is in
docs/guidelines/icon-style-guide.md.
The whole governed set at 20px — the size the
rail renders, which is the only size the review test is run at. The two panels are the
same twenty <partial>
calls; the ink one declares a colour and changes nothing else.
Destinations
Fifteen marks, three of them level one and the rest level two. Collapsed, the rail is 84px
and the icon is the row — so these carry the least detail in the system, and
the three app marks carry the least of all.
Chrome
The marks on controls. Same canvas, same stroke, same silence about colour.
close was inline in nine
places before this section existed — three in the drawer, three in the modal, two in the
toast, one in this page's own chrome — and every copy agreed, which is the kind of
agreement that holds until someone edits one of them.
Off the contract
Twelve marks that ship and do not meet the rules. They are drawn here exactly as they render
on their own pages — the 1.6-weight ones at 1.6, the round caps round, the solid ones
solid — because a specimen redrawn to the contract would quietly hide the thing worth
seeing. Set them beside the panels at the top of this section: the feature and counter marks
read as a lighter, softer family, and they are on the same screen as the rail.
Nothing here is a bug. It is a list of decisions that were made one at a time, before
there was a set to make them against.
Injected by the behaviour layer
Three marks go into elements that do not exist until JavaScript runs — the code block's
copy button, its copied state, and the anchor on every prose heading — so a Razor partial
cannot reach them. The geometry has a key here and the strings that actually ship live in
wwwroot/js/ds/.
That is the one real seam in the set: two copies of one drawing, which have to be changed
together. Saying so is cheaper than a build step that would prove it.
Using one
<partial name="_Icon" model='@(new IconSpec("close"))' />
<!-- the rail is the one surface that needs a class on the element,
because it hangs per-glyph modifiers off the same hook -->
<partial name="_Icon" model='@(new IconSpec(app.Icon, "ds-rail__icon"))' />
/* everything else styles the glyph through its container, and the mark
supplies nothing but coordinates */
.ds-drawer__close svg {
fill: none;
stroke: currentColor;
stroke-width: 2.4;
stroke-linecap: square;
stroke-linejoin: miter;
}
An unrecognised key renders nothing — not a placeholder, not a dashed box. A row
that lost its icon should look like a row without one, so the fault reads as a gap rather than
as a glyph someone has to learn to recognise as an error. The full contract, including the
counter minimums that decide what can be drawn at all and the five-question review test, is in
docs/guidelines/icon-style-guide.md.
Chrome
Site header & navigation
The frame every page opens in
(_SiteHeader):
brand, the uppercase nav whose accent underline wipes in on hover, the current
destination held open, and the CV button. Past 60px of scroll it turns the dark
is-scrolled state
and flips the button to periwinkle so it does not read black-on-black; below 768px
the links become a numbered full-height menu behind the toggle.
--floating is the
alternative: the band goes transparent and the inner row becomes a card that clears
the top edge and rides over the page — the modal's logic, since anything
floating free of an edge earns the pop shadow. Same markup, one modifier, so the
two never drift apart. Both specimens below are the live partial.
The band. Sticky at the top of every
page, closed by the 2px underline. Brand left, nav right; the links are uppercase and
tracked out, and the accent underline
(background-size,
not a border, so it wipes rather than appears) fills on hover and stays filled on
.is-active —
Blog is held open here.
Scrolled. Past 60px
(headerScroll in
the behaviour layer) the band fills with
--ds-surface-dark,
the logo inverts and the links go pale. The solid CV button would read black-on-black,
so it flips to periwinkle — the same move the mobile menu makes.
The floating alternative
(.ds-header--floating).
The band turns transparent and stops taking clicks; the inner row becomes the surface
— a card inset from all three edges, carrying the 2px border and the
--ds-shadow-pop
lift. Square corners: it is a surface, not a control. The band stays sticky and in
flow, so the card holds its 16px gap at every scroll position and nothing beneath it
shifts — the page reserves the same
--ds-header-height
as the band does, and every anchor offset on the site keeps working untouched.
Floating, scrolled. The card darkens
instead of the band. Ink-on-ink would swallow the shadow, so the lift switches to the
periwinkle --ds-shadow-pop-soft;
every other flip — brand, links, button — is inherited from the shared
is-scrolled rules,
not restated.
Try it on a real page. A layout picks its
skin with ViewData["DsChrome"];
this page exposes that as a switch, so the chrome at the top of your screen changes
rather than one in a box. Scroll after switching to see the dark state arrive.
Rail is the third skin and the only one
that is a different partial rather than a modifier — the two levels it carries
have no counterpart in a single row of links, so it swaps
_SiteHeader for
_SiteRail and the
page composes against a
--ds-rail-width
margin instead of a header height. Pick it and this page grows a spine listing
every registered style. Its own section is below.
And the mark it opens with. The brand is a
parameter of the chrome, not a constant inside it: whichever skin is showing renders
_BrandMark, so the
four lockups from the brand section are all reachable from here and
none of them can drift from what that section documents. The band ships the bitmap mark
and the rail defaults to the monogram — a 264px column has room for the initials
box and a wrapped name, which a one-line bitmap lockup does not use as well. Try the
plate on the rail: it is the lockup that was drawn for a corner.
The toggle. Hidden until 768px, where the
nav becomes a full-height menu with mono numbering down its rows. The bars are three
spans and the cross is a transform off
aria-expanded —
the attribute a screen reader needs is the one that draws the state, so the two cannot
disagree. Both states are shown here; on a phone it is one button.
Before the header, the skip link.
.ds-skip-link is the
first thing in the body and sits 100px above the viewport until it takes focus, then
drops into the top-left corner and jumps to
#ds-main. Press
Tab from the address bar on any page to see it — it is not framed here because it
only exists in relation to the top of the window.
Chrome
Spine rail & two-level navigation
The header turned through ninety degrees
(--rail):
chrome leaves the top edge for the left margin, and the room that buys pays for
the second level of navigation the band never had space for. The three apps stack
as one bordered plate; beneath them the 2px rule turns inward into a timeline
carrying the current app's sections. Switch app and the spine
re-populates — slides in the Resume, archive surfaces in the Blog, the two
skins here — while the geometry never moves. Primary wears the ink,
secondary wears the accent, so a place you are and a position that moves can sit
40px apart and still never be confused. The latch parks it at one cell wide, and
from there hover or focus opens the full column over the page rather
than pushing it: horizontal padding is the same at both widths, so the cell lands
on the same pixel and collapsing changes what is written beside a row, never
where the row is. Below 992px it becomes a slide-over, which keeps the two levels
stacked instead of flattening them into one menu.
The rail. The header turned through
ninety degrees: chrome leaves the top edge for the left margin, and in the room that
buys it grows the second level of navigation the band never had space for. Brand,
then the three apps as one bordered plate divided by the 2px rule, then that rule
turned inward into a timeline carrying the current app’s sections, then the CV
button on the floor. Primary wears the ink,
secondary wears the accent — the current app is a filled plate, a place
you are; the current section is a periwinkle marker, a position that
moves. Two levels, two colours, and no way to confuse them.
Switch app, and the spine re-populates.
The geometry never moves — only the label and the rows under it. In the Portfolio
the spine reads
slides and
lists the deck’s panels; in the Blog it reads
sections and
lists the four surfaces of the archive. Both come from one
ISiteNavigation,
so a destination cannot exist in the rail and nowhere else.
The shortest spine on the site. This
page’s own second level is not its forty-one sections — those are a table
of contents for one page, and they stay in the drawer. It is which skin you
are reading in: Classic or Flow. Two rows, and the spine still reads as the same
component, because a level that can be short without looking broken is a level that
can be honest about how much is under it.
Collapsed, and the breather.
Hover the rail below. The latch parks it at one cell wide; from there hover or
keyboard focus opens the full column over the
page rather than pushing it, on the pop shadow that anything floating free of an edge
earns. The page’s margin follows the resting width and never the hovered
one, so content holds still while the column passes across it — the reason to
overlay rather than reflow is that a reader glancing at the nav should not lose their
place in the text. Closing carries a
--ds-rail-close-delay
so a cursor travelling diagonally back to the page does not snap the column shut as it
clips the corner; opening has none.
Nothing moves when the rail breathes. Every
row is a --ds-rail-cell
square plus a label that fades, and the horizontal padding is identical in both states, so
the collapsed width is derived — cell plus two paddings — rather than
picked. The cell therefore lands on the same pixel either way: collapsing changes what is
written beside a row, never where the row is. That is what stops it reading as two
components swapping. The same discipline decides the marks: the plate is the lockup drawn
for a corner and tops the open column, but 84-odd pixels have room for initials and
nothing else, so collapsed always falls back to the dot — and it is swapped by
display, not
opacity, so only one of the two is ever in the accessibility tree.
The icons are load-bearing here, not
decoration: collapsed, the cell is the entire row, and a mono number that read fine beside
a word does not survive being the only thing left. They follow their own style guide
(docs/guidelines/icon-style-guide.md)
— 24px canvas, 2.4 stroke, square caps, no curves, because a set that drifts in
weight stops reading as one row.
Below 992px, a band and a full-page menu.
A phone cannot spare a permanent 264px column, so the rail hands the page its width
back and keeps only a band — the mark and the toggle, in both states. What the
toggle opens is the page: a 264px drawer is a shape borrowed from the column
it came from, and at this width the column is not the point. The map gets the screen,
carrying the same two levels in the same order — order kept intact
rather than two levels reflowed into one flat menu is the whole reason the component
exists. There is no scrim, because a full-page panel has no outside to dim; the
toggle stays put above it and becomes the way out. 992px is deliberate: it is the
same threshold the deck disengages at, so the Portfolio never has a rail without slides
to track. Shown here at desktop width, declared open rather than tapped open.
Two class choices carry the component.
The panel is
.ds-rail__nav,
not .ds-nav:
the phone overlay restyles every
.ds-nav
descendant into the full-screen ink menu — down to a counter on
::before,
which is precisely where the spine draws its marker — and that block sorts
after this one, so taking a different class is cheaper and steadier than
out-specifying it. The section links, though, keep
.ds-nav__link,
because that is the hook the deck reads: any such link whose fragment names a panel
is already wired to page the deck and to receive
.is-active as
it moves. The spine gets slide tracking for nothing, and pays only by overriding the
underline wipe it would otherwise inherit.
Chrome
Reading progress strip
One line of chrome doing four jobs
(.ds-progress):
identity, app, the section actually being read, and how far through — written
as a single shell invocation, because the mono voice is the brand’s own. The
prompt holds the left end; the middle is a
cd into the page
whose --section
flag rewrites itself by scroll-spy, caret still blinking; the right side composes up
to three optional readouts — section counter, percentage, and the bar itself as
either a 2px accent rule along the bottom edge or a ten-glyph ASCII meter drawn in
text. Light-mode native on a hairline rather than an ink plate, so it can sit under
the white header band without reading as a second header;
--pinned sticks
it below whatever chrome the page wears via
--ds-chrome-top.
The markup is passive; progressStrips()
drives the flag, the counters and the fill, and speaks the state through
role="progressbar".
It ships live on all three apps, composed per app through
ViewData["DsProgress"]:
blog posts read --section
over their own h2s with the percentage and the rule; this page reads its
catalog into the ASCII meter — look up — and the resume deck
presents a
--slide flag
with a slide counter, fed by the deck itself, because a stage that pages by
transform has no scroll to spy on.
The command line — the default.
The whole state as a single shell invocation: the prompt, a
cd into the
page, and a live --section
flag that rewrites itself as you scroll, the caret still blinking at the end.
The right side carries the section counter and the percentage; the progress bar
is the 2px accent rule filling along the strip’s bottom edge — present,
never competing with the text. Scroll inside the frame.
kc@kumarChandrachooda.com:~$
cd /blog/an-event-bus-in-seven-files && read --section="01 the-contract"
section 1/5
0%
01The contract comes first
02Commands and events on the wire
03A shadow registry of every message
04Swapping the transport underneath
05Seven files, one idea
The ASCII meter — the same strip
with the progress drawn in block glyphs instead of a rule:
████░░░░░░,
ten characters filling in periwinkle beside the percentage. No track element, no
div — the meter is text, which is exactly the joke and exactly the
credibility. A strip carries the meter or the fill, never both; two progress
readings a rounding error apart read as a bug.
kc@kumarChandrachooda.com:~$
cd /blog/an-event-bus-in-seven-files && read --section="01 the-contract"
0%
01The contract comes first
02Commands and events on the wire
03A shadow registry of every message
04Swapping the transport underneath
05Seven files, one idea
The readouts, composed — the right
side is three optional spans, and a page keeps only what it needs: the section
counter alone for a piece where the count is the point, the percentage for a long
single read, and one of the two bars where the glance should not need numbers at
all. Held at sixty-two percent so the four can be compared.
kc@kumarChandrachooda.com:~$
read --section="03 the-registry"
section 3/5
kc@kumarChandrachooda.com:~$
read --section="03 the-registry"
section 3/5
62%
kc@kumarChandrachooda.com:~$
read --section="03 the-registry"
62%
kc@kumarChandrachooda.com:~$
read --section="03 the-registry"
62%
| Piece | Hook | Notes |
|---|---|---|
| Strip | .ds-progress[data-ds-progress] | Wired by progressStrips(). Add --pinned to stick it below the page’s chrome via --ds-chrome-top. |
| Sections | [data-ds-progress-mark="01 name"] | On the content, in order. The last mark scrolled past the strip is “current”. |
| Flag value | [data-ds-progress-section] | Rewritten, quoted, as the reader scrolls. |
| Counter | [data-ds-progress-index] | 1-based; the total is server-rendered. |
| Percentage | [data-ds-progress-pct] | Whole numbers; __pct reserves 4ch so 9→10 cannot nudge the row. |
| Rule fill | .ds-progress__fill | Reads --ds-progress off the strip; 2px accent along the bottom edge. |
| ASCII meter | [data-ds-progress-meter-on/-off] | Ten glyphs, █ lit / ░ spent. Carry one bar per strip, never both. |
| App | The command | The readouts |
|---|---|---|
| Blog post | cd /blog/<slug> && read --section=…, spied off the article’s own h2s | Percentage + rule fill — a read is one continuous measure. |
| Design system | cd /design-system && read --section=…, one mark per catalog section | ASCII meter + percentage — the audience that gets the joke. |
| Resume deck | present --slide=… — no cd; the prompt is already home | Slide counter + rule fill, fed by ds-deck-state from the deck — six discrete slides make a percentage fake precision. |
A page opts in by putting a ProgressStrip
in ViewData["DsProgress"];
the layout renders it at the top of main
through _ProgressStrip, pinned under
the page’s chrome — or fixed over the deck, which has no scroll to pin in.
The strip exposes role="progressbar"
with aria-valuenow and a
spoken aria-valuetext
(“section 2 of 5, 34% read”); the meter glyphs and the caret are decoration and
say so. Under prefers-reduced-motion
the caret holds solid instead of blinking — still live, no longer motion. Below 768px
the path drops from the command whole, then the prompt folds to
kc:~$ — the constant
and the answer survive, the piece already committed to goes first. The whole strip stays
one line, under 30px, so it can never become a second header.