@synopackageland/ui

Shared Vue 3 UI component library with a DSM 7 inspired application theme
4,448
Install
npm install @synopackageland/ui
Latest Version:0.14.7
Vue:3
License:MIT
Last Updated:Oct 23, 2026
Links: npm
Maintainer: synopackageland

@synopackageland/ui

Vue 3 component library with a DSM 7-inspired theme for application interiors. Import the components you need into your Vue application.

Installation

For DSM window / full-page Portal / phone layouts, see Responsive DSM application layout. To generate UI as JSON (including with an AI and the syno-pkg-land-ui validate/render CLI), see Declarative DSM UI. For page layout rules, see Layout rules; for which button goes where, see Button policy.

Synology Package Land UI provides Vue components for Synology package applications, including an embedded theme and AppRoot for shared DSM desktop windows. See the public documentation.

npm install @synopackageland/ui --registry https://registry.npmjs.org

Vue 3.5 or later within Vue 3 is required by the package. Building from source requires Node.js 20+ and pnpm 10.33.2. The browser acceptance scripts require Node.js 22+ (global WebSocket); CI uses Node.js 24.

Quick start

1. Import the stylesheet once

In your application's entry point (src/main.ts):

import { createApp } from 'vue'
import App from './App.vue'
import '@synopackageland/ui/theme.css'

createApp(App).mount('#app')

The stylesheet includes the DSM 7 theme and compiled component utilities. You do not need to install or configure Tailwind to use these components. The package also exports themes/dsm7.css with the same content; import only one of them. For an app inside a DSM window, see Using inside a DSM window.

2. Import and use components

Example App.vue:

<script setup lang="ts">
import { ref } from 'vue'
import { Button, Input, Select } from '@synopackageland/ui'
import type { SelectOption } from '@synopackageland/ui'

const name = ref('')
const access = ref('read')
const message = ref('')
const options: SelectOption[] = [
  { label: 'Read only', value: 'read' },
  { label: 'Read / write', value: 'write' },
]

function save() {
  message.value = `Saved ${name.value} with ${access.value} access`
}
</script>

<template>
  <form class="settings-form" @submit.prevent="save">
    <label for="name">Name</label>
    <Input id="name" v-model="name" placeholder="Enter a name" />
    <Select v-model="access" :options="options" aria-label="Access level" />
    <Button type="submit" variant="primary" :disabled="!name.trim()">
      Save
    </Button>
    <p role="status">{{ message }}</p>
  </form>
</template>

<style scoped>
.settings-form {
  display: grid;
  gap: 12px;
  max-width: 360px;
  padding: 24px;
}
</style>

Components imported in <script setup> are available directly in the template. No app.use() plugin registration is required. JavaScript consumers can omit lang="ts", the type import, and the : SelectOption[] annotation.

AboutDialog legal links

AboutDialog displays each legal link only when the application supplies its URL through tosUrl or privacyUrl. Both default to an empty string; omitting a prop or passing '' hides that link. URLs can be updated while the dialog is open. The footer's close button remains available without legal links.

<AboutDialog
  v-model:open="aboutOpen"
  title="My App"
  tos-url="https://example.com/terms"
  privacy-url="https://example.com/privacy"
/>

Replace these example URLs with your application's Terms of Service and Privacy Policy. Apps that relied on the former Synology legal URL defaults must now provide their intended URLs explicitly.

Using inside a DSM window

A DSM package window is not a page of its own: every window shares the DSM desktop's document and its stylesheets. Two things break theme.css there:

  • DSM's stylesheets are not in cascade layers, and an unlayered rule beats every layered one whatever its specificity. theme.css is layered (Tailwind 4), so DSM's element resets such as div,p,td{margin:0;padding:0} win over the library's utilities.
  • theme.css puts its tokens on :root, where they would restyle the desktop and collide with other apps that use another version of this library.

Use the embedded theme and the AppRoot component instead:

// the DSM entry of your app, instead of theme.css
import '@synopackageland/ui/themes/dsm7-embedded.css'
<script setup lang="ts">
import { AppRoot, ToastProvider } from '@synopackageland/ui'
</script>

<template>
  <AppRoot style="height: 100%">
    <ToastProvider>
      <!-- your app -->
    </ToastProvider>
  </AppRoot>
</template>

themes/dsm7-embedded.css:

  • has no @layer, so library classes beat DSM's element rules;
  • scopes every token and rule to the app root, the element with the data-ui-app attribute (APP_ROOT_ATTRIBUTE / APP_ROOT_SELECTOR are exported), so nothing outside it changes;
  • starts with a host reset that reverts, inside the app root, what DSM's element-only rules set (margins, paddings, borders, outline, position, display, fonts, colors, list style, appearance, …), including the body button:lang(…) font rules;
  • makes the app root the containing block of fixed overlays, so dialogs, sheets and toasts stay inside the window.

AppRoot renders <div data-ui-app data-syno-pkg-land-ui> and routes every overlay portal (Dialog, Sheet, Toast, Popover, Select, DropdownMenu, ContextMenu, Tooltip) into itself, not document.body, where the scoped tokens and reset would not reach them. Each AppRoot teleports into its own element; theme and density tokens on sibling roots reach their own portals. By default (modal-scope="app", since 0.6.0) each root owns its Dialog/Sheet and popup layers and never mutates document.body, so give it a bounded size such as style="height: 100%". A full-page web or Portal app that wants document-wide modality passes modal-scope="document" (see below).

Give each independently mounted Vue application a unique ID prefix before mounting, so generated dialog titles/descriptions and other ARIA references cannot resolve to another app's elements:

const app = createApp(App)
app.config.idPrefix = 'my-package' // unique across apps in this document
app.mount('#my-package-root')

