feat: replace histoire with a minimal in-house stories harness

Histoire is unmaintained and blocks upgrading Vite and other dev
dependencies. Replace it with a small standalone Vite app that keeps the
same `<Story>`/`<Variant>` template API so story files need no changes.

- Add vite.stories.config.ts and app/javascript/stories/ (sidebar tree
  with collapsible groups, search, dark-mode and RTL/LTR toggles, and a
  per-story error boundary)
- Globally register Story/Variant; migrate the histoire setup (store,
  i18n, plugins/directives) and seed a memory router for route-aware
  components
- Repoint story:dev/build/preview scripts and drop the histoire deps
- Remove histoire.config.ts, histoire.setup.ts and histoire.scss
This commit is contained in:
Shivam Mishra
2026-06-03 14:45:51 +05:30
parent 7acbe8b3ff
commit 4ede0f58d2
18 changed files with 504 additions and 1143 deletions
@@ -1,21 +0,0 @@
@import 'dashboard/assets/scss/app';
*,
::before,
::after {
--_histoire-color-primary-50: 235 245 255;
--_histoire-color-primary-100: 194 225 255;
--_histoire-color-primary-200: 153 206 255;
--_histoire-color-primary-300: 112 186 255;
--_histoire-color-primary-400: 71 166 255;
--_histoire-color-primary-500: 31 147 255;
--_histoire-color-primary-600: 25 118 204;
--_histoire-color-primary-700: 19 88 153;
--_histoire-color-primary-800: 12 59 102;
--_histoire-color-primary-900: 6 29 51;
}
html,
body {
font-family: 'Inter', sans-serif;
}
@@ -0,0 +1,6 @@
@import 'dashboard/assets/scss/app';
html,
body {
font-family: 'Inter', sans-serif;
}
+125
View File
@@ -0,0 +1,125 @@
<script setup>
import { ref, computed, defineAsyncComponent } from 'vue';
import { stories, buildTree } from './registry';
import TreeNode from './TreeNode.vue';
import StoryFrame from './StoryFrame.vue';
const query = ref('');
const filteredStories = computed(() => {
const term = query.value.trim().toLowerCase();
if (!term) return stories;
return stories.filter(story => story.title.toLowerCase().includes(term));
});
const tree = computed(() => buildTree(filteredStories.value));
const isSearching = computed(() => query.value.trim().length > 0);
function pathFromHash() {
const hash = decodeURIComponent(window.location.hash.replace(/^#/, ''));
return stories.find(story => story.path === hash)?.path;
}
const selectedPath = ref(pathFromHash() || stories[0]?.path || '');
const current = computed(() =>
stories.find(story => story.path === selectedPath.value)
);
const StoryComponent = computed(() =>
current.value ? defineAsyncComponent(current.value.loader) : null
);
function select(path) {
selectedPath.value = path;
window.location.hash = encodeURIComponent(path);
}
const isDark = ref(false);
function toggleDark() {
isDark.value = !isDark.value;
document.documentElement.classList.toggle('dark', isDark.value);
}
// Scoped to the preview canvas only (see the <main :dir> below), so the
// sidebar stays LTR while stories can be inspected in RTL.
const isRtl = ref(false);
function toggleDir() {
isRtl.value = !isRtl.value;
}
// Remount the active story on every hot update so a fixed file clears the
// error boundary (otherwise a caught error stays latched until a full reload).
const hmrTick = ref(0);
if (import.meta.hot) {
import.meta.hot.on('vite:afterUpdate', () => {
hmrTick.value += 1;
});
}
</script>
<template>
<div
class="flex w-screen h-screen overflow-hidden bg-n-background text-n-slate-12"
>
<aside
dir="ltr"
class="flex flex-col border-r w-72 shrink-0 border-n-weak bg-n-solid-1"
>
<div
class="flex items-center justify-between h-12 gap-2 px-4 border-b shrink-0 border-n-weak"
>
<span class="text-sm font-semibold text-n-slate-12">
@chatwoot/design
</span>
<div class="flex items-center gap-1">
<button
type="button"
class="px-2 py-1 text-xs font-medium uppercase rounded-md text-n-slate-11 hover:bg-n-alpha-1"
@click="toggleDir"
>
{{ isRtl ? 'RTL' : 'LTR' }}
</button>
<button
type="button"
class="px-2 py-1 text-sm rounded-md text-n-slate-11 hover:bg-n-alpha-1"
@click="toggleDark"
>
{{ isDark ? 'Light' : 'Dark' }}
</button>
</div>
</div>
<div class="p-3 border-b shrink-0 border-n-weak">
<input
v-model="query"
type="search"
placeholder="Search stories"
class="w-full px-3 py-1.5 text-sm rounded-md border outline-none border-n-weak bg-n-background text-n-slate-12 placeholder:text-n-slate-10 focus:border-n-brand"
/>
</div>
<nav class="flex-1 p-2 overflow-y-auto">
<TreeNode
:nodes="tree"
:selected-path="selectedPath"
:force-expand="isSearching"
@select="select"
/>
<p
v-if="!filteredStories.length"
class="px-2 py-4 text-sm text-n-slate-10"
>
No stories match "{{ query }}".
</p>
</nav>
</aside>
<main :dir="isRtl ? 'rtl' : 'ltr'" class="flex-1 min-w-0 overflow-hidden">
<StoryFrame
v-if="StoryComponent"
:key="`${selectedPath}:${hmrTick}`"
:component="StoryComponent"
/>
<div
v-else
class="flex items-center justify-center h-full text-n-slate-10"
>
Select a story to preview.
</div>
</main>
</div>
</template>
+39
View File
@@ -0,0 +1,39 @@
<script setup>
import { computed, provide } from 'vue';
const props = defineProps({
title: { type: String, required: true },
layout: {
type: Object,
default: () => ({ type: 'grid', width: '80%' }),
},
// Accepted for API compatibility with Histoire; not used by the harness.
group: { type: String, default: '' },
});
const layout = computed(() => ({ type: 'grid', ...props.layout }));
provide('storyLayout', layout);
const name = computed(() => props.title.split('/').pop());
const containerClass = computed(() =>
layout.value.type === 'single'
? 'flex flex-col gap-6'
: 'flex flex-wrap items-start gap-6'
);
</script>
<template>
<div class="flex flex-col h-full min-h-0 bg-n-background">
<header
class="flex flex-col gap-0.5 px-6 py-4 border-b border-n-weak shrink-0"
>
<h1 class="text-lg font-semibold text-n-slate-12">{{ name }}</h1>
<p class="text-xs text-n-slate-10">{{ title }}</p>
</header>
<div class="flex-1 min-h-0 overflow-auto p-6">
<div :class="containerClass">
<slot />
</div>
</div>
</div>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup>
import { ref, onErrorCaptured } from 'vue';
// Per-story error boundary. Keyed by the story path in App.vue, so it remounts
// (and resets) on navigation. Containing the error here keeps one broken story
// from tearing down the whole harness, mirroring Histoire's per-story isolation.
defineProps({
component: { type: [Object, Function], default: null },
});
const error = ref(null);
onErrorCaptured(err => {
error.value = err;
return false;
});
</script>
<template>
<div v-if="error" class="flex flex-col h-full gap-3 p-6 overflow-auto">
<p class="text-sm font-semibold text-n-ruby-11">
This story failed to render.
</p>
<div
class="p-3 font-mono text-xs whitespace-pre-wrap rounded-md text-n-slate-11 bg-n-alpha-1"
>
{{ error.stack || error.message }}
</div>
</div>
<component :is="component" v-else />
</template>
+76
View File
@@ -0,0 +1,76 @@
<script setup>
import { reactive } from 'vue';
// Recursive sidebar node. Each instance owns the collapsed state of its own
// direct child groups, so nesting just works. Plain buttons/divs (no <ul>/<li>)
// avoid the list markers the dashboard stylesheet injects.
const props = defineProps({
nodes: { type: Array, required: true },
selectedPath: { type: String, default: '' },
forceExpand: { type: Boolean, default: false },
depth: { type: Number, default: 0 },
});
defineEmits(['select']);
// Default: top-level (L1) groups open, everything deeper collapsed.
// `overrides` holds the explicit open/closed state once the user toggles a group.
const overrides = reactive({});
const isOpen = name => {
if (props.forceExpand) return true;
if (name in overrides) return overrides[name];
return props.depth === 0;
};
const toggle = name => {
overrides[name] = !isOpen(name);
};
</script>
<template>
<div class="flex flex-col gap-px">
<template v-for="node in nodes" :key="node.name">
<div v-if="node.type === 'group'" class="flex flex-col">
<button
type="button"
class="flex items-center w-full gap-1.5 px-2 py-1 rounded-md group text-n-slate-11 hover:bg-n-alpha-1"
@click="toggle(node.name)"
>
<span
class="transition-transform i-lucide-chevron-right size-3.5 shrink-0 text-n-slate-10"
:class="{ 'rotate-90': isOpen(node.name) }"
/>
<span
class="text-xs font-semibold tracking-wide uppercase truncate text-n-slate-10"
>
{{ node.name }}
</span>
</button>
<div
v-show="isOpen(node.name)"
class="ml-2.5 pl-2 border-l border-n-weak"
>
<TreeNode
:nodes="node.children"
:selected-path="selectedPath"
:force-expand="forceExpand"
:depth="depth + 1"
@select="$emit('select', $event)"
/>
</div>
</div>
<button
v-else
type="button"
class="block w-full px-2 py-1 ml-1 text-sm text-left truncate transition-colors rounded-md"
:class="
node.path === selectedPath
? 'bg-n-brand/10 text-n-brand font-medium'
: 'text-n-slate-11 hover:bg-n-alpha-1 hover:text-n-slate-12'
"
@click="$emit('select', node.path)"
>
{{ node.name }}
</button>
</template>
</div>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup>
import { computed, inject } from 'vue';
defineProps({
title: { type: String, default: '' },
});
const layout = inject('storyLayout', null);
// Normalizes layout sizes: numbers and bare numeric strings -> px,
// everything else (e.g. '100%', '800px') passes through unchanged.
function normalizeSize(value) {
if (value === undefined || value === null || value === '') return undefined;
if (typeof value === 'number') return `${value}px`;
return /^\d+$/.test(value) ? `${value}px` : value;
}
const cellStyle = computed(() => {
const current = layout?.value ?? {};
return {
width: normalizeSize(current.width),
height: normalizeSize(current.height),
};
});
</script>
<template>
<section
class="flex flex-col overflow-hidden rounded-lg border border-n-weak bg-n-solid-1"
:style="cellStyle"
>
<div
class="px-3 py-2 text-sm font-medium border-b text-n-slate-12 border-n-weak bg-n-alpha-1"
>
{{ title }}
</div>
<div class="flex-1 p-4 bg-n-background">
<slot />
</div>
</section>
</template>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@chatwoot/design</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/main.js"></script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
import { createApp } from 'vue';
import App from './App.vue';
import Story from './Story.vue';
import Variant from './Variant.vue';
import { setupApp } from './setup';
const app = createApp(App);
// Story files reference <Story> and <Variant> as globals (same as Histoire).
app.component('Story', Story);
app.component('Variant', Variant);
setupApp(app);
app.mount('#app');
+61
View File
@@ -0,0 +1,61 @@
// Lazy component loaders for every *.story.vue file under app/javascript.
const storyModules = import.meta.glob('../**/*.story.vue');
// Raw sources, eagerly loaded, so we can read each story's title without
// instantiating the (potentially heavy) component just to build the sidebar.
const storySources = import.meta.glob('../**/*.story.vue', {
query: '?raw',
import: 'default',
eager: true,
});
function parseTitle(source) {
const match = source.match(/<Story\b[^>]*?\btitle\s*=\s*"([^"]+)"/);
return match ? match[1] : null;
}
export const stories = Object.keys(storyModules)
.map(filePath => ({
path: filePath,
title: parseTitle(storySources[filePath] || ''),
loader: storyModules[filePath],
}))
.filter(story => story.title)
.sort((a, b) => a.title.localeCompare(b.title));
function insert(nodes, parts, story) {
const [head, ...rest] = parts;
if (rest.length === 0) {
nodes.push({ type: 'story', name: head, path: story.path });
return;
}
let group = nodes.find(node => node.type === 'group' && node.name === head);
if (!group) {
group = { type: 'group', name: head, children: [] };
nodes.push(group);
}
insert(group.children, rest, story);
}
// Sorts each level so groups come before individual stories, both alphabetically.
function sortNodes(nodes) {
nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === 'group' ? -1 : 1;
return a.name.localeCompare(b.name);
});
nodes.forEach(node => {
if (node.type === 'group') sortNodes(node.children);
});
return nodes;
}
// Builds a nested tree from the slash-delimited story titles
// (e.g. "Components/Button" -> group "Components" > story "Button").
export function buildTree(items) {
const roots = [];
items.forEach(story => {
const parts = story.title.split('/').map(part => part.trim());
insert(roots, parts, story);
});
return sortNodes(roots);
}
@@ -1,14 +1,33 @@
import './design-system/histoire.scss';
import { defineSetupVue3 } from '@histoire/plugin-vue';
import dashboardI18n from 'dashboard/i18n';
import widgetI18n from 'widget/i18n';
import '../design-system/stories.scss';
import { createI18n } from 'vue-i18n';
import { createRouter, createMemoryHistory } from 'vue-router';
import { vResizeObserver } from '@vueuse/components';
import store from 'dashboard/store';
import FloatingVue from 'floating-vue';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js';
import { directive as onClickaway } from 'vue3-click-away';
import dashboardI18n from 'dashboard/i18n';
import widgetI18n from 'widget/i18n';
import store from 'dashboard/store';
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js';
// A throwaway router so composables like useRoute()/useAccount() resolve a real
// route object instead of throwing. We seed realistic Help Center params
// (accountId/portalSlug/locale) so page-level stories that key off the route
// render instead of bailing out. Story selection itself is hash-based and
// independent of this router.
const noop = { render: () => null };
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/app/accounts/:accountId/portals/:portalSlug/:locale?',
name: 'stories',
component: noop,
},
{ path: '/:pathMatch(.*)*', name: 'fallback', component: noop },
],
});
router.replace('/app/accounts/1/portals/chatwoot/en');
function mergeMessages(...sources) {
return sources.reduce((acc, src) => {
@@ -36,9 +55,12 @@ const i18n = createI18n({
),
});
export const setupVue3 = defineSetupVue3(({ app }) => {
// Registers the same global plugins/directives the dashboard components expect
// at runtime, so stories render identically to the real app.
export function setupApp(app) {
app.use(store);
app.use(i18n);
app.use(router);
app.use(FloatingVue, {
instantMove: true,
arrowOverflow: false,
@@ -48,4 +70,4 @@ export const setupVue3 = defineSetupVue3(({ app }) => {
app.directive('resize', vResizeObserver);
app.use(VueDOMPurifyHTML, domPurifyConfig);
app.directive('on-clickaway', onClickaway);
});
}