Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 11 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codebar-ag/storybook",
"version": "1.8.0",
"version": "1.9.0",
"description": "codebar-ag DocuHub — shared Vue 3 + Tailwind v4 design-system atoms and tokens, documented in Storybook.",
"license": "MIT",
"author": "codebar Solutions AG",
Expand All @@ -25,7 +25,8 @@
"scripts": {
"prepare": "npm run build",
"dev": "storybook dev -p 6006",
"build": "vite build && npm run build:tokens",
"build": "vite build && npm run build:tokens && npm run verify:externals",
"verify:externals": "node scripts/verify-externals.mjs",
"build:tokens": "node -e \"require('node:fs').copyFileSync('src/tokens.css','dist/tokens.css')\"",
"build-storybook": "storybook build",
"lint": "eslint \"src/**/*.{ts,vue}\"",
Expand All @@ -39,7 +40,14 @@
},
"peerDependencies": {
"tailwindcss": "^4.0.0",
"vue": "^3.5.0"
"vue": "^3.5.0",
"apexcharts": "^4.5.0 || ^5.0.0",
"@codemirror/commands": "^6.10.0",
"@codemirror/lang-json": "^6.0.0",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.43.0"
},
"peerDependenciesMeta": {
"apexcharts": {
Expand Down
39 changes: 39 additions & 0 deletions scripts/verify-externals.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Guards the library build's externalisation contract, which nothing else can.
//
// Storybook and its Playwright suite compile from `src`, so they always see a
// single copy of every dependency — they cannot observe what the PUBLISHED
// bundle does. If a peer package is missing from `rollupOptions.external`,
// Rollup quietly inlines it into an extra chunk and the consumer ends up with
// two instances of it. For `@codemirror/language` that means the parser
// registers its syntax tree against one set of facets while
// `syntaxHighlighting()` reads the other, and every code surface in every
// consuming app renders as flat, unhighlighted text — with no error anywhere.
//
// The observable symptom in `dist` is an extra chunk file plus a relative
// import out of `flows.js`, so both are asserted here.
import { readdirSync, readFileSync } from 'node:fs';

const EXPECTED_FILES = ['flows.css', 'flows.js', 'index.d.ts', 'tokens.css'];

const actual = readdirSync('dist').sort();
const unexpected = actual.filter((file) => !EXPECTED_FILES.includes(file));

if (unexpected.length > 0) {
console.error(
`dist/ has unexpected chunk(s): ${unexpected.join(', ')}\n` +
'A dependency was bundled instead of externalised. Add it to ' +
"`rollupOptions.external` in vite.config.ts (and to `peerDependencies`).",
);
process.exit(1);
}

const bundle = readFileSync('dist/flows.js', 'utf8');
const relativeImports = [...bundle.matchAll(/(?:from|import\()\s*["'](\.[^"']*)["']/g)].map((match) => match[1]);

if (relativeImports.length > 0) {
console.error(
`dist/flows.js imports emitted chunk(s): ${[...new Set(relativeImports)].join(', ')}\n` +
'Every dependency must resolve to a bare specifier so the consuming app supplies one copy.',
);
process.exit(1);
}
40 changes: 38 additions & 2 deletions src/components/organisms/CodeEditor.stories.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,35 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import { expect, waitFor } from 'storybook/test';
import CodeEditor from './CodeEditor.vue';

/**
* Asserts the document is actually SYNTAX HIGHLIGHTED, not merely rendered.
*
* This guards a failure mode with no error signal: if the library build ever
* bundles `@codemirror/language` instead of externalising it, the consumer
* loads two copies of it, the language's syntax tree registers against one
* set of facets and `syntaxHighlighting()` reads the other, and every editor
* silently renders as flat monochrome text.
*
* The check is "some token is painted a colour other than the body text's",
* not "tokens use more than one colour between them": a short JSON document
* may legitimately contain only one *styled* tag kind (`defaultHighlightStyle`
* leaves plain `propertyName` uncoloured), which would make a colour-diversity
* assertion fail on working code.
*/
async function expectHighlighted(canvasElement: HTMLElement): Promise<void> {
await waitFor(async () => {
const content = canvasElement.querySelector('.cm-content');
await expect(content).not.toBeNull();

const tokens = canvasElement.querySelectorAll('.cm-line span');
await expect(tokens.length).toBeGreaterThan(0);

const base = getComputedStyle(content as Element).color;
await expect([...tokens].some((token) => getComputedStyle(token).color !== base)).toBe(true);
});
}

const meta: Meta<typeof CodeEditor> = {
title: 'Organisms/CodeEditor',
component: CodeEditor,
Expand All @@ -19,13 +48,16 @@ const meta: Meta<typeof CodeEditor> = {
export default meta;
type Story = StoryObj<typeof CodeEditor>;

export const Json: Story = {};
export const Json: Story = {
play: ({ canvasElement }) => expectHighlighted(canvasElement),
};

export const Markdown: Story = {
args: {
modelValue: '# Extraction prompt\n\nSummarize the invoice fields below.',
modelValue: '# Extraction prompt\n\nSummarize the **invoice** fields below.',
language: 'markdown',
},
play: ({ canvasElement }) => expectHighlighted(canvasElement),
};

export const ReadOnlyEmpty: Story = {
Expand All @@ -38,3 +70,7 @@ export const ReadOnlyEmpty: Story = {
export const AutoHeight: Story = {
args: { autoHeight: true, maxHeight: '12rem' },
};

export const Copyable: Story = {
args: { copyable: true, readonly: true },
};
31 changes: 30 additions & 1 deletion src/components/organisms/CodeEditor.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import type { EditorView as EditorViewType } from '@codemirror/view';
import { createCodeMirrorTheme } from '../../helpers/codeMirrorTheme';
import CopyButton from '../molecules/CopyButton.vue';

// CodeMirror is an OPTIONAL peer dependency: it is imported lazily so apps
// that never render an editor don't pay for the bundle (same convention as
Expand All @@ -16,6 +17,10 @@ const props = withDefaults(
placeholder?: string | null;
autoHeight?: boolean;
maxHeight?: string | null;
/** Pins a copy-to-clipboard button over the top-right of the editor. Opt-in, so existing surfaces keep their chrome. */
copyable?: boolean;
copyLabel?: string;
copiedMessage?: string;
}>(),
{
modelValue: '',
Expand All @@ -24,6 +29,9 @@ const props = withDefaults(
placeholder: null,
autoHeight: false,
maxHeight: null,
copyable: false,
copyLabel: 'Copy to clipboard',
copiedMessage: 'Copied to clipboard',
},
);

Expand All @@ -44,6 +52,10 @@ function formatValue(raw: string): string {
}
}

// Copies what the operator can actually see — the pretty-printed document,
// not the raw (often single-line) `modelValue` handed in by the caller.
const copyValue = computed(() => formatValue(props.modelValue ?? ''));

async function loadLanguage(language: string) {
if (language === 'markdown') {
const { markdown, markdownLanguage } = await import('@codemirror/lang-markdown');
Expand Down Expand Up @@ -140,6 +152,23 @@ onBeforeUnmount(() => view?.destroy());
:class="autoHeight ? 'overflow-y-auto' : 'h-full min-h-0 overflow-hidden'"
:style="autoHeight && maxHeight ? { maxHeight } : undefined"
>
<!--
Sticky rather than absolute, and zero-height so it claims no layout: in
`autoHeight` mode THIS element is the scroll container, and an absolutely
positioned child would scroll out of sight on any document longer than
the visible box.
-->
<div
v-if="copyable && modelValue"
class="sticky top-0 z-10 flex h-0 justify-end"
>
<CopyButton
:value="copyValue"
:label="copyLabel"
:copied-message="copiedMessage"
class="mt-1.5 mr-1.5 rounded-control border border-line bg-surface/90 text-dim backdrop-blur-sm hover:text-ink focus-visible:ring-accent/50"
/>
</div>
<div
v-if="!modelValue && readonly && placeholder"
:class="autoHeight ? 'flex min-h-16 items-center justify-center' : 'pointer-events-none absolute inset-0 flex items-center justify-center'"
Expand Down
7 changes: 6 additions & 1 deletion src/components/organisms/CodePreview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,10 @@ async function mount(): Promise<void> {
return;
}

const [{ EditorState }, EditorViewModule, lang] = await Promise.all([
const [{ EditorState }, EditorViewModule, { syntaxHighlighting, defaultHighlightStyle }, lang] = await Promise.all([
import('@codemirror/state'),
import('@codemirror/view'),
import('@codemirror/language'),
loadLanguage(props.language),
]);
const { EditorView, lineNumbers } = EditorViewModule;
Expand All @@ -76,6 +77,10 @@ async function mount(): Promise<void> {
EditorView.lineWrapping,
lineNumbers(),
lang,
// Same highlighter as CodeEditor — a preview surface that
// renders code as flat grey text is the one thing it exists
// not to do.
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
// Shared theme (same as CodeEditor) plus this component's own
// maxHeight cap, which only makes sense for a preview surface.
createCodeMirrorTheme(EditorViewModule, { autoHeight: true }),
Expand Down
13 changes: 13 additions & 0 deletions tests/interactions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,16 @@ test('copy button writes to the clipboard and toasts', async ({ page, context })
await page.getByRole('button', { name: 'Copy to clipboard' }).first().click();
await expect(page.getByText('Copied to clipboard').first()).toBeVisible();
});

test('a copyable code editor copies the document it displays', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await gotoStory(page, 'organisms-codeeditor--copyable');
await expect(page.locator('.cm-content')).toBeVisible();
await page.getByRole('button', { name: 'Copy to clipboard' }).click();

// Only the clipboard write is asserted here — the story renders no Toaster,
// and the toast itself is already covered by the CopyButton test above.
// The pretty-printed document, not whatever shape the caller passed in.
const clipboard = await page.evaluate(() => navigator.clipboard.readText());
expect(clipboard).toBe('{\n "vendor": "string",\n "invoice_number": "string"\n}');
});
11 changes: 11 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,22 @@ export default defineConfig({
formats: ['es'],
},
rollupOptions: {
// EVERY @codemirror/* package must be listed here, not just the
// ones imported by name in a component. `@codemirror/language`
// owns the facets that tie a language's syntax tree to the
// highlighter; if it is bundled while `lang-json`/`lang-markdown`
// stay external, the consumer ends up with two instances of it —
// the parser registers its tree against one set of facets and
// `syntaxHighlighting()` reads the other, so code renders
// completely unhighlighted with no error anywhere.
// `@codemirror/commands` is the same hazard for the default keymap.
external: [
'vue',
'apexcharts',
'@codemirror/state',
'@codemirror/view',
'@codemirror/language',
'@codemirror/commands',
'@codemirror/lang-json',
'@codemirror/lang-markdown',
],
Expand Down
Loading