Without AppRoot, put data-ui-app on your mount element and wrap the app in reka-ui's <ConfigProvider :teleport-to="rootElement" :scroll-body="false">. Inside AppRoot, a nested ConfigProvider (for dir or locale) must pass the same teleport-to, and your own <Teleport>s must target an element inside the root.

Theme overrides go on your own app root, after the library stylesheet. Use a package-specific selector, so an override does not also match other apps:

[data-ui-app][data-package='my-package'] {
  --ui-primary: #0086e5;
}

Set data-package="my-package" on that app's <AppRoot>.

Keep your app's own CSS out of cascade layers too, for the same reason.

If your app has its own base rules for bare elements, such as a button reset or heading margins, load the embedded theme as its two parts, with your rules between them:

import '@synopackageland/ui/themes/dsm7-embedded-reset.css'
import './base.css' // e.g. [data-ui-app] :where(button) { border: 0 solid; }
import '@synopackageland/ui/themes/dsm7-embedded-theme.css'

Your rules and the library classes have the same specificity as the reset, so order decides: after the reset, your rules are not reverted; before the theme, they do not override library classes. Loaded one after the other, the two parts render as dsm7-embedded.css. The reset reverts font-family on form controls with a doubled root attribute, [data-ui-app][data-ui-app] :where(button, input, select, textarea), to beat DSM's body button:lang(…) rules; a font rule of yours for them needs the same selector.

