@clearlaunch/ink
v0.2.0
Published
Ink — a dark-first, flat, hairline-bordered design system for Vue 3. Tokens, CSS layer and components.
Readme
@ink/vue
Ink — a dark-first, flat, hairline-bordered design system for Vue 3.
Everything interactive is a full pill. Surfaces are defined by a 1px hairline, never a shadow. The brand gradient marks exactly one thing: the primary or active state.
Full spec: docs/STYLE-GUIDE.md in the ResponseFlow repo.
Install
pnpm add @ink/vueimport { createApp } from 'vue';
import InkVue from '@ink/vue';
import '@ink/vue/style.css';
createApp(App).use(InkVue).mount('#app');All components register globally — no per-file imports. For TypeScript
autocomplete on those global components, add to tsconfig.json:
{ "compilerOptions": { "types": ["@ink/vue/global"] } }Prefer explicit imports? They're named exports:
import { InkButton, InkSegmented } from '@ink/vue';Rebranding
The palette is CSS custom properties. Override --ink-accent* after the
package stylesheet and everything follows:
:root {
--ink-accent: #f97316;
--ink-accent-2: #fbbf24;
--ink-grad: linear-gradient(135deg, #f97316 0%, #fb923c 45%, #fbbf24 100%);
}Already have a token system? Bridge it, so there's one source of truth:
:root, body.body--dark, body.body--light {
--ink-accent: var(--my-accent);
--ink-surface: var(--my-surface);
/* … */
}Or take the SCSS source and override at build time:
@use '@ink/vue/scss';Components
| Component | Purpose |
|---|---|
| InkButton | Pill button — gradient | outline | flat | elevated | danger |
| InkIconButton | 28px squircle. The one control that is not a pill |
| InkSegmented | Pill track with segments — tabs and icon-only view toggles |
| InkPageHeader | Sticky header that collapses on scroll |
| InkSearch | Search pill; focus is a border change, never a ring |
| InkStatus | Status chip — ok | error | warning | info | muted |
| InkMeter | Usage meter — recessed track, gradient or status fill |
| InkRow | Flat row. Use instead of a card per list item |
| InkCard | Flat bordered panel. No shadow, no hover lift |
| InkEmpty | Empty state |
Examples
<!-- One gradient per screen region; siblings are outline -->
<InkButton variant="outline">Cancel</InkButton>
<InkButton variant="gradient" :loading="saving" @click="save">Save</InkButton>
<!-- list | card toggle -->
<InkSegmented
v-model="view"
:options="[
{ value: 'list', ariaLabel: 'List view', icon: 'list' },
{ value: 'card', ariaLabel: 'Card view', icon: 'grid' },
]"
icon-only
>
<template #icon="{ option }"><MyIcon :name="option.icon" /></template>
</InkSegmented>
<!-- Tabs with counts -->
<InkSegmented v-model="tab" :options="[
{ value: 'all', label: 'All', count: 12 },
{ value: 'new', label: 'New' },
]" />
<!-- A list of things is rows, not a grid of cards -->
<InkRow v-for="c in items" :key="c.id" clickable @click="open(c)">
<div class="name">{{ c.name }}</div>
<template #trailing>
<InkStatus :tone="c.live ? 'ok' : 'muted'" dot>
{{ c.live ? 'Live' : 'Draft' }}
</InkStatus>
</template>
</InkRow>
<InkMeter :value="65" :max="100" tone="warning" />Icons are slots, never bundled — the host app picks its own icon library.
Using Ink with a component framework
Ink is a styling layer, not a framework. It works three ways, and you can mix them in one app:
| Layer | What it is | Works with |
|---|---|---|
| Tokens | --ink-* CSS custom properties | anything, including plain HTML |
| Components | the Ink* Vue components above | any Vue 3 app |
| Adapters | thin wrappers over another framework's controls | that framework |
Quasar
import { InkQuasar } from '@clearlaunch/ink/quasar';
app.use(Quasar, { /* … */ }).use(InkQuasar);<InkQInput v-model="email" label="Email" placeholder="[email protected]" />
<InkQInput v-model="key" label="API key" variant="gradient" />
<InkQBtn label="Save" />
<InkQBtn label="Cancel" variant="ghost" />InkQInput renders a real q-input, so rules, mask, debounce, slots and
refs all behave exactly as they do on a bare one. What it adds is the shape
(outlined dense, 10px radius, border-colour focus) and — importantly —
stack-label, which is a PROP and therefore not fixable with CSS. Without it,
a dense field with a label floats that label into the input and it overlaps the
placeholder until the user types.
variant="gradient" draws the brand gradient as the border itself. Use it for
the one field on a page that should pull focus; two on a screen and neither
reads as primary.
Both are optional. Quasar is an optional peer dependency and lives on a
separate entry point, so a Tailwind or Nuxt app importing @clearlaunch/ink
never pulls Quasar-shaped code into its bundle.
Why wrap rather than replace
InkInput (standalone) and InkQInput (Quasar) both exist on purpose. In a
Quasar app that shipped both, q-input outnumbered the standalone InkInput
roughly 4:1 — because q-input is wired into Quasar's form context and slot
conventions that a from-scratch input cannot join. Buttons went the other way:
the standalone InkButton won comfortably, since a button has no form context
to join.
So: use the standalone components in apps with no component framework, and the adapters where one is already in play.
Design rules worth knowing
- One gradient per screen region. If everything is gradient, nothing is.
- A list of things is
InkRow, not a grid ofInkCard. Cards are for genuinely discrete objects. - Hover changes colour, not position. The only lift is on pill buttons.
- Shadows are for overlays only — menus, dialogs, toasts.
elevatedis the deliberate exception for controls that genuinely float. - No nested bordered boxes. Inside a panel, children are flat rows.
Accessibility
InkIconButtonrequiresariaLabel— an icon-only control needs a name.InkSegmentedrendersrole="tablist"/role="tab"witharia-selected.InkRowwithclickablerenders a real<button>, so keyboard works.InkMeterrendersrole="progressbar"with proper value attributes.- Motion respects
prefers-reduced-motion; hover transforms drop at ≤480px.
