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:
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user