Known limits:

  • Tailwind sizes are in rem, which follows the desktop's html font size.
  • DSM rules more specific than one class (.x-panel div, #id p) are not reverted; the reset covers element-only rules.
  • With the opt-in modal-scope="document", Dialog/Sheet modality is document-wide: it locks body scrolling and pointer events, disables earlier panels and hides other apps. Do not use it in a DSM window.
  • The bundled patched Reka restores host body style values/priorities after the final lock release, including padding-right/margin-right. Consumers need no Reka installation or package-manager patch.
  • With the default app scope (<AppRoot style="height: 100%">), Dialog/Sheet and popup layers block only that app's background. Other apps/host remain usable; local dialogs omit aria-modal and permit deliberate focus switching. Provide a bounded root size and an accessible host window switch. Select scope at mount time. Nested app-local AppRoots and simultaneous app/document overlays are unsupported and produce explicit errors. Multiple SDK/Reka runtime copies do not coordinate.
  • Independently mounted Vue apps without unique app.config.idPrefix values generate colliding IDs. AppRoot does not assign those prefixes automatically.
  • Keyframe names are global, so every keyframe in the embedded theme starts with ui-. Tailwind's @property registrations are global too; they only register custom-property types and do not restyle the host.

Body cleanup is now a normal regression test. The unprefixed ID collision remains an expected failure, reproducible with UI_ISOLATION_STRICT=1. See App-local modal for the implemented contract, patch delivery and browser support limits. Vue stays an external peer; the patched Reka implementation is bundled and tree-shakeable.

Run pnpm check:local-browser [output-dir] [existing-ego-space-id] for a clean packed-consumer Chromium acceptance run (requires ego-browser).

Common component patterns

Spacing, action rows, field widths, status display and section loading follow Layout rules.

Dialog

Use v-model:open to control visibility. The default slot contains the body; trigger and footer provide the opening button and action area.

<script setup lang="ts">
import { ref } from 'vue'
import { Button, Dialog } from '@synopackageland/ui'

const open = ref(false)
</script>

<template>
  <Dialog v-model:open="open" title="Settings" description="Review your settings.">
    <template #trigger>
      <Button>Open settings</Button>
    </template>
    <p>Your settings content goes here.</p>
    <template #footer>
      <Button variant="primary" @click="open = false">Done</Button>
    </template>
  </Dialog>
</template>

Without width or size the dialog sizes to its content between --ui-dialog-min-width (420px) and --ui-dialog-max-width (760px, or the max-width prop); a long paragraph wraps at the maximum. A field without a width of its own raises that minimum to the DSM width of a dialog built around it: a Textarea to md (530px), a row-wide Input to sm (450px). size picks a fixed DSM dialog width: sm 450px (one field), md 530px (a form with a text area), lg 740px (a wide form), xl 860px (tabs of settings), as measured on DSM 7.4. width sets any fixed width and wins over size. Every width stays inside the room the container leaves: the viewport or the app less a margin, and in a DSM window the window itself, also when the dialog floats over the desktop. A dialog with no title and no close button is a question and takes DSM's message-box layout. Buttons in the footer slot are capsule action buttons (kind="tool" opts one out). height or min-height (for example min-height="420px") keeps a panel with little content from collapsing. In a DSM window, <AppRoot dialog-bounds="viewport"> lets dialogs be dragged out of the window, within the browser viewport. See docs/app-local-modal.md.

Toast notifications

Wrap your page with ToastProvider in App.vue:

<script setup lang="ts">
import { ToastProvider } from '@synopackageland/ui'
import SettingsPage from './SettingsPage.vue'
</script>

<template>
  <ToastProvider>
    <SettingsPage />
  </ToastProvider>
</template>

Then call useToast() inside a descendant, such as SettingsPage.vue:

<script setup lang="ts">
import { Button, useToast } from '@synopackageland/ui'

const toast = useToast()
</script>

<template>
  <Button @click="toast.success('Settings applied')">Show notification</Button>
</template>

useToast() requires a provider ancestor; calling it in the same component that renders the provider does not satisfy that requirement.

Application layout

<script setup lang="ts">
import { AppLayout, Button, PageHeader } from '@synopackageland/ui'
</script>

<template>
  <AppLayout min-height="100vh">
    <PageHeader title="Package settings" description="Manage your application.">
      <template #actions>
        <Button variant="primary">Save</Button>
      </template>
    </PageHeader>
    <section style="padding: 24px">Your page content goes here.</section>
  </AppLayout>
</template>

AppLayout also accepts sidebar, toolbar, and footer slots.

Listen to @brand to make the default logo and name a native home button:

<AppLayout wide-layout="web" brand-name="My App" @brand="page = 'home'">
  <template #sidebar><AppSidebar v-model="page" :groups="groups" /></template>
  <PageContent>…</PageContent>
</AppLayout>

The event receives the original MouseEvent, including native Enter/Space activation. The button uses a localized “Go to home” accessible name in all nine built-in languages, token-based hover and keyboard focus styles, and works in the expanded/collapsed sidebar, top navigation, phone bar and phone drawer. A brand activation closes the phone drawer and returns focus to its menu button. Without a listener the existing logo/name markup stays unchanged. The brand slot replaces the default brand and owns its own interaction; it is never wrapped in a button. A DSM window's title bar remains managed by DSM.

navigation="top" (0.13.1+) lays the sidebar slot out as one bar above the page instead of a left column: the brand, then the AppSidebar items in a row (a row with children opens them in a menu; the bar scrolls sideways when the items run out of room), then sidebar-footer. It applies in the DSM-window and web presentations; a phone keeps the drawer. The default "sidebar" is unchanged. The JSON AppShell accepts the same prop; the SDK's AppShell must forward it before an app can choose it through that shell.

<AppLayout navigation="top" wide-layout="web">
  <template #sidebar><AppSidebar v-model="page" :groups="groups" /></template>
  <template #sidebar-footer><AppSidebarUser name="Ada Lin" /></template>
  <PageContent>…</PageContent>
</AppLayout>

For standalone content that owns its layout, use <PageContent surface="none" height="fill">. It removes the page's horizontal gutters and adds no surface in web, DSM-window or phone layouts. The default surface="auto" remains unchanged; flat keeps the dashboard canvas. In 0.12.4+, auto already has no outer page card in web mode. Child components keep their own styling and spacing. The JSON Page component accepts the same surface="none" option. SDK AppShell is a separate package: if it creates PageContent internally, it must expose/forward this option before an application can select it through that shell.

<AppLayout wide-layout="web" height="fill" min-height="0">
  <PageContent surface="none" height="fill">
    <MyEditor />
  </PageContent>
</AppLayout>

PageContent stacks its direct children (a search row, a toolbar, cards) at --ui-section-gap, so a page needs no margins between them. Under a PageHeader the space is the header's own bottom padding (12px in a DSM window), with no section gap added.

Fill-height pages. A list page whose grid pages its rows should not scroll as a whole, or a 50-row page pushes its pager far below. Give both the layout and the page height="fill": AppLayout then takes its container's height (a DSM window) and scrolls its content area instead of the document; PageContent keeps its header, tabs and toolbar in place, and its last child takes the height left. Make that child the DataGrid itself, or a flex column (flex flex-col min-h-0) holding it: the grid scrolls its rows under its sticky header, with the pager always in view. Pages that are not fill in the same layout scroll as before. In the SDK's AppShell, the same is a page's fill: true.

<AppLayout height="fill" min-height="0">
  <template #sidebar>…</template>
  <PageContent height="fill">
    <PageHeader title="下載" />
    <div class="flex items-center justify-between gap-2">
      <SectionTabs v-model="tab" :items="tabs" />
      <SearchField v-model="filter" variant="filter" />
    </div>
    <DataGrid :columns="columns" :rows="rows" :filter="filter" :page-sizes="[50, 100, 200]" column-chooser />
  </PageContent>
</AppLayout>

To set a block apart inside a card, use <Surface variant="inset">. DSM draws it as a faint grey block with no border of its own, at 12px × 16px padding.

Theme override

Components use semantic CSS tokens, each a step of a 12-step scale built from DSM 7's colours (colour system). Without an override an app looks like DSM's own apps: DSM blue for selection, focus and main actions.

An app with a colour of its own sets it on its root. The theme derives the hover, pressed, selected and focus colours from it, keeps a white label readable on it, and leaves the status tones DSM's:

<AppRoot accent="#7a5af8">
  <App />
</AppRoot>

Other tokens are overridden in a stylesheet imported after the library's:

:root {
  --ui-radius-md: 6px;
  --ui-page-padding-x: 24px;
  --ui-section-gap: 16px;
}

Status tones come in pairs, a soft fill and the text that reads on it: --ui-{neutral,info,success,warning,danger}-soft and -text. Badge, StatusDot, StepList and ConnectionTopology use only these and the base tones (--ui-success, …).

Theme hooks. The data-ui-* and data-* attributes and the layout tokens listed in docs/theme-hooks.md are public API with stability rules (0.13.1+): --ui-icon-size, --ui-heading-weight, --ui-card-padding, the top bar's --ui-top-nav-* and the phone bar's --ui-mobile-bar-*, Button's data-variant, the layout regions (data-ui-layout-sidebar, data-ui-layout-main, data-ui-mobile-navigation, …). Each token is unset by default and the component then looks as before. Everything else (classes, nesting) is an implementation detail.

Density can be switched per application subtree:

<main data-ui-density="compact">
  <!-- controls, navigation rows, toolbar and page spacing become denser -->
</main>

Current component set

Primitives:

  • Button / IconButton / CopyButton
  • Input / Textarea
  • Checkbox / CheckboxGroup / RadioGroup / Switch
  • Select
  • Badge / StatusDot / Separator / Spinner / LoadingMask / LoadingArea / Progress / SizeBar / Skeleton / Surface
  • SplitButton
  • Tooltip / Popover / DropdownMenu / ContextMenu / Dialog
  • Sheet / Drawer
  • DatePicker
  • ToastProvider + useToast()
  • Tabs / Accordion

DSM-style application building blocks:

  • SearchField
  • SettingsRow / SettingsSection
  • Callout / EmptyState / StepList
  • ConnectionTopology
  • DataTable / DataGrid
  • TreeView
  • FilePicker
  • AppRoot (DSM-embedded app root)
  • AppLayout / AppSidebar / AppToolbar / AppActionBar
  • PageHeader / SectionTabs
  • SplitPane / StorageMeter (see docs/size-and-split.md)

Tabs and SectionTabs move a raised selection notch in 120ms. Selected labels are blue; main labels are bold 14px and section labels bold 13px. Tabs fades its new panel in over 160ms, with immediate selection and keyboard response. A strip narrower than its tabs keeps one row and scrolls sideways (mouse wheel, trackpad, touch or keyboard) with no visible scrollbar; it never scrolls vertically. A side that hides tabs fades out (data-overflow-start, data-overflow-end on the strip), and the selected tab is scrolled into view when it changes or the strip resizes. In SectionTabs, the arrow keys, Home and End move focus between tabs; Enter or Space selects one. In a grid or flex parent, give the strip's track or item min-width: 0 (minmax(0, 1fr)) so it can be narrower than its tabs. SectionTabs can also render content using named panel-${value} slots:

<SectionTabs v-model="section" :items="sections">
  <template #panel-general>General settings</template>
  <template #panel-advanced>Advanced settings</template>
</SectionTabs>

Navigation-only SectionTabs usage still works. For content managed separately by the caller, add class="ui-tab-panel" to a panel keyed by the selected value to use the same short fade. These effects and the loading ring respect the system's reduced-motion preference.

Status: StatusDot, StepList, ConnectionTopology

StatusDot is an 8px tone dot; pulse ripples a ring out of it for a state that is still changing. It is decorative unless it has a label.

StepList is a static numbered list (items: { title, description?, status? }[]) for instructions or a checklist. status (done, current, pending, error, warning) recolours the marker and is read out to assistive technology; #description and #action scoped slots add content per step. It does not navigate; use Wizard for that.

ConnectionTopology draws paths from several sources, each through an optional relay, to one shared target. It knows nothing about any product: the caller maps its own states to six link states.

<ConnectionTopology
  aria-label="Connections"
  :target="{ id: 'server', label: 'Server', caption: 'office-01', icon: Server }"
  :lanes="[{
    id: 'relay',
    source: { id: 'browser', label: 'Any browser', icon: Globe },
    via: { id: 'relay', label: 'Edge relay', icon: Cloud },
    links: [{ state: 'active', label: 'Reachable' }, { state: 'active', label: 'Running' }],
  }]"
  @select-lane="open"
