diff --git a/CHANGELOG.md b/CHANGELOG.md
index fd12831..9ad331d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.2.0] - 2026-09-01
+
+The components the dashboard kept for itself, so both surfaces can share one
+set. Everything from 0.1.0 keeps working unchanged.
+
+### Added
+
+- `Drawer`, a panel that comes in from either side at the full height of the
+ window. A modal is right for a question with an answer and wrong for work: it
+ floats in the middle, it is as tall as its contents, and it covers the thing
+ being worked on
+- A failed read with the reason and a retry, a list placeholder that holds the
+ shape of what is coming, a dismissible action error, page navigation with the
+ helpers that decide which numbers to offer, themed line art, and a theme
+ switch
+- `useTheme`, which reads the theme off the root element so a toggle renders the
+ right icon on first paint
+- The severity scale and the ordered figure palette, so a severity means the
+ same colour wherever it is shown
+
+### Changed
+
+- `Tabs` carries an icon and a count, can turn an option off, and comes in three sizes, which is what kept
+ applications spelling the strip out by hand: a switcher whose options are not
+ all available has to say so on the option itself
+- `Loading` takes the richer spinner and can name where the work is going, which
+ makes the second loading treatment unnecessary
+
## [0.1.0] - 2026-08-30
First release. Versioned on its own, because nothing about the components is
diff --git a/package.json b/package.json
index 456736e..6f6d932 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@sourceant/design",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "SourceAnt design system: tokens, primitives, and the graph renderer",
"license": "MIT",
"type": "module",
diff --git a/src/assets/illustrations/all-the-data.svg b/src/assets/illustrations/all-the-data.svg
new file mode 100644
index 0000000..a86be03
--- /dev/null
+++ b/src/assets/illustrations/all-the-data.svg
@@ -0,0 +1,48 @@
+
diff --git a/src/assets/illustrations/bug-fixing.svg b/src/assets/illustrations/bug-fixing.svg
new file mode 100644
index 0000000..5299cdc
--- /dev/null
+++ b/src/assets/illustrations/bug-fixing.svg
@@ -0,0 +1,175 @@
+
diff --git a/src/assets/illustrations/code-inspection.svg b/src/assets/illustrations/code-inspection.svg
new file mode 100644
index 0000000..4b1558f
--- /dev/null
+++ b/src/assets/illustrations/code-inspection.svg
@@ -0,0 +1,34 @@
+
diff --git a/src/assets/illustrations/code-review.svg b/src/assets/illustrations/code-review.svg
new file mode 100644
index 0000000..31c17db
--- /dev/null
+++ b/src/assets/illustrations/code-review.svg
@@ -0,0 +1,34 @@
+
diff --git a/src/assets/illustrations/code-thinking.svg b/src/assets/illustrations/code-thinking.svg
new file mode 100644
index 0000000..673c0fc
--- /dev/null
+++ b/src/assets/illustrations/code-thinking.svg
@@ -0,0 +1,35 @@
+
diff --git a/src/assets/illustrations/code-typing.svg b/src/assets/illustrations/code-typing.svg
new file mode 100644
index 0000000..d39160a
--- /dev/null
+++ b/src/assets/illustrations/code-typing.svg
@@ -0,0 +1,38 @@
+
diff --git a/src/assets/illustrations/conceptual-idea.svg b/src/assets/illustrations/conceptual-idea.svg
new file mode 100644
index 0000000..eda8b41
--- /dev/null
+++ b/src/assets/illustrations/conceptual-idea.svg
@@ -0,0 +1,36 @@
+
diff --git a/src/assets/illustrations/connecting-teams.svg b/src/assets/illustrations/connecting-teams.svg
new file mode 100644
index 0000000..57d9222
--- /dev/null
+++ b/src/assets/illustrations/connecting-teams.svg
@@ -0,0 +1,47 @@
+
diff --git a/src/assets/illustrations/control-panel.svg b/src/assets/illustrations/control-panel.svg
new file mode 100644
index 0000000..7371cac
--- /dev/null
+++ b/src/assets/illustrations/control-panel.svg
@@ -0,0 +1,70 @@
+
diff --git a/src/assets/illustrations/data-points.svg b/src/assets/illustrations/data-points.svg
new file mode 100644
index 0000000..f9bd153
--- /dev/null
+++ b/src/assets/illustrations/data-points.svg
@@ -0,0 +1,53 @@
+
diff --git a/src/assets/illustrations/developer-activity.svg b/src/assets/illustrations/developer-activity.svg
new file mode 100644
index 0000000..baeac27
--- /dev/null
+++ b/src/assets/illustrations/developer-activity.svg
@@ -0,0 +1,80 @@
+
diff --git a/src/assets/illustrations/engineering-team.svg b/src/assets/illustrations/engineering-team.svg
new file mode 100644
index 0000000..204b894
--- /dev/null
+++ b/src/assets/illustrations/engineering-team.svg
@@ -0,0 +1,48 @@
+
diff --git a/src/assets/illustrations/file-searching.svg b/src/assets/illustrations/file-searching.svg
new file mode 100644
index 0000000..92e45e6
--- /dev/null
+++ b/src/assets/illustrations/file-searching.svg
@@ -0,0 +1,48 @@
+
diff --git a/src/assets/illustrations/firmware.svg b/src/assets/illustrations/firmware.svg
new file mode 100644
index 0000000..74449db
--- /dev/null
+++ b/src/assets/illustrations/firmware.svg
@@ -0,0 +1,133 @@
+
diff --git a/src/assets/illustrations/for-review.svg b/src/assets/illustrations/for-review.svg
new file mode 100644
index 0000000..ad23aa5
--- /dev/null
+++ b/src/assets/illustrations/for-review.svg
@@ -0,0 +1,17 @@
+
diff --git a/src/assets/illustrations/good-team.svg b/src/assets/illustrations/good-team.svg
new file mode 100644
index 0000000..6fc03f5
--- /dev/null
+++ b/src/assets/illustrations/good-team.svg
@@ -0,0 +1,71 @@
+
diff --git a/src/assets/illustrations/mind-map.svg b/src/assets/illustrations/mind-map.svg
new file mode 100644
index 0000000..5dee1a5
--- /dev/null
+++ b/src/assets/illustrations/mind-map.svg
@@ -0,0 +1,48 @@
+
diff --git a/src/assets/illustrations/programming.svg b/src/assets/illustrations/programming.svg
new file mode 100644
index 0000000..05c41eb
--- /dev/null
+++ b/src/assets/illustrations/programming.svg
@@ -0,0 +1,106 @@
+
diff --git a/src/assets/illustrations/secure-server.svg b/src/assets/illustrations/secure-server.svg
new file mode 100644
index 0000000..2c66485
--- /dev/null
+++ b/src/assets/illustrations/secure-server.svg
@@ -0,0 +1,47 @@
+
diff --git a/src/assets/illustrations/server-status.svg b/src/assets/illustrations/server-status.svg
new file mode 100644
index 0000000..431e4c5
--- /dev/null
+++ b/src/assets/illustrations/server-status.svg
@@ -0,0 +1,36 @@
+
diff --git a/src/assets/illustrations/version-control.svg b/src/assets/illustrations/version-control.svg
new file mode 100644
index 0000000..350d544
--- /dev/null
+++ b/src/assets/illustrations/version-control.svg
@@ -0,0 +1,41 @@
+
diff --git a/src/components/ActionError.vue b/src/components/ActionError.vue
new file mode 100644
index 0000000..6aa3583
--- /dev/null
+++ b/src/components/ActionError.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
+
diff --git a/src/components/Tabs.vue b/src/components/Tabs.vue
index de2fda8..3f73ef8 100644
--- a/src/components/Tabs.vue
+++ b/src/components/Tabs.vue
@@ -1,15 +1,29 @@
@@ -35,15 +60,20 @@ defineEmits<{ 'update:modelValue': [string] }>()
:key="tab.id"
type="button"
:aria-pressed="modelValue === tab.id"
+ :disabled="tab.disabled"
+ :title="tab.title"
:class="cn(
- 'rounded px-3 py-1 text-xs font-medium transition-colors',
+ 'inline-flex shrink-0 items-center rounded font-medium transition-colors disabled:pointer-events-none disabled:opacity-40',
+ sizes[size],
modelValue === tab.id
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground',
)"
@click="$emit('update:modelValue', tab.id)"
>
+
{{ tab.label }}
+ {{ tab.count }}
diff --git a/src/components/ThemeToggle.vue b/src/components/ThemeToggle.vue
new file mode 100644
index 0000000..6f355aa
--- /dev/null
+++ b/src/components/ThemeToggle.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/src/composables/useContextKinds.ts b/src/composables/useContextKinds.ts
index 207e3c2..73d826e 100644
--- a/src/composables/useContextKinds.ts
+++ b/src/composables/useContextKinds.ts
@@ -10,6 +10,7 @@
* rather than assembled.
*/
const TONE: Record = {
+ intent: 'bg-fuchsia-600/10 text-fuchsia-600 border-fuchsia-600/20',
decision: 'bg-blue-600/10 text-blue-600 border-blue-600/20',
constraint: 'bg-amber-600/10 text-amber-600 border-amber-600/20',
workaround: 'bg-red-600/10 text-red-600 border-red-600/20',
@@ -21,6 +22,7 @@ const TONE: Record = {
}
const FILL: Record = {
+ intent: 'bg-fuchsia-600',
decision: 'bg-blue-600',
constraint: 'bg-amber-600',
workaround: 'bg-red-600',
@@ -40,19 +42,41 @@ const FILL: Record = {
* colourblind reader, and review against discussion was hard to tell apart with
* full colour vision.
*
- * Eight hues cannot all be separated from one another, which is a fact about
- * eight rather than about these eight: red-green colourblindness collapses pink
- * onto green whatever shades are picked. These pass every check except one pair
- * of neighbours, and a drawing that needs more than colour to be read needs a
- * second channel rather than better colours.
+ * Nine hues cannot all be separated from one another, which is a fact about
+ * nine rather than about these nine: red-green colourblindness collapses pink
+ * onto green whatever shades are picked. A drawing that needs more than colour
+ * to be read needs a second channel rather than better colours.
+ *
+ * They are spaced by hue rather than picked by eye. The two widest gaps left
+ * were near 123 and 298 degrees; intent took 298 because the other falls in the
+ * green that collapses onto pink.
*/
const INK: Record = {
system: '#7c3aed', part: '#d97706', contract: '#0891b2',
+ intent: '#c026d3',
decision: '#2563eb', constraint: '#d97706', workaround: '#dc2626',
convention: '#7c3aed', pattern: '#059669', discussion: '#0891b2',
review: '#db2777', plan: '#65a30d',
}
+const OPTIONS = [
+ 'Intent', 'Decision', 'Constraint', 'Workaround', 'Convention',
+ 'Pattern', 'Discussion', 'Review', 'Plan',
+] as const
+
+/** What each kind is for, in the words a person picking one needs. */
+const DESCRIBED: Record = {
+ Intent: 'What this exists to do, who for, and what it deliberately is not',
+ Decision: 'A deliberate choice made about architecture, tooling, or approach',
+ Constraint: 'A hard limitation imposed by external factors or requirements',
+ Workaround: 'A temporary fix for a known issue or limitation',
+ Convention: 'An established principle or standard the team follows',
+ Pattern: 'An observed model, recurring choice, or emergent practice',
+ Discussion: 'Key points from a team discussion or debate',
+ Review: 'Insights surfaced during code review',
+ Plan: 'A future intention or roadmap item',
+}
+
export function useContextKinds() {
const NEUTRAL = 'bg-muted text-muted-foreground border-border'
@@ -74,5 +98,5 @@ export function useContextKinds() {
color: INK[kind]!,
}))
- return { tone, fill, ink, legend, kinds: Object.keys(TONE) }
+ return { tone, fill, ink, legend, kinds: Object.keys(TONE), kindOptions: OPTIONS, kindDescriptions: DESCRIBED }
}
diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts
new file mode 100644
index 0000000..3dc6670
--- /dev/null
+++ b/src/composables/useTheme.ts
@@ -0,0 +1,43 @@
+import { onMounted, ref } from 'vue'
+
+/**
+ * The light/dark preference, shared by everything that can change it.
+ *
+ * The class on the root element is the source of truth rather than the ref: an
+ * application sets it before Vue mounts to avoid a flash of the wrong theme, so
+ * reading it back on mount is what keeps a toggle from rendering the wrong icon
+ * on first paint.
+ *
+ * The ref lives at module scope so every caller shares one, without depending
+ * on a framework's state API. Under server rendering that means it is shared
+ * across requests, which is harmless only because its value before mount is a
+ * default nobody renders a decision from.
+ */
+const isDark = ref(true)
+
+const STORAGE_KEY = 'theme'
+
+export function useTheme() {
+ function readFromDocument() {
+ isDark.value = !document.documentElement.classList.contains('light')
+ }
+
+ function setTheme(dark: boolean) {
+ isDark.value = dark
+ document.documentElement.classList.toggle('dark', dark)
+ document.documentElement.classList.toggle('light', !dark)
+ try {
+ localStorage.setItem(STORAGE_KEY, dark ? 'dark' : 'light')
+ } catch {
+ // A browser refusing storage still gets the theme it asked for.
+ }
+ }
+
+ function toggleTheme() {
+ setTheme(!isDark.value)
+ }
+
+ onMounted(readFromDocument)
+
+ return { isDark, toggleTheme, setTheme }
+}
diff --git a/src/index.js b/src/index.js
index 77fba35..e3df1c8 100644
--- a/src/index.js
+++ b/src/index.js
@@ -22,15 +22,31 @@ export { default as Chip } from './components/Chip.vue'
export { default as Diff } from './components/Diff.vue'
export { default as Logo } from './components/Logo.vue'
export { default as Markdown } from './components/Markdown.vue'
+export { default as Drawer } from './components/Drawer.vue'
export { default as Modal } from './components/Modal.vue'
export { default as Notice } from './components/Notice.vue'
export { default as Status } from './components/Status.vue'
export { default as Empty } from './components/Empty.vue'
export { default as DotIndicator } from './components/DotIndicator.vue'
export { default as Loading } from './components/Loading.vue'
+export { default as LoadFailure } from './components/LoadFailure.vue'
+export { default as SkeletonList } from './components/SkeletonList.vue'
+export { default as ActionError } from './components/ActionError.vue'
+export { default as Pagination } from './components/Pagination.vue'
+export { default as Illustration } from './components/Illustration.vue'
+export { default as ThemeToggle } from './components/ThemeToggle.vue'
export { default as Origin } from './components/Origin.vue'
export { default as Section } from './components/Section.vue'
export { useContextKinds } from './composables/useContextKinds'
+export { useTheme } from './composables/useTheme'
export { controlVariants, roomyVariants } from './lib/control'
export { cn } from './lib/utils'
+export {
+ DEFAULT_PAGE_SIZE,
+ MAX_PAGE_SIZE,
+ emptyPage,
+ pageKey,
+ pageSummary,
+ pageWindow,
+} from './lib/pagination'
diff --git a/src/lib/pagination.ts b/src/lib/pagination.ts
new file mode 100644
index 0000000..0601bfc
--- /dev/null
+++ b/src/lib/pagination.ts
@@ -0,0 +1,67 @@
+/** One page of a list, as every list endpoint answers. */
+export interface Paged {
+ items: T[]
+ total: number
+ page: number
+ size: number
+ pages: number
+}
+
+export interface PageRequest {
+ page?: number
+ size?: number
+}
+
+/** Enough to fill a list pane without asking the provider for more than it will answer. */
+export const DEFAULT_PAGE_SIZE = 20
+
+/** What the API says a page is at most, so a request never comes back a 422. */
+export const MAX_PAGE_SIZE = 100
+
+export function emptyPage(page: PageRequest = {}): Paged {
+ return {
+ items: [],
+ total: 0,
+ page: page.page ?? 1,
+ size: page.size ?? DEFAULT_PAGE_SIZE,
+ pages: 0,
+ }
+}
+
+/** Identifies a page of a scope, so two scopes never share a cached answer. */
+export function pageKey(scope: string[], page: PageRequest): string {
+ return [
+ [...scope].sort().join(','),
+ page.page ?? 1,
+ page.size ?? DEFAULT_PAGE_SIZE,
+ ].join('|')
+}
+
+/**
+ * The page numbers to offer, with gaps where there are too many to show.
+ * A null stands for a run of pages that is not worth a button.
+ */
+export function pageWindow(current: number, total: number, span = 1): (number | null)[] {
+ if (total <= 1) return total === 1 ? [1] : []
+
+ const shown = new Set([1, total])
+ for (let n = current - span; n <= current + span; n++) {
+ if (n >= 1 && n <= total) shown.add(n)
+ }
+
+ const ordered = [...shown].sort((a, b) => a - b)
+ const withGaps: (number | null)[] = []
+ ordered.forEach((n, index) => {
+ if (index > 0 && n - ordered[index - 1]! > 1) withGaps.push(null)
+ withGaps.push(n)
+ })
+ return withGaps
+}
+
+/** What a reader is looking at, phrased the way they would say it. */
+export function pageSummary(page: Paged): string {
+ if (!page.total) return 'Nothing here'
+ const first = (page.page - 1) * page.size + 1
+ const last = Math.min(page.page * page.size, page.total)
+ return `${first}–${last} of ${page.total}`
+}
diff --git a/src/styles/tokens.css b/src/styles/tokens.css
index 5a7314e..2849fdf 100644
--- a/src/styles/tokens.css
+++ b/src/styles/tokens.css
@@ -36,6 +36,23 @@
--pillar-graph: 262 83% 66%;
--pillar-review: 142 71% 45%;
--pillar-tokens: 38 92% 50%;
+
+ /* How bad a finding is. Reserved: never reused to tell two series apart. */
+ --severity-critical: 0 72% 51%;
+ --severity-high: 25 90% 48%;
+ --severity-moderate: 38 92% 50%;
+ --severity-low: 217 91% 60%;
+
+ /* One per figure in a row, in a fixed order so a card keeps its colour as
+ the row grows. Measured, not chosen: the pair a colourblind reader would
+ struggle with is amber against green, which every card's own words carry
+ it past. */
+ --figure-1: 217 91% 55%;
+ --figure-2: 330 81% 50%;
+ --figure-3: 32 95% 44%;
+ --figure-4: 160 94% 30%;
+ --figure-5: 262 83% 58%;
+ --figure-6: 192 91% 36%;
}
.light {
diff --git a/tailwind-preset.js b/tailwind-preset.js
index 5cd8407..80d28db 100644
--- a/tailwind-preset.js
+++ b/tailwind-preset.js
@@ -73,6 +73,20 @@ export default {
review: 'hsl(var(--pillar-review) / )',
tokens: 'hsl(var(--pillar-tokens) / )',
},
+ severity: {
+ critical: 'hsl(var(--severity-critical) / )',
+ high: 'hsl(var(--severity-high) / )',
+ moderate: 'hsl(var(--severity-moderate) / )',
+ low: 'hsl(var(--severity-low) / )',
+ },
+ figure: {
+ 1: 'hsl(var(--figure-1) / )',
+ 2: 'hsl(var(--figure-2) / )',
+ 3: 'hsl(var(--figure-3) / )',
+ 4: 'hsl(var(--figure-4) / )',
+ 5: 'hsl(var(--figure-5) / )',
+ 6: 'hsl(var(--figure-6) / )',
+ },
},
borderRadius: {
lg: 'var(--radius)',