>
  <template #lane-footer-relay>https://office.example.com</template>
</ConnectionTopology>
  • States. active (green, dots flow toward the target), pending (amber, slow dashes and a pulsing dot), attention (amber, waits for the user), error (red), idle (grey dashes), unknown (grey dots). Every state but idle has a glyph and every link a word, so colour is never the only cue; link.icon replaces the glyph (a key for a sign-in, say). A missing label shows the localized state word (text overrides them); label: '' hides it.
  • Keyboard and screen readers. Each lane is a button named after its nodes and link states; Enter or Space emits select-lane, the arrow keys, Home and End move between lanes. selectable: false renders plain rows. lane-footer-<id> content sits below the lane button, so it may hold its own buttons and links; node-<id> replaces a node's content.
  • Layout. Below 600px of container width (or with compact), each lane stacks with its own copy of the target and the link words move to a caption line. With no lanes, one idle line shows empty-label.
  • Motion. The keyframes ui-flow and ui-pulse stop under prefers-reduced-motion; a working link then turns solid with an arrow.

FilePicker owns file selection/validation/drop-zone UI only; applications remain responsible for upload transport.

DataGrid

DataGrid is the list for data that grows (downloads, tasks, files): sorting, column chooser, column reorder, resize, quick filter, paging and saved view state. DataTable stays the small read-only table that becomes cards on a phone; use DataGrid for anything a user searches or arranges.

<SearchField v-model="filter" variant="filter" />
<DataGrid
  :columns="[
    { key: 'name', label: '名稱', sortable: true, flex: 2, minWidth: 200, hideable: false },
    { key: 'size', label: '大小', sortable: true, width: 90, type: 'number' },
    { key: 'location', label: '存放位置', minWidth: 120 },
    { key: 'started', label: '開始時間', sortable: true, width: 140, type: 'date', defaultHidden: true },
  ]"
  :rows="rows"
  :filter="filter"
  :page-sizes="[50, 100, 200]"
  state-key="downloads"
  column-chooser
  column-reorder
  footer
  refreshable
  @refresh="reload"
/>
  • Column widths. A column with width (px, or a percentage of the grid) keeps it; a column without one is flexible and shares the rest by flex (default 1), never below its minWidth (default 80px for a flexible column, 40px for a fixed one). A long text cannot widen its column, and a header never wraps. When the minimums do not fit (a phone), the grid scrolls sideways instead of squeezing.

  • Alignment. Give a column a type instead of aligning it: text (the default), number (a size, a percentage, a speed, a count, a duration), date, or actions (a row of icon buttons). As in DSM's own grids (File Station, Drive, Resource Monitor, Container Manager), every column sits left, its header with it; number and date give their digits one width so they line up down the column, and actions lays its buttons out from the left with one gap, the first icon under the header's text (put the IconButtons straight in the #cell-<key> slot). The selection checkbox is centred. A — placeholder sits where the column's values do. align still overrides the type. DataTable takes the same type.

  • Long text. A plain cell is cut to one line with an ellipsis and shows its full text on hover (title); wrap: true wraps a column instead. A #cell-<key> slot renders its own content: CellText cuts and titles it the same way. Every body cell, a slot's included, carries data-column="<key>".

  • Row height. Rows are one height (28px, 24px compact). CellStatus with a running progress fits it (a 4px bar under the label); its detail adds a line, so put speed or a reason in its own column. Dates: formatDateTime(value, { locale, style: 'compact' }) in the cell (this year: 10/17 14:32, Oct 17, 2:32 PM; another year: the date with its year, 2025/3/2, Mar 2, 2025) and the full formatDateTime(value, { locale }) as its title; it fits a 140px column in every built-in locale.

  • Folders and status reasons. Show a folder in a cell with PathDisplay display="name": the folder icon and the last segment of the path on one unframed line (/volume1/downloads/ reads downloads), cut at its end, the whole path as its title and read to a screen reader ("Path: …", nine locales); the copy button still copies the whole path. The default display="path" is the framed full path for forms. Give CellStatus a description to explain a status (why it failed, how to resume): the label opens it in a Tooltip on hover or keyboard focus (it becomes a Tab stop with a focus ring) and drops its own title, so two tooltips never stack; without one the cell is unchanged.

    <template #cell-location="{ row }"><PathDisplay :path="row.location" display="name" /></template>
    <template #cell-status="{ row }">
      <CellStatus :label="row.status" :tone="row.tone" :progress="row.progress" :description="row.reason" />
    </template>
    
  • Sort. Click a sortable header, or focus it and press Enter/Space (aria-sort is set). Cycle: ascending, descending, off. Leave sort unbound and the grid sorts itself (start with default-sort); bind v-model:sort to control it; with :local-sort="false" it only emits.

  • Column chooser. column-chooser adds a columns button at the right of the header: the user shows and hides columns and resets them. hideable: false keeps a column, defaultHidden: true starts one hidden; the last shown column cannot be hidden. Bind v-model:hidden-columns to control it. A click on a column toggles it and keeps the menu open, so several columns can be changed in a row; Reset, Escape or a click outside closes it.

  • Column priority (narrow windows). Give optional columns a priority: when the shown columns do not fit at their minimum widths (a fixed width, else minWidth), the grid leaves out the lowest priority first (the rightmost of a tie first) until the rest fits, so an 800px DSM window loses its least needed columns instead of scrolling sideways. A column without a priority, or with hideable: false, is never left out, so a grid of such columns still scrolls (a phone). A column left out for width is not hidden by the user: it is not saved, the quick filter still searches it, it returns when the window widens, and the chooser lists it unchecked with "Too narrow" (labels.hiddenByWidth). Checking it there shows it anyway for this view; the next lowest priority then makes room, or the grid scrolls.

  • Column reorder. column-reorder lets the user drag a header onto another, or focus it and press Alt+Left/Right. Bind v-model:column-order to control it; keys of new columns not in a saved order follow at the end.

  • Column resize. Drag the right edge of a header, or focus the edge and press the left/right arrows (10px). Persist widths with v-model:column-widths (or @column-resize); pass them back to restore. resizable is on by default; turn it off per grid or per column.

  • Quick filter. filter shows the rows whose shown, searchable columns contain the text (case-insensitive), across every page, before sorting and paging. searchable: false leaves a column out, searchText(row) gives the text to search, filter-row(row, text) replaces the match. Rows that come filtered from a server: :local-filter="false". The footer reads "3 of 240 items" and an empty result says nothing matches.

  • Paging. Long lists are paged in the footer (20 rows per page by default). page and page-size work unbound or with v-model:page / v-model:page-size; page-sizes adds the page-size choice. A new filter, sort or page size goes back to page 1, and a page past the end shows the last one. With :local-pagination="false" and total-rows the app pages on its server. Virtual scrolling is not provided.

  • Saved view. state-key="downloads" saves the user's column order, hidden columns, widths, sort and page size in this browser (localStorage) and restores them; bound props win. To keep them on a server, bind the v-models instead, or use the exported loadGridState / saveGridState. resetColumns() (exposed) and the chooser's Reset restore order, visibility and widths.

  • Layout. Sticky header (default), column dividers, hover and selected row colours, max-height to scroll inside the grid. selectable adds checkboxes; with select-on-row-click a row click selects the row and Ctrl/Cmd-click toggles it.

  • Selection. The header box selects every selectable row of its scope: the current page (select-all-scope="page", the default) or every row the filter leaves, across pages ("all"). It is checked when all of them are selected, mixed (aria-checked="mixed", a dash) when some are, and a click when all are clears them; keys outside the scope (another page, rows the filter hides) are kept. row-selectable(row) returning false disables a row's box, and the header box skips that row and keeps its state. row-select-disabled-reason(row) says why: the box is wrapped in a Tab stop with that text as its accessible name and as a Tooltip, so the reason is visible on hover and keyboard focus, not only in a native title. With no selectable row the header box is disabled.

  • Footer bar. footer shows the DSM bar with the item count; refreshable adds the refresh button (@refresh, refreshing spins it). Without footer it appears when there is more than one page, a page-size choice that matters, or a #footer slot.

  • Localisation. Every built-in string goes through labels: empty, noMatch, items(count), filtered(count, total), page(page, count), previous, next, pageSize, refresh, selectAll, selectRow(key, row), resizeColumn(label), columns, resetColumns, hiddenByWidth. emptyText still works.

  • Loading. loading masks the table with a centred indicator; loading-text adds a message.

  • Footer layout. The footer has top and bottom divider lines and a separator before Refresh. In a flex column with a bounded height, the grid fills the remaining space and its rows scroll above the footer. Use class="flex-none" for a grid that should retain its content height.

Loading

Spinner is the DSM ring (size is the box in px, default 24). LoadingMask wraps content and covers it with a white 60% mask and a centred message box while loading (delay in ms holds the mask back so a quick load never flashes it; the region is aria-busy at once, so disable the actions it gates yourself); LoadingArea fills its container (give the parent a height) and centres the indicator, for whole-page or whole-panel waits. Button loading shows the 16px ring inline. Do not place a bare Spinner in a block container and expect it to be centred: use LoadingArea.

Toolbar

SplitButton (button + menu arrow) and SearchField variant="filter" (the DSM funnel filter box) join Button, whose default look is the flat grey DSM toolbar button.

DatePicker uses an ISO YYYY-MM-DD string as its v-model so consumers do not need to expose the underlying date library in application state.

Library development

This section is for changing the component library itself. Applications consuming the published npm package do not need these steps.

pnpm install
pnpm build

Use the pnpm version specified in package.json.

pnpm story       # component workbench
pnpm dev         # rebuild dist when library source changes
pnpm check       # production build, then lint, typecheck and tests in parallel, then the package audit
pnpm check:fast  # as check, but only the tests the changes since origin/master reach (vitest --changed)
pnpm story:build # build the component workbench
pnpm check:host  # after pnpm build: DSM host simulation screenshots (Chrome)
pnpm check:date-picker # after pnpm build: app-local calendars, Dialog Escape and full-bleed pages (ego-browser)
pnpm check:switch # after pnpm build: Switch knob geometry and click toggling (Chrome)
pnpm check:topology # after pnpm build: ConnectionTopology motion, keyboard and narrow layout (Chrome)

Use pnpm check:fast while developing (SYNOPKGLAND_GATE_BASE sets the base), and pnpm check once before a release: the full run is the release gate. check prints one line per passing task and a failing task's whole log.

With SYNOPKGLAND_DOCKER_SSH=<ssh host>, pnpm check, check:fast and check:polish run on that host in Docker instead, so a busy machine cannot time out their tests; this machine only sends the working tree (untracked files included, node_modules and dist left out) and waits. scripts/docker-check.sh runs them there in one of the host's two gate slots, in the image synopkgland-gate-frontend:<tag> that scripts/gate.Dockerfile describes (Node.js 24.20.0, pnpm 10.33.2, Debian's Chromium and the Noto CJK fonts), after pnpm install from the shared synopkgland-cache-npm volume. check:polish builds first and brings its screenshots back to .polish-shots, even when it fails. Set SYNOPKGLAND_DOCKER_NPM_REGISTRY when the host cannot reach registry.npmjs.org (the lockfile's integrity still holds). Without SYNOPKGLAND_DOCKER_SSH every check runs here, as before. check:local-browser stays here with ego-browser: run there, with Playwright's Chromium (LOCAL_BROWSER=playwright), as bash scripts/docker-check.sh --build --out .dsm-host-shots/local 'node scripts/app-local-browser.mjs', its two default-modal checks "programmatic focus in A survives with B open" fail, because Playwright keeps its page focused and ego-browser does not.

pnpm check:host serves tests/dsm-host and screenshots each overlay scene in English and Traditional Chinese with headless Chrome (set CHROME to its path if it is not in /Applications), with and without host-simulation.css, a stylesheet written for these tests that reproduces DSM's known element rules. The images must be identical, and must match theme.css on a plain page. They are left in .dsm-host-shots/.

pnpm check:switch renders the Switch off, on and disabled on the standalone theme and the embedded theme (also under host-simulation.css), checks in headless Chrome that the knob stays inside and centred in the track and that a real mouse click toggles an enabled switch. Screenshots are left in .switch-shots/.

pnpm check:topology renders every link state wide, on dark tokens, at 360px and empty, on the standalone and the embedded theme. In headless Chrome it checks that working links flow and changing ones pulse, that reduced motion stills them, that the arrow keys and Enter select a lane with a visible focus ring, and that the narrow layout stacks without overflow. Screenshots are left in .topology-shots/.

In the workbench, open DSM 7 / App Interior and DSM 7 / Advanced Components for reference compositions. See SPEC.md for architecture and contribution rules, and src/index.ts for public exports.

Try local changes in another application

Build this library first, then run the following in the consuming project, replacing the path with your local checkout:

pnpm add link:/absolute/path/to/syno-pkg-land-ui

Keep pnpm dev running in the library checkout to rebuild dist as you edit. Use the same package imports and stylesheet import shown above. To return to the published package, replace the local link with the package:

pnpm add @synopackageland/ui

Releases

The package is published to npmjs as @synopackageland/ui only. The source repository is https://github.com/synopackageland/syno-pkg-land-ui. See the documentation for usage.

CI and tag releases

GitHub Actions runs on Ubuntu for pushes to master and ci/**, pull requests, and manual dispatch. Both workflows share .github/actions/verify: a frozen pnpm install from npmjs, pnpm check (build, lint, typecheck, unit tests and the package-content audit), notices:check, responsive/host/switch/layout/motion browser checks, and the Histoire workbench build. pnpm and Playwright Chromium are cached. Browser screenshots and measurements are retained for seven days.

To release the package:

  1. Update package.json to a new stable X.Y.Z version and add the matching ## X.Y.Z (date) section to CHANGELOG.md. Keep pnpm-lock.yaml current.

  2. Merge the changes into master after CI passes.

  3. Tag that merged commit and push the tag:

    git switch master
    git pull --ff-only
    git tag vX.Y.Z
    git push origin vX.Y.Z
    

The release workflow checks the exact tag/version, default-branch ancestry, nonempty changelog section and npm version availability. It runs the same full CI gates, stages the package into an empty directory, packs it, and audits the actual tarball (every exported dist file, MIT license, notices, bundled Reka, package name and no publishConfig). A separate job rechecks the artifact and registry before publishing with NPM_TOKEN, then creates a GitHub Release from that version's changelog using GITHUB_TOKEN. Publishing uses --access public --ignore-scripts, without provenance. Only the publish job has contents: write. If npm publication succeeds but GitHub Release creation fails, create the Release from the changelog separately; rerunning publishing intentionally refuses the already published version.

On branches and PRs, the release workflow performs the full pipeline through npm publish <tgz> --dry-run --access public --ignore-scripts, without npm credentials or creating a GitHub Release. An existing npm version is allowed only in dry-run mode and is reported explicitly; npm 11 also rejects duplicate versions during dry-runs, so only that confirmed case adds --force while retaining --dry-run. Real publishing never uses --force; registry errors still fail. Manual dispatch defaults to dry_run: true; false is rejected because only a pushed version tag may publish. This permits rehearsal with the current version before tagging. Do not use a real release tag to test workflows.

For local Linux browser checks, run pnpm exec playwright install --with-deps chromium, set CHROME to chromium.executablePath() from Playwright, and use MOTION_BROWSER=playwright pnpm check:motion. The default motion runner remains ego-browser. check:local-browser requires the interactive ego-browser runtime and a user browser space, so it remains a local acceptance check rather than a GitHub-hosted job; the host gate also exercises the default-modal isolation fixture.

CI 與 tag 發版

Ubuntu CI 在 master、ci/** push、PR 與手動執行時,從 npmjs 安裝鎖定依賴, 共用完整 build/lint/typecheck/test/package audit/notices 與 responsive/host/switch/layout/motion、Histoire build 檢查。 快取 pnpm 與 Chromium;瀏覽器證據保留七天。

發版時先更新 package.json 版號與 CHANGELOG 對應段落,通過 CI 後合併至 master,再推送合併 commit 的 vX.Y.Z tag(指令如上)。Workflow 驗證 tag、 預設分支歷史與 npm 版本未存在,完成同一組檢查後 stage、pack 並稽核 實際 tgz,確保 dist、所有 exports、MIT/第三方授權與套件 metadata 齊全。 獨立 publish job 重新驗證,再以 NPM_TOKEN 發 npm,成功後以 changelog 建立 GitHub Release;不使用 provenance。npm 已成功但 GitHub Release 失敗時,另行 建立 Release;重跑會拒絕已存在的 npm 版本。

分支、PR 與預設手動執行會走到 npm publish --dry-run,不帶 npm 憑證、不建 Release;既有版號只在 dry-run 放行。npm 11 在演練也會拒絕重複版號,故只在 確認已存在後,演練加入 --force 並保留 --dry-run;正式發佈不加 --force。 查詢 registry 失敗仍拒絕。 手動 dry_run: false 會拒絕;正式發佈只能由推 tag 觸發。 Linux motion 使用 MOTION_BROWSER=playwright,本機預設 ego-browser 保留。 check:local-browser 需使用者的互動 ego-browser space,保留為本機驗收項。 設了 SYNOPKGLAND_DOCKER_SSH 時,pnpm check、check:fast、check:polish 改在該主機的 Docker(synopkgland-gate-frontend image、gate slot)跑, 本機只送工作樹並等待;未設時照舊在本機跑。check:local-browser 仍在本機用 ego-browser。

Form controls and compact settings (0.5.7)

Form and standalone FormItem default to direction="auto". They use the container's width, including a DSM package window, and stack labels below 520px. Set direction="horizontal" to keep the previous layout or direction="vertical" to stack at every width. An item can override its form. Inherited auto is resolved against each FormItem's own width, so items inside 96px FieldCells stack even in a wide Form. Remove a FieldCell item's explicit vertical workaround to restore auto. SettingsRow likewise stacks below 520px of its own width. Controls bind their FormItem id and ARIA state automatically; remove Select role="group" workarounds and manual field.controlId bindings unless an application deliberately chooses an explicit id. Explicit descriptions are merged with the field's help/error ids.

Use one FormItem for each primary control. For rows containing multiple fields, put each labelled field in its own FormItem inside a FieldCell; use text-only on an outer item that is only a layout heading. Multiple siblings do not reuse the primary id. Native/custom controls can still use the field.controlProps slot or useFormField() contract. If mounting independent Vue apps in the same document, set a distinct app.config.idPrefix for each app.

<ConfigProvider :locale="locale">
  <Form>
    <FormItem label="Protocol" description="Transport protocol">
      <Select v-model="protocol" :options="protocols" />
    </FormItem>
    <FormItem label="Upload" field-width="full">
      <FilePicker v-model="files" variant="compact" :max-size="524288" />
    </FormItem>
  </Form>
  <KeyValueRow label="Version" value="0.5.7" />
  <KeyValueRow label="Download">
    <Link :href="downloadUrl" download="notes.txt">{{ t('download') }}</Link>
  </KeyValueRow>
  <Link href="https://example.com/docs" external>{{ t('docs') }}</Link>
</ConfigProvider>

FilePicker sizes (max {size} and each file row) follow DSM: a 1024 base with B, KB, MB, GB, TB labels and at most one decimal, so :max-size="16 * 1024 ** 3" reads max 16 GB, as DSM File Station would show it. The same rule is exported as formatFileSize(bytes); use it, or the same DSM labels, in application text that repeats a limit, rather than GiB/MiB.

Copy text with writeClipboard(text), which resolves true once copied and false when the page cannot copy. It tries the Clipboard API, which a DSM app window (a frame without clipboard-write) or a page outside a secure context refuses, then the legacy copy command; navigator.clipboard.writeText alone fails there. PathDisplay, CopyCutField and CopyButton use it.

CopyButton is a copy action that shows its result on the button itself: on a press it copies text (a string, or a function read at the press; undefined copies nothing), takes the success colour and a check, holds it for 1.8s and fades back; when the page cannot copy it does the same in the danger colour with a cross and emits failed(text), so the app can show the text selected in a CopyCutField for copying by hand. Nothing is added beside or under it. label (default "Copy") stays its tooltip and accessible name; a visually hidden polite live region announces "Copied" or "Couldn't copy" (words replaces them). It is an icon button, or with labelled an icon and the label; icon replaces the copy icon, and variant (ghost, secondary, primary) and size (sm, the default, or md) follow IconButton and Button. Under reduced motion only the colour changes. Attributes and classes go to the button; the button carries data-copy-state (copied, failed, then settling while it fades back).

FilePicker retains browser File[] semantics. Built-in locale values are en, zh-TW, zh-CN, ja, ko, de, fr, es, it; an explicit picker locale overrides ConfigProvider. Without multiple it words the button and drop zone for one file (Choose file, Drop a file here). The hint under it names the accept types rather than repeating the attribute: accept=".yml,.yaml" reads File types: YML, YAML, and image/*, video/* and audio/* read as Images, Videos, Audio. Its text accepts partial overrides of title, description, chooseFiles, chooseFolder, titleOne, descriptionOne, chooseFile, acceptedTypes, images, videos, audio, maxSize, maxFiles, remove, typeRejected, sizeRejected, countRejected, duplicateRejected. The messages use {name}, {size}, {count} and {types} placeholders. Existing title/description props still win. Other controls retain their exposed text props and partial text objects; built-in text throughout the library now follows Provider locale. Explicit strings, including '', win over built-in translations. DatePicker's calendar inherits Provider locale unless its own Intl locale is supplied. Caller-owned option labels, content, URLs, rules' custom messages and business terminology stay caller-owned. Validation fallbacks use the locale at validation time; imperative message-box default titles use the locale at opening. See the complete default-text and container audit.

KeyValueRow renders a dl with dt/dd; its default slot accepts rich values, including Link. Wizard show-steps shows registered step headlines and does not permit jumping past guards. Its built-in accessible name, footer actions and step progress follow Provider locale; explicit overrides remain available. RepeatRow places wrapped cells inside [data-repeat-fields] and keeps its remove action beside the first control; removeDisabled disables the action. Use wrapping rows for adjacent controls.

DOM attrs on controls target the focusable control (a Reka checkbox is a button with role="checkbox"), while class/style target the layout wrapper. Overlay attrs target content, except Tooltip, whose attrs target the slotted trigger. Renderless providers without a rendered surface do not provide an attr destination.

The scoped preflight removes browser heading/paragraph/list margins and bare button chrome inside library roots. Use explicit classes or Typo components for intentional typography. Restore EmptyState's former spacing with class="min-h-48 py-10". The supplied DSM theme is light; forced-colors keyboard focus has an outline. A dark theme is not part of this release.

Testing Select with jsdom

Mount with attachTo: document.body and unmount after each test. Reka uses keyboard interaction reliably in jsdom; pointer simulation depends on the PointerEvent implementation. This helper works without synthesizing pointer positions or exposing component internals:

import { flushPromises } from '@vue/test-utils'

async function chooseSelect(selectWrapper, label: string) {
  await selectWrapper.get('[role="combobox"]').trigger('keydown', { key: 'ArrowDown' })
  await flushPromises()
  // Options may be portalled into AppRoot or document.body.
  const option = [...document.querySelectorAll<HTMLElement>('[role="option"]')]
    .find(node => node.textContent?.trim() === label)
  if (!option || option.getAttribute('aria-disabled') === 'true') throw new Error(`No enabled option: ${label}`)
  option.focus()
  option.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
  await flushPromises()
}

Assert the emitted model value and label/ARIA links. For browser checks, use actual ArrowDown/ArrowUp/Enter/Escape input and verify the selected value and focus. pnpm check is valid without existing dist; release:stage requires a complete build and refuses missing/empty exported artifacts before copying files.

FilePicker rejection contract

FilePicker selects browser File[]; it does not read or parse their contents. Each incoming file gets at most one reason, in the following order:

Reason When emitted
type The name/MIME does not match accept (case-insensitive extension, MIME or MIME wildcard).
size A nonzero maxSize is exceeded; a file exactly at the limit is accepted.
duplicate Name, size and lastModified equal an existing or earlier accepted file. Directory path/content are not compared.
count Adding the file would exceed maxFiles, or a single-file picker already contains one file.

Single-file mode accepts the first valid file and rejects subsequent files with count. It keeps an already selected file; remove it before selecting a replacement. A canceled/empty selection emits neither rejected nor update:modelValue. Disabled selection/drop is ignored. Size/type/count/duplicate are sufficient for the component's selection policy. Failed reads, malformed JSON, encoding errors, content validation, uploads and network failures belong to the application and must be displayed separately; do not invent another FilePicker rejection reason.

Related Packages

@synopackageland/sdk

Frontend SDK for DSM packages built with synopkgland: headless client and Vue co...

0 0

Version History

Version Released Vue License
0.14.7 ^3.5.0 MIT
0.14.6 ^3.5.0 MIT
0.14.5 ^3.5.0 MIT
0.14.4 ^3.5.0 MIT
0.14.3 ^3.5.0 MIT