@astryx-vue/cli 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +428 -41
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -633,7 +633,7 @@ const catalog = {
633
633
  {
634
634
  "name": "@astryx-vue/core",
635
635
  "directory": "packages/core",
636
- "version": "0.4.1",
636
+ "version": "0.5.0",
637
637
  "description": "Vue 3 components for the unofficial astryx-vue port of Astryx. Not affiliated with Meta.",
638
638
  "canary": false,
639
639
  "published": true
@@ -657,15 +657,15 @@ const catalog = {
657
657
  {
658
658
  "name": "@astryx-vue/richtext",
659
659
  "directory": "packages/richtext",
660
- "version": "0.4.0",
660
+ "version": "0.5.0",
661
661
  "description": "Astryx rich text for Vue — Lexical nodes and lossless Markdown import/export. Not affiliated with Meta.",
662
662
  "canary": false,
663
- "published": false
663
+ "published": true
664
664
  },
665
665
  {
666
666
  "name": "@astryx-vue/themes",
667
667
  "directory": "packages/themes",
668
- "version": "0.4.1",
668
+ "version": "0.5.0",
669
669
  "description": "CSS themes for the unofficial astryx-vue port of Astryx. Not affiliated with Meta.",
670
670
  "canary": false,
671
671
  "published": true
@@ -4166,7 +4166,7 @@ const catalog = {
4166
4166
  },
4167
4167
  {
4168
4168
  "heading": "Slot",
4169
- "text": "- The default slot is required.\n- The default slot must contain one icon that represents the action.\n- `IconButton` does not display text.\n- `IconButton` does not provide an `end` slot.\n\n:::warning Accessible name\nProvide an accurate `label`. Without it, assistive technology cannot identify the button's purpose.\n\n`tooltip` provides additional information. Do not use it instead of `label`."
4169
+ "text": "- The default slot is required.\n- The default slot must contain one icon that represents the action.\n- `IconButton` does not display text.\n- `IconButton` does not provide an `end` slot."
4170
4170
  },
4171
4171
  {
4172
4172
  "heading": "Events",
@@ -4188,7 +4188,7 @@ const catalog = {
4188
4188
  },
4189
4189
  {
4190
4190
  "heading": "插槽",
4191
- "text": "- 默认插槽为必需项。\n- 默认插槽必须提供一个表示操作的图标。\n- `IconButton` 不提供可见文字。\n- `IconButton` 不提供 `end` 插槽。\n\n:::warning 无障碍名称\n必须提供准确的 `label`。缺少 `label` 时,辅助技术无法确定按钮用途。\n\n`tooltip` 提供补充说明。不得用 `tooltip` 代替 `label`。"
4191
+ "text": "- 默认插槽为必需项。\n- 默认插槽必须提供一个表示操作的图标。\n- `IconButton` 不提供可见文字。\n- `IconButton` 不提供 `end` 插槽。"
4192
4192
  },
4193
4193
  {
4194
4194
  "heading": "事件",
@@ -61582,7 +61582,7 @@ const catalog = {
61582
61582
  },
61583
61583
  {
61584
61584
  "heading": "Native attributes",
61585
- "text": "The root element receives:\n\n- `aria-*` attributes.\n- `role`.\n- `id`.\n- `data-*` attributes.\n- Native event listeners.\n\n:::warning Hidden content\nDo not put interactive controls inside `VisuallyHidden`. The visual interface cannot show those controls.\n\nTo hide content from all users, use conditional rendering or the native `hidden` attribute."
61585
+ "text": "The root element receives:\n\n- `aria-*` attributes.\n- `role`.\n- `id`.\n- `data-*` attributes.\n- Native event listeners."
61586
61586
  }
61587
61587
  ],
61588
61588
  "zh": [
@@ -61596,7 +61596,7 @@ const catalog = {
61596
61596
  },
61597
61597
  {
61598
61598
  "heading": "原生属性",
61599
- "text": "以下内容传入根元素:\n\n- `aria-*` 属性。\n- `role`。\n- `id`。\n- `data-*` 属性。\n- 原生事件监听器。\n\n:::warning 隐藏内容\n不得在 `VisuallyHidden` 内放置交互控件。视觉界面无法显示这些控件。\n\n必须对所有用户隐藏内容时,使用条件渲染或原生 `hidden` 属性。"
61599
+ "text": "以下内容传入根元素:\n\n- `aria-*` 属性。\n- `role`。\n- `id`。\n- `data-*` 属性。\n- 原生事件监听器。"
61600
61600
  }
61601
61601
  ]
61602
61602
  },
@@ -74735,6 +74735,217 @@ const catalog = {
74735
74735
  "zh": "https://astryx-vue.pages.dev/guide/changelog"
74736
74736
  }
74737
74737
  },
74738
+ {
74739
+ "id": "guide/charts",
74740
+ "kind": "guide",
74741
+ "title": {
74742
+ "en": "Charts",
74743
+ "zh": "图表"
74744
+ },
74745
+ "summary": {
74746
+ "en": "`@astryx-vue/charts` is a lightweight chart component library for Vue. It splits the work in two: d3 handles pure math calculations, such as computing scales and curve paths. Vue templates then take those computed positions and render clean SVG vector elements.",
74747
+ "zh": "`@astryx-vue/charts` 是一个轻量的图表组件库。它把工作分成两半:底层的 d3 只负责纯数学计算(比如算比例尺、算曲线的路径),算好位置后,再由 Vue 模板把内容画成一个个干净的 SVG 矢量图形。"
74748
+ },
74749
+ "headings": {
74750
+ "en": [
74751
+ "What this is",
74752
+ "Install",
74753
+ "Your first chart",
74754
+ "`<Chart>` props and slots",
74755
+ "Props",
74756
+ "Slots",
74757
+ "Series",
74758
+ "Line (`line`)",
74759
+ "Bar (`bar`)",
74760
+ "Area (`area`)",
74761
+ "Dot (`dot`)",
74762
+ "Band (`band`)",
74763
+ "Reference line (`referenceLine`)",
74764
+ "Error bar (`errorBar`)",
74765
+ "Candlestick (`candlestick`)",
74766
+ "Axes, grid, legend, tooltip",
74767
+ "Axes (`ChartAxis`)",
74768
+ "Grid (`ChartGrid`)",
74769
+ "Legend (`ChartLegend`)",
74770
+ "Tooltip (`ChartTooltip`)",
74771
+ "Swatch (`ChartSwatch`)",
74772
+ "Custom tooltip content",
74773
+ "Colors",
74774
+ "Formatting functions",
74775
+ "Accessibility",
74776
+ "Server-side rendering",
74777
+ "Not supported yet"
74778
+ ],
74779
+ "zh": [
74780
+ "这是什么",
74781
+ "安装",
74782
+ "第一张图",
74783
+ "`<Chart>` 的属性与插槽",
74784
+ "属性",
74785
+ "插槽",
74786
+ "系列",
74787
+ "折线(line)",
74788
+ "柱状(bar)",
74789
+ "面积(area)",
74790
+ "散点(dot)",
74791
+ "区间带(band)",
74792
+ "参考线(referenceLine)",
74793
+ "误差条(errorBar)",
74794
+ "蜡烛图(candlestick)",
74795
+ "坐标轴、网格、图例、提示框",
74796
+ "坐标轴(ChartAxis)",
74797
+ "网格(ChartGrid)",
74798
+ "图例(ChartLegend)",
74799
+ "提示框(ChartTooltip)",
74800
+ "颜色色块(ChartSwatch)",
74801
+ "自定义提示框内容",
74802
+ "颜色",
74803
+ "格式化函数",
74804
+ "无障碍",
74805
+ "服务端渲染",
74806
+ "现在不支持的"
74807
+ ]
74808
+ },
74809
+ "keywords": {
74810
+ "en": [
74811
+ "data",
74812
+ "xKey",
74813
+ "series",
74814
+ "height",
74815
+ "width",
74816
+ "margin",
74817
+ "yBaseline",
74818
+ "yDomain",
74819
+ "xDomain",
74820
+ "legend",
74821
+ "tooltip",
74822
+ "title",
74823
+ "subtitle",
74824
+ "grid",
74825
+ "axes",
74826
+ "interactions",
74827
+ "default",
74828
+ "Option",
74829
+ "color",
74830
+ "curve",
74831
+ "strokeWidth",
74832
+ "dots",
74833
+ "label",
74834
+ "opacity",
74835
+ "radius",
74836
+ "stack",
74837
+ "group",
74838
+ "gradient",
74839
+ "stroke",
74840
+ "dodge",
74841
+ "upper",
74842
+ "lower",
74843
+ "y",
74844
+ "y2",
74845
+ "x",
74846
+ "strokeDasharray",
74847
+ "labelPosition",
74848
+ "bandOpacity",
74849
+ "high",
74850
+ "low",
74851
+ "capWidth",
74852
+ "open",
74853
+ "close",
74854
+ "upColor",
74855
+ "downColor",
74856
+ "Prop",
74857
+ "position",
74858
+ "tickCount",
74859
+ "maxTicks",
74860
+ "tickFormat",
74861
+ "truncate",
74862
+ "animated",
74863
+ "showAxisLine",
74864
+ "showTicks",
74865
+ "horizontal",
74866
+ "vertical",
74867
+ "items",
74868
+ "alignment",
74869
+ "hoverIndicator",
74870
+ "showHoverDots",
74871
+ "placement",
74872
+ "variant"
74873
+ ],
74874
+ "zh": [
74875
+ "名称",
74876
+ "data",
74877
+ "xKey",
74878
+ "series",
74879
+ "height",
74880
+ "width",
74881
+ "margin",
74882
+ "yBaseline",
74883
+ "yDomain",
74884
+ "xDomain",
74885
+ "legend",
74886
+ "tooltip",
74887
+ "title",
74888
+ "subtitle",
74889
+ "grid",
74890
+ "axes",
74891
+ "interactions",
74892
+ "default",
74893
+ "选项",
74894
+ "color",
74895
+ "curve",
74896
+ "strokeWidth",
74897
+ "dots",
74898
+ "label",
74899
+ "opacity",
74900
+ "radius",
74901
+ "stack",
74902
+ "group",
74903
+ "gradient",
74904
+ "stroke",
74905
+ "dodge",
74906
+ "upper",
74907
+ "lower",
74908
+ "y",
74909
+ "y2",
74910
+ "x",
74911
+ "strokeDasharray",
74912
+ "labelPosition",
74913
+ "bandOpacity",
74914
+ "high",
74915
+ "low",
74916
+ "capWidth",
74917
+ "open",
74918
+ "close",
74919
+ "upColor",
74920
+ "downColor",
74921
+ "属性",
74922
+ "position",
74923
+ "tickCount",
74924
+ "maxTicks",
74925
+ "tickFormat",
74926
+ "truncate",
74927
+ "animated",
74928
+ "showAxisLine",
74929
+ "showTicks",
74930
+ "horizontal",
74931
+ "vertical",
74932
+ "items",
74933
+ "alignment",
74934
+ "hoverIndicator",
74935
+ "showHoverDots",
74936
+ "placement",
74937
+ "variant"
74938
+ ]
74939
+ },
74940
+ "body": {
74941
+ "en": "# Charts\n\n## What this is\n\n`@astryx-vue/charts` is a lightweight chart component library for Vue. It splits the work in two: d3 handles pure math calculations, such as computing scales and curve paths. Vue templates then take those computed positions and render clean SVG vector elements.\n\nBuilding a chart takes two main pieces: an outer `<Chart>` canvas and one or more series. A series is a visual mark on the chart, such as a line, a set of bars, or an area fill. You create a series with a helper function like `line('revenue')`. That tells the chart to read the `revenue` field from each row of data.\n\n## Install\n\nThis package is published under the `canary` tag only. Include the tag when installing:\n\n```bash\npnpm add @astryx-vue/charts@canary\n```\n\n`@astryx-vue/charts` depends on `@astryx-vue/core` and `vue`. Make sure both are installed in your project.\n\nThe library includes base styles. Import the full stylesheet in your entry file:\n\n```ts\nimport '@astryx-vue/charts/charts.css'\n```\n\nTo load styles on demand, import individual files from `@astryx-vue/charts/styles/`, such as `chart.css`, `chart-axis.css`, `chart-grid.css`, `chart-legend.css`, `chart-tooltip.css`, or `chart-swatch.css`.\n\n## Your first chart\n\nA minimal chart requires three props on `<Chart>`:\n- `data`: An array of objects where each item represents one row of data.\n- `xKey`: The field name in each row to use for the horizontal x-axis.\n- `series`: An array of series definitions created with functions such as `line()` or `bar()`.\n\nHere is the shortest working line chart:\n\n```vue\n<script setup lang=\"ts\">\nimport { Chart, ChartAxis, ChartGrid, line } from '@astryx-vue/charts'\n\n// 1. Prepare data\nconst data = [\n { month: 'Jan', revenue: 100, cost: 60 },\n { month: 'Feb', revenue: 150, cost: 80 },\n { month: 'Mar', revenue: 120, cost: 70 },\n]\n\n// 2. Define series: two lines reading revenue and cost\nconst series = [\n line('revenue', { label: 'Revenue' }),\n line('cost', { label: 'Cost' }),\n]\n<\/script>\n\n<template>\n <Chart :data=\"data\" x-key=\"month\" :series=\"series\" :height=\"260\" legend tooltip>\n <template #grid>\n <ChartGrid />\n </template>\n <template #axes>\n <ChartAxis position=\"bottom\" />\n <ChartAxis position=\"left\" />\n </template>\n </Chart>\n</template>\n```\n\nHere is the live result:\n\n## `<Chart>` props and slots\n\n`<Chart>` is the root container. It measures container dimensions, builds coordinate scales, calculates geometry positions, and renders the SVG tree.\n\n### Props\n\n| Name | Type | Default | Description |\n| :--- | :--- | :--- | :--- |\n| `data` | `Record<string, unknown>[]` | **Required** | Rows of data to render. Each row contains an x-axis field and one or more series fields. |\n| `xKey` | `string` | **Required** | Field name used as the x value across all series. |\n| `series` | `SeriesDef[]` | **Required** | Array of series definitions, rendered in order on a shared coordinate system. |\n| `height` | `number` | `300` | Chart height in CSS pixels. Non-finite values and negative numbers are treated as 0. |\n| `width` | `number` | `0` | Chart width in CSS pixels. Ignored after the browser first measures the container. Used during server-side rendering when no DOM container exists. |\n| `margin` | `Partial<ChartMargin>` | `{ top: 24, right: 24, bottom: 32, left: 48 }` | Partial margin overrides for the plotting area. Omitted sides use defaults. |\n| `yBaseline` | `'auto' \\| 'zero' \\| 'data'` | `'auto'` | How to determine the y-axis domain when `yDomain` is not set. |\n| `yDomain` | `[number, number]` | `undefined` | Explicit y-axis range `[min, max]`. When set, this range is used directly without baseline adjustments or edge padding. |\n| `xDomain` | `[number, number]` | `undefined` | Explicit range `[min, max]` for numeric x-axes. Stays active even when `data` is empty, keeping streaming windows stable. Ignored on categorical band axes. |\n| `legend` | `boolean \\| ChartLegendConfig` | `false` | Whether to show the legend derived from series, or a configuration object to adjust position and alignment. |\n| `tooltip` | `boolean \\| ChartTooltipConfig` | `false` | Whether to show grouped hover tooltips. |\n| `title` | `string` | `undefined` | Chart title, used as the SVG accessible name. |\n| `subtitle` | `string` | `undefined` | Supporting text below the title, used as the SVG accessible description. |\n\n### Slots\n\n| Name | Bindings | Description |\n| :--- | :--- | :--- |\n| `grid` | None | Background grid rendered behind series, usually containing `<ChartGrid />`. |\n| `axes` | None | Axes rendered behind series, usually containing one or more `<ChartAxis />` components. |\n| `interactions` | None | Custom interaction layer rendered above the pointer capture surface. |\n| `tooltip` | `{ xValue: unknown, rows: unknown[] }` | Custom tooltip slot. `xValue` is the active x value under the pointer. `rows` is the list of series data items for that row. |\n| `default` | None | Extra content rendered after the built-in tooltip layer. |\n\n## Series\n\nA series defines a visual representation of data on the chart. A series object holds internal state during layout and rendering. Because of this, provide each series object to one `<Chart>` only. Create fresh series definitions for each chart instance.\n\nWhen data has missing values (`null`, `undefined`) or non-finite numbers (`NaN`, `Infinity`), line marks break cleanly and leave an empty gap. They never drop to zero or draw false connections across gaps.\n\nWhen `yDomain` is omitted, `yBaseline` controls how the y-axis range is calculated:\n- `'auto'` (default): Marks with a zero baseline (bars, areas) include 0 in the scale. Continuous point marks (lines, dots) keep small padding on both ends so points do not clip at the edges.\n- `'zero'`': Expands the scale symmetrically around 0. Useful for values that swing positive and negative, such as profit and loss.\n- `'data'`: Clamps tightly to the minimum and maximum data values without forcing 0 or adding edge padding.\n\n### Line (`line`)\n\nA line series shows continuous trends over time or across categories.\n\n```ts\ndeclare function line(dataKey: string, options?: LineOptions): SeriesDef\n```\n\n| Option | Type | Default | Description |\n| :--- | :--- | :--- | :--- |\n| `color` | `string` | Auto from theme palette | Line color. |\n| `curve` | `'linear' \\| 'monotone' \\| 'natural' \\| 'step'` | `'monotone'` | Curve interpolation method. Options are linear, monotone cubic spline, natural spline, and step. |\n| `strokeWidth` | `number` | `2` | Line stroke width in pixels. |\n| `dots` | `boolean` | `false` | Whether to render solid circular dots at each data point. |\n| `label` | `string` | `dataKey` | Human-readable name shown in legends and tooltips. |\n\n### Bar (`bar`)\n\nA bar series compares magnitudes across categories or time steps. Bars with the same `group` split the category bandwidth and sit side by side. Bars with the same `stack` stack on top of each other.\n\n```ts\ndeclare function bar(dataKey: string, options?: BarOptions): SeriesDef\n```\n\n| Option | Type | Default | Description |\n| :--- | :--- | :--- | :--- |\n| `color` | `ColorAccessor` | Auto from theme palette | Bar fill color. Accepts a color string or a function `(datum, index) => string`. |\n| `opacity` | `number` | `1` | Bar opacity, between 0 and 1. |\n| `radius` | `number` | `4` | Corner radius on the bar top in pixels. In stacked bars, only the top segment receives rounded corners. |\n| `stack` | `string` | `undefined` | Stack group name. Series sharing a `stack` accumulate upward from the baseline. |\n| `group` | `string` | `undefined` | Side-by-side group name. Series sharing a `group` sit next to each other within each category. |\n| `label` | `string` | `dataKey` | Human-readable name shown in legends and tooltips. |\n\n### Area (`area`)\n\nAn area series fills the region below a line. It is useful for showing total volume and changing proportions over time. Giving multiple area series the same `stack` name stacks them in order.\n\n```ts\ndeclare function area(dataKey: string, options?: AreaOptions): SeriesDef\n```\n\n| Option | Type | Default | Description |\n| :--- | :--- | :--- | :--- |\n| `color` | `string` | Auto from theme palette | Area fill color. |\n| `opacity` | `number` | `0.3` | Area fill opacity. |\n| `curve` | `'linear' \\| 'monotone' \\| 'natural' \\| 'step'` | `'monotone'` | Curve interpolation algorithm. |\n| `stack` | `string` | `undefined` | Stack group name. Areas sharing a `stack` stack sequentially. |\n| `gradient` | `boolean` | `false` | Whether to apply a vertical gradient fill. |\n| `stroke` | `boolean` | `true` | Whether to draw a stroke line along the top edge of the area. |\n| `label` | `string` | `dataKey` | Human-readable name shown in legends and tooltips. |\n\n### Dot (`dot`)\n\nA dot series shows the distribution of discrete data points.\n\n```ts\ndeclare function dot(dataKey: string, options?: DotOptions): SeriesDef\n```\n\n| Option | Type | Default | Description |\n| :--- | :--- | :--- | :--- |\n| `color` | `ColorAccessor` | Auto from theme palette | Dot color. Accepts a color string or a per-point accessor function. |\n| `radius` | `number` | `4` | Dot radius in pixels. |\n| `opacity` | `number` | `0.8` | Dot opacity. |\n| `dodge` | `boolean` | `false` | When multiple points share the same x value, offsets them horizontally to reduce overlap. |\n| `label` | `string` | `dataKey` | Human-readable name shown in legends and tooltips. |\n\n### Band (`band`)\n\nA band series fills a translucent area between an upper and a lower boundary field. It is often used to show forecast confidence intervals. Band marks are annotations, so they do not appear in legends or tooltips.\n\n```ts\ndeclare function band(options: BandOptions): SeriesDef\n```\n\n| Option | Type | Default | Description |\n| :--- | :--- | :--- | :--- |\n| `upper` | `string` | **Required** | Field name for the upper boundary value. |\n| `lower` | `string` | **Required** | Field name for the lower boundary value. |\n| `color` | `string` | `'var(--color-accent)'` | Fill color for the band area. |\n| `opacity` | `number` | `0.15` | Fill opacity for the band area. |\n\n### Reference line (`referenceLine`)\n\nA reference line marks a specific horizontal or vertical threshold on the chart, or fills a shaded region between two horizontal values. Reference lines are annotations, so they do not appear in legends or tooltips.\n\n```ts\ndeclare function referenceLine(options: ReferenceLineOptions): SeriesDef\n```\n\n| Option | Type | Default | Description |\n| :--- | :--- | :--- | :--- |\n| `y` | `number` | `undefined` | Draws a horizontal line at this y-axis value. |\n| `y2` | `number` | `undefined` | Second y-axis value. Providing both `y` and `y2` shades a reference band between them. |\n| `x` | `number` | `undefined` | Draws a vertical line at this x-axis value. |\n| `color` | `string` | `'var(--astryx-color-border-emphasized)'` | Line or band fill color. |\n| `strokeWidth` | `number` | `1` | Stroke width in pixels. |\n| `strokeDasharray` | `string` | `undefined` | Dash pattern string, such as `'4 4'`. |\n| `label` | `string` | `undefined` | Text label displayed alongside the line. |\n| `labelPosition` | `'start' \\| 'end'` | `'end'` | Label placement along the line: start/end corresponds to left/right for horizontal lines, and bottom/top for vertical lines. |\n| `bandOpacity` | `number` | `0.1` | Fill opacity for the reference band (active only when `y2` is set). |\n\n### Error bar (`errorBar`)\n\nAn error bar (whisker) displays value uncertainty. It is usually paired with `bar()` or `dot()`. Boundary fields use absolute values, allowing asymmetric error intervals. If a row lacks an upper or lower bound, the chart skips that point instead of drawing misleading lines to zero. Error bars are annotations, so they do not appear in legends or tooltips.\n\n```ts\ndeclare function errorBar(options: ErrorBarOptions): SeriesDef\n```\n\n| Option | Type | Default | Description |\n| :--- | :--- | :--- | :--- |\n| `high` | `string` | **Required** | Field name for the upper bound (absolute value). |\n| `low` | `string` | **Required** | Field name for the lower bound (absolute value). |\n| `color` | `string` | `'var(--astryx-color-text-primary)'` | Stroke color. |\n| `strokeWidth` | `number` | `1.5` | Stroke width in pixels. |\n| `capWidth` | `number` | `8` | Width in pixels of the horizontal end caps. |\n\n### Candlestick (`candlestick`)\n\nA candlestick series displays open, high, low, and close prices over time, commonly used in financial charts.\n\n```ts\ndeclare function candlestick(options: CandlestickOptions): SeriesDef\n```\n\n| Option | Type | Default | Description |\n| :--- | :--- | :--- | :--- |\n| `open` | `string` | **Required** | Field name for the open price. |\n| `high` | `string` | **Required** | Field name for the high price. |\n| `low` | `string` | **Required** | Field name for the low price. |\n| `close` | `string` | **Required** | Field name for the close price. |\n| `upColor` | `string` | `'var(--astryx-color-success)'` | Color for up candles, where close is greater than or equal to open. |\n| `downColor` | `string` | `'var(--astryx-color-error)'` | Color for down candles, where close is lower than open. |\n\n## Axes, grid, legend, tooltip\n\nSupporting chart elements are provided as individual components. You can combine and configure them independently. Setting `legend` or `tooltip` to `true` on `<Chart>` enables their defaults. Pass an object to customize position and layout.\n\n### Axes (`ChartAxis`)\n\nAxes render along chart edges to display ticks and label text.\n\n| Prop | Type | Default | Description |\n| :--- | :--- | :--- | :--- |\n| `position` | `'top' \\| 'right' \\| 'bottom' \\| 'left'` | **Required** | Edge to place the axis on. |\n| `tickCount` | `number` | `5` | Desired number of ticks. d3 uses this as a hint to find clean intervals. |\n| `maxTicks` | `number` | `undefined` | Maximum number of tick labels to render. Excess ticks are skipped at uniform intervals. |\n| `tickFormat` | `(value: unknown) => string` | `undefined` | Custom tick label formatting function. |\n| `truncate` | `number` | `undefined` | Maximum characters for tick labels before truncating with an ellipsis. |\n| `animated` | `boolean` | `true` | Whether to smoothly animate tick transitions when positions change. |\n| `showAxisLine` | `boolean` | `true` on bottom axis only | Whether to render the axis baseline. If `showTicks` is true, the baseline is always drawn regardless of position. |\n| `showTicks` | `boolean` | `false` | Whether to draw short perpendicular tick lines at each step. |\n\n### Grid (`ChartGrid`)\n\nGrid lines render behind series elements to help align values visually.\n\n| Prop | Type | Default | Description |\n| :--- | :--- | :--- | :--- |\n| `horizontal` | `boolean` | `true` | Whether to draw horizontal grid lines. |\n| `vertical` | `boolean` | `false` | Whether to draw vertical grid lines. |\n| `tickCount` | `number` | `5` | Approximate number of grid lines. Matching this with `<ChartAxis>` aligns grid lines with axis ticks. |\n\n### Legend (`ChartLegend`)\n\nA legend lists series names alongside their representative colors. Setting `legend` on `<Chart>` shows the legend (off by default), or use the `<ChartLegend>` component directly.\n\n| Prop | Type | Default | Description |\n| :--- | :--- | :--- | :--- |\n| `items` | `LegendItem[]` | `[]` | Explicit array of legend items. Inferred automatically from series when omitted. |\n| `position` | `'top' \\| 'bottom' \\| 'start' \\| 'end'` | `'bottom'` | Docking side relative to the chart canvas. `start` and `end` follow reading direction. |\n| `alignment` | `'start' \\| 'center' \\| 'end'` | `'start'` | Alignment of items along the docked edge. |\n\n### Tooltip (`ChartTooltip`)\n\nA tooltip displays details for the data row under the pointer.\n\n| Prop | Type | Default | Description |\n| :--- | :--- | :--- | :--- |\n| `series` | `readonly SeriesDef[]` | Injected by Chart | Series definitions. When using `<ChartTooltip>` directly, pass the same `series` array. |\n| `hoverIndicator` | `boolean` | `true` | Whether to draw hover indicators. Bars highlight the active column, while lines, areas, and dots draw a vertical rule. |\n| `showHoverDots` | `boolean` | `true` | Whether to highlight active data points with dots (skipped automatically on bars). |\n| `placement` | `'auto' \\| 'right' \\| 'left' \\| 'top'` | `'auto'` | Card placement relative to the active point. `auto` defaults to the right and flips left when room runs out. |\n\n### Swatch (`ChartSwatch`)\n\nA swatch renders a small color marker representing a series, useful inside legends or custom tooltips.\n\n| Prop | Type | Default | Description |\n| :--- | :--- | :--- | :--- |\n| `color` | `string` | **Required** | Fill color value. |\n| `variant` | `'square' \\| 'line'` | `'square'` | Swatch shape. `square` renders a small square (for bars); `line` renders a short horizontal line (for lines and areas). |\n\n### Custom tooltip content\n\nWhen the default tooltip layout is not enough, customize the card content through the `#tooltip` slot on `<Chart>`. The slot exposes the active `xValue` and row details in `rows`:\n\n```vue\n<template>\n <Chart :data=\"monthly\" x-key=\"month\" :series=\"series\" tooltip>\n <template #tooltip=\"{ xValue, rows }\">\n <div class=\"my-custom-tooltip\">\n <strong>{{ xValue }}</strong>\n <div v-for=\"row in asRows(rows)\" :key=\"row.key\">\n <ChartSwatch :color=\"row.color\" variant=\"line\" />\n {{ row.label }}: {{ money(row.value) }}\n </div>\n </div>\n </template>\n </Chart>\n</template>\n```\n\n## Colors\n\nWhen a series does not set an explicit color, the chart assigns one automatically in order from the active theme palette.\n\nAll chart colors use CSS variables underneath. When switching themes or toggling dark mode, chart colors update automatically without re-rendering the SVG tree.\n\nTo inspect or use the current chart palette programmatically, call `useChartColors()`. It returns a shared object across the package:\n\n- `categorical(n: number)`: Returns an array of `n` categorical colors. If `n` exceeds palette size, colors cycle.\n- `sequential`: Sequential color scale helper supporting nine hues (`blue`, `shamrock`, `orange`, `pink`, `purple`, `red`, `teal`, `yellow`, `gray`). Passing a count `n` returns a gradient color array.\n- `diverging`: Two-pole diverging palette helper. Provides `positiveNegative`, `coldHot`, and `custom` (custom hues on each pole).\n- `semantic`: Semantic status colors, containing `positive`, `negative`, `warning`, and `neutral`.\n- `structural`: Structural colors for chart framing, containing `axis`, `grid`, `tick`, and `label`.\n- `alpha(color: string, opacity: number)`: Generates a color with an alpha channel for a given opacity. CSS variables use `color-mix()` automatically.\n\n## Formatting functions\n\nThe library includes formatting helpers for common chart values. Formatting functions follow a safe fallback rule: if an input is `NaN`, `Infinity`, or cannot be parsed, the function returns the original string as-is instead of printing errors or throwing exceptions.\n\n- `compactNumber(value, locale)`: Abbreviates large numbers. For example, `1200` becomes \"1.2K\", `1500000` becomes \"1.5M\", and `1e12` becomes \"1T\".\n- `currency(symbol?, locale?)`: Creates a currency formatter. For example, `currency('$')` can be passed to `ChartAxis`'s `tickFormat`. Values over 1,000 are automatically abbreviated (such as \"$1.5K\").\n- `percent(value, locale)`: Formats a decimal ratio as a percentage. For example, `0.45` becomes \"45%\" and `0.005` becomes \"0.5%\".\n- `shortDate(value, locale)`: Formats dates as a short month and day. For example, \"Jan 5\".\n- `monthYear(value, locale)`: Formats dates as month and year. For example, \"Jan 2024\".\n- `formatList(values, locale)`: Formats a list of words into a natural language string according to locale rules, such as \"A, B, and C\".\n\n## Accessibility\n\nCharts include built-in accessibility features so screen reader users can understand chart content:\n\n- The root element has a `role=\"img\"` attribute.\n- The chart's accessible name prioritizes the `title` prop. When `title` is omitted, the component builds a semantic description from primary series and the x-axis field (such as \"Monthly Revenue and Cost trend\"). If only annotations are present, it falls back to \"Chart\".\n- The `subtitle` prop maps to the chart's accessible description.\n- When total data points (data rows × data columns) do not exceed 100 (`MAX_TABLE_POINTS`), the chart generates a visually hidden data table using `VisuallyHidden`. Screen readers can read exact values row by row. Above 100 points, the table is omitted to prevent excessive speech noise.\n\n## Server-side rendering\n\nDuring server-side rendering (SSR), no browser DOM exists, so the component cannot measure parent container width in pixels.\n\nWhen rendering on the server, pass an explicit `width` prop on `<Chart>`. This allows the server to compute scales and render complete SVG paths and rectangles into HTML. After client-side hydration, once the browser measures the real container width, the chart takes over and responds to container resize events.\n\n## Not supported yet\n\nTo stay lightweight and focused, the package currently leaves out:\n- Four WebGL-based series marks for very large datasets (such as `dotGL`) are not yet included.\n- The Nuxt module does not yet include auto-import switches for `@astryx-vue/charts`. Import components manually when using Nuxt.",
74942
+ "zh": "# 图表\n\n## 这是什么\n\n`@astryx-vue/charts` 是一个轻量的图表组件库。它把工作分成两半:底层的 d3 只负责纯数学计算(比如算比例尺、算曲线的路径),算好位置后,再由 Vue 模板把内容画成一个个干净的 SVG 矢量图形。\n\n画一张图非常直观:外层写一个 `<Chart>` 画布,里面传入数据和一个或多个「系列」。所谓系列,就是图上的一条折线、一组柱子或一片阴影。我们用普通的 JavaScript 函数来创建系列,比如写 `line('revenue')`,图表就知道要读取每行数据里的 `revenue` 字段来画折线。\n\n## 安装\n\n这个包只发布在 `canary` 标签下,安装时需要带上标签:\n\n```bash\npnpm add @astryx-vue/charts@canary\n```\n\n`@astryx-vue/charts` 依赖同级的 `@astryx-vue/core` 和 `vue`,请确保项目中已经安装了它们。\n\n图表内置了基础样式。你可以在入口文件引入完整样式文件:\n\n```ts\nimport '@astryx-vue/charts/charts.css'\n```\n\n如果你想按需加载样式,也可以只引入 `@astryx-vue/charts/styles/` 目录下的某一个样式文件(例如 `chart.css`、`chart-axis.css`、`chart-grid.css`、`chart-legend.css`、`chart-tooltip.css` 或 `chart-swatch.css`)。\n\n## 第一张图\n\n画一张图最少需要给 `<Chart>` 传三个必填属性:\n- `data`:要展示的数据数组,每一项是一行对象。\n- `xKey`:指定每行对象中哪个字段当作横坐标(x 轴)。\n- `series`:标记系列数组,用 `line()`、`bar()` 等函数生成。\n\n下面是一段最短的可运行折线图代码:\n\n```vue\n<script setup lang=\"ts\">\nimport { Chart, ChartAxis, ChartGrid, line } from '@astryx-vue/charts'\n\n// 1. 准备数据\nconst data = [\n { month: '1月', revenue: 100, cost: 60 },\n { month: '2月', revenue: 150, cost: 80 },\n { month: '3月', revenue: 120, cost: 70 },\n]\n\n// 2. 定义系列:画两条线,一条读 revenue,一条读 cost\nconst series = [\n line('revenue', { label: '收入' }),\n line('cost', { label: '成本' }),\n]\n<\/script>\n\n<template>\n <Chart :data=\"data\" x-key=\"month\" :series=\"series\" :height=\"260\" legend tooltip>\n <template #grid>\n <ChartGrid />\n </template>\n <template #axes>\n <ChartAxis position=\"bottom\" />\n <ChartAxis position=\"left\" />\n </template>\n </Chart>\n</template>\n```\n\n实际运行效果如下:\n\n## `<Chart>` 的属性与插槽\n\n`<Chart>` 是整张图表的根容器。它负责测量尺寸、建立坐标系、计算点位并把图形画在 SVG 里。\n\n### 属性\n\n| 名称 | 类型 | 默认值 | 说明 |\n| :--- | :--- | :--- | :--- |\n| `data` | `Record<string, unknown>[]` | **必填** | 要画的数据行。每行既有 x 对应的列,也有一到多个系列的数据列。 |\n| `xKey` | `string` | **必填** | 每行里当 x 用的字段名,所有系列共用。 |\n| `series` | `SeriesDef[]` | **必填** | 标记定义数组,按顺序画在同一套坐标系上。 |\n| `height` | `number` | `300` | 图表高度(单位是 CSS 像素)。非有限值和负数都会当成 0 处理。 |\n| `width` | `number` | `0` | 图表宽度(单位是 CSS 像素)。浏览器里第一次量到容器宽度后就不再使用;服务端渲染时没有容器可量,全靠它出图。 |\n| `margin` | `Partial<ChartMargin>` | `{ top: 24, right: 24, bottom: 32, left: 48 }` | 绘图区内边距的部分覆盖值,没传的边使用默认留白。 |\n| `yBaseline` | `'auto' \\| 'zero' \\| 'data'` | `'auto'` | 没写 `yDomain` 时 y 轴范围怎么定。 |\n| `yDomain` | `[number, number]` | `undefined` | 显式指定的 y 轴范围 `[min, max]`。一旦指定就以此为准,不额外加基线、也不留上下余量。 |\n| `xDomain` | `[number, number]` | `undefined` | 数值 x 轴的显式范围 `[min, max]`。即使 `data` 为空也会照常生效(保证流式更新窗口稳定)。在离散类目的 band 轴上会被忽略。 |\n| `legend` | `boolean \\| ChartLegendConfig` | `false` | 是否显示从系列推导出的图例,或者传配置对象调整位置与对齐。 |\n| `tooltip` | `boolean \\| ChartTooltipConfig` | `false` | 是否显示分组的悬停提示框。 |\n| `title` | `string` | `undefined` | 图表标题,同时作为 SVG 的无障碍名称(Accessible Name)。 |\n| `subtitle` | `string` | `undefined` | 标题下方的补充说明文字,同时作为 SVG 的无障碍描述。 |\n\n### 插槽\n\n| 名称 | 参数 | 说明 |\n| :--- | :--- | :--- |\n| `grid` | 无 | 画在系列后方的背景网格,通常放置 `<ChartGrid />`。 |\n| `axes` | 无 | 画在系列后方的坐标轴,通常放置若干个 `<ChartAxis />`。 |\n| `interactions` | 无 | 画在指针捕获层上方的自定义交互图层。 |\n| `tooltip` | `{ xValue: unknown, rows: unknown[] }` | 提示框的自定义内容插槽。`xValue` 是当前悬停的 x 坐标值,`rows` 是当前行对应各个系列的数据明细。 |\n| `default` | 无 | 额外扩展内容,画在内置提示框图层之后。 |\n\n## 系列\n\n系列代表图表上展示的具体数据图形。同一个系列对象在布局和渲染时会被写入内部状态,所以同一个系列对象只能提供给一个 `<Chart>` 使用,每张图都要重新创建一份。\n\n如果数据中存在缺失值(`null`、`undefined`)或非有限数字(`NaN`、`Infinity`),折线会自然断开形成空洞,不会强行掉到 0 基线上画出一条错误虚假的连线。\n\n在不设置显式 `yDomain` 时,y 轴的范围由 `yBaseline` 决定:\n- `'auto'`(默认):带零基线的标记(柱状图、面积图)会把 0 算进坐标范围;只有离散连续点的图表(折线图、散点图等)会在上下两侧保留一点余量,防止数据点贴在画布边缘。\n- `'zero'`:以 0 为中心对称展开坐标轴,适合展示正负波动数据(比如盈利与亏损)。\n- `'data'`:紧贴实际数据的最大值与最小值,不强制包含 0,也不留任何边缘余量。\n\n### 折线(line)\n\n折线用于展示数据随时间或类目的连续变化趋势。\n\n```ts\ndeclare function line(dataKey: string, options?: LineOptions): SeriesDef\n```\n\n| 选项 | 类型 | 默认值 | 说明 |\n| :--- | :--- | :--- | :--- |\n| `color` | `string` | 自动取色 | 折线颜色。 |\n| `curve` | `'linear' \\| 'monotone' \\| 'natural' \\| 'step'` | `'monotone'` | 曲线插值方式。分别代表折线、单调三次样条、自然样条和阶梯线。 |\n| `strokeWidth` | `number` | `2` | 线条粗细(像素)。 |\n| `dots` | `boolean` | `false` | 是否在每个数据点上画出实体小圆点。 |\n| `label` | `string` | `dataKey` | 图例和提示框中展示的可读名称。 |\n\n### 柱状(bar)\n\n柱状图用于对比不同分类或时间点下的数值大小。相同 `group` 的柱子会把带宽平分并排展示;相同 `stack` 的柱子会一层层堆叠起来展示。\n\n```ts\ndeclare function bar(dataKey: string, options?: BarOptions): SeriesDef\n```\n\n| 选项 | 类型 | 默认值 | 说明 |\n| :--- | :--- | :--- | :--- |\n| `color` | `ColorAccessor` | 自动取色 | 柱子填充色,可以是固定颜色字符串,也可以是基于数据项计算颜色的函数 `(datum, index) => string`。 |\n| `opacity` | `number` | `1` | 柱子透明度,取值范围在 0 到 1 之间。 |\n| `radius` | `number` | `4` | 柱子顶部的圆角半径(像素)。堆叠时只有最上面一层有圆角。 |\n| `stack` | `string` | `undefined` | 堆叠分组名称。相同 `stack` 的系列会自底向上累加堆叠。 |\n| `group` | `string` | `undefined` | 并排分组名称。相同 `group` 的系列会在每个类目下并排排列。 |\n| `label` | `string` | `dataKey` | 图例和提示框中展示的可读名称。 |\n\n### 面积(area)\n\n面积图在折线下方填充颜色,非常适合表现总量以及各部分占比的变化。如果给多个面积系列指定相同的 `stack` 名字,它们会逐层叠起来。\n\n```ts\ndeclare function area(dataKey: string, options?: AreaOptions): SeriesDef\n```\n\n| 选项 | 类型 | 默认值 | 说明 |\n| :--- | :--- | :--- | :--- |\n| `color` | `string` | 自动取色 | 面积填充色。 |\n| `opacity` | `number` | `0.3` | 面积填充透明度。 |\n| `curve` | `'linear' \\| 'monotone' \\| 'natural' \\| 'step'` | `'monotone'` | 曲线插值算法。 |\n| `stack` | `string` | `undefined` | 堆叠分组名称。相同 `stack` 的面积会依次堆叠。 |\n| `gradient` | `boolean` | `false` | 是否使用垂直渐变填充。 |\n| `stroke` | `boolean` | `true` | 是否在面积顶部画出边缘描边。 |\n| `label` | `string` | `dataKey` | 图例和提示框中展示的可读名称。 |\n\n### 散点(dot)\n\n散点图用于展示离散数据点的分布情况。\n\n```ts\ndeclare function dot(dataKey: string, options?: DotOptions): SeriesDef\n```\n\n| 选项 | 类型 | 默认值 | 说明 |\n| :--- | :--- | :--- | :--- |\n| `color` | `ColorAccessor` | 自动取色 | 圆点颜色,支持传函数按点取色。 |\n| `radius` | `number` | `4` | 圆点半径(像素)。 |\n| `opacity` | `number` | `0.8` | 圆点透明度。 |\n| `dodge` | `boolean` | `false` | 当多个点落在同一个 x 坐标时,是否在水平方向上稍微错开位置,避免重叠遮挡。 |\n| `label` | `string` | `dataKey` | 图例和提示框中展示的可读名称。 |\n\n### 区间带(band)\n\n区间带用于在上下两个边界字段之间涂满一层半透明的带状区域,通常用来展示预测区间的波动范围。它属于辅助标注标记,不会出现在图例和悬停提示框中。\n\n```ts\ndeclare function band(options: BandOptions): SeriesDef\n```\n\n| 选项 | 类型 | 默认值 | 说明 |\n| :--- | :--- | :--- | :--- |\n| `upper` | `string` | **必填** | 上界数据列的字段名。 |\n| `lower` | `string` | **必填** | 下界数据列的字段名。 |\n| `color` | `string` | `'var(--color-accent)'` | 区域填充颜色。 |\n| `opacity` | `number` | `0.15` | 区域透明度。 |\n\n### 参考线(referenceLine)\n\n参考线可以在图表指定的数值位置画一条横线、竖线,或者在两条横线之间涂上一段浅色参考带,常用于标记阈值或目标值。它属于辅助标注标记,不进入图例和提示框。\n\n```ts\ndeclare function referenceLine(options: ReferenceLineOptions): SeriesDef\n```\n\n| 选项 | 类型 | 默认值 | 说明 |\n| :--- | :--- | :--- | :--- |\n| `y` | `number` | `undefined` | 在指定的 y 轴数值位置画一条水平横线。 |\n| `y2` | `number` | `undefined` | 第二个 y 轴数值。如果同时传入 `y` 和 `y2`,会在两者之间涂上一片参考带。 |\n| `x` | `number` | `undefined` | 在指定的 x 轴数值位置画一条垂直竖线。 |\n| `color` | `string` | `'var(--astryx-color-border-emphasized)'` | 线条或参考带的颜色。 |\n| `strokeWidth` | `number` | `1` | 描边粗细(像素)。 |\n| `strokeDasharray` | `string` | `undefined` | 虚线样式,如 `'4 4'`。 |\n| `label` | `string` | `undefined` | 显示在线条旁边的文字标签内容。 |\n| `labelPosition` | `'start' \\| 'end'` | `'end'` | 标签对齐位置。横线对应左/右端,竖线对应底/顶端。 |\n| `bandOpacity` | `number` | `0.1` | 参考带的填充透明度(仅在同时设置了 `y2` 时生效)。 |\n\n### 误差条(errorBar)\n\n误差条(须线)用于展示数值的不确定性范围。它通常配合 `bar()` 或 `dot()` 一起使用。需要注意的是,误差条传入的上下界字段是绝对数值,因此即使上下误差不对称也可以正确绘制。如果某一行缺失了上界或下界,则会直接跳过该点,绝不绘制误导人的零刻度须线。它属于辅助标注标记,不进入图例和提示框。\n\n```ts\ndeclare function errorBar(options: ErrorBarOptions): SeriesDef\n```\n\n| 选项 | 类型 | 默认值 | 说明 |\n| :--- | :--- | :--- | :--- |\n| `high` | `string` | **必填** | 上界数据列的字段名(绝对值)。 |\n| `low` | `string` | **必填** | 下界数据列的字段名(绝对值)。 |\n| `color` | `string` | `'var(--astryx-color-text-primary)'` | 描边线条颜色。 |\n| `strokeWidth` | `number` | `1.5` | 线条粗细(像素)。 |\n| `capWidth` | `number` | `8` | 须线两端小横杠的宽度(像素)。 |\n\n### 蜡烛图(candlestick)\n\n蜡烛图(K 线图)用于金融等场景,展示一段时间内的开盘、最高、最低和收盘价。\n\n```ts\ndeclare function candlestick(options: CandlestickOptions): SeriesDef\n```\n\n| 选项 | 类型 | 默认值 | 说明 |\n| :--- | :--- | :--- | :--- |\n| `open` | `string` | **必填** | 开盘价字段名。 |\n| `high` | `string` | **必填** | 最高价字段名。 |\n| `low` | `string` | **必填** | 最低价字段名。 |\n| `close` | `string` | **必填** | 收盘价字段名。 |\n| `upColor` | `string` | `'var(--astryx-color-success)'` | 阳线(收盘价高于或等于开盘价)的颜色。 |\n| `downColor` | `string` | `'var(--astryx-color-error)'` | 阴线(收盘价低于开盘价)的颜色。 |\n\n## 坐标轴、网格、图例、提示框\n\n图表的辅助元素均以独立组件的形式提供,你可以自由组合和调整。在 `<Chart>` 上直接给 `legend` 或 `tooltip` 传 `true` 即可启用默认样式,也可以传配置对象调整位置。\n\n### 坐标轴(ChartAxis)\n\n坐标轴绘制在图表四周,负责标注刻度与文字。\n\n| 属性 | 类型 | 默认值 | 说明 |\n| :--- | :--- | :--- | :--- |\n| `position` | `'top' \\| 'right' \\| 'bottom' \\| 'left'` | **必填** | 绘制在哪一条边上。 |\n| `tickCount` | `number` | `5` | 期望显示的刻度数量(该值为参考值,d3 会计算出最工整的步长)。 |\n| `maxTicks` | `number` | `undefined` | 允许显示的最大刻度文字数量,超出时会等间隔跳过。 |\n| `tickFormat` | `(value: unknown) => string` | `undefined` | 自定义刻度文字的格式化函数。 |\n| `truncate` | `number` | `undefined` | 刻度文字最大字符数,超出截断并追加省略号。 |\n| `animated` | `boolean` | `true` | 刻度位置发生变化时是否开启平滑过渡动画。 |\n| `showAxisLine` | `boolean` | 仅底轴为 `true` | 是否绘制轴线。如果设置了 `showTicks`,无论哪条轴都会强制画出轴线。 |\n| `showTicks` | `boolean` | `false` | 是否在每个刻度位置画一小段垂直的短刻度须线。 |\n\n### 网格(ChartGrid)\n\n网格线绘制在数据图形底层,帮助读者对齐数值。\n\n| 属性 | 类型 | 默认值 | 说明 |\n| :--- | :--- | :--- | :--- |\n| `horizontal` | `boolean` | `true` | 是否绘制水平网格线。 |\n| `vertical` | `boolean` | `false` | 是否绘制垂直网格线。 |\n| `tickCount` | `number` | `5` | 大约绘制几条网格线。与 `<ChartAxis>` 的 `tickCount` 保持一致可使网格与刻度完全对齐。 |\n\n### 图例(ChartLegend)\n\n图例列出各个系列的名称与代表色。在 `<Chart>` 上写 `legend` 就会显示(默认不显示),也可以单独使用 `<ChartLegend>` 组件。\n\n| 属性 | 类型 | 默认值 | 说明 |\n| :--- | :--- | :--- | :--- |\n| `items` | `LegendItem[]` | `[]` | 显式指定的图例项数组。不传则自动从系列推导。 |\n| `position` | `'top' \\| 'bottom' \\| 'start' \\| 'end'` | `'bottom'` | 图例相对图表画布的停靠方位。`start` 和 `end` 会跟随阅读方向。 |\n| `alignment` | `'start' \\| 'center' \\| 'end'` | `'start'` | 图例项在所在边上的对齐方式。 |\n\n### 提示框(ChartTooltip)\n\n鼠标悬停在图表上时,提示框会展示当前位置对应的数据明细。\n\n| 属性 | 类型 | 默认值 | 说明 |\n| :--- | :--- | :--- | :--- |\n| `series` | `readonly SeriesDef[]` | 由 Chart 注入 | 系列定义。独立使用该组件时需要传入同一份 `series` 数组。 |\n| `hoverIndicator` | `boolean` | `true` | 悬停时是否绘制辅助指示。柱状图高亮整列,折线、面积和散点绘制垂直指示线。 |\n| `showHoverDots` | `boolean` | `true` | 是否在悬停的数据点上显示高亮小圆点(柱状图自动跳过)。 |\n| `placement` | `'auto' \\| 'right' \\| 'left' \\| 'top'` | `'auto'` | 浮层卡片相对悬停点的位置。`auto` 会优先靠右,放不下时自动翻转到左侧。 |\n\n### 颜色色块(ChartSwatch)\n\n用于在图例或自定义提示框中绘制一小块系列代表色标。\n\n| 属性 | 类型 | 默认值 | 说明 |\n| :--- | :--- | :--- | :--- |\n| `color` | `string` | **必填** | 填充颜色值。 |\n| `variant` | `'square' \\| 'line'` | `'square'` | 色块形态。`square` 渲染为小方块(适合柱状),`line` 渲染为一小段短线(适合折线和面积)。 |\n\n### 自定义提示框内容\n\n如果内置提示框的样式无法满足要求,可以通过 `<Chart>` 的 `#tooltip` 插槽自定义浮层内容。插槽向外暴露当前悬停的横坐标值 `xValue` 和各系列的数据明细 `rows`:\n\n```vue\n<template>\n <Chart :data=\"monthly\" x-key=\"month\" :series=\"series\" tooltip>\n <template #tooltip=\"{ xValue, rows }\">\n <div class=\"my-custom-tooltip\">\n <strong>{{ xValue }}</strong>\n <div v-for=\"row in asRows(rows)\" :key=\"row.key\">\n <ChartSwatch :color=\"row.color\" variant=\"line\" />\n {{ row.label }}:{{ money(row.value) }}\n </div>\n </div>\n </template>\n </Chart>\n</template>\n```\n\n## 颜色\n\n如果创建系列时没有显式指定颜色,图表会根据主题里的分类色板,按顺序自动给每个系列分配颜色。\n\n所有的图表颜色底层都是基于 CSS 变量实现的。这意味着当页面切换主题风格,或者从浅色模式切换到深色模式时,图表上的颜色会自动跟随环境刷新,不需要重新渲染整张图表。\n\n如果你需要手动获取当前图表体系的配色方案,可以调用 `useChartColors()` 组合式函数。它返回的对象在整个包中全局共享且保持不变,其包含的成员如下:\n\n- `categorical(n: number)`:获取包含指定数量的分类色数组。数量超出槽位时会自动循环。\n- `sequential`:顺序色阶对象,支持 9 种色相(`blue`、`shamrock`、`orange`、`pink`、`purple`、`red`、`teal`、`yellow`、`gray`)。传入数量 `n` 即可返回渐变过渡色数组。\n- `diverging`:双极发散配色。提供 `positiveNegative`(正负分明)、`coldHot`(冷暖分明)以及 `custom`(自定义两侧色相)方法。\n- `semantic`:语义单色值对象,包含 `positive`、`negative`、`warning` 和 `neutral` 四种标准状态色。\n- `structural`:图表骨架的结构色,包含 `axis`(坐标轴线)、`grid`(网格线)、`tick`(刻度短线)和 `label`(文本标签)四种颜色。\n- `alpha(color: string, opacity: number)`:按指定透明度生成带透明通道的颜色值(若为 CSS 变量则自动采用 `color-mix()` 处理)。\n\n## 格式化函数\n\n图表库内置了一组常用的格式化函数。所有格式化函数都遵循安全原则:如果遇到 `NaN`、`Infinity` 或无法解析的值,会原样返回原字符串,绝对不会在界面上打印出异常信息,也不会报错崩溃。\n\n- `compactNumber(value, locale)`:将大数字缩写。例如 `1200` 转为「1.2K」,`1500000` 转为「1.5M」,`1e12` 转为「1T」。\n- `currency(symbol?, locale?)`:创建货币格式化器。例如 `currency('¥')` 可以直接传给 `ChartAxis` 的 `tickFormat` 属性,数值大于一千时会自动应用缩写(如「¥1.5K」)。\n- `percent(value, locale)`:将小数比例转换为百分比。例如 `0.45` 转换为「45%」,`0.005` 转换为「0.5%」。\n- `shortDate(value, locale)`:将日期格式化为简短的月日。例如「Jan 5」或「3月12日」。\n- `monthYear(value, locale)`:将日期格式化为年月。例如「Jan 2024」或「2025年3月」。\n- `formatList(values, locale)`:按指定语言的语法规则把多个词汇拼接为自然语言列表,例如将数组拼成「甲、乙和丙」。\n\n## 无障碍\n\n图表在可访问性方面做了完善的无障碍设计,让视障人士通过读屏软件也能了解图表内容:\n\n- 整个图表根元素具有 `role=\"img\"` 角色属性。\n- 图表的无障碍名称优先使用传入的 `title` 属性;如果没有提供 `title`,系统会根据包含的主要系列与 x 轴字段自动拼接出清晰的语义名称(如「月度 收入和成本 走势」);如果全是辅助标注线,则默认退化为「图表」。\n- 传入的 `subtitle` 会自动关联为图表的无障碍长描述。\n- 当数据点总数(数据行数 × 数据列数)不超过 100 个(即常量 `MAX_TABLE_POINTS`)时,图表会自动生成一份在视觉上完全隐藏的数据表格(通过 `VisuallyHidden` 隐藏在屏幕外)。读屏软件能够直接逐行朗读表格中的精准数据;如果数据量超过 100 点,则不会生成表格以避免造成大量无效的语音噪音。\n\n## 服务端渲染\n\n在服务端渲染(SSR)环境中运行时,由于不存在真实的浏览器 DOM,组件无法自动测量父级容器的实际像素宽度。\n\n因此,在服务端渲染时,必须在 `<Chart>` 上显式传入 `width` 属性指定宽度像素值,服务端才能正确完成比例尺计算并完整输出包含路径和矩形的 SVG 标签。在浏览器端水合后,图表一旦首次测量到真实的容器宽度,便会自动接管并按真实容器尺寸响应式排版。\n\n## 现在不支持的\n\n为了保持包的轻量和聚焦,目前以下能力暂未包含在当前包中:\n- 4 个基于 WebGL 的超大数据集标记(例如 `dotGL` 等标记)尚未收录于此包中。\n- Nuxt 模块中尚未提供针对 `@astryx-vue/charts` 的自动引入配置开关,在 Nuxt 项目中使用时需手动通过 npm 依赖导入。"
74943
+ },
74944
+ "docsUrl": {
74945
+ "en": "https://astryx-vue.pages.dev/en/guide/charts",
74946
+ "zh": "https://astryx-vue.pages.dev/guide/charts"
74947
+ }
74948
+ },
74738
74949
  {
74739
74950
  "id": "guide/cli",
74740
74951
  "kind": "guide",
@@ -75456,6 +75667,145 @@ const catalog = {
75456
75667
  "zh": "https://astryx-vue.pages.dev/guide/responsive"
75457
75668
  }
75458
75669
  },
75670
+ {
75671
+ "id": "guide/richtext",
75672
+ "kind": "guide",
75673
+ "title": {
75674
+ "en": "Rich text",
75675
+ "zh": "富文本"
75676
+ },
75677
+ "summary": {
75678
+ "en": "`@astryx-vue/richtext` is a rich text editor package for Vue 3. It builds on the Lexical framework so users can type headings, bold text, lists, code blocks, and tables right in the browser with immediate visual feedback.",
75679
+ "zh": "`@astryx-vue/richtext` 是一个给 Vue 3 用的富文本编辑器组件包。底层基于 Lexical 框架,让用户在网页里直接输入标题、加粗文字、列表、代码块和表格,所见即所得。"
75680
+ },
75681
+ "headings": {
75682
+ "en": [
75683
+ "What this is",
75684
+ "Install",
75685
+ "Your first content",
75686
+ "`<RichTextEditor>` props",
75687
+ "Props",
75688
+ "Slots",
75689
+ "Events",
75690
+ "Methods on the template ref",
75691
+ "Read-only and disabled",
75692
+ "Toolbar",
75693
+ "Actions and keyboard shortcuts",
75694
+ "`<RichTextEditorToolbar>` props and slots",
75695
+ "Custom icons",
75696
+ "Plugins",
75697
+ "Default plugins",
75698
+ "Auto-link plugin: `RichTextEditorAutoLinkPlugin`",
75699
+ "Markdown in and out",
75700
+ "Pure conversion functions",
75701
+ "Source preservation on round trips",
75702
+ "Markdown plugin nodes",
75703
+ "Read-only view: RichTextView",
75704
+ "Design role and accessibility",
75705
+ "Keyboard",
75706
+ "Accessibility",
75707
+ "Not supported yet"
75708
+ ],
75709
+ "zh": [
75710
+ "这是什么",
75711
+ "安装",
75712
+ "第一段内容",
75713
+ "`<RichTextEditor>` 的属性",
75714
+ "属性",
75715
+ "插槽",
75716
+ "事件",
75717
+ "模板引用暴露的方法",
75718
+ "只读与禁用",
75719
+ "工具栏",
75720
+ "工具栏功能与快捷键",
75721
+ "`<RichTextEditorToolbar>` 的属性与插槽",
75722
+ "自定义图标",
75723
+ "插件",
75724
+ "默认已开启的功能插件",
75725
+ "自动链接插件:`RichTextEditorAutoLinkPlugin`",
75726
+ "Markdown 进出",
75727
+ "纯函数转换",
75728
+ "原样往返设计",
75729
+ "Markdown 插件节点",
75730
+ "只读视图 RichTextView",
75731
+ "设计定位与无障碍考虑",
75732
+ "键盘操作",
75733
+ "无障碍",
75734
+ "现在不支持的"
75735
+ ]
75736
+ },
75737
+ "keywords": {
75738
+ "en": [
75739
+ "label",
75740
+ "isLabelHidden",
75741
+ "description",
75742
+ "isOptional",
75743
+ "isRequired",
75744
+ "defaultValue",
75745
+ "placeholder",
75746
+ "isReadOnly",
75747
+ "isDisabled",
75748
+ "status",
75749
+ "statusVariant",
75750
+ "width",
75751
+ "minHeight",
75752
+ "labelTooltip",
75753
+ "size",
75754
+ "nodes",
75755
+ "extensions",
75756
+ "markdownExtensions",
75757
+ "hasMarkdownShortcuts",
75758
+ "transformers",
75759
+ "hasAutoFocus",
75760
+ "tabEscapeHint",
75761
+ "maxLength",
75762
+ "namespace",
75763
+ "headingLevels",
75764
+ "hasLink",
75765
+ "hasNewTabLinks",
75766
+ "promptForUrl"
75767
+ ],
75768
+ "zh": [
75769
+ "属性名",
75770
+ "label",
75771
+ "isLabelHidden",
75772
+ "description",
75773
+ "isOptional",
75774
+ "isRequired",
75775
+ "defaultValue",
75776
+ "placeholder",
75777
+ "isReadOnly",
75778
+ "isDisabled",
75779
+ "status",
75780
+ "statusVariant",
75781
+ "width",
75782
+ "minHeight",
75783
+ "labelTooltip",
75784
+ "size",
75785
+ "nodes",
75786
+ "extensions",
75787
+ "markdownExtensions",
75788
+ "hasMarkdownShortcuts",
75789
+ "transformers",
75790
+ "hasAutoFocus",
75791
+ "tabEscapeHint",
75792
+ "maxLength",
75793
+ "namespace",
75794
+ "headingLevels",
75795
+ "hasLink",
75796
+ "hasNewTabLinks",
75797
+ "promptForUrl"
75798
+ ]
75799
+ },
75800
+ "body": {
75801
+ "en": "# Rich text\n\n## What this is\n\n`@astryx-vue/richtext` is a rich text editor package for Vue 3. It builds on the Lexical framework so users can type headings, bold text, lists, code blocks, and tables right in the browser with immediate visual feedback.\n\nIt does three main things:\n1. Provides a ready-to-use `<RichTextEditor>` input area and an optional `<RichTextEditorToolbar>`.\n2. Turns `# ` or `- ` into headings and lists as you type (Markdown shortcuts), and exports the document back to Markdown without touching unchanged blocks.\n3. Provides `<RichTextView>`, a lightweight read-only component that renders saved content without the overhead of an editor.\n\n## Install\n\nThe editor depends on `@astryx-vue/core`, `vue`, and Meta's `lexical` packages. Run this command in your project terminal (keep all `lexical` and `@lexical/*` packages pinned to `0.52.0`):\n\n```bash\npnpm add @astryx-vue/richtext \\\n lexical@0.52.0 \\\n @lexical/code@0.52.0 \\\n @lexical/extension@0.52.0 \\\n @lexical/headless@0.52.0 \\\n @lexical/history@0.52.0 \\\n @lexical/html@0.52.0 \\\n @lexical/link@0.52.0 \\\n @lexical/list@0.52.0 \\\n @lexical/markdown@0.52.0 \\\n @lexical/rich-text@0.52.0 \\\n @lexical/selection@0.52.0 \\\n @lexical/table@0.52.0 \\\n @lexical/utils@0.52.0\n```\n\nImport the base styles in your application entry file (such as `main.ts`):\n\n```ts\nimport '@astryx-vue/richtext/richtext.css'\n```\n\n## Your first content\n\n`<RichTextEditor>` requires only the `label` prop to get started. Screen readers use this label to announce the field, so it is required for accessibility.\n\nHere is the shortest working example:\n\n```vue\n<script setup lang=\"ts\">\nimport { RichTextEditor } from '@astryx-vue/richtext'\n<\/script>\n\n<template>\n <RichTextEditor label=\"Release notes\" placeholder=\"Type something here...\" />\n</template>\n```\n\nHere is the rendered result:\n\n## `<RichTextEditor>` props\n\nThis section covers all props, slots, events, and template ref methods supported by `<RichTextEditor>`.\n\n### Props\n\n| Name | Type | Default | Description |\n| :--- | :--- | :--- | :--- |\n| `label` | `string` | Required | Accessible label text for the editor. Screen readers require it. |\n| `isLabelHidden` | `boolean` | `false` | Visually hides the label while keeping it accessible to screen readers. |\n| `description` | `string` | - | Helper text displayed between the label and the editing area. |\n| `isOptional` | `boolean` | `false` | Marks the field as optional. Mutually exclusive with `isRequired`. |\n| `isRequired` | `boolean` | `false` | Marks the field as required. Mutually exclusive with `isOptional`. |\n| `defaultValue` | `string` | - | Initial content string produced by `JSON.stringify(editorState.toJSON())`. The editor is uncontrolled and reads this value only once upon mount. |\n| `placeholder` | `string` | - | Dim helper text displayed when the editing area is completely empty. |\n| `isReadOnly` | `boolean` | `false` | Read-only state. Text remains sharp and stays in the Tab order so users can select, copy, and read content without modifying it. |\n| `isDisabled` | `boolean` | `false` | Disabled state. Muted colors, non-editable, and removed from the keyboard Tab order. Takes precedence if both `isReadOnly` and `isDisabled` are true. |\n| `status` | `RichTextEditorStatus` | - | Validation status object `{ type: 'warning' \\| 'error' \\| 'success', message?: string }`. Sets the border color and renders an alert message below. |\n| `statusVariant` | `'attached' \\| 'detached' \\| 'tooltip'` | `'attached'` | Placement of the status alert message. |\n| `width` | `number \\| string` | - | Total width of the form field. Numbers are treated as pixels (px); strings are passed as CSS lengths. |\n| `minHeight` | `number \\| string` | `'4.5rem'` | Minimum height of the editable surface. Numbers are treated as pixels; strings are passed as CSS lengths. |\n| `labelTooltip` | `string` | - | Tooltip text shown when hovering the info icon next to the label. |\n| `size` | `'sm' \\| 'md' \\| 'lg'` | `'md'` | Size variant. Controls the inner padding of the editing box. |\n| `nodes` | `ReadonlyArray<Klass<LexicalNode>>` | - | Extra Lexical node classes to register beyond the defaults (headings, quote, lists, link, code block, table). |\n| `extensions` | `ReadonlyArray<AnyLexicalExtensionArgument>` | - | Extra Lexical extension arguments. |\n| `markdownExtensions` | `ReadonlyArray<RichTextMarkdownExtension>` | - | Markdown extension plugins created by `createRichTextExtension`. |\n| `hasMarkdownShortcuts` | `boolean` | `true` | Enables Markdown shortcuts such as typing `# ` for headings or `- ` for bullet lists. |\n| `transformers` | `ReadonlyArray<Transformer>` | Default transformers | Custom Markdown transformer rules. Defaults to standard transformers plus GFM tables. |\n| `hasAutoFocus` | `boolean` | `false` | Moves focus into the editing surface as soon as the component mounts. |\n| `tabEscapeHint` | `string` | Default copy | Instruction announced to screen readers on how to exit the editor. Because Tab indents text inside the editor, exiting requires pressing Escape then Tab. Pass an empty string to silence this hint. |\n| `maxLength` | `number` | - | Plain-text character limit. Displays a counter in the bottom-right corner. Text is not truncated when exceeding the limit, but the counter turns red. |\n| `namespace` | `string` | `'astryx-editor'` | Namespace for the underlying Lexical instance. Distinguishes multiple editors on the same page. |\n\n### Slots\n\n- `#toolbar`: Full-width slot positioned directly above the editing surface. Typically holds `<RichTextEditorToolbar />`.\n- `#default`: Slot inside the editing surface. Used for headless plugin components (such as an auto-link plugin or custom tools that call `useLexicalEditor()`).\n\n### Events\n\n- `@change`: Fires when the user edits the document. Receives two arguments:\n - `editorState: EditorState`: Current state object. Call `editorState.toJSON()` to serialize it for storage.\n - `editor: LexicalEditor`: Underlying Lexical editor instance.\n\n### Methods on the template ref\n\nBind a `ref` to `<RichTextEditor>` to call these instance methods:\n\n```ts\nconst editorRef = ref<RichTextEditorExposed>()\n\n// Focus the editing surface\neditorRef.value?.focus()\n\n// Clear all content and reset to a single empty paragraph\neditorRef.value?.clear()\n\n// Read the current EditorState object\nconst state = editorRef.value?.getEditorState()\n\n// Export current content as a Markdown string (preserves untouched blocks)\nconst markdown = editorRef.value?.getMarkdown()\n\n// Export current content as an HTML string (requires a browser DOM environment)\nconst html = editorRef.value?.getHTML()\n\n// Access the underlying LexicalEditor instance for advanced customization\nconst rawEditor = editorRef.value?.getEditor()\n```\n\nThis example shows how to listen to `@change` and call `clear()` and `getMarkdown()`:\n\n## Read-only and disabled\n\nThe editor provides two non-editable states: `isReadOnly` and `isDisabled`. They serve distinct interactive and accessibility needs:\n\n1. **Read-only (`isReadOnly`)**:\n - Text color remains at full contrast.\n - Remains in the keyboard Tab order. Screen readers announce the field as read-only.\n - Users can select and copy text.\n2. **Disabled (`isDisabled`)**:\n - The entire input and its text appear dimmed.\n - Completely removed from the Tab order; Tab skips past it.\n - Screen readers announce the field as disabled.\n - If both `isReadOnly` and `isDisabled` are passed to the same component, **disabled wins**.\n\n```vue\n<!-- Read-only: easy to inspect and copy, stays focusable and accessible -->\n<RichTextEditor label=\"Release notes\" :default-value=\"savedContent\" is-read-only />\n\n<!-- Disabled: dimmed and removed from the Tab order -->\n<RichTextEditor label=\"Release notes\" :default-value=\"savedContent\" is-disabled />\n```\n\nNotice the distinction between a read-only form field and a display view:\n- **`<RichTextEditor is-read-only>`**: Built for **forms**. It keeps its field label, helper description, and border frame. Semantically it remains a form control.\n- **`<RichTextView>`**: Built for **article bodies** (such as blog posts or detail views). It strips all form semantics, borders, and labels, rendering pure document content.\n\nHere is how the `status` prop and `maxLength` look in practice:\n\n## Toolbar\n\n`<RichTextEditorToolbar>` is the companion format bar for the editor. Place it in the `#toolbar` slot of `<RichTextEditor>` to dock it flush to the top of the editing area.\n\n```vue\n<script setup lang=\"ts\">\nimport { RichTextEditor, RichTextEditorToolbar } from '@astryx-vue/richtext'\n<\/script>\n\n<template>\n <RichTextEditor label=\"Weekly report\">\n <template #toolbar>\n <RichTextEditorToolbar />\n </template>\n </RichTextEditor>\n</template>\n```\n\n### Actions and keyboard shortcuts\n\nThe toolbar includes common typographic controls:\n- **History**: Undo and Redo.\n- **Block formats**: Paragraph, Heading 1 (H1), Heading 2 (H2), Heading 3 (H3), Quote, Bullet List, and Numbered List.\n- **Inline styles**: Bold, Italic, Underline, Strikethrough, and Inline Code.\n- **Insert and edit links**: Click the link icon on the toolbar, or select text and press <kbd>Cmd</kbd> + <kbd>K</kbd> (<kbd>Ctrl</kbd> + <kbd>K</kbd> on Windows) to open the built-in popover.\n - Type a destination URL in the popover and press Enter to save. The text selection stays intact while the popover is open.\n - Every URL is sanitized through the built-in `sanitizeUrl` function. Only `http:`, `https:`, `mailto:`, and `tel:` protocols are allowed; scripts like `javascript:` are blocked.\n\n### `<RichTextEditorToolbar>` props and slots\n\n| Name | Type | Default | Description |\n| :--- | :--- | :--- | :--- |\n| `label` | `string` | Localized string | Accessible name for the entire toolbar (defaults to \"Text formatting\"). |\n| `headingLevels` | `ReadonlyArray<'h1' \\| 'h2' \\| 'h3'>` | `['h1', 'h2', 'h3']` | Which heading levels to display in the block format dropdown, in the given order. |\n| `size` | `'sm' \\| 'md' \\| 'lg'` | `'sm'` | Size variant. Scales all buttons and dropdown menus inside the toolbar. |\n| `hasLink` | `boolean` | `true` | Shows the link button and enables the <kbd>Cmd/Ctrl</kbd> + <kbd>K</kbd> shortcut. |\n| `hasNewTabLinks` | `boolean` | `true` | Opens inserted links in a new tab by default (attaches `target=\"_blank\"` and `rel=\"noopener noreferrer\"`). |\n| `promptForUrl` | `(selectedText: string) => string \\| null \\| undefined` | - | Custom URL prompt callback. Use this to provide your own dialog instead of the built-in popover. |\n\nSlots:\n- `#end`: Custom slot rendered after all default buttons, separated by a vertical divider. Useful for project-specific buttons such as @mentions, AI helpers, or an emoji picker.\n\n### Custom icons\n\nThe toolbar ships with built-in SVG icons. To use icons from your own icon library (such as Lucide or Carbon), override them through the global theme helper `defineTheme`. Every icon key uses the `richtext:` prefix:\n\n```ts\nimport { defineTheme } from '@astryx-vue/core'\n\ndefineTheme({\n icons: {\n 'richtext:bold': 'lucide:bold',\n 'richtext:italic': 'lucide:italic',\n 'richtext:link': 'lucide:link',\n // See the list below for all replaceable keys\n },\n})\n```\n\n`RICHTEXT_ICON_KEYS` exports all replaceable icon keys:\n- `bold` -> `'richtext:bold'`\n- `italic` -> `'richtext:italic'`\n- `underline` -> `'richtext:underline'`\n- `strikethrough` -> `'richtext:strikethrough'`\n- `code` -> `'richtext:code'`\n- `link` -> `'richtext:link'`\n- `paragraph` -> `'richtext:paragraph'`\n- `h1`, `h2`, `h3` -> `'richtext:h1'`, `'richtext:h2'`, `'richtext:h3'`\n- `quote` -> `'richtext:quote'`\n- `bullet` -> `'richtext:bullet'`\n- `number` -> `'richtext:number'`\n- `undo` -> `'richtext:undo'`\n- `redo` -> `'richtext:redo'`\n\n## Plugins\n\nPlugins extend the editor with new behaviors, such as task checkboxes, code syntax highlighting, or turning typed URLs into links automatically.\n\n### Default plugins\n\nWhen you mount `<RichTextEditor>`, it includes these capabilities out of the box with no extra configuration:\n- **Task checkboxes**: Supports `- [ ]` and `- [x]` checklist syntax. Checkboxes can be toggled by clicking them and lock automatically when the editor is read-only.\n- **Code block header and copy button (CodeBlockHeaderPlugin)**: Displays the language name (such as `TypeScript` or `Python`) above code blocks, along with a one-click copy button.\n- **Code syntax highlighting (CodeSyntaxPlugin)**: Colors keywords, strings, numbers, and comments inside code blocks using the same theme variables as `CodeBlock`.\n- **Table width sizing and scrolling (Table Plugins)**: Enforces minimum column widths and adds accessible horizontal scrolling when tables overflow their container.\n\n### Auto-link plugin: `RichTextEditorAutoLinkPlugin`\n\nTurning typed or pasted URLs into clickable links is a frequent requirement. To keep the core package light, this feature is a headless Vue component plugin. Place it directly inside the default slot of `<RichTextEditor>`:\n\n```vue\n<script setup lang=\"ts\">\nimport { RichTextEditor, RichTextEditorAutoLinkPlugin, RichTextEditorToolbar } from '@astryx-vue/richtext'\n<\/script>\n\n<template>\n <RichTextEditor label=\"Notes\" placeholder=\"Try pasting a link here...\">\n <template #toolbar>\n <RichTextEditorToolbar />\n </template>\n <!-- Add the auto-link plugin -->\n <RichTextEditorAutoLinkPlugin />\n </RichTextEditor>\n</template>\n```\n\nThe plugin provides two default matchers (`DEFAULT_LINK_MATCHERS`): one for web URLs and one for email addresses. URLs without a protocol prefix receive `https://`, emails receive `mailto:`, and all generated links attach `target=\"_blank\"` and `rel=\"noopener noreferrer\"` to open safely in a new tab.\n\nTo recognize custom patterns (such as turning `#123` into an issue tracker link), pass custom rules to the `matchers` prop:\n\n```vue\n<RichTextEditorAutoLinkPlugin :matchers=\"myCustomMatchers\" @change=\"(url, prevUrl) => onLinkChange(url, prevUrl)\" />\n```\n\nThe plugin emits a `change` event whenever a link is added, modified, or removed, passing the new URL (`null` on removal) and the previous URL. Build `myCustomMatchers` with `createLinkMatcherWithRegExp` from `@lexical/link`.\n\n## Markdown in and out\n\nMost workflows edit rich text in the browser but store plain Markdown on the server. `@astryx-vue/richtext` provides a lossless round-trip conversion pipeline.\n\n### Pure conversion functions\n\nImport two conversion functions from `@astryx-vue/richtext/markdown`. This entry point has no browser DOM dependency and runs safely in server-side environments such as Node.js or Cloudflare Workers:\n\n```ts\nimport { editorStateJSONToMarkdown, markdownToEditorStateJSON } from '@astryx-vue/richtext/markdown'\n\n// 1. On the server or during initialization: convert Markdown text to JSON for defaultValue\nconst defaultValue = markdownToEditorStateJSON('# Welcome\\n\\nThis is **Markdown** text.')\n\n// 2. When saving or submitting: restore the JSON string back to Markdown text\nconst markdown = editorStateJSONToMarkdown(savedJson)\n```\n\n### Source preservation on round trips\n\nStandard rich text converters often scramble formatting during a Markdown -> editor -> Markdown cycle, dropping indentations or turning every `*` into a `-`.\n\nAstryx Rich Text uses a source-preservation mechanism: imported Markdown blocks keep their original punctuation, spacing, and indents intact on export as long as the user did not edit that block. Only modified or newly added blocks are reformatted with standard syntax.\n\n## Markdown plugin nodes\n\nProjects sometimes need custom Markdown syntax, such as `@username` mentions or custom alert callout blocks.\n\nUse `createRichTextExtension` to register a custom Markdown plugin:\n\n```ts\nimport { createRichTextExtension } from '@astryx-vue/richtext'\nimport { mentionPlugin } from './myMentionPlugin'\n\n// Wrap into an extension recognized by the editor\nconst mentionsExtension = createRichTextExtension(mentionPlugin)\n```\n\nPass the extension to `<RichTextEditor>` or `<RichTextView>` via the `markdownExtensions` prop:\n\n```vue\n<RichTextEditor\n label=\"Discussion\"\n :markdown-extensions=\"[mentionsExtension]\"\n/>\n```\n\nYou can also pass it to the conversion functions:\n\n```ts\nconst json = markdownToEditorStateJSON(markdownText, {\n extensions: [mentionsExtension],\n})\n```\n\n## Read-only view: RichTextView\n\nWhen you need to display an article, a blog post, or a release announcement without any editing capabilities, use the `<RichTextView>` component.\n\n```vue\n<script setup lang=\"ts\">\nimport { RichTextView } from '@astryx-vue/richtext'\n\n// The value is the JSON string saved earlier from editorState.toJSON()\nconst content = '{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"Display content\",\"type\":\"text\",\"version\":1}],\"direction\":\"ltr\",\"format\":\"\",\"indent\":0,\"type\":\"paragraph\",\"version\":1}],\"direction\":\"ltr\",\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}'\n<\/script>\n\n<template>\n <RichTextView :value=\"content\" />\n</template>\n```\n\n### Design role and accessibility\n\n- **Document content, not a form control**: Even when set to non-editable, Lexical defaults to `role=\"textbox\"` on its root element. Tagging a multi-page article as a text input causes screen readers to misidentify it and triggers accessibility audit warnings. `<RichTextView>` strips all form input semantics upon mount and renders as a clean document container.\n- **In-place updates**: When the parent updates the `value` prop, `<RichTextView>` re-parses the content in place without tearing down and recreating the DOM tree.\n- **Resilient error handling**: If saved JSON cannot be parsed due to a version migration or corrupted data, the component will not crash the entire page. It emits a `@parseError` event and supports a fallback display through the `#errorFallback` slot:\n\n```vue\n<RichTextView :value=\"maybeCorruptedContent\" @parse-error=\"onParseError\">\n <template #errorFallback>\n <div class=\"error-box\">Unable to display document content.</div>\n </template>\n</RichTextView>\n```\n\n## Keyboard\n\nThe editor is optimized for full keyboard workflows:\n\n- **Tab indentation and escape**:\n - Pressing <kbd>Tab</kbd> in a paragraph or list item increases its indentation level; pressing <kbd>Shift</kbd> + <kbd>Tab</kbd> decreases it.\n - Because <kbd>Tab</kbd> is used for typography, keyboard-only and screen reader users need a reliable way to exit the editor. Following accessibility standards, **press <kbd>Escape</kbd> once, then press <kbd>Tab</kbd>** to move focus to the next interactive element on the page.\n - The editor announces this escape sequence to screen readers by default. You can change this copy with the `tabEscapeHint` prop, or silence it by passing `tabEscapeHint=\"\"`.\n- **Navigating tables**:\n - While editing inside a table cell, pressing arrow keys (<kbd>↑</kbd>, <kbd>↓</kbd>, <kbd>←</kbd>, <kbd>→</kbd>) moves the cursor smoothly between adjacent cells.\n- **Keyboard shortcuts**:\n - <kbd>Cmd</kbd> + <kbd>K</kbd> (macOS) or <kbd>Ctrl</kbd> + <kbd>K</kbd> (Windows): Opens the link editing popover.\n - <kbd>Cmd/Ctrl</kbd> + <kbd>B</kbd>: Bold.\n - <kbd>Cmd/Ctrl</kbd> + <kbd>I</kbd>: Italic.\n - <kbd>Cmd/Ctrl</kbd> + <kbd>Z</kbd>: Undo; <kbd>Cmd/Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>Z</kbd>: Redo.\n - Underline has no default shortcut; use the toolbar button.\n\n## Accessibility\n\nAstryx Rich Text includes extensive accessibility (a11y) considerations:\n\n1. **Explicit label association**: The required `label` prop creates an accessible name for the editor. Even when `isLabelHidden` is true, the label is preserved for screen readers via `aria-label`.\n2. **Read-only vs. disabled semantics**: `isReadOnly` applies `aria-readonly=\"true\"` and keeps `tabindex=\"0\"`, announcing the field as read-only text; `isDisabled` applies `aria-disabled=\"true\"` and removes the field from the Tab sequence entirely.\n3. **Character count feedback**: When `maxLength` is set, an off-screen `aria-live=\"polite\"` region alerts screen reader users when character count nears the threshold (over 80%) or exceeds it. Error states set `aria-invalid=\"true\"` automatically.\n4. **Preserved focus and selection**: Opening toolbar dialogs (such as the link popover) preserves the active text selection in the editor. Focus returns smoothly to the text area when the popover closes.\n\n## Not supported yet\n\nTo keep the Vue implementation clean and maintainable, this package intentionally differs from the upstream React implementation in two areas:\n\n1. **No `xstyle` styling system**: The upstream React package uses an internal `xstyle` object system, which is omitted in the Vue port. Use standard Vue `class` and `style` attributes to customize appearance.\n2. **Plugins use Vue component slots**: Upstream passes plugins as an array of React nodes; in `@astryx-vue/richtext`, plugins are headless, logic-only Vue components (such as `<RichTextEditorAutoLinkPlugin />`) placed directly in the default slot of `<RichTextEditor>`.",
75802
+ "zh": "# 富文本\n\n## 这是什么\n\n`@astryx-vue/richtext` 是一个给 Vue 3 用的富文本编辑器组件包。底层基于 Lexical 框架,让用户在网页里直接输入标题、加粗文字、列表、代码块和表格,所见即所得。\n\n它主要帮你做三件事:\n1. 提供开箱即用的富文本编辑框 `<RichTextEditor>` 与顶部格式工具栏 `<RichTextEditorToolbar>`。\n2. 支持输入 `# ` 或 `- ` 自动生成标题与列表(Markdown 快捷输入),并能原样把内容导出成 Markdown。\n3. 提供纯展示的只读视图 `<RichTextView>`,用轻量的方式把存下来的文档内容画出来。\n\n## 安装\n\n富文本编辑器依赖同级的 `@astryx-vue/core`、`vue` 以及 Meta 的 `lexical` 系列包。请在项目终端里执行安装命令(注意:所有 `@lexical/*` 和 `lexical` 的版本必须保持在 `0.52.0`):\n\n```bash\npnpm add @astryx-vue/richtext \\\n lexical@0.52.0 \\\n @lexical/code@0.52.0 \\\n @lexical/extension@0.52.0 \\\n @lexical/headless@0.52.0 \\\n @lexical/history@0.52.0 \\\n @lexical/html@0.52.0 \\\n @lexical/link@0.52.0 \\\n @lexical/list@0.52.0 \\\n @lexical/markdown@0.52.0 \\\n @lexical/rich-text@0.52.0 \\\n @lexical/selection@0.52.0 \\\n @lexical/table@0.52.0 \\\n @lexical/utils@0.52.0\n```\n\n接着在你的应用入口文件(如 `main.ts`)里引入编辑器的基础样式:\n\n```ts\nimport '@astryx-vue/richtext/richtext.css'\n```\n\n## 第一段内容\n\n使用 `<RichTextEditor>` 最少只要传一个 `label` 属性。`label` 是给读屏软件识别这个输入框用途的名字,为了无障碍体验,它是必填项。\n\n这是一段最短的可运行代码:\n\n```vue\n<script setup lang=\"ts\">\nimport { RichTextEditor } from '@astryx-vue/richtext'\n<\/script>\n\n<template>\n <RichTextEditor label=\"发布说明\" placeholder=\"请在这里输入内容...\" />\n</template>\n```\n\n实际运行效果如下:\n\n## `<RichTextEditor>` 的属性\n\n本节列出 `<RichTextEditor>` 支持的所有属性、插槽、事件以及模板引用暴露的方法。\n\n### 属性\n\n| 属性名 | 类型 | 默认值 | 说明 |\n| :--- | :--- | :--- | :--- |\n| `label` | `string` | 必填 | 编辑器的标签文字。无障碍读屏软件需要用到它,因此必须填写。 |\n| `isLabelHidden` | `boolean` | `false` | 是否只对读屏软件朗读标签,视觉上把标签隐藏起来。 |\n| `description` | `string` | - | 摆在标签和编辑区之间的详细说明文字。 |\n| `isOptional` | `boolean` | `false` | 是否标记为选填。和 `isRequired` 互斥。 |\n| `isRequired` | `boolean` | `false` | 是否标记为必填。和 `isOptional` 互斥。 |\n| `defaultValue` | `string` | - | 初始内容字符串(由 `JSON.stringify(editorState.toJSON())` 得到)。编辑器是非受控的,只在创建挂载时读取一次。 |\n| `placeholder` | `string` | - | 编辑区完全为空时显示的提示灰字。 |\n| `isReadOnly` | `boolean` | `false` | 是否只读。文字保持原有清晰度,键盘 Tab 键依然能跳进来,方便复制或读屏,但不能改动文字。 |\n| `isDisabled` | `boolean` | `false` | 是否禁用。不可编辑、颜色变淡,且会从键盘 Tab 顺序中移除。若与 `isReadOnly` 同时为 true,以禁用为准。 |\n| `status` | `RichTextEditorStatus` | - | 校验状态对象 `{ type: 'warning' \\| 'error' \\| 'success', message?: string }`。决定边框颜色并在下方显示提示信息。 |\n| `statusVariant` | `'attached' \\| 'detached' \\| 'tooltip'` | `'attached'` | 状态提示信息的摆放位置。 |\n| `width` | `number \\| string` | - | 整个表单字段的宽度。传入数字当像素(px),传入字符串原样作为 CSS 长度。 |\n| `minHeight` | `number \\| string` | `'4.5rem'` | 编辑区域的最小高度。传入数字当像素,传入字符串作为 CSS 长度。 |\n| `labelTooltip` | `string` | - | 标签右侧信息小图标悬浮时出现的提示文本。 |\n| `size` | `'sm' \\| 'md' \\| 'lg'` | `'md'` | 编辑器尺寸,影响编辑框的内部留白(内边距)。 |\n| `nodes` | `ReadonlyArray<Klass<LexicalNode>>` | - | 在默认节点(标题、引用、列表、链接、代码块、表格)之外额外注册的 Lexical 节点类型。 |\n| `extensions` | `ReadonlyArray<AnyLexicalExtensionArgument>` | - | 额外的 Lexical 扩展插件数组。 |\n| `markdownExtensions` | `ReadonlyArray<RichTextMarkdownExtension>` | - | 要识别的 Markdown 插件扩展数组,由 `createRichTextExtension` 生成。 |\n| `hasMarkdownShortcuts` | `boolean` | `true` | 是否开启 Markdown 快捷输入(例如行首打 `# ` 变大标题,打 `- ` 变无序列表)。 |\n| `transformers` | `ReadonlyArray<Transformer>` | 默认转换器 | 自定义 Markdown 转换规则。如果不传,默认使用标准转换器加 GFM 表格。 |\n| `hasAutoFocus` | `boolean` | `false` | 组件挂载完成后是否自动把输入光标聚焦到编辑区。 |\n| `tabEscapeHint` | `string` | 默认文案 | 给读屏软件朗读的退出编辑提示。因为 Tab 键在编辑器里用来缩进文字,离开编辑器需要先按 Escape 再按 Tab。传空字符串表示不输出这段提示。 |\n| `maxLength` | `number` | - | 纯文字字数上限。设置后会在右下角显示计数;超出后不会截断文字,但计数器会变红提醒。 |\n| `namespace` | `string` | `'astryx-editor'` | 底层 Lexical 实例的命名空间,同一页面有多个编辑器时用来互相区分。 |\n\n### 插槽\n\n- `#toolbar`:放在编辑区正上方、顶到两边的工具栏插槽。通常在这里放 `<RichTextEditorToolbar />`。\n- `#default`:编辑区内部的插槽。可以放无界面的扩展组件(例如调用 `useLexicalEditor()` 的自动链接插件或自定义功能组件)。\n\n### 事件\n\n- `@change`:当用户修改了编辑内容时触发。提供两个参数:\n - `editorState: EditorState`:最新的编辑器状态对象,可以用 `editorState.toJSON()` 转成 JSON 存进数据库。\n - `editor: LexicalEditor`:底层的 Lexical 编辑器实例。\n\n### 模板引用暴露的方法\n\n给 `<RichTextEditor>` 绑定 `ref` 后,可以调用以下实例方法:\n\n```ts\nconst editorRef = ref<RichTextEditorExposed>()\n\n// 聚焦到编辑区\neditorRef.value?.focus()\n\n// 清空所有内容,恢复成一个空段落\neditorRef.value?.clear()\n\n// 读取当前的编辑器状态对象\nconst state = editorRef.value?.getEditorState()\n\n// 把当前内容导出为 Markdown 字符串(原本未修改的块保持原样)\nconst markdown = editorRef.value?.getMarkdown()\n\n// 把当前内容导出为 HTML 字符串(依赖浏览器 DOM 环境)\nconst html = editorRef.value?.getHTML()\n\n// 获取底层的 LexicalEditor 实例,供深度定制使用\nconst rawEditor = editorRef.value?.getEditor()\n```\n\n下面的例子展示了监听 `@change` 事件以及调用 `clear()` 和 `getMarkdown()` 的用法:\n\n## 只读与禁用\n\n编辑器提供了 `isReadOnly`(只读)和 `isDisabled`(禁用)两种不可编辑状态。它们在交互和无障碍上有明确的分工:\n\n1. **只读(`isReadOnly`)**:\n - 内容文字颜色不淡化,保持清晰可读。\n - 依然保留在键盘 Tab 键的切焦顺序中,视障用户通过键盘切过来时,读屏软件会明确朗读它是「只读」文本。\n - 用户可以选中里面的文字进行复制。\n2. **禁用(`isDisabled`)**:\n - 整个输入框与文字都会变淡变灰。\n - 彻底从键盘 Tab 键顺序中移出,按 Tab 键会直接跳过它。\n - 读屏软件会明确朗读它是「禁用」。\n - 如果同一个组件上同时写了 `isReadOnly` 和 `isDisabled`,**以禁用为准**。\n\n```vue\n<!-- 只读模式:方便查看和复制,且支持键盘聚焦与读屏 -->\n<RichTextEditor label=\"发布说明\" :default-value=\"savedContent\" is-read-only />\n\n<!-- 禁用模式:灰显且不在键盘切换顺序中 -->\n<RichTextEditor label=\"发布说明\" :default-value=\"savedContent\" is-disabled />\n```\n\n注意区分带标签的只读字段与纯展示视图:\n- **`<RichTextEditor is-read-only>`**:适用于**表单场景**。它带有字段标签(Label)、辅助说明与边框外框,语义上依然是一个表单输入框(只读控件)。\n- **`<RichTextView>`**:适用于**正文展示场景**(比如文章详情页、博客正文)。它没有外层表单框,剥离了所有表单语义,只是纯粹渲染文档内容。\n\n校验状态属性 `status` 与字数上限 `maxLength` 的展示效果如下:\n\n## 工具栏\n\n`<RichTextEditorToolbar>` 是配合编辑器使用的格式控制栏。把它放在 `<RichTextEditor>` 的 `#toolbar` 插槽里,就会贴合固定在编辑区顶部。\n\n```vue\n<script setup lang=\"ts\">\nimport { RichTextEditor, RichTextEditorToolbar } from '@astryx-vue/richtext'\n<\/script>\n\n<template>\n <RichTextEditor label=\"工作周报\">\n <template #toolbar>\n <RichTextEditorToolbar />\n </template>\n </RichTextEditor>\n</template>\n```\n\n### 工具栏功能与快捷键\n\n工具栏内置了常用的排版能力:\n- **历史记录**:撤销(Undo)与重做(Redo)。\n- **块级格式**:段落正文、一级标题(H1)、二级标题(H2)、三级标题(H3)、引用块(Quote)、无序圆点列表(Bullet List)、有序数字列表(Numbered List)。\n- **行内样式**:加粗(Bold)、斜体(Italic)、下划线(Underline)、删除线(Strikethrough)、行内代码(Code)。\n- **插入与编辑链接**:点击工具栏的链接图标,或者在选中文本后按下快捷键 <kbd>Cmd</kbd> + <kbd>K</kbd>(Windows 上为 <kbd>Ctrl</kbd> + <kbd>K</kbd>),会弹出内置的链接浮层。\n - 在弹出的浮层里输入地址并回车即可插入链接。浮层打开时选区依然保留,不会导致光标跑丢。\n - 所有输入的链接地址都会经过内置的 `sanitizeUrl` 校验清洗,系统只放行 `http:`、`https:`、`mailto:` 和 `tel:` 开头的安全协议,阻断 `javascript:` 等恶意脚本。\n\n### `<RichTextEditorToolbar>` 的属性与插槽\n\n| 属性名 | 类型 | 默认值 | 说明 |\n| :--- | :--- | :--- | :--- |\n| `label` | `string` | 本地化文案 | 工具栏整体的无障碍名称(默认为「文字格式」)。 |\n| `headingLevels` | `ReadonlyArray<'h1' \\| 'h2' \\| 'h3'>` | `['h1', 'h2', 'h3']` | 在「块格式」下拉菜单里显示哪几级标题,按数组顺序排列。 |\n| `size` | `'sm' \\| 'md' \\| 'lg'` | `'sm'` | 工具栏尺寸,同步缩放里面所有按钮和下拉选择框。 |\n| `hasLink` | `boolean` | `true` | 是否显示链接按钮,以及是否开启 <kbd>Cmd/Ctrl</kbd> + <kbd>K</kbd> 快捷键。 |\n| `hasNewTabLinks` | `boolean` | `true` | 插入的链接是否默认在新标签页打开(新标签页会自带 `target=\"_blank\"` 与 `rel=\"noopener noreferrer\"`)。 |\n| `promptForUrl` | `(selectedText: string) => string \\| null \\| undefined` | - | 自定义输入网址的回调。如果不想使用内置链接弹窗,可以传入此函数自行弹窗获取网址。 |\n\n插槽:\n- `#end`:尾部自定义插槽。内容会渲染在所有默认按钮右侧(中间以一条竖分割线隔开),方便放入你自己的业务按钮,例如 @提及人、AI 帮写或表情选择器。\n\n### 自定义图标\n\n工具栏内置了一套简洁的 SVG 图标。如果你想换成项目自己的图标库(例如 Lucide 或 Carbon),可以通过全局主题配置 `defineTheme` 替换,每个图标键都带有 `richtext:` 前缀:\n\n```ts\nimport { defineTheme } from '@astryx-vue/core'\n\ndefineTheme({\n icons: {\n 'richtext:bold': 'lucide:bold',\n 'richtext:italic': 'lucide:italic',\n 'richtext:link': 'lucide:link',\n // 支持替换的键见下表\n },\n})\n```\n\n`RICHTEXT_ICON_KEYS` 导出了所有支持替换的图标键:\n- `bold`(加粗) -> `'richtext:bold'`\n- `italic`(斜体) -> `'richtext:italic'`\n- `underline`(下划线) -> `'richtext:underline'`\n- `strikethrough`(删除线) -> `'richtext:strikethrough'`\n- `code`(行内代码) -> `'richtext:code'`\n- `link`(超链接) -> `'richtext:link'`\n- `paragraph`(正文段落) -> `'richtext:paragraph'`\n- `h1`、`h2`、`h3`(标题) -> `'richtext:h1'`、`'richtext:h2'`、`'richtext:h3'`\n- `quote`(引用) -> `'richtext:quote'`\n- `bullet`(无序列表) -> `'richtext:bullet'`\n- `number`(有序列表) -> `'richtext:number'`\n- `undo`(撤销) -> `'richtext:undo'`\n- `redo`(重做) -> `'richtext:redo'`\n\n## 插件\n\n插件能为编辑器扩展新玩法,例如检测任务勾选、代码块高亮或者网址自动变超链接。\n\n### 默认已开启的功能插件\n\n当你使用 `<RichTextEditor>` 时,编辑器内部已经默认集成了以下几项能力,不需要你额外配置:\n- **任务勾选框(Task Checkbox)**:支持 `- [ ]` 和 `- [x]` 任务清单语法,生成的勾选框可以直接点击勾选或取消,在只读模式下会自动锁定不可点。\n- **代码块标题栏与复制按钮(CodeBlockHeaderPlugin)**:遇到代码块时,顶部会展示语言名称(如 `TypeScript`、`Python`)并附带一键复制代码的按钮。\n- **代码语法高亮(CodeSyntaxPlugin)**:对代码块里的关键字、字符串、数字等按语法上色,颜色和 `CodeBlock` 用同一套语法色变量。\n- **表格列宽自适应与横向滚动(Table Plugins)**:表格列设定了最小宽度保护,并在超宽时提供无障碍横向滚动支持。\n\n### 自动链接插件:`RichTextEditorAutoLinkPlugin`\n\n输入或粘贴一段网址时,自动把它转成可点击的超链接是一项常见需求。为了保证轻量,这项能力做成了无界面的 Vue 组件插件。使用时直接放在 `<RichTextEditor>` 的默认插槽里:\n\n```vue\n<script setup lang=\"ts\">\nimport { RichTextEditor, RichTextEditorAutoLinkPlugin, RichTextEditorToolbar } from '@astryx-vue/richtext'\n<\/script>\n\n<template>\n <RichTextEditor label=\"便签\" placeholder=\"粘贴网址试试看...\">\n <template #toolbar>\n <RichTextEditorToolbar />\n </template>\n <!-- 放入自动链接插件 -->\n <RichTextEditorAutoLinkPlugin />\n </RichTextEditor>\n</template>\n```\n\n插件默认自带了网址和电子邮箱两个匹配器(`DEFAULT_LINK_MATCHERS`)。识别出的网址如果没写协议会自动补齐 `https://`,邮箱会自动加上 `mailto:`,并且生成的链接都会加上 `target=\"_blank\"` 和 `rel=\"noopener noreferrer\"` 以便安全地在新窗口打开。\n\n如果你想识别自定义语法(例如把 `#123` 自动变成工单链接),可以通过 `matchers` 属性传入自定义的匹配规则:\n\n```vue\n<RichTextEditorAutoLinkPlugin :matchers=\"myCustomMatchers\" @change=\"(url, prevUrl) => onLinkChange(url, prevUrl)\" />\n```\n\n自动加上、改掉或去掉链接时,插件会发出 `change` 事件,参数是新网址(去掉时是 `null`)和原来的网址。`myCustomMatchers` 用 `@lexical/link` 的 `createLinkMatcherWithRegExp` 来建。\n\n## Markdown 进出\n\n大部分时候,我们在前端编辑富文本,后端想存的却是通用的 Markdown 纯文本。`@astryx-vue/richtext` 提供了「无损往返」的转换机制。\n\n### 纯函数转换\n\n你可以从 `@astryx-vue/richtext/markdown` 导出两个转换函数。这个入口不依赖浏览器 DOM,在 Node.js、Cloudflare Workers 等服务端渲染(SSR)环境里都可以安全调用:\n\n```ts\nimport { editorStateJSONToMarkdown, markdownToEditorStateJSON } from '@astryx-vue/richtext/markdown'\n\n// 1. 服务端或初始化时:把 Markdown 字符串转成给 defaultValue 用的 JSON 字符串\nconst defaultValue = markdownToEditorStateJSON('# 欢迎\\n\\n这是一段 **Markdown** 文本。')\n\n// 2. 保存或提交时:把编辑器导出的 JSON 字符串还原回 Markdown 文本\nconst markdown = editorStateJSONToMarkdown(savedJson)\n```\n\n### 原样往返设计\n\n传统的富文本转换器在「Markdown -> 编辑器 -> 重新导出 Markdown」这个过程中,经常会打乱排版,比如把使用者的缩进弄丢、把 `*` 号全改成 `-` 号。\n\nAstryx 富文本采用了一套源码保留机制:对于导入进来的 Markdown 文本,只要用户在编辑时没有改动这个段落,导出时就会**原封不动保持用户最初写下的每一个标点和空格**;只有被改动过或全新写出的段落,才会使用规范的语法格式化输出。\n\n## Markdown 插件节点\n\n有些时候,项目里会有一些定制的 Markdown 语法,比如用 `@用户名` 提及同事、用特定指令插入告警框等。\n\n你可以使用 `createRichTextExtension` 函数把自定义的 Markdown 语法插件接入进来:\n\n```ts\nimport { createRichTextExtension } from '@astryx-vue/richtext'\nimport { mentionPlugin } from './myMentionPlugin'\n\n// 包装成编辑器能识别的扩展\nconst mentionsExtension = createRichTextExtension(mentionPlugin)\n```\n\n然后在 `<RichTextEditor>` 或 `<RichTextView>` 上通过 `markdownExtensions` 属性传给它们:\n\n```vue\n<RichTextEditor\n label=\"讨论区\"\n :markdown-extensions=\"[mentionsExtension]\"\n/>\n```\n\n在转换函数中也可以传入:\n\n```ts\nconst json = markdownToEditorStateJSON(markdownText, {\n extensions: [mentionsExtension],\n})\n```\n\n## 只读视图 RichTextView\n\n当你只需要在页面上展示文章、博客或发版公告,而不需要任何输入编辑功能时,应该使用 `<RichTextView>` 组件。\n\n```vue\n<script setup lang=\"ts\">\nimport { RichTextView } from '@astryx-vue/richtext'\n\n// 这里的 value 是先前通过 editorState.toJSON() 存下的 JSON 字符串\nconst content = '{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"这是展示内容\",\"type\":\"text\",\"version\":1}],\"direction\":\"ltr\",\"format\":\"\",\"indent\":0,\"type\":\"paragraph\",\"version\":1}],\"direction\":\"ltr\",\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}'\n<\/script>\n\n<template>\n <RichTextView :value=\"content\" />\n</template>\n```\n\n### 设计定位与无障碍考虑\n\n- **是文档内容,不是表单控件**:底层 Lexical 即使在不可编辑状态下,默认也会给根节点套上 `role=\"textbox\"`。但如果展示一整篇几千字的文章却被标记为「文本输入框」,视障用户的读屏软件就无法正确按文章阅读,无障碍检测工具也会报错。`<RichTextView>` 会在挂载时主动去除所有表单输入框语义,把它还原为一个纯粹的文档容器。\n- **就地热更新**:当父组件更新了 `value` 属性时,内部会就地切换解析状态,不会频繁销毁和重新挂载整个 DOM 结构。\n- **错误容灾机制**:从后端读出的历史 JSON 如果因为版本升级或格式损坏导致无法解析,组件不会让整个页面崩溃白屏。它会触发 `@parseError` 事件,并且可以通过 `#errorFallback` 插槽展示友好的降级提示界面:\n\n```vue\n<RichTextView :value=\"maybeCorruptedContent\" @parse-error=\"onParseError\">\n <template #errorFallback>\n <div class=\"error-box\">抱歉,文档内容加载失败。</div>\n </template>\n</RichTextView>\n```\n\n## 键盘操作\n\n编辑器针对键盘使用者进行了深度优化:\n\n- **Tab 缩进与退出**:\n - 在正文段落或列表里按下 <kbd>Tab</kbd> 键,会增加当前行的文字缩进;按下 <kbd>Shift</kbd> + <kbd>Tab</kbd> 会减少缩进。\n - 由于 <kbd>Tab</kbd> 键被用来做文本排版,视障或全键盘用户需要能够把焦点移出编辑器。编辑器遵循无障碍规范:**先按一次 <kbd>Escape</kbd> 键,再按 <kbd>Tab</kbd> 键**,焦点就会跳出编辑器,移动到网页的下一个可交互元素上。\n - 编辑器默认会向读屏软件朗读这段退出指引。如果你想自定义朗读文本,可以修改 `tabEscapeHint` 属性;传空字符串 `tabEscapeHint=\"\"` 则可以关闭该朗读。\n- **表格中的键盘移动**:\n - 在表格单元格中输入时,按下方向键(<kbd>↑</kbd>、<kbd>↓</kbd>、<kbd>←</kbd>、<kbd>→</kbd>)可以在相邻单元格之间平滑穿梭移动。\n- **快捷键**:\n - <kbd>Cmd</kbd> + <kbd>K</kbd>(macOS)或 <kbd>Ctrl</kbd> + <kbd>K</kbd>(Windows):快速打开超链接设置弹窗。\n - <kbd>Cmd/Ctrl</kbd> + <kbd>B</kbd>:粗体。\n - <kbd>Cmd/Ctrl</kbd> + <kbd>I</kbd>:斜体。\n - <kbd>Cmd/Ctrl</kbd> + <kbd>Z</kbd>:撤销;<kbd>Cmd/Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>Z</kbd>:重做。\n - 下划线没有键盘快捷键,用工具栏上的按钮。\n\n## 无障碍\n\nAstryx 富文本编辑器在无障碍体验(a11y)上做了大量细节处理:\n\n1. **清晰的 Label 关联**:通过必填的 `label` 属性,外层自动建立无障碍名称映射。即便设置了 `isLabelHidden`,该名称依然会通过 `aria-label` 忠实传达给读屏软件。\n2. **区分只读与禁用**:`isReadOnly` 会挂载 `aria-readonly=\"true\"` 并保持 `tabindex=\"0\"`,读屏朗读为只读文本;`isDisabled` 会挂载 `aria-disabled=\"true\"` 并彻底移除 Tab 聚焦顺序。\n3. **字数与表单状态联动**:当配置了 `maxLength` 时,实时的纯文本字数会在接近上限(超过 80%)及超额时,通过 `aria-live=\"polite\"` 的隐藏广播区向读屏用户报读剩余或超出的字数;错误状态时会自动设置 `aria-invalid=\"true\"`。\n4. **焦点与选区保护**:工具栏中的弹窗(如添加链接)在开启时,不会销毁编辑区原有的文本选区;关闭弹窗后焦点能够平滑归还。\n\n## 现在不支持的\n\n为了保持 Vue 实现的纯粹性与稳定性,本包与上游 React 原版实现存在以下差异:\n\n1. **不支持 `xstyle` 样式方案**:上游 React 版本依赖内部的 `xstyle` 样式对象机制,在 Vue 移植版中不予支持。请统一使用标准的 Vue `class` 和 `style` 属性来定义外观。\n2. **插件机制为 Vue 组件插槽**:上游的 `plugins` 是作为 React 节点数组传递的;而在 `@astryx-vue/richtext` 中,扩展插件是纯逻辑的无界面 Vue 组件(例如 `<RichTextEditorAutoLinkPlugin />`),直接写在 `<RichTextEditor>` 的默认插槽内部即可。"
75803
+ },
75804
+ "docsUrl": {
75805
+ "en": "https://astryx-vue.pages.dev/en/guide/richtext",
75806
+ "zh": "https://astryx-vue.pages.dev/guide/richtext"
75807
+ }
75808
+ },
75459
75809
  {
75460
75810
  "id": "guide/styling",
75461
75811
  "kind": "guide",
@@ -76225,6 +76575,7 @@ const catalog = {
76225
76575
  "Card",
76226
76576
  "Center",
76227
76577
  "CheckboxInput",
76578
+ "ChatComposer",
76228
76579
  "ChatMessage",
76229
76580
  "ChatMessageBubble",
76230
76581
  "ChatMessageList",
@@ -76237,6 +76588,7 @@ const catalog = {
76237
76588
  "Icon",
76238
76589
  "Item",
76239
76590
  "Link",
76591
+ "OverflowList",
76240
76592
  "MoreMenu",
76241
76593
  "NumberInput",
76242
76594
  "RadioList",
@@ -76254,7 +76606,7 @@ const catalog = {
76254
76606
  ],
76255
76607
  "dependencies": ["@astryx-vue/core", "vue"],
76256
76608
  "isReady": true,
76257
- "source": "<script setup lang=\"ts\">\nimport type { TableColumn } from '@astryx-vue/core'\nimport {\n AspectRatio,\n Badge,\n Banner,\n Button,\n Card,\n Center,\n CheckboxInput,\n ChatMessage,\n ChatMessageBubble,\n ChatMessageList,\n ChatSystemMessage,\n Divider,\n Grid,\n GridSpan,\n Heading,\n HStack,\n Icon,\n Item,\n Link,\n MoreMenu,\n NumberInput,\n pixel,\n proportional,\n RadioList,\n RadioListItem,\n Section,\n SelectableCard,\n Selector,\n Table,\n Text,\n TextInput,\n TopNav,\n TopNavHeading,\n TopNavItem,\n useAppShellMobile,\n VStack,\n} from '@astryx-vue/core'\nimport { computed, h, ref } from 'vue'\n\n/* ChatComposer is not exported from @astryx-vue/core — composer uses TextInput. */\n\ntype ShowcaseBadgeVariant = 'blue' | 'cyan' | 'green' | 'orange' | 'pink' | 'purple' | 'red' | 'teal' | 'yellow'\n\ninterface ProductSpec {\n name: string\n description: string\n badge: string\n badgeVariant: ShowcaseBadgeVariant\n}\n\ninterface TagSpec { label: string, variant: ShowcaseBadgeVariant }\n\ninterface InventoryRow extends Record<string, unknown> {\n id: string\n name: string\n meta: string\n available: number\n location: string\n tags: TagSpec[]\n imageKey?: string\n thumbnailFallback: string\n selected: boolean\n}\n\nconst PRODUCT_IMAGE_KEYS = ['watch', 'headphones', 'backpack']\n\nconst images: Record<string, string> = {\n watch: '/template-assets/Neutral-Watch.png',\n headphones: '/template-assets/Neutral-Headphones.png',\n backpack: '/template-assets/Neutral-Backpack.png',\n wallet: '/template-assets/Neutral-Wallet.png',\n tumbler: '/template-assets/Neutral-Tumbler.png',\n throw_: '/template-assets/Neutral-Blanket.png',\n}\n\nconst products: ProductSpec[] = [\n { name: 'Minimalist Watch', description: 'Clean design meets everyday durability.', badge: 'New', badgeVariant: 'blue' },\n { name: 'Wireless Headphones', description: 'Immersive sound, all-day comfort.', badge: 'Popular', badgeVariant: 'green' },\n { name: 'Canvas Backpack', description: 'Water-resistant canvas with a quiet, modern profile.', badge: 'Limited', badgeVariant: 'yellow' },\n]\n\nconst inventory = ref<InventoryRow[]>([\n { id: 'a', name: 'Minimalist Watch', meta: 'Stainless steel, sapphire crystal', available: 42, location: 'Aisle 3', tags: [{ label: 'New', variant: 'blue' }], imageKey: 'watch', thumbnailFallback: 'M', selected: false },\n { id: 'b', name: 'Wireless Headphones', meta: 'ANC, 30hr battery', available: 128, location: 'Aisle 1', tags: [{ label: 'Popular', variant: 'green' }], imageKey: 'headphones', thumbnailFallback: 'W', selected: true },\n { id: 'c', name: 'Canvas Backpack', meta: 'Water-resistant, 25L', available: 63, location: 'Aisle 2', tags: [{ label: 'Limited', variant: 'yellow' }], imageKey: 'backpack', thumbnailFallback: 'C', selected: false },\n { id: 'd', name: 'Leather Wallet', meta: 'Full-grain, RFID blocking', available: 15, location: 'Aisle 4', tags: [{ label: 'Leather', variant: 'yellow' }], imageKey: 'wallet', thumbnailFallback: 'L', selected: true },\n { id: 'e', name: 'Travel Tumbler', meta: 'Vacuum insulated, 16oz', available: 87, location: 'Aisle 5', tags: [{ label: 'Drinkware', variant: 'green' }], imageKey: 'tumbler', thumbnailFallback: 'T', selected: false },\n { id: 'f', name: 'Linen Throw', meta: 'Heavyweight, oat', available: 24, location: 'Aisle 6', tags: [{ label: 'Home', variant: 'orange' }], imageKey: 'throw_', thumbnailFallback: 'L', selected: true },\n])\n\nconst SUGGESTED_QUESTIONS = ['Reschedule delivery', 'Update shipping address', 'Start a return']\n\nconst ACTIVITY = [\n { id: '1', label: 'Order #1043', detail: 'Placed · 1:59 pm', amount: 248 },\n { id: '2', label: 'Order #1041', detail: 'Refunded · 12:40 pm', amount: -89 },\n { id: '3', label: 'Order #1040', detail: 'Placed · 10:30 am', amount: 156 },\n { id: '4', label: 'Order #1038', detail: 'Placed · 9:11 am', amount: 412 },\n { id: '5', label: 'Order #1037', detail: 'Placed · 8:42 am', amount: 95 },\n]\n\nconst ActivityIcons: Record<string, string> = { '1': 'heroicons:shopping-bag', '2': 'heroicons:banknotes', '3': 'heroicons:shopping-bag', '4': 'heroicons:shopping-bag', '5': 'heroicons:shopping-bag' }\nconst SearchIcon = () => h(Icon, { name: 'heroicons:magnifying-glass', size: 'sm' })\nconst { isMobile } = useAppShellMobile()\nconst gridColumns = computed(() => (isMobile ? 1 : { minWidth: 200, repeat: 'fit' as const }))\nconst productColumns = computed(() => (isMobile ? 1 : { minWidth: 200, max: 3 }))\n\nconst shippingMethod = ref('economy')\nconst email = ref('')\nconst cardNumber = ref('')\nconst expiry = ref('')\nconst cvc = ref('')\nconst country = ref('us')\nconst saveInfo = ref(true)\nconst quantity = ref(1)\nconst chatDraft = ref('')\nconst search = ref('')\nconst paymentCard = ref(true)\nconst paymentApple = ref(false)\nconst paymentGoogle = ref(false)\n\nconst lowStockCount = computed(() => inventory.value.filter(row => row.available < 25).length)\n\nfunction formatAmount(amount: number) {\n const sign = amount < 0 ? '−' : '+'\n return `${sign}$${Math.abs(amount).toLocaleString()}`\n}\n\nfunction selectPayment(which: 'card' | 'apple' | 'google') {\n paymentCard.value = which === 'card'\n paymentApple.value = which === 'apple'\n paymentGoogle.value = which === 'google'\n}\n\nconst inventoryColumns = computed((): TableColumn<InventoryRow>[] => [\n {\n key: 'select',\n header: '',\n width: pixel(64),\n renderCell: row => h(CheckboxInput, {\n label: 'Select ' + row.name,\n isLabelHidden: true,\n modelValue: row.selected,\n 'onUpdate:modelValue': (v: boolean | 'indeterminate') => { row.selected = v === true },\n }),\n },\n {\n key: 'item',\n header: 'Item',\n width: proportional(3, { minWidth: 80 }),\n renderCell: (row) => {\n const thumb = row.imageKey && images[row.imageKey]\n ? h('img', { src: images[row.imageKey!], alt: '', class: 'thumbnail' })\n : h('div', { class: 'thumbnail-fallback', 'aria-hidden': 'true' }, row.thumbnailFallback)\n return h(HStack, { gap: 3, vAlign: 'center' }, () => [\n thumb,\n h(VStack, { gap: 0, style: { minWidth: 0 } }, () => [\n h(Text, { type: 'body', weight: 'bold' }, () => row.name),\n h(Text, { type: 'supporting', color: 'secondary' }, () => row.meta),\n ]),\n ])\n },\n },\n {\n key: 'available',\n header: 'Available',\n width: pixel(100),\n renderCell: row => h(Text, { type: 'body' }, () => String(row.available)),\n },\n {\n key: 'location',\n header: 'Location',\n width: pixel(100),\n renderCell: row => h(Text, { type: 'body' }, () => row.location),\n },\n {\n key: 'tags',\n header: 'Tags',\n width: proportional(2, { minWidth: 80 }),\n align: 'end',\n renderCell: row => h(HStack, { gap: 1, wrap: 'wrap', hAlign: 'end' }, () =>\n row.tags.map(tag => h(Badge, { key: tag.label, label: tag.label, variant: tag.variant })),\n ),\n },\n {\n key: 'actions',\n header: '',\n width: pixel(64),\n align: 'end',\n renderCell: () => h(MoreMenu, {\n label: 'Row actions',\n size: 'sm',\n items: [{ label: 'Edit' }, { label: 'Duplicate' }, { label: 'Move to…' }, { type: 'divider' }, { label: 'Delete' }],\n }),\n },\n])\n<\/script>\n\n<template>\n <div class=\"page-root\">\n <div data-theme-preview=\"true\">\n <VStack :gap=\"0\">\n <TopNav label=\"Theme preview navigation\">\n <template #heading>\n <TopNavHeading heading=\"Studio\" />\n </template>\n <template v-if=\"!isMobile\" #center>\n <TopNavItem label=\"Shop\" href=\"#\" is-selected />\n <TopNavItem label=\"New In\" href=\"#\" />\n <TopNavItem label=\"Stories\" href=\"#\" />\n <TopNavItem label=\"Help\" href=\"#\" />\n </template>\n <template #end>\n <HStack :gap=\"2\" :align=\"'center'\">\n <HStack :gap=\"0.5\">\n <Button label=\"Search\" tooltip=\"Search\" variant=\"ghost\" is-icon-only href=\"#\">\n <template #icon><span class=\"i-carbon-search\" aria-hidden=\"true\" /></template>\n </Button>\n <Button label=\"Account\" tooltip=\"Account\" variant=\"ghost\" is-icon-only href=\"#\">\n <template #icon><span class=\"i-carbon-user\" aria-hidden=\"true\" /></template>\n </Button>\n <Button label=\"Cart\" tooltip=\"Cart\" variant=\"ghost\" is-icon-only href=\"#\">\n <template #icon><span class=\"i-carbon-shopping-bag\" aria-hidden=\"true\" /></template>\n </Button>\n </HStack>\n <Button label=\"Sign in\" variant=\"primary\" href=\"#\" />\n </HStack>\n </template>\n </TopNav>\n\n <Section :padding=\"6\" variant=\"transparent\">\n <VStack :gap=\"10\" class=\"content content-fluid\">\n <Center>\n <VStack :gap=\"4\" :justify=\"'center'\" class=\"hero-text\">\n <Text type=\"display-2\" color=\"accent\">\n Little joys,<br>everywhere you go\n </Text>\n <Text type=\"body\" color=\"secondary\">\n We believe the smallest details are the ones that matter most.\n Turn an ordinary day into something worth remembering.\n </Text>\n </VStack>\n </Center>\n <Grid :columns=\"productColumns\" :gap=\"4\">\n <Card v-for=\"(p, i) in products\" :key=\"p.name\" :padding=\"0\" height=\"100%\">\n <VStack :gap=\"0\" class=\"card-stack\">\n <AspectRatio :ratio=\"1\">\n <img class=\"product-image\" :src=\"images[PRODUCT_IMAGE_KEYS[i]]\" :alt=\"p.name\">\n </AspectRatio>\n <div class=\"card-body\">\n <VStack :gap=\"2\" :justify=\"'center'\" class=\"card-stack\">\n <HStack><Badge :label=\"p.badge\" :variant=\"p.badgeVariant\" /></HStack>\n <Heading :level=\"2\" class=\"center-text\">{{ p.name }}</Heading>\n <Text type=\"supporting\" color=\"secondary\" class=\"card-description center-text\">{{ p.description }}</Text>\n <HStack :gap=\"2\" :align=\"'center'\" :justify=\"'center'\" wrap=\"wrap\" class=\"cart-row\">\n <span class=\"quantity-field\">\n <NumberInput v-model=\"quantity\" label=\"Quantity\" is-label-hidden :min=\"1\" :max=\"99\" size=\"sm\" />\n </span>\n <Button label=\"Add to cart\" variant=\"secondary\" size=\"sm\" href=\"#\" class=\"cart-button\" />\n </HStack>\n </VStack>\n </div>\n </VStack>\n </Card>\n </Grid>\n </VStack>\n </Section>\n </VStack>\n </div>\n\n <div class=\"showcase-surface\">\n <VStack :gap=\"8\">\n <HStack :gap=\"4\" wrap=\"wrap\">\n <div class=\"checkout-column\">\n <Card :padding=\"isMobile ? 4 : 5\" class=\"card-surface\">\n <VStack :gap=\"4\" class=\"checkout-stack\">\n <Heading :level=\"2\">Checkout</Heading>\n <VStack :gap=\"3\" class=\"checkout-stack\">\n <TextInput v-model=\"email\" label=\"Email\" placeholder=\"you@studio.com\" size=\"lg\" />\n <RadioList v-model=\"shippingMethod\" label=\"Shipping method\" description=\"Delivery time may vary based on location and availability.\">\n <RadioListItem value=\"economy\" label=\"Economy Shipping\" description=\"Delivered in 5–7 business days\">\n <template #end><Text type=\"body\" weight=\"bold\">$12.00</Text></template>\n </RadioListItem>\n <RadioListItem value=\"standard\" label=\"Standard Shipping\" description=\"Delivered in 3–5 business days\">\n <template #end><Text type=\"body\" weight=\"bold\">$16.00</Text></template>\n </RadioListItem>\n <RadioListItem value=\"express\" label=\"Express Shipping\" description=\"Delivered in 1–2 business days\">\n <template #end><Text type=\"body\" weight=\"bold\">$24.00</Text></template>\n </RadioListItem>\n </RadioList>\n <VStack :gap=\"2\" class=\"checkout-stack\">\n <Text type=\"supporting\" weight=\"bold\">Payment method</Text>\n <Grid :columns=\"isMobile ? 1 : { minWidth: 80, max: 3 }\" :gap=\"2\">\n <SelectableCard :is-selected=\"paymentCard\" label=\"Pay with card\" :padding=\"3\" @change=\"selectPayment('card')\">\n <VStack :gap=\"1\" :justify=\"'center'\" class=\"payment-card-content\">\n <span class=\"i-carbon-purchase\" aria-hidden=\"true\" />\n <Text type=\"supporting\" weight=\"bold\">Card</Text>\n </VStack>\n </SelectableCard>\n <SelectableCard :is-selected=\"paymentApple\" label=\"Pay with Apple Pay\" :padding=\"3\" @change=\"selectPayment('apple')\">\n <VStack :gap=\"1\" :justify=\"'center'\" class=\"payment-card-content\">\n <span class=\"i-carbon-mobile\" aria-hidden=\"true\" />\n <Text type=\"supporting\" weight=\"bold\">Apple Pay</Text>\n </VStack>\n </SelectableCard>\n <SelectableCard :is-selected=\"paymentGoogle\" label=\"Pay with Google Pay\" :padding=\"3\" @change=\"selectPayment('google')\">\n <VStack :gap=\"1\" :justify=\"'center'\" class=\"payment-card-content\">\n <span class=\"i-carbon-wallet\" aria-hidden=\"true\" />\n <Text type=\"supporting\" weight=\"bold\">Google Pay</Text>\n </VStack>\n </SelectableCard>\n </Grid>\n </VStack>\n <TextInput v-model=\"cardNumber\" label=\"Card number\" placeholder=\"1234 1234 1234 1234\" size=\"lg\" />\n <Grid :columns=\"isMobile ? 1 : { minWidth: 90, max: 2 }\" :gap=\"2\">\n <TextInput v-model=\"expiry\" label=\"Expiry\" placeholder=\"MM / YY\" size=\"lg\" />\n <TextInput v-model=\"cvc\" label=\"CVC\" placeholder=\"123\" size=\"lg\" />\n </Grid>\n <Selector\n v-model=\"country\"\n label=\"Country\"\n size=\"lg\"\n :options=\"[\n { value: 'us', label: 'United States' },\n { value: 'ca', label: 'Canada' },\n { value: 'uk', label: 'United Kingdom' },\n { value: 'de', label: 'Germany' },\n { value: 'jp', label: 'Japan' },\n { value: 'au', label: 'Australia' },\n ]\"\n />\n </VStack>\n <CheckboxInput v-model=\"saveInfo\" label=\"Securely save my information for 1-click checkout\" description=\"Pay faster on Studio and everywhere Link is accepted.\" />\n <Button variant=\"primary\" size=\"lg\" label=\"Pay now\">\n <template #icon><span class=\"i-carbon-locked\" aria-hidden=\"true\" /></template>\n </Button>\n </VStack>\n </Card>\n </div>\n <div class=\"chat-column\">\n <Card :padding=\"0\" class=\"chat-card\">\n <HStack :justify=\"'between'\" :align=\"'center'\" :gap=\"3\" class=\"chat-header\">\n <Heading :level=\"2\">Studio AI</Heading>\n <HStack :gap=\"1\" :align=\"'center'\">\n <Button variant=\"ghost\" size=\"sm\" is-icon-only label=\"Export conversation\" tooltip=\"Export conversation\">\n <template #icon><span class=\"i-carbon-download\" aria-hidden=\"true\" /></template>\n </Button>\n <Button variant=\"ghost\" size=\"sm\" is-icon-only label=\"Close chat\" tooltip=\"Close chat\">\n <template #icon><span class=\"i-carbon-close\" aria-hidden=\"true\" /></template>\n </Button>\n </HStack>\n </HStack>\n <Divider variant=\"subtle\" />\n <div class=\"chat-body\">\n <ChatMessageList>\n <ChatSystemMessage>Today</ChatSystemMessage>\n <ChatMessage sender=\"user\">\n <ChatMessageBubble variant=\"filled\">Where’s my order?</ChatMessageBubble>\n </ChatMessage>\n <ChatMessage sender=\"assistant\">\n <VStack :gap=\"3\">\n <Text type=\"body\">Your order #1043 shipped this morning and is on track to arrive by end of day tomorrow.</Text>\n <Text type=\"body\">Let me know if you’d like to reschedule delivery or start a return.</Text>\n </VStack>\n </ChatMessage>\n </ChatMessageList>\n </div>\n <div class=\"chat-suggestions\">\n <HStack :gap=\"1\" :justify=\"'center'\" wrap=\"wrap\">\n <Button v-for=\"question in SUGGESTED_QUESTIONS\" :key=\"question\" variant=\"secondary\" size=\"sm\" :label=\"question\" />\n </HStack>\n </div>\n <div class=\"chat-composer\">\n <TextInput v-model=\"chatDraft\" label=\"Message\" is-label-hidden placeholder=\"Ask Studio AI…\" />\n </div>\n </Card>\n </div>\n </HStack>\n\n <Grid :columns=\"gridColumns\" :gap=\"4\">\n <GridSpan :columns=\"isMobile ? 1 : 3\">\n <Card :padding=\"0\" class=\"inventory-card\">\n <HStack :justify=\"'between'\" :align=\"'center'\" class=\"inventory-header\">\n <Heading :level=\"2\">Inventory</Heading>\n <Button label=\"Add item\" variant=\"primary\" size=\"sm\">\n <template #icon><span class=\"i-carbon-add\" aria-hidden=\"true\" /></template>\n </Button>\n </HStack>\n <Divider variant=\"subtle\" />\n <HStack :gap=\"3\" :align=\"'center'\" :justify=\"'between'\" class=\"inventory-filter-row\">\n <HStack :gap=\"2\" :align=\"'center'\" class=\"inventory-filter-left\">\n <div class=\"search-input\">\n <TextInput v-model=\"search\" label=\"Search inventory\" is-label-hidden placeholder=\"Type and hit enter…\" :start-icon=\"SearchIcon\" />\n </div>\n <Button label=\"Filters\" variant=\"ghost\" size=\"sm\">\n <template #icon><Icon name=\"heroicons:tag\" size=\"sm\" /></template>\n </Button>\n </HStack>\n <HStack :gap=\"1\" :align=\"'center'\">\n <Button variant=\"ghost\" size=\"sm\" is-icon-only label=\"List view\" tooltip=\"List view\">\n <template #icon><Icon name=\"heroicons:list-bullet\" size=\"md\" /></template>\n </Button>\n <Button variant=\"ghost\" size=\"sm\" is-icon-only label=\"Grid view\" tooltip=\"Grid view\">\n <template #icon><Icon name=\"heroicons:squares-2x2\" size=\"md\" /></template>\n </Button>\n </HStack>\n </HStack>\n <div v-if=\"lowStockCount > 0\" class=\"inventory-banner-wrap\">\n <Banner status=\"warning\" :title=\"`${lowStockCount} items are running low`\" />\n </div>\n <div class=\"inventory-table-wrap\">\n <Table :data=\"inventory\" :columns=\"inventoryColumns\" density=\"spacious\" dividers=\"rows\" has-hover />\n </div>\n </Card>\n </GridSpan>\n <GridSpan :columns=\"1\">\n <Card :padding=\"5\" class=\"activity-card\">\n <VStack :gap=\"4\" class=\"activity-card-stack\">\n <Heading :level=\"2\">Revenue</Heading>\n <Grid :columns=\"isMobile ? 1 : 2\" :gap=\"3\">\n <VStack :gap=\"0\"><span class=\"kpi-value\">18K</span><Text type=\"supporting\" color=\"secondary\">Monthly revenue</Text></VStack>\n <VStack :gap=\"0\"><span class=\"kpi-value\">+12%</span><Text type=\"supporting\" color=\"secondary\">Order growth</Text></VStack>\n </Grid>\n <Divider variant=\"subtle\" />\n <HStack :justify=\"'between'\" :align=\"'center'\">\n <Heading :level=\"3\">Activity</Heading>\n <Link href=\"#\">See all</Link>\n </HStack>\n <VStack :gap=\"1\" class=\"activity-list-fade\">\n <Item v-for=\"item in ACTIVITY\" :key=\"item.id\" :label=\"item.label\" :description=\"item.detail\" href=\"#\">\n <template #startContent>\n <div class=\"activity-icon\" aria-hidden=\"true\"><Icon :name=\"ActivityIcons[item.id]\" size=\"sm\" /></div>\n </template>\n <template #endContent>\n <Text type=\"body\" weight=\"bold\" :color=\"item.amount < 0 ? 'secondary' : 'primary'\">{{ formatAmount(item.amount) }}</Text>\n </template>\n </Item>\n </VStack>\n </VStack>\n </Card>\n </GridSpan>\n </Grid>\n </VStack>\n </div>\n </div>\n</template>\n\n<style scoped>\n.page-root { min-height: 100%; background: var(--astryx-color-background-body); }\n.showcase-surface { padding: var(--astryx-spacing-6); background: var(--astryx-color-background-surface); }\n.content { max-width: 960px; margin-inline: auto; min-width: 0; }\n.content-fluid { max-width: 880px; }\n.hero-text { text-align: center; max-width: 560px; }\n.center-text { text-align: center; }\n.card-stack { height: 100%; }\n.card-description { flex: 1; }\n.cart-row { width: 100%; min-width: 0; }\n.quantity-field { display: block; flex: none; width: 72px; }\n.cart-button { flex: 1 1 auto; min-width: 0; }\n.card-surface { background: var(--astryx-color-background-body); color: var(--astryx-color-text-primary); min-width: 0; border-color: transparent; }\n.checkout-stack { min-width: 0; width: 100%; }\n.payment-card-content { min-width: 0; width: 100%; text-align: center; word-break: break-word; }\n.inventory-card { background: var(--astryx-color-background-surface); overflow: hidden; }\n.inventory-header { padding-block: var(--astryx-spacing-6); padding-inline: var(--astryx-spacing-6); }\n.inventory-filter-row { padding-block: var(--astryx-spacing-4); padding-inline: var(--astryx-spacing-6); width: 100%; overflow-x: auto; }\n.inventory-filter-left { flex: 1; min-width: 0; }\n.search-input { flex: 1; min-width: 0; max-width: 240px; }\n.activity-icon { width: 32px; height: 32px; border-radius: var(--astryx-radius-full); background: var(--astryx-color-background-muted); color: var(--astryx-color-text-secondary); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }\n.inventory-banner-wrap { padding-inline: var(--astryx-spacing-6); padding-bottom: var(--astryx-spacing-4); }\n.inventory-table-wrap { padding-inline: var(--astryx-spacing-6); padding-block-end: var(--astryx-spacing-2); }\n.chat-card { background: var(--astryx-color-background-surface); min-width: 0; overflow: hidden; display: flex; flex-direction: column; }\n.chat-header { padding: var(--astryx-spacing-4); }\n.chat-body { flex: 1; min-height: 0; overflow: hidden; }\n.chat-suggestions { padding-inline: var(--astryx-spacing-4); padding-bottom: var(--astryx-spacing-2); }\n.chat-composer { padding-inline: var(--astryx-spacing-4); padding-bottom: var(--astryx-spacing-4); }\n.activity-card { background: var(--astryx-color-background-surface); min-width: 0; height: 100%; }\n.activity-card-stack { height: 100%; }\n.activity-list-fade { flex: 1; min-height: 0; overflow: hidden; mask-image: linear-gradient(to bottom, black calc(100% - 48px), transparent); margin-inline: calc(var(--astryx-spacing-2) * -1); }\n.checkout-column { flex: 1 1 300px; min-width: 0; display: grid; }\n.chat-column { flex: 2 1 520px; min-width: 0; display: grid; }\n.product-image { width: 100%; height: 100%; object-fit: cover; }\n.card-body { padding: var(--astryx-spacing-4); flex: 1; display: flex; flex-direction: column; }\n.kpi-value { font-size: 24px; font-weight: 700; line-height: 1.1; color: var(--astryx-color-text-primary); font-family: var(--astryx-font-family-heading); }\n:deep(.thumbnail) { width: 40px; height: 40px; border-radius: var(--astryx-radius-element); object-fit: cover; display: block; flex-shrink: 0; }\n:deep(.thumbnail-fallback) { width: 40px; height: 40px; border-radius: var(--astryx-radius-element); background: var(--astryx-color-background-muted); color: var(--astryx-color-text-secondary); display: flex; align-items: center; justify-content: center; font-size: var(--astryx-font-size-sm); font-weight: 600; flex-shrink: 0; }\n</style>\n",
76609
+ "source": "<script setup lang=\"ts\">\nimport type { TableColumn } from '@astryx-vue/core'\nimport {\n AspectRatio,\n Badge,\n Banner,\n Button,\n Card,\n Center,\n CheckboxInput,\n ChatComposer,\n ChatMessage,\n ChatMessageBubble,\n ChatMessageList,\n ChatSystemMessage,\n Divider,\n Grid,\n GridSpan,\n Heading,\n HStack,\n Icon,\n Item,\n Link,\n OverflowList,\n MoreMenu,\n NumberInput,\n pixel,\n proportional,\n RadioList,\n RadioListItem,\n Section,\n SelectableCard,\n Selector,\n Table,\n Text,\n TextInput,\n TopNav,\n TopNavHeading,\n TopNavItem,\n useAppShellMobile,\n VStack,\n} from '@astryx-vue/core'\nimport { computed, h, ref } from 'vue'\n\n/* ChatComposer 从 @astryx-vue/core 引入。 */\n\ntype ShowcaseBadgeVariant = 'blue' | 'cyan' | 'green' | 'orange' | 'pink' | 'purple' | 'red' | 'teal' | 'yellow'\n\ninterface ProductSpec {\n name: string\n description: string\n badge: string\n badgeVariant: ShowcaseBadgeVariant\n}\n\nconst FILTER_ITEMS = [\n { id: 'categories', label: 'Categories', icon: 'i-heroicons-folder', options: ['Wearables', 'Audio', 'Bags', 'Drinkware', 'Home'] },\n { id: 'locations', label: 'Locations', icon: 'i-heroicons-map-pin', options: ['Aisle 1', 'Aisle 2', 'Aisle 3', 'Aisle 4', 'Aisle 5', 'Aisle 6'] },\n { id: 'tags', label: 'Tags', icon: 'i-heroicons-tag', options: ['New', 'Popular', 'Limited', 'Leather', 'Drinkware', 'Home'] },\n]\n\nconst FILTER_BY_ID: Record<string, (typeof FILTER_ITEMS)[number]> = Object.fromEntries(FILTER_ITEMS.map(item => [item.id, item]))\n\ninterface TagSpec { label: string, variant: ShowcaseBadgeVariant }\n\ninterface InventoryRow extends Record<string, unknown> {\n id: string\n name: string\n meta: string\n available: number\n location: string\n tags: TagSpec[]\n imageKey?: string\n thumbnailFallback: string\n selected: boolean\n}\n\nconst PRODUCT_IMAGE_KEYS = ['watch', 'headphones', 'backpack']\n\nconst images: Record<string, string> = {\n watch: '/template-assets/Neutral-Watch.png',\n headphones: '/template-assets/Neutral-Headphones.png',\n backpack: '/template-assets/Neutral-Backpack.png',\n wallet: '/template-assets/Neutral-Wallet.png',\n tumbler: '/template-assets/Neutral-Tumbler.png',\n throw_: '/template-assets/Neutral-Blanket.png',\n}\n\nconst products: ProductSpec[] = [\n { name: 'Minimalist Watch', description: 'Clean design meets everyday durability.', badge: 'New', badgeVariant: 'blue' },\n { name: 'Wireless Headphones', description: 'Immersive sound, all-day comfort.', badge: 'Popular', badgeVariant: 'green' },\n { name: 'Canvas Backpack', description: 'Water-resistant canvas with a quiet, modern profile.', badge: 'Limited', badgeVariant: 'yellow' },\n]\n\nconst inventory = ref<InventoryRow[]>([\n { id: 'a', name: 'Minimalist Watch', meta: 'Stainless steel, sapphire crystal', available: 42, location: 'Aisle 3', tags: [{ label: 'New', variant: 'blue' }], imageKey: 'watch', thumbnailFallback: 'M', selected: false },\n { id: 'b', name: 'Wireless Headphones', meta: 'ANC, 30hr battery', available: 128, location: 'Aisle 1', tags: [{ label: 'Popular', variant: 'green' }], imageKey: 'headphones', thumbnailFallback: 'W', selected: true },\n { id: 'c', name: 'Canvas Backpack', meta: 'Water-resistant, 25L', available: 63, location: 'Aisle 2', tags: [{ label: 'Limited', variant: 'yellow' }], imageKey: 'backpack', thumbnailFallback: 'C', selected: false },\n { id: 'd', name: 'Leather Wallet', meta: 'Full-grain, RFID blocking', available: 15, location: 'Aisle 4', tags: [{ label: 'Leather', variant: 'yellow' }], imageKey: 'wallet', thumbnailFallback: 'L', selected: true },\n { id: 'e', name: 'Travel Tumbler', meta: 'Vacuum insulated, 16oz', available: 87, location: 'Aisle 5', tags: [{ label: 'Drinkware', variant: 'green' }], imageKey: 'tumbler', thumbnailFallback: 'T', selected: false },\n { id: 'f', name: 'Linen Throw', meta: 'Heavyweight, oat', available: 24, location: 'Aisle 6', tags: [{ label: 'Home', variant: 'orange' }], imageKey: 'throw_', thumbnailFallback: 'L', selected: true },\n])\n\nconst SUGGESTED_QUESTIONS = ['Reschedule delivery', 'Update shipping address', 'Start a return']\n\nconst ACTIVITY = [\n { id: '1', label: 'Order #1043', detail: 'Placed · 1:59 pm', amount: 248 },\n { id: '2', label: 'Order #1041', detail: 'Refunded · 12:40 pm', amount: -89 },\n { id: '3', label: 'Order #1040', detail: 'Placed · 10:30 am', amount: 156 },\n { id: '4', label: 'Order #1038', detail: 'Placed · 9:11 am', amount: 412 },\n { id: '5', label: 'Order #1037', detail: 'Placed · 8:42 am', amount: 95 },\n]\n\nconst ActivityIcons: Record<string, string> = { '1': 'heroicons:shopping-bag', '2': 'heroicons:banknotes', '3': 'heroicons:shopping-bag', '4': 'heroicons:shopping-bag', '5': 'heroicons:shopping-bag' }\nconst SearchIcon = () => h(Icon, { name: 'heroicons:magnifying-glass', size: 'sm' })\nconst CreditCardIcon = () => h(Icon, { name: 'heroicons:credit-card', size: 'sm' })\n\nconst { isMobile } = useAppShellMobile()\nconst gridColumns = computed(() => (isMobile ? 1 : { minWidth: 200, repeat: 'fit' as const }))\nconst productColumns = computed(() => (isMobile ? 1 : { minWidth: 200, max: 3 }))\n\nconst shippingMethod = ref('economy')\nconst email = ref('')\nconst cardNumber = ref('')\nconst expiry = ref('')\nconst cvc = ref('')\nconst country = ref('us')\nconst saveInfo = ref(true)\nconst quantity = ref(1)\nconst chatDraft = ref('')\nconst search = ref('')\nconst paymentCard = ref(true)\nconst paymentApple = ref(false)\nconst paymentGoogle = ref(false)\n\nconst lowStockCount = computed(() => inventory.value.filter(row => row.available < 25).length)\n\nfunction formatAmount(amount: number) {\n const sign = amount < 0 ? '−' : '+'\n return `${sign}$${Math.abs(amount).toLocaleString()}`\n}\n\nfunction selectPayment(which: 'card' | 'apple' | 'google') {\n paymentCard.value = which === 'card'\n paymentApple.value = which === 'apple'\n paymentGoogle.value = which === 'google'\n}\n\nconst inventoryColumns = computed((): TableColumn<InventoryRow>[] => [\n {\n key: 'select',\n header: 'Select',\n headerHidden: true,\n width: pixel(64),\n renderCell: row => h(CheckboxInput, {\n label: 'Select ' + row.name,\n isLabelHidden: true,\n modelValue: row.selected,\n 'onUpdate:modelValue': (v: boolean | 'indeterminate') => { row.selected = v === true },\n }),\n },\n {\n key: 'item',\n header: 'Item',\n width: proportional(3, { minWidth: 80 }),\n renderCell: (row) => {\n const thumb = row.imageKey && images[row.imageKey]\n ? h('img', { src: images[row.imageKey!], alt: '', class: 'thumbnail' })\n : h('div', { class: 'thumbnail-fallback', 'aria-hidden': 'true' }, row.thumbnailFallback)\n return h(HStack, { gap: 3, vAlign: 'center' }, () => [\n thumb,\n h(VStack, { gap: 0, style: { minWidth: 0 } }, () => [\n h(Text, { type: 'body', weight: 'bold' }, () => row.name),\n h(Text, { type: 'supporting', color: 'secondary' }, () => row.meta),\n ]),\n ])\n },\n },\n {\n key: 'available',\n header: 'Available',\n width: pixel(100),\n renderCell: row => h(Text, { type: 'body' }, () => String(row.available)),\n },\n {\n key: 'location',\n header: 'Location',\n width: pixel(100),\n renderCell: row => h(Text, { type: 'body' }, () => row.location),\n },\n {\n key: 'tags',\n header: 'Tags',\n width: proportional(2, { minWidth: 80 }),\n align: 'end',\n renderCell: row => h(HStack, { gap: 1, wrap: 'wrap', hAlign: 'end' }, () =>\n row.tags.map(tag => h(Badge, { key: tag.label, label: tag.label, variant: tag.variant })),\n ),\n },\n {\n key: 'actions',\n header: 'Actions',\n headerHidden: true,\n width: pixel(64),\n align: 'end',\n renderCell: () => h(MoreMenu, {\n label: 'Row actions',\n size: 'sm',\n items: [{ label: 'Edit' }, { label: 'Duplicate' }, { label: 'Move to…' }, { type: 'divider' }, { label: 'Delete' }],\n }),\n },\n])\n<\/script>\n\n<template>\n <div class=\"page-root\">\n <div data-theme-preview=\"true\">\n <VStack :gap=\"0\">\n <TopNav label=\"Theme preview navigation\">\n <template #heading>\n <TopNavHeading heading=\"Studio\" />\n </template>\n <template v-if=\"!isMobile\" #center>\n <TopNavItem label=\"Shop\" href=\"#\" is-selected />\n <TopNavItem label=\"New In\" href=\"#\" />\n <TopNavItem label=\"Stories\" href=\"#\" />\n <TopNavItem label=\"Help\" href=\"#\" />\n </template>\n <template #end>\n <HStack :gap=\"2\" :align=\"'center'\">\n <HStack :gap=\"0.5\">\n <Button label=\"Search\" tooltip=\"Search\" variant=\"ghost\" is-icon-only href=\"#\">\n <template #icon><span class=\"i-carbon-search\" aria-hidden=\"true\" /></template>\n </Button>\n <Button label=\"Account\" tooltip=\"Account\" variant=\"ghost\" is-icon-only href=\"#\">\n <template #icon><span class=\"i-carbon-user\" aria-hidden=\"true\" /></template>\n </Button>\n <Button label=\"Cart\" tooltip=\"Cart\" variant=\"ghost\" is-icon-only href=\"#\">\n <template #icon><span class=\"i-carbon-shopping-bag\" aria-hidden=\"true\" /></template>\n </Button>\n </HStack>\n <Button label=\"Sign in\" variant=\"primary\" href=\"#\" />\n </HStack>\n </template>\n </TopNav>\n\n <Section :padding=\"6\" variant=\"transparent\">\n <VStack :gap=\"10\" class=\"content content-fluid\">\n <Center>\n <VStack :gap=\"4\" :hAlign=\"'center'\" class=\"hero-text\">\n <Text type=\"display-2\" color=\"accent\">\n Little joys,<br>everywhere you go\n </Text>\n <Text type=\"body\" color=\"secondary\">\n We believe the smallest details are the ones that matter most.\n Turn an ordinary day into something worth remembering.\n </Text>\n </VStack>\n </Center>\n <Grid :columns=\"productColumns\" :gap=\"4\">\n <Card v-for=\"(p, i) in products\" :key=\"p.name\" :padding=\"0\" height=\"100%\">\n <VStack :gap=\"0\" class=\"card-stack\">\n <AspectRatio :ratio=\"1\">\n <img class=\"product-image\" :src=\"images[PRODUCT_IMAGE_KEYS[i]]\" :alt=\"p.name\">\n </AspectRatio>\n <div class=\"card-body\">\n <VStack :gap=\"2\" :hAlign=\"'center'\" class=\"card-stack\">\n <HStack><Badge :label=\"p.badge\" :variant=\"p.badgeVariant\" /></HStack>\n <Heading :level=\"2\" class=\"center-text\">{{ p.name }}</Heading>\n <Text type=\"supporting\" color=\"secondary\" class=\"card-description center-text\">{{ p.description }}</Text>\n <HStack :gap=\"2\" :align=\"'center'\" :justify=\"'center'\" wrap=\"wrap\" class=\"cart-row\">\n <span class=\"quantity-field\">\n <NumberInput v-model=\"quantity\" label=\"Quantity\" is-label-hidden :min=\"1\" :max=\"99\" size=\"sm\" />\n </span>\n <Button label=\"Add to cart\" variant=\"secondary\" size=\"sm\" href=\"#\" class=\"cart-button\" />\n </HStack>\n </VStack>\n </div>\n </VStack>\n </Card>\n </Grid>\n </VStack>\n </Section>\n </VStack>\n </div>\n\n <div class=\"showcase-surface\">\n <VStack :gap=\"8\">\n <HStack :gap=\"4\" wrap=\"wrap\">\n <div class=\"checkout-column\">\n <Card :padding=\"isMobile ? 4 : 5\" class=\"card-surface\">\n <VStack :gap=\"4\" class=\"checkout-stack\">\n <Heading :level=\"2\">Checkout</Heading>\n <VStack :gap=\"3\" class=\"checkout-stack\">\n <TextInput v-model=\"email\" label=\"Email\" placeholder=\"you@studio.com\" size=\"lg\" />\n <RadioList v-model=\"shippingMethod\" label=\"Shipping method\" description=\"Delivery time may vary based on location and availability.\">\n <RadioListItem value=\"economy\" label=\"Economy Shipping\" description=\"Delivered in 5–7 business days\">\n <template #end><Text type=\"body\" weight=\"bold\">$12.00</Text></template>\n </RadioListItem>\n <RadioListItem value=\"standard\" label=\"Standard Shipping\" description=\"Delivered in 3–5 business days\">\n <template #end><Text type=\"body\" weight=\"bold\">$16.00</Text></template>\n </RadioListItem>\n <RadioListItem value=\"express\" label=\"Express Shipping\" description=\"Delivered in 1–2 business days\">\n <template #end><Text type=\"body\" weight=\"bold\">$24.00</Text></template>\n </RadioListItem>\n </RadioList>\n <VStack :gap=\"2\" class=\"checkout-stack\">\n <Text type=\"supporting\" weight=\"bold\">Payment method</Text>\n <Grid :columns=\"isMobile ? 1 : { minWidth: 80, max: 3 }\" :gap=\"2\">\n <SelectableCard :is-selected=\"paymentCard\" label=\"Pay with card\" :padding=\"3\" @change=\"selectPayment('card')\">\n <VStack :gap=\"1\" :hAlign=\"'center'\" class=\"payment-card-content\">\n <Icon name=\"heroicons:credit-card\" size=\"md\" />\n <Text type=\"supporting\" weight=\"bold\">Card</Text>\n </VStack>\n </SelectableCard>\n <SelectableCard :is-selected=\"paymentApple\" label=\"Pay with Apple Pay\" :padding=\"3\" @change=\"selectPayment('apple')\">\n <VStack :gap=\"1\" :hAlign=\"'center'\" class=\"payment-card-content\">\n <Icon name=\"heroicons:device-phone-mobile\" size=\"md\" />\n <Text type=\"supporting\" weight=\"bold\">Apple Pay</Text>\n </VStack>\n </SelectableCard>\n <SelectableCard :is-selected=\"paymentGoogle\" label=\"Pay with Google Pay\" :padding=\"3\" @change=\"selectPayment('google')\">\n <VStack :gap=\"1\" :hAlign=\"'center'\" class=\"payment-card-content\">\n <Icon name=\"heroicons:wallet\" size=\"md\" />\n <Text type=\"supporting\" weight=\"bold\">Google Pay</Text>\n </VStack>\n </SelectableCard>\n </Grid>\n </VStack>\n <TextInput v-model=\"cardNumber\" label=\"Card number\" placeholder=\"1234 1234 1234 1234\" size=\"lg\" :start-icon=\"isMobile ? undefined : CreditCardIcon\" />\n <Grid :columns=\"isMobile ? 1 : { minWidth: 90, max: 2 }\" :gap=\"2\">\n <TextInput v-model=\"expiry\" label=\"Expiry\" placeholder=\"MM / YY\" size=\"lg\" />\n <TextInput v-model=\"cvc\" label=\"CVC\" placeholder=\"123\" size=\"lg\" />\n </Grid>\n <Selector\n v-model=\"country\"\n label=\"Country\"\n size=\"lg\"\n :options=\"[\n { value: 'us', label: 'United States' },\n { value: 'ca', label: 'Canada' },\n { value: 'uk', label: 'United Kingdom' },\n { value: 'de', label: 'Germany' },\n { value: 'jp', label: 'Japan' },\n { value: 'au', label: 'Australia' },\n ]\"\n />\n </VStack>\n <CheckboxInput v-model=\"saveInfo\" label=\"Securely save my information for 1-click checkout\" description=\"Pay faster on Studio and everywhere Link is accepted.\" />\n <Button variant=\"primary\" size=\"lg\" label=\"Pay now\">\n <template #icon><span class=\"i-carbon-locked\" aria-hidden=\"true\" /></template>\n </Button>\n </VStack>\n </Card>\n </div>\n <div class=\"chat-column\">\n <Card :padding=\"0\" class=\"chat-card\">\n <HStack :justify=\"'between'\" :align=\"'center'\" :gap=\"3\" class=\"chat-header\">\n <Heading :level=\"2\">Studio AI</Heading>\n <HStack :gap=\"1\" :align=\"'center'\">\n <Button variant=\"ghost\" size=\"sm\" is-icon-only label=\"Export conversation\" tooltip=\"Export conversation\">\n <template #icon><span class=\"i-carbon-download\" aria-hidden=\"true\" /></template>\n </Button>\n <Button variant=\"ghost\" size=\"sm\" is-icon-only label=\"Close chat\" tooltip=\"Close chat\">\n <template #icon><span class=\"i-carbon-close\" aria-hidden=\"true\" /></template>\n </Button>\n </HStack>\n </HStack>\n <Divider variant=\"subtle\" />\n <div class=\"chat-body\">\n <ChatMessageList>\n <ChatSystemMessage>Today</ChatSystemMessage>\n <ChatMessage sender=\"user\">\n <ChatMessageBubble variant=\"filled\">Where’s my order?</ChatMessageBubble>\n </ChatMessage>\n <ChatMessage sender=\"assistant\">\n <VStack :gap=\"3\">\n <Text type=\"body\">\n Your order #1043 — the Minimalist Watch and Linen Throw — shipped this morning from the Aisle 3 warehouse and is currently in transit with UPS. It’s on track to arrive at your address by end of day tomorrow.\n </Text>\n <Text type=\"body\">\n Let me know if you’d like to reschedule the delivery, redirect it to a pickup point, or start a return once it arrives.\n </Text>\n </VStack>\n </ChatMessage>\n <ChatMessage sender=\"user\">\n <ChatMessageBubble variant=\"filled\">Can you show me the full details?</ChatMessageBubble>\n </ChatMessage>\n <ChatMessage sender=\"assistant\">\n <VStack :gap=\"3\">\n <Text type=\"body\">Here’s everything I have on order #1043:</Text>\n <Card :padding=\"3\">\n <VStack :gap=\"1\">\n <Item label=\"Items\" description=\"Minimalist Watch · Linen Throw\">\n <template #endContent>\n <Text type=\"body\" weight=\"bold\">$248</Text>\n </template>\n </Item>\n <Item label=\"Shipping\" description=\"UPS Ground\">\n <template #endContent>\n <Text type=\"body\" weight=\"bold\">$12</Text>\n </template>\n </Item>\n <Item label=\"Estimated arrival\" description=\"Tomorrow by 8pm\">\n <template #endContent>\n <Badge variant=\"green\" label=\"On time\" />\n </template>\n </Item>\n <Item label=\"Tracking\" description=\"UPS 1Z 999 AA1 0123 4567 84\">\n <template #endContent>\n <Link href=\"#\">Track →</Link>\n </template>\n </Item>\n </VStack>\n </Card>\n </VStack>\n </ChatMessage>\n </ChatMessageList>\n </div>\n <div class=\"chat-suggestions\">\n <HStack :gap=\"1\" :justify=\"'center'\" wrap=\"wrap\">\n <Button v-for=\"question in SUGGESTED_QUESTIONS\" :key=\"question\" variant=\"secondary\" size=\"sm\" :label=\"question\" />\n </HStack>\n </div>\n <div class=\"chat-composer\">\n <!-- 原版用 ChatComposer:左下附件、右下麦克风,发送按钮是组件自带的。 -->\n <ChatComposer v-model=\"chatDraft\" placeholder=\"Ask Studio AI…\">\n <template #footerActions>\n <Button variant=\"ghost\" size=\"md\" is-icon-only label=\"Attach\" tooltip=\"Attach\">\n <template #icon>\n <Icon name=\"heroicons:plus\" />\n </template>\n </Button>\n </template>\n <template #sendActions>\n <Button variant=\"ghost\" size=\"md\" is-icon-only label=\"Voice input\" tooltip=\"Voice input\">\n <template #icon>\n <Icon name=\"carbon:microphone\" />\n </template>\n </Button>\n </template>\n </ChatComposer>\n </div>\n </Card>\n </div>\n </HStack>\n\n <Grid :columns=\"gridColumns\" :gap=\"4\">\n <GridSpan :columns=\"isMobile ? 1 : 3\">\n <Card :padding=\"0\" class=\"inventory-card\">\n <HStack :justify=\"'between'\" :align=\"'center'\" class=\"inventory-header\">\n <Heading :level=\"2\">Inventory</Heading>\n <Button label=\"Add item\" variant=\"primary\" size=\"sm\">\n <template #icon><span class=\"i-carbon-add\" aria-hidden=\"true\" /></template>\n </Button>\n </HStack>\n <Divider variant=\"subtle\" />\n <HStack :gap=\"3\" :align=\"'center'\" :justify=\"'between'\" class=\"inventory-filter-row\">\n <HStack :gap=\"2\" :align=\"'center'\" class=\"inventory-filter-left\">\n <TextInput v-model=\"search\" label=\"Search inventory\" is-label-hidden placeholder=\"Type and hit enter…\" :start-icon=\"SearchIcon\" />\n <OverflowList :items=\"FILTER_ITEMS\" :gap=\"2\">\n <template #item=\"{ item }\">\n <Selector\n :label=\"item.label\"\n is-label-hidden\n :placeholder=\"item.label\"\n size=\"sm\"\n :start-icon=\"FILTER_BY_ID[item.id ?? ''].icon\"\n :model-value=\"null\"\n :options=\"FILTER_BY_ID[item.id ?? ''].options\"\n />\n </template>\n <template #overflow>\n <Button label=\"Filters\" variant=\"ghost\" size=\"sm\">\n <template #icon><Icon name=\"heroicons:tag\" size=\"sm\" /></template>\n </Button>\n </template>\n </OverflowList>\n </HStack>\n <HStack :gap=\"1\" :align=\"'center'\">\n <Button variant=\"ghost\" size=\"sm\" is-icon-only label=\"List view\" tooltip=\"List view\">\n <template #icon><Icon name=\"heroicons:list-bullet\" size=\"md\" /></template>\n </Button>\n <Button variant=\"ghost\" size=\"sm\" is-icon-only label=\"Grid view\" tooltip=\"Grid view\">\n <template #icon><Icon name=\"heroicons:squares-2x2\" size=\"md\" /></template>\n </Button>\n </HStack>\n </HStack>\n <div v-if=\"lowStockCount > 0\" class=\"inventory-banner-wrap\">\n <Banner status=\"warning\" :title=\"`${lowStockCount} items are running low`\" />\n </div>\n <div class=\"inventory-table-wrap\">\n <Table :data=\"inventory\" :columns=\"inventoryColumns\" density=\"spacious\" dividers=\"rows\" has-hover />\n </div>\n </Card>\n </GridSpan>\n <GridSpan :columns=\"1\">\n <Card :padding=\"5\" class=\"activity-card\">\n <VStack :gap=\"4\" class=\"activity-card-stack\">\n <Heading :level=\"2\">Revenue</Heading>\n <Grid :columns=\"isMobile ? 1 : 2\" :gap=\"3\">\n <VStack :gap=\"0\"><span class=\"kpi-value\">18K</span><Text type=\"supporting\" color=\"secondary\">Monthly revenue</Text></VStack>\n <VStack :gap=\"0\"><span class=\"kpi-value\">+12%</span><Text type=\"supporting\" color=\"secondary\">Order growth</Text></VStack>\n </Grid>\n <Divider variant=\"subtle\" />\n <HStack :justify=\"'between'\" :align=\"'center'\">\n <Heading :level=\"3\">Activity</Heading>\n <Link href=\"#\">See all</Link>\n </HStack>\n <VStack :gap=\"1\" class=\"activity-list-fade\">\n <Item v-for=\"item in ACTIVITY\" :key=\"item.id\" :label=\"item.label\" :description=\"item.detail\" href=\"#\">\n <template #startContent>\n <div class=\"activity-icon\" aria-hidden=\"true\"><Icon :name=\"ActivityIcons[item.id]\" size=\"sm\" /></div>\n </template>\n <template #endContent>\n <Text type=\"body\" weight=\"bold\" :color=\"item.amount < 0 ? 'secondary' : 'primary'\">{{ formatAmount(item.amount) }}</Text>\n </template>\n </Item>\n </VStack>\n </VStack>\n </Card>\n </GridSpan>\n </Grid>\n </VStack>\n </div>\n </div>\n</template>\n\n<style scoped>\n.page-root { min-height: 100%; background: var(--astryx-color-background-body); }\n.showcase-surface { padding: var(--astryx-spacing-6); background: var(--astryx-color-background-surface); }\n.content { max-width: 960px; margin-inline: auto; min-width: 0; }\n.content-fluid { max-width: 880px; }\n.hero-text { text-align: center; max-width: 560px; }\n.center-text { text-align: center; }\n.card-stack { height: 100%; }\n.card-description { flex: 1; }\n.cart-row { width: 100%; min-width: 0; }\n.quantity-field { display: block; flex: none; width: 72px; }\n.cart-button { flex: 1 1 auto; min-width: 0; }\n.card-surface { background: var(--astryx-color-background-body); color: var(--astryx-color-text-primary); min-width: 0; border-color: transparent; }\n.checkout-stack { min-width: 0; width: 100%; }\n.payment-card-content { min-width: 0; width: 100%; text-align: center; word-break: break-word; }\n.inventory-card { background: var(--astryx-color-background-surface); overflow: hidden; }\n.inventory-header { padding-block: var(--astryx-spacing-6); padding-inline: var(--astryx-spacing-6); }\n.inventory-filter-row { padding-block: var(--astryx-spacing-4); padding-inline: var(--astryx-spacing-6); width: 100%; overflow-x: auto; }\n.inventory-filter-left { flex: 1; min-width: 0; }\n.activity-icon { width: 32px; height: 32px; border-radius: var(--astryx-radius-full); background: var(--astryx-color-background-muted); color: var(--astryx-color-text-secondary); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }\n.inventory-banner-wrap { padding-inline: var(--astryx-spacing-6); padding-bottom: var(--astryx-spacing-4); }\n/* 原版 Table 默认 `table-layout: fixed`,列宽严格按声明的比例分;core 目前是 auto,列宽按内容算。 */\n.inventory-table-wrap :deep(.astryx-table) { table-layout: fixed; }\n.inventory-table-wrap { padding-inline: var(--astryx-spacing-6); padding-block-end: var(--astryx-spacing-2); }\n.chat-card { background: var(--astryx-color-background-surface); min-width: 0; overflow: hidden; display: flex; flex-direction: column; }\n.chat-header { padding: var(--astryx-spacing-4); }\n.chat-body { flex: 1; min-height: 0; overflow: hidden; }\n.chat-suggestions { padding-inline: var(--astryx-spacing-4); padding-bottom: var(--astryx-spacing-2); }\n.chat-composer { padding-inline: var(--astryx-spacing-4); padding-bottom: var(--astryx-spacing-4); }\n.activity-card { background: var(--astryx-color-background-surface); min-width: 0; height: 100%; }\n.activity-card-stack { height: 100%; }\n.activity-list-fade { flex: 1; min-height: 0; overflow: hidden; mask-image: linear-gradient(to bottom, black calc(100% - 48px), transparent); margin-inline: calc(var(--astryx-spacing-2) * -1); }\n.checkout-column { flex: 1 1 300px; min-width: 0; display: grid; }\n.chat-column { flex: 2 1 520px; min-width: 0; display: grid; }\n.product-image { width: 100%; height: 100%; object-fit: cover; }\n.card-body { padding: var(--astryx-spacing-4); flex: 1; display: flex; flex-direction: column; }\n.kpi-value { font-size: 24px; font-weight: 700; line-height: 1.1; color: var(--astryx-color-text-primary); font-family: var(--astryx-font-family-heading); }\n:deep(.thumbnail) { width: 40px; height: 40px; border-radius: var(--astryx-radius-element); object-fit: cover; display: block; flex-shrink: 0; }\n:deep(.thumbnail-fallback) { width: 40px; height: 40px; border-radius: var(--astryx-radius-element); background: var(--astryx-color-background-muted); color: var(--astryx-color-text-secondary); display: flex; align-items: center; justify-content: center; font-size: var(--astryx-font-size-sm); font-weight: 600; flex-shrink: 0; }\n</style>\n",
76258
76610
  "docsUrl": {
76259
76611
  "en": "https://astryx-vue.pages.dev/en/playground/?template=theme-showcase",
76260
76612
  "zh": "https://astryx-vue.pages.dev/playground/?template=theme-showcase"
@@ -76806,7 +77158,7 @@ const catalog = {
76806
77158
  ],
76807
77159
  "dependencies": ["@astryx-vue/core", "vue"],
76808
77160
  "isReady": true,
76809
- "source": "<script setup lang=\"ts\">\nimport {\n AspectRatio,\n Badge,\n Button,\n Center,\n Collapsible,\n CollapsibleGroup,\n Divider,\n Grid,\n Heading,\n HStack,\n Layout,\n LayoutContent,\n NumberInput,\n SegmentedControl,\n SegmentedControlItem,\n SelectableCard,\n Text,\n VStack,\n} from '@astryx-vue/core'\nimport type { Component } from 'vue'\nimport { computed, defineComponent, h, ref } from 'vue'\n\n/** 线性图标用内联 SVG 画出,不依赖外部图标字体。 */\nfunction strokeIcon(path: string): Component {\n return defineComponent({\n render: () => h('svg', {\n viewBox: '0 0 24 24',\n fill: 'none',\n stroke: 'currentColor',\n 'stroke-width': 1.5,\n 'stroke-linecap': 'round',\n 'aria-hidden': 'true',\n style: 'width:1em;height:1em',\n }, [h('path', { d: path })]),\n })\n}\n\nconst MinusIcon = strokeIcon('M5 12h14')\nconst PlusIcon = strokeIcon('M12 5v14M5 12h14')\n\nconst IMAGES = [\n '/template-assets/light-product-1.png',\n '/template-assets/light-product-1.png',\n '/template-assets/light-product-2.png',\n '/template-assets/light-product-3.png',\n '/template-assets/light-product-4.png',\n '/template-assets/light-product-5.png',\n '/template-assets/light-product-3.png',\n]\n\nconst PRODUCT = {\n name: 'Solstice Mug & Plate Set',\n price: 89.0,\n originalPrice: 119.0,\n description:\n 'A hand-thrown mug and plate set that brings quiet warmth to every meal. The mug sits easy in the hand with a generous 12 oz capacity, while the 8-inch plate works for everything from toast to tapas. Each piece is kiln-fired at 2,300°F for a finish that resists chips and stains. Subtle variations in the reactive glaze mean no two sets are exactly alike. Dishwasher and microwave safe.',\n composition:\n 'High-fire stoneware clay, wheel-thrown and trimmed by hand. Reactive glaze applied by dipping — color pools and breaks naturally over the clay body. Lead-free and food-safe. Unglazed foot ring reveals the raw clay underneath. Each piece is bisque-fired, glazed, then fired again to cone 10 in a gas reduction kiln.',\n deliveryReturns:\n 'Free shipping on all ceramics orders over $75. Each piece is individually wrapped in recycled kraft paper and cushioned for transit. Returns accepted within 30 days — items must be unused and in original packaging. Replacement pieces available individually.',\n dimensions:\n 'Mug height: 9.5 cm / 3.75 in. Mug diameter: 8.5 cm / 3.35 in. Capacity: 350 ml / 12 oz. Plate diameter: 20 cm / 8 in. Plate height: 2 cm / 0.75 in. Weight: 680 g / 1.5 lb (set).',\n}\n\nconst COLORS = [\n { value: 'snow', label: 'Snow' },\n { value: 'sage', label: 'Sage' },\n { value: 'charcoal', label: 'Charcoal' },\n]\n\nconst FINISHES = [\n { value: 'matte', label: 'Matte' },\n { value: 'satin', label: 'Satin' },\n { value: 'speckled', label: 'Speckled' },\n]\n\nconst fmt = (n: number) => `$${n.toFixed(2)}`\n\nconst selectedThumb = ref(0)\nconst color = ref('snow')\nconst finish = ref('matte')\nconst quantity = ref<number | null>(1)\n\nconst heroSrc = computed(() => IMAGES[selectedThumb.value + 1] ?? IMAGES[0])\nconst thumbnails = IMAGES.slice(1)\n\nfunction decrement() {\n quantity.value = Math.max(1, (quantity.value ?? 1) - 1)\n}\n\nfunction increment() {\n quantity.value = Math.min(10, (quantity.value ?? 1) + 1)\n}\n\nconst filledStars = computed(() => Math.round(4.3))\n<\/script>\n\n<template>\n <Layout height=\"fill\" :content-width=\"1200\">\n <LayoutContent :padding=\"6\">\n <Grid :columns=\"{ minWidth: 320, repeat: 'fit' }\" :gap=\"5\">\n <VStack :gap=\"3\">\n <AspectRatio :ratio=\"4 / 5\">\n <img class=\"hero-image\" :src=\"heroSrc\" :alt=\"PRODUCT.name\">\n </AspectRatio>\n <Grid :columns=\"3\" :gap=\"2\">\n <AspectRatio v-for=\"(src, i) in thumbnails\" :key=\"i\" :ratio=\"1\">\n <SelectableCard\n :label=\"`Product image ${i + 1}`\"\n :is-selected=\"selectedThumb === i\"\n variant=\"transparent\"\n :padding=\"0\"\n width=\"100%\"\n height=\"100%\"\n @change=\"selectedThumb = i\"\n >\n <img class=\"thumb-image\" :src=\"src\" :alt=\"`Product image ${i + 1}`\">\n </SelectableCard>\n </AspectRatio>\n </Grid>\n </VStack>\n\n <VStack :gap=\"0\" class=\"sticky-info\">\n <VStack :gap=\"5\">\n <VStack :gap=\"2\">\n <Heading :level=\"1\" type=\"display-2\">\n {{ PRODUCT.name }}\n </Heading>\n <HStack :gap=\"1\" :align=\"'center'\">\n <span\n v-for=\"n in filledStars\"\n :key=\"`full-${n}`\"\n class=\"star star--filled\"\n aria-hidden=\"true\"\n >★</span>\n <span\n v-for=\"n in (5 - filledStars)\"\n :key=\"`empty-${n}`\"\n class=\"star\"\n aria-hidden=\"true\"\n >☆</span>\n <Text type=\"body\" color=\"secondary\">\n 4.3 (128)\n </Text>\n </HStack>\n <HStack :gap=\"2\" :align=\"'center'\">\n <Text type=\"large\" weight=\"bold\">\n {{ fmt(PRODUCT.price) }}\n </Text>\n <Text type=\"body\" color=\"secondary\" has-strikethrough>\n {{ fmt(PRODUCT.originalPrice) }}\n </Text>\n <Badge variant=\"error\" label=\"Sale\" />\n </HStack>\n </VStack>\n <Text type=\"large\">\n {{ PRODUCT.description }}\n </Text>\n <VStack :gap=\"2\">\n <Text type=\"label\">\n Glaze\n </Text>\n <VStack justify=\"start\">\n <SegmentedControl v-model=\"color\" label=\"Glaze\">\n <SegmentedControlItem\n v-for=\"c in COLORS\"\n :key=\"c.value\"\n :value=\"c.value\"\n :label=\"c.label\"\n />\n </SegmentedControl>\n </VStack>\n </VStack>\n <VStack :gap=\"2\">\n <Text type=\"label\">\n Finish\n </Text>\n <VStack justify=\"start\">\n <SegmentedControl v-model=\"finish\" label=\"Finish\">\n <SegmentedControlItem\n v-for=\"f in FINISHES\"\n :key=\"f.value\"\n :value=\"f.value\"\n :label=\"f.label\"\n />\n </SegmentedControl>\n </VStack>\n </VStack>\n <VStack :gap=\"2\">\n <Text type=\"label\">\n Quantity\n </Text>\n <HStack :gap=\"1\" :align=\"'center'\">\n <Button\n label=\"Decrease quantity\"\n variant=\"ghost\"\n :is-disabled=\"(quantity ?? 1) <= 1\"\n is-icon-only\n :click-action=\"decrement\"\n >\n <template #icon>\n <MinusIcon />\n </template>\n </Button>\n <Center :width=\"100\">\n <NumberInput\n v-model=\"quantity\"\n label=\"Quantity\"\n is-label-hidden\n :min=\"1\"\n :max=\"10\"\n is-integer-only\n />\n </Center>\n <Button\n label=\"Increase quantity\"\n variant=\"ghost\"\n :is-disabled=\"(quantity ?? 1) >= 10\"\n is-icon-only\n :click-action=\"increment\"\n >\n <template #icon>\n <PlusIcon />\n </template>\n </Button>\n </HStack>\n </VStack>\n <VStack :gap=\"2\">\n <Button label=\"Add to Cart\" variant=\"primary\" size=\"lg\" />\n <Button label=\"Buy it now\" size=\"lg\" />\n </VStack>\n <CollapsibleGroup type=\"multiple\" :default-value=\"['composition']\">\n <Divider />\n <Collapsible value=\"composition\">\n <template #trigger>\n <Heading :level=\"3\">\n Composition\n </Heading>\n </template>\n <Text type=\"body\">\n {{ PRODUCT.composition }}\n </Text>\n </Collapsible>\n <Divider />\n <Collapsible value=\"delivery\" :default-is-open=\"false\">\n <template #trigger>\n <Heading :level=\"3\">\n Delivery &amp; Returns\n </Heading>\n </template>\n <Text type=\"body\">\n {{ PRODUCT.deliveryReturns }}\n </Text>\n </Collapsible>\n <Divider />\n <Collapsible value=\"dimensions\" :default-is-open=\"false\">\n <template #trigger>\n <Heading :level=\"3\">\n Dimensions\n </Heading>\n </template>\n <Text type=\"body\">\n {{ PRODUCT.dimensions }}\n </Text>\n </Collapsible>\n <Divider />\n </CollapsibleGroup>\n </VStack>\n </VStack>\n </Grid>\n </LayoutContent>\n </Layout>\n</template>\n\n<style scoped>\n.sticky-info {\n position: sticky;\n top: var(--astryx-spacing-8);\n align-self: start;\n}\n\n.hero-image {\n width: 100%;\n height: 100%;\n object-fit: cover;\n border-radius: var(--astryx-radius-container);\n}\n\n.thumb-image {\n width: 100%;\n height: 100%;\n object-fit: cover;\n display: block;\n}\n\n.star {\n color: var(--astryx-color-text-secondary);\n font-size: var(--astryx-font-size-sm);\n}\n\n.star--filled {\n color: var(--astryx-color-text-primary);\n}\n</style>\n",
77161
+ "source": "<script setup lang=\"ts\">\nimport {\n AspectRatio,\n Badge,\n Button,\n Center,\n Collapsible,\n CollapsibleGroup,\n Divider,\n Grid,\n Heading,\n HStack,\n Layout,\n LayoutContent,\n NumberInput,\n SegmentedControl,\n SegmentedControlItem,\n SelectableCard,\n Text,\n VStack,\n} from '@astryx-vue/core'\nimport type { Component } from 'vue'\nimport { computed, defineComponent, h, ref } from 'vue'\n\n/** 线性图标用内联 SVG 画出,不依赖外部图标字体。 */\nfunction strokeIcon(path: string): Component {\n return defineComponent({\n render: () => h('svg', {\n viewBox: '0 0 24 24',\n fill: 'none',\n stroke: 'currentColor',\n 'stroke-width': 1.5,\n 'stroke-linecap': 'round',\n 'aria-hidden': 'true',\n style: 'width:1em;height:1em',\n }, [h('path', { d: path })]),\n })\n}\n\nconst MinusIcon = strokeIcon('M5 12h14')\nconst PlusIcon = strokeIcon('M12 5v14M5 12h14')\n\nconst IMAGES = [\n '/template-assets/light-product-1.png',\n '/template-assets/light-product-1.png',\n '/template-assets/light-product-2.png',\n '/template-assets/light-product-3.png',\n '/template-assets/light-product-4.png',\n '/template-assets/light-product-5.png',\n '/template-assets/light-product-3.png',\n]\n\nconst PRODUCT = {\n name: 'Solstice Mug & Plate Set',\n price: 89.0,\n originalPrice: 119.0,\n description:\n 'A hand-thrown mug and plate set that brings quiet warmth to every meal. The mug sits easy in the hand with a generous 12 oz capacity, while the 8-inch plate works for everything from toast to tapas. Each piece is kiln-fired at 2,300°F for a finish that resists chips and stains. Subtle variations in the reactive glaze mean no two sets are exactly alike. Dishwasher and microwave safe.',\n composition:\n 'High-fire stoneware clay, wheel-thrown and trimmed by hand. Reactive glaze applied by dipping — color pools and breaks naturally over the clay body. Lead-free and food-safe. Unglazed foot ring reveals the raw clay underneath. Each piece is bisque-fired, glazed, then fired again to cone 10 in a gas reduction kiln.',\n deliveryReturns:\n 'Free shipping on all ceramics orders over $75. Each piece is individually wrapped in recycled kraft paper and cushioned for transit. Returns accepted within 30 days — items must be unused and in original packaging. Replacement pieces available individually.',\n dimensions:\n 'Mug height: 9.5 cm / 3.75 in. Mug diameter: 8.5 cm / 3.35 in. Capacity: 350 ml / 12 oz. Plate diameter: 20 cm / 8 in. Plate height: 2 cm / 0.75 in. Weight: 680 g / 1.5 lb (set).',\n}\n\nconst COLORS = [\n { value: 'snow', label: 'Snow' },\n { value: 'sage', label: 'Sage' },\n { value: 'charcoal', label: 'Charcoal' },\n]\n\nconst FINISHES = [\n { value: 'matte', label: 'Matte' },\n { value: 'satin', label: 'Satin' },\n { value: 'speckled', label: 'Speckled' },\n]\n\nconst fmt = (n: number) => `$${n.toFixed(2)}`\n\nconst selectedThumb = ref(0)\nconst color = ref('snow')\nconst finish = ref('matte')\nconst quantity = ref<number | null>(1)\n\nconst heroSrc = computed(() => IMAGES[selectedThumb.value + 1] ?? IMAGES[0])\nconst thumbnails = IMAGES.slice(1)\n\nfunction decrement() {\n quantity.value = Math.max(1, (quantity.value ?? 1) - 1)\n}\n\nfunction increment() {\n quantity.value = Math.min(10, (quantity.value ?? 1) + 1)\n}\n\nconst filledStars = computed(() => Math.round(4.3))\n<\/script>\n\n<template>\n <Layout height=\"fill\" :content-width=\"1200\">\n <LayoutContent :padding=\"6\">\n <Grid :columns=\"{ minWidth: 320, repeat: 'fit' }\" :gap=\"5\">\n <VStack :gap=\"3\">\n <AspectRatio :ratio=\"4 / 5\">\n <img class=\"hero-image\" :src=\"heroSrc\" :alt=\"PRODUCT.name\">\n </AspectRatio>\n <Grid :columns=\"3\" :gap=\"2\">\n <AspectRatio v-for=\"(src, i) in thumbnails\" :key=\"i\" :ratio=\"1\">\n <SelectableCard\n :label=\"`Product image ${i + 1}`\"\n :is-selected=\"selectedThumb === i\"\n variant=\"transparent\"\n :padding=\"0\"\n width=\"100%\"\n height=\"100%\"\n @change=\"selectedThumb = i\"\n >\n <img class=\"thumb-image\" :src=\"src\" :alt=\"`Product image ${i + 1}`\">\n </SelectableCard>\n </AspectRatio>\n </Grid>\n </VStack>\n\n <VStack :gap=\"0\" class=\"sticky-info\">\n <VStack :gap=\"5\">\n <VStack :gap=\"2\">\n <Heading :level=\"1\" type=\"display-2\">\n {{ PRODUCT.name }}\n </Heading>\n <HStack :gap=\"1\" :align=\"'center'\">\n <span\n v-for=\"n in filledStars\"\n :key=\"`full-${n}`\"\n class=\"star star--filled\"\n aria-hidden=\"true\"\n >★</span>\n <span\n v-for=\"n in (5 - filledStars)\"\n :key=\"`empty-${n}`\"\n class=\"star\"\n aria-hidden=\"true\"\n >☆</span>\n <Text type=\"body\" color=\"secondary\">\n 4.3 (128)\n </Text>\n </HStack>\n <HStack :gap=\"2\" :align=\"'center'\">\n <Text type=\"large\" weight=\"bold\">\n {{ fmt(PRODUCT.price) }}\n </Text>\n <Text type=\"body\" color=\"secondary\" has-strikethrough>\n {{ fmt(PRODUCT.originalPrice) }}\n </Text>\n <Badge variant=\"error\" label=\"Sale\" />\n </HStack>\n </VStack>\n <Text type=\"large\">\n {{ PRODUCT.description }}\n </Text>\n <VStack :gap=\"2\">\n <Text type=\"label\">\n Glaze\n </Text>\n <VStack hAlign=\"start\">\n <SegmentedControl v-model=\"color\" label=\"Glaze\">\n <SegmentedControlItem\n v-for=\"c in COLORS\"\n :key=\"c.value\"\n :value=\"c.value\"\n :label=\"c.label\"\n />\n </SegmentedControl>\n </VStack>\n </VStack>\n <VStack :gap=\"2\">\n <Text type=\"label\">\n Finish\n </Text>\n <VStack hAlign=\"start\">\n <SegmentedControl v-model=\"finish\" label=\"Finish\">\n <SegmentedControlItem\n v-for=\"f in FINISHES\"\n :key=\"f.value\"\n :value=\"f.value\"\n :label=\"f.label\"\n />\n </SegmentedControl>\n </VStack>\n </VStack>\n <VStack :gap=\"2\">\n <Text type=\"label\">\n Quantity\n </Text>\n <HStack :gap=\"1\" :align=\"'center'\">\n <Button\n label=\"Decrease quantity\"\n variant=\"ghost\"\n :is-disabled=\"(quantity ?? 1) <= 1\"\n is-icon-only\n :click-action=\"decrement\"\n >\n <template #icon>\n <MinusIcon />\n </template>\n </Button>\n <Center :width=\"100\">\n <NumberInput\n v-model=\"quantity\"\n label=\"Quantity\"\n is-label-hidden\n :min=\"1\"\n :max=\"10\"\n is-integer-only\n />\n </Center>\n <Button\n label=\"Increase quantity\"\n variant=\"ghost\"\n :is-disabled=\"(quantity ?? 1) >= 10\"\n is-icon-only\n :click-action=\"increment\"\n >\n <template #icon>\n <PlusIcon />\n </template>\n </Button>\n </HStack>\n </VStack>\n <VStack :gap=\"2\">\n <Button label=\"Add to Cart\" variant=\"primary\" size=\"lg\" />\n <Button label=\"Buy it now\" size=\"lg\" />\n </VStack>\n <CollapsibleGroup type=\"multiple\" :default-value=\"['composition']\">\n <Divider />\n <Collapsible value=\"composition\">\n <template #trigger>\n <Heading :level=\"3\">\n Composition\n </Heading>\n </template>\n <Text type=\"body\">\n {{ PRODUCT.composition }}\n </Text>\n </Collapsible>\n <Divider />\n <Collapsible value=\"delivery\" :default-is-open=\"false\">\n <template #trigger>\n <Heading :level=\"3\">\n Delivery &amp; Returns\n </Heading>\n </template>\n <Text type=\"body\">\n {{ PRODUCT.deliveryReturns }}\n </Text>\n </Collapsible>\n <Divider />\n <Collapsible value=\"dimensions\" :default-is-open=\"false\">\n <template #trigger>\n <Heading :level=\"3\">\n Dimensions\n </Heading>\n </template>\n <Text type=\"body\">\n {{ PRODUCT.dimensions }}\n </Text>\n </Collapsible>\n <Divider />\n </CollapsibleGroup>\n </VStack>\n </VStack>\n </Grid>\n </LayoutContent>\n </Layout>\n</template>\n\n<style scoped>\n.sticky-info {\n position: sticky;\n top: var(--astryx-spacing-8);\n align-self: start;\n}\n\n.hero-image {\n width: 100%;\n height: 100%;\n object-fit: cover;\n border-radius: var(--astryx-radius-container);\n}\n\n.thumb-image {\n width: 100%;\n height: 100%;\n object-fit: cover;\n display: block;\n}\n\n.star {\n color: var(--astryx-color-text-secondary);\n font-size: var(--astryx-font-size-sm);\n}\n\n.star--filled {\n color: var(--astryx-color-text-primary);\n}\n</style>\n",
76810
77162
  "docsUrl": {
76811
77163
  "en": "https://astryx-vue.pages.dev/en/playground/?template=product-detail",
76812
77164
  "zh": "https://astryx-vue.pages.dev/playground/?template=product-detail"
@@ -76956,7 +77308,7 @@ const catalog = {
76956
77308
  ],
76957
77309
  "dependencies": ["@astryx-vue/core", "vue"],
76958
77310
  "isReady": true,
76959
- "source": "<script setup lang=\"ts\">\nimport type { TableColumn } from '@astryx-vue/core'\nimport {\n Button,\n Card,\n Divider,\n Grid,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n Link,\n pixel,\n ProgressBar,\n proportional,\n Table,\n Text,\n VStack,\n} from '@astryx-vue/core'\nimport { computed, h, onBeforeUnmount, onMounted, ref } from 'vue'\n\ninterface PageRow extends Record<string, unknown> {\n id: string\n page: string\n views: number\n newUsers: string\n avgTime: string\n exits: string\n}\n\ninterface EventRow extends Record<string, unknown> {\n id: string\n event: string\n count: number\n users: number\n newUsers: number\n}\n\nconst CHART_COLORS = {\n allUsers: 'var(--astryx-color-data-categorical-blue, #0171E3)',\n desktop: 'var(--astryx-color-data-categorical-orange, #EB6E00)',\n mobile: 'var(--astryx-color-data-categorical-purple, #6B1EFD)',\n}\n\n/** 24 小时的活跃用户曲线,每 15 分钟一个点,共 96 点。 */\nconst ACTIVE_ALL = [116, 118, 117, 119, 116, 113, 109, 111, 110, 107, 104, 101, 100, 99, 97, 95, 93, 91, 89, 87, 85, 83, 80, 77, 75, 76, 76, 75, 73, 71, 69, 67, 65, 63, 61, 60, 59, 58, 57, 56, 56, 56, 57, 56, 56, 55, 55, 54, 54, 53, 53, 52, 51, 50, 49, 49, 48, 48, 49, 49, 50, 51, 52, 54, 56, 58, 61, 64, 67, 70, 73, 76, 80, 85, 90, 95, 99, 102, 104, 104, 106, 109, 112, 114, 116, 117, 119, 119, 120, 115, 111, 110, 113, 116, 118, 120]\nconst ACTIVE_DESKTOP = [79, 80, 79, 81, 79, 76, 72, 74, 72, 69, 66, 62, 57, 54, 50, 46, 41, 39, 37, 36, 34, 32, 30, 28, 26, 26, 27, 27, 27, 27, 27, 28, 29, 29, 30, 31, 33, 34, 35, 36, 38, 39, 40, 40, 41, 41, 41, 40, 40, 39, 39, 38, 37, 36, 35, 35, 34, 33, 33, 32, 31, 30, 29, 28, 27, 26, 26, 27, 28, 30, 33, 37, 43, 49, 55, 61, 66, 69, 72, 72, 74, 76, 78, 80, 81, 81, 83, 82, 83, 78, 74, 73, 76, 79, 81, 83]\nconst ACTIVE_MOBILE = [37, 38, 38, 38, 37, 37, 37, 37, 38, 38, 38, 39, 43, 45, 47, 49, 52, 52, 52, 51, 51, 51, 50, 49, 49, 50, 49, 48, 46, 44, 42, 39, 36, 34, 31, 29, 26, 24, 22, 20, 18, 17, 17, 16, 15, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 15, 16, 17, 19, 21, 23, 26, 29, 32, 35, 37, 39, 40, 40, 39, 37, 36, 35, 34, 33, 33, 32, 32, 32, 33, 34, 34, 35, 36, 36, 37, 37, 37, 37, 37, 37, 37, 37, 37]\n\nconst ACTIVE_SERIES = [\n { key: 'allUsers', color: CHART_COLORS.allUsers, name: 'All Users', values: ACTIVE_ALL },\n { key: 'desktop', color: CHART_COLORS.desktop, name: 'Desktop', values: ACTIVE_DESKTOP },\n { key: 'mobile', color: CHART_COLORS.mobile, name: 'Mobile', values: ACTIVE_MOBILE },\n]\n\n/** 图表画布:800×300,留出左侧刻度与底部时间轴的边距。 */\nconst CHART = { h: 300, padLeft: 36, padRight: 12, padTop: 10, padBottom: 26 }\n\n/** 图表宽度跟着容器走,viewBox 和像素一比一,线和文字都不会被拉伸。 */\nconst chartEl = ref<SVGSVGElement | null>(null)\nconst chartWidth = ref(800)\nconst chartInnerWidth = computed(() => chartWidth.value - CHART.padLeft - CHART.padRight)\nlet chartObserver: ResizeObserver | null = null\nonMounted(() => {\n if (!chartEl.value || typeof ResizeObserver === 'undefined')\n return\n chartObserver = new ResizeObserver(([entry]) => {\n if (entry && entry.contentRect.width > 0)\n chartWidth.value = entry.contentRect.width\n })\n chartObserver.observe(chartEl.value)\n})\nonBeforeUnmount(() => chartObserver?.disconnect())\nconst CHART_INNER_H = CHART.h - CHART.padTop - CHART.padBottom\n\n/** 把一个数组折成 polyline 的 points 字符串。 */\nfunction pointsFor(values: number[], min: number, max: number): string {\n return values.map((value, i) => {\n const x = CHART.padLeft + (i / (values.length - 1)) * chartInnerWidth.value\n const y = CHART.padTop + CHART_INNER_H - ((value - min) / (max - min)) * CHART_INNER_H\n return `${x.toFixed(1)},${y.toFixed(1)}`\n }).join(' ')\n}\n\nfunction tickY(value: number, min: number, max: number): number {\n return CHART.padTop + CHART_INNER_H - ((value - min) / (max - min)) * CHART_INNER_H\n}\n\nfunction tickX(index: number): number {\n return CHART.padLeft + (index / (ACTIVE_ALL.length - 1)) * chartInnerWidth.value\n}\n\nconst Y_AXIS = [0, 20, 40, 60, 80, 100, 120]\nconst Y_MIN = 0\nconst Y_MAX = 120\n\n/** 横轴只标 4 个时间点,分别是曲线上的 0 / 32 / 64 / 95。 */\nconst X_TICKS = [0, 32, 64, 95]\nconst X_LABELS: Record<number, string> = {\n 0: 'Apr 1 14:00',\n 32: 'Apr 1 22:00',\n 64: 'Apr 2 06:00',\n 95: 'Apr 2 14:00',\n}\n\nconst METRICS = [\n { label: 'Monthly Visitors', value: '27.3 k', change: '+18.2%', positive: true },\n { label: 'Monthly Page Views', value: '48.2 k', change: '+12.5%', positive: true },\n { label: 'Avg. Session', value: '4.5 min', change: '-14.3%', positive: false },\n { label: 'Bounce Rate', value: '42.3%', change: '-8.7%', positive: false },\n]\n\nconst SPARKLINES = [\n [48, 46, 44, 42, 40, 18, 16, 38, 36, 34, 32, 30, 12, 10, 28, 26, 28, 32, 36, 14, 12, 40, 44, 48, 52, 56, 28, 24, 58, 62],\n [36, 38, 35, 37, 36, 14, 12, 38, 36, 34, 37, 35, 12, 10, 36, 34, 36, 35, 38, 14, 12, 40, 44, 50, 54, 56, 26, 22, 58, 60],\n [58, 56, 60, 58, 62, 30, 26, 60, 58, 62, 60, 58, 28, 24, 56, 54, 50, 46, 42, 18, 14, 38, 36, 34, 32, 30, 10, 8, 28, 26],\n [52, 56, 50, 54, 58, 62, 60, 54, 52, 56, 50, 54, 60, 58, 50, 48, 46, 44, 40, 46, 44, 38, 36, 34, 36, 32, 38, 36, 30, 28],\n]\n\nconst REGION_DATA = [\n { label: 'NORAM', value: 38, color: 'var(--astryx-color-data-categorical-blue, #0171E3)' },\n { label: 'EMEA', value: 28, color: 'var(--astryx-color-data-categorical-orange, #EB6E00)' },\n { label: 'APAC', value: 22, color: 'var(--astryx-color-data-categorical-green, #0B991F)' },\n { label: 'LATAM', value: 8, color: 'var(--astryx-color-data-categorical-purple, #6B1EFD)' },\n { label: 'Other', value: 4, color: 'var(--astryx-color-data-neutral, #8494A3)' },\n]\n\nconst ROLE_DATA = [\n { label: 'Engineer', value: 45, color: 'var(--astryx-color-data-categorical-blue, #0171E3)' },\n { label: 'Manager', value: 20, color: 'var(--astryx-color-data-categorical-orange, #EB6E00)' },\n { label: 'Designer', value: 15, color: 'var(--astryx-color-data-categorical-green, #0B991F)' },\n { label: 'Data Scientist', value: 12, color: 'var(--astryx-color-data-categorical-purple, #6B1EFD)' },\n { label: 'Other', value: 8, color: 'var(--astryx-color-data-neutral, #8494A3)' },\n]\n\nconst TOP_PAGES: PageRow[] = [\n { id: '1', page: '/home', views: 8420, newUsers: '62.3%', avgTime: '3:42', exits: '18.5%' },\n { id: '2', page: '/products', views: 6150, newUsers: '45.1%', avgTime: '4:15', exits: '22.8%' },\n { id: '3', page: '/pricing', views: 4830, newUsers: '38.7%', avgTime: '2:58', exits: '35.2%' },\n { id: '4', page: '/blog', views: 3920, newUsers: '71.4%', avgTime: '5:30', exits: '12.1%' },\n { id: '5', page: '/docs', views: 3410, newUsers: '29.8%', avgTime: '6:12', exits: '8.4%' },\n { id: '6', page: '/about', views: 2980, newUsers: '55.6%', avgTime: '2:15', exits: '28.3%' },\n { id: '7', page: '/contact', views: 2540, newUsers: '48.2%', avgTime: '1:48', exits: '41.7%' },\n { id: '8', page: '/changelog', views: 2210, newUsers: '22.1%', avgTime: '4:55', exits: '15.6%' },\n { id: '9', page: '/support', views: 1870, newUsers: '59.3%', avgTime: '3:22', exits: '30.9%' },\n { id: '10', page: '/careers', views: 1520, newUsers: '83.1%', avgTime: '2:34', exits: '45.2%' },\n]\n\nconst TOP_EVENTS: EventRow[] = [\n { id: '1', event: 'page_view', count: 18420, users: 12300, newUsers: 4920 },\n { id: '2', event: 'session_start', count: 14850, users: 9870, newUsers: 3950 },\n { id: '3', event: 'first_visit', count: 8230, users: 8230, newUsers: 8230 },\n { id: '4', event: 'user_engagement', count: 6120, users: 4510, newUsers: 1580 },\n { id: '5', event: 'click', count: 3540, users: 2680, newUsers: 940 },\n { id: '6', event: 'scroll', count: 2910, users: 2140, newUsers: 750 },\n { id: '7', event: 'form_submit', count: 1870, users: 1350, newUsers: 540 },\n { id: '8', event: 'video_play', count: 1240, users: 980, newUsers: 390 },\n { id: '9', event: 'search', count: 960, users: 720, newUsers: 290 },\n { id: '10', event: 'share', count: 580, users: 410, newUsers: 160 },\n]\n\nconst TOP_PAGES_MAX = Math.max(...TOP_PAGES.map(row => row.views))\nconst TOP_EVENTS_MAX = Math.max(...TOP_EVENTS.map(row => row.count))\n\n/** 数值列里画一条进度条,条下再写原始数字。 */\nfunction barCell(value: number, max: number, label: string) {\n return h(VStack, { gap: 1 }, () => [\n h(ProgressBar, { value, max, label, isLabelHidden: true }),\n h(Text, { type: 'supporting' }, () => value.toLocaleString()),\n ])\n}\n\nconst TOP_PAGES_COLUMNS: TableColumn<PageRow>[] = [\n { key: 'page', header: 'Page', width: pixel(160) },\n { key: 'views', header: 'Views', width: proportional(1), renderCell: (row: PageRow) => barCell(row.views, TOP_PAGES_MAX, `${row.page} views`) },\n { key: 'newUsers', header: 'New Users', width: pixel(120) },\n { key: 'avgTime', header: 'Avg. Time', width: pixel(120) },\n]\n\nconst TOP_EVENTS_COLUMNS: TableColumn<EventRow>[] = [\n { key: 'event', header: 'Event', width: pixel(160) },\n { key: 'count', header: 'Count', width: proportional(1), renderCell: (row: EventRow) => barCell(row.count, TOP_EVENTS_MAX, `${row.count}`) },\n { key: 'users', header: 'Users', width: pixel(120), renderCell: (row: EventRow) => row.users.toLocaleString() },\n { key: 'newUsers', header: 'New Users', width: pixel(120), renderCell: (row: EventRow) => row.newUsers.toLocaleString() },\n]\n\nfunction tickLabel(value: number): string {\n return X_LABELS[value] ?? ''\n}\n\n/** 指标卡里的迷你折线,只看走势,不带坐标轴,画在 100×40 的盒子里。 */\nfunction sparklinePoints(values: number[]): string {\n const min = Math.min(...values)\n const max = Math.max(...values)\n const range = max - min || 1\n return values.map((value, i) => {\n const x = (i / (values.length - 1)) * 100\n const y = 2 + 36 - ((value - min) / range) * 36\n return `${x.toFixed(1)},${y.toFixed(1)}`\n }).join(' ')\n}\n<\/script>\n\n<template>\n <Layout height=\"fill\">\n <LayoutContent :padding=\"6\">\n <VStack :gap=\"6\">\n <VStack :gap=\"6\">\n <HStack h-align=\"between\" align=\"center\">\n <Heading :level=\"3\">\n Active users\n </Heading>\n <Button label=\"Reload\" variant=\"secondary\">\n <template #icon>\n <Icon name=\"heroicons:arrow-path\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n <VStack :gap=\"3\">\n <svg ref=\"chartEl\" class=\"active-users-chart\" :viewBox=\"`0 0 ${chartWidth} ${CHART.h}`\" role=\"img\" aria-label=\"Active users\">\n <g>\n <line\n v-for=\"tick in Y_AXIS\"\n :key=\"`grid-${tick}`\"\n class=\"chart-grid\"\n :x1=\"CHART.padLeft\"\n :x2=\"chartWidth - CHART.padRight\"\n :y1=\"tickY(tick, Y_MIN, Y_MAX)\"\n :y2=\"tickY(tick, Y_MIN, Y_MAX)\"\n />\n <text\n v-for=\"tick in Y_AXIS\"\n :key=\"`ylabel-${tick}`\"\n class=\"chart-axis\"\n :x=\"CHART.padLeft - 8\"\n :y=\"tickY(tick, Y_MIN, Y_MAX) + 4\"\n text-anchor=\"end\"\n >{{ tick }}</text>\n <text\n v-for=\"index in X_TICKS\"\n :key=\"`xlabel-${index}`\"\n class=\"chart-axis\"\n :x=\"tickX(index)\"\n :y=\"CHART.h - 8\"\n :text-anchor=\"index === 0 ? 'start' : index === X_TICKS[X_TICKS.length - 1] ? 'end' : 'middle'\"\n >{{ tickLabel(index) }}</text>\n <polyline\n v-for=\"series in ACTIVE_SERIES\"\n :key=\"series.key\"\n fill=\"none\"\n :stroke=\"series.color\"\n stroke-width=\"2\"\n :points=\"pointsFor(series.values, Y_MIN, Y_MAX)\"\n />\n </g>\n </svg>\n <HStack :gap=\"6\" align=\"center\">\n <HStack v-for=\"series in ACTIVE_SERIES\" :key=\"series.key\" :gap=\"2\" align=\"center\">\n <span class=\"legend-swatch\" :style=\"{ backgroundColor: series.color }\" aria-hidden=\"true\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ series.name }}\n </Text>\n </HStack>\n </HStack>\n </VStack>\n </VStack>\n\n <Grid :columns=\"{ minWidth: 320, repeat: 'fit' }\" :gap=\"4\">\n <Grid v-for=\"(start, group) in [0, 2]\" :key=\"group\" :columns=\"{ minWidth: 240, repeat: 'fit' }\" :gap=\"4\">\n <Card v-for=\"(metric, i) in METRICS.slice(start, start + 2)\" :key=\"metric.label\">\n <VStack :gap=\"2\">\n <Heading :level=\"4\">\n {{ metric.label }}\n </Heading>\n <HStack :gap=\"2\" align=\"center\">\n <Heading :level=\"2\">\n {{ metric.value }}\n </Heading>\n <HStack :gap=\"1\" align=\"center\">\n <Icon :icon=\"metric.positive ? 'arrowUp' : 'arrowDown'\" size=\"xsm\" :color=\"metric.positive ? 'success' : 'error'\" />\n <Text type=\"body\" color=\"secondary\">\n {{ metric.change }}\n </Text>\n </HStack>\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n Last 30 days vs. Previous\n </Text>\n <svg class=\"sparkline\" viewBox=\"0 0 100 40\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n <polyline fill=\"none\" :stroke=\"CHART_COLORS.allUsers\" stroke-width=\"1.5\" :points=\"sparklinePoints(SPARKLINES[start + i])\" />\n </svg>\n </VStack>\n </Card>\n </Grid>\n </Grid>\n\n <Divider />\n\n <HStack h-align=\"between\" align=\"center\">\n <Heading :level=\"3\">\n Demographics\n </Heading>\n <Button label=\"View more\" variant=\"secondary\" />\n </HStack>\n <Grid :columns=\"{ minWidth: 320, repeat: 'fit' }\" :gap=\"4\">\n <Card v-for=\"group in [{ title: 'Region', data: REGION_DATA }, { title: 'Role', data: ROLE_DATA }]\" :key=\"group.title\">\n <VStack :gap=\"4\">\n <Heading :level=\"4\">\n {{ group.title }}\n </Heading>\n <div class=\"stacked-bar\" role=\"img\" :aria-label=\"group.title\">\n <span\n v-for=\"segment in group.data\"\n :key=\"segment.label\"\n class=\"stacked-bar__segment\"\n :style=\"{ backgroundColor: segment.color, flexGrow: segment.value }\"\n />\n </div>\n <HStack :gap=\"4\" wrap=\"wrap\">\n <VStack v-for=\"segment in group.data\" :key=\"segment.label\" :gap=\"0\">\n <HStack :gap=\"2\" align=\"center\">\n <span class=\"legend-swatch\" :style=\"{ backgroundColor: segment.color }\" aria-hidden=\"true\" />\n <Text type=\"supporting\">\n {{ segment.label }}\n </Text>\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n {{ segment.value }} - {{ ((segment.value / group.data.reduce((sum, s) => sum + s.value, 0)) * 100).toFixed(2) }}%\n </Text>\n </VStack>\n </HStack>\n </VStack>\n </Card>\n </Grid>\n\n <Divider />\n\n <HStack h-align=\"between\" align=\"center\">\n <Heading :level=\"3\">\n Engagement\n </Heading>\n <Button label=\"View more\" variant=\"secondary\" />\n </HStack>\n <Grid :columns=\"{ minWidth: 320, repeat: 'fit' }\" :gap=\"4\">\n <Card>\n <VStack :gap=\"6\">\n <HStack h-align=\"between\" align=\"center\">\n <Heading :level=\"4\">\n Top pages\n </Heading>\n <Link href=\"#\">\n All pages\n </Link>\n </HStack>\n <Table :data=\"TOP_PAGES\" :columns=\"TOP_PAGES_COLUMNS\" id-key=\"id\" density=\"compact\" dividers=\"rows\" has-hover />\n </VStack>\n </Card>\n <Card>\n <VStack :gap=\"6\">\n <HStack h-align=\"between\" align=\"center\">\n <Heading :level=\"4\">\n Top events\n </Heading>\n <Link href=\"#\">\n All events\n </Link>\n </HStack>\n <Table :data=\"TOP_EVENTS\" :columns=\"TOP_EVENTS_COLUMNS\" id-key=\"id\" density=\"compact\" dividers=\"rows\" has-hover />\n </VStack>\n </Card>\n </Grid>\n </VStack>\n </LayoutContent>\n </Layout>\n</template>\n\n<style scoped>\n.legend-swatch {\n flex-shrink: 0;\n width: 8px;\n height: 8px;\n border-radius: 2px;\n}\n\n.stacked-bar {\n display: flex;\n height: 24px;\n overflow: clip;\n border-radius: var(--astryx-radius-element);\n}\n.stacked-bar__segment {\n flex-basis: 0;\n}\n.active-users-chart {\n width: 100%;\n height: 300px;\n display: block;\n}\n.chart-grid {\n stroke: var(--astryx-color-border);\n stroke-width: 1;\n}\n.chart-axis {\n fill: var(--astryx-color-text-secondary);\n font-size: 12px;\n}\n.sparkline {\n width: 100%;\n height: 40px;\n display: block;\n}\n</style>\n",
77311
+ "source": "<script setup lang=\"ts\">\nimport type { TableColumn } from '@astryx-vue/core'\nimport {\n Button,\n Card,\n Divider,\n Grid,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n Link,\n pixel,\n ProgressBar,\n proportional,\n Table,\n Text,\n VStack,\n} from '@astryx-vue/core'\nimport { computed, h, onBeforeUnmount, onMounted, ref } from 'vue'\n\ninterface PageRow extends Record<string, unknown> {\n id: string\n page: string\n views: number\n newUsers: string\n avgTime: string\n exits: string\n}\n\ninterface EventRow extends Record<string, unknown> {\n id: string\n event: string\n count: number\n users: number\n newUsers: number\n}\n\nconst CHART_COLORS = {\n allUsers: 'var(--astryx-color-data-categorical-blue, #0171E3)',\n desktop: 'var(--astryx-color-data-categorical-orange, #EB6E00)',\n mobile: 'var(--astryx-color-data-categorical-purple, #6B1EFD)',\n}\n\n/** 24 小时的活跃用户曲线,每 15 分钟一个点,共 96 点。 */\nconst ACTIVE_ALL = [116, 118, 117, 119, 116, 113, 109, 111, 110, 107, 104, 101, 100, 99, 97, 95, 93, 91, 89, 87, 85, 83, 80, 77, 75, 76, 76, 75, 73, 71, 69, 67, 65, 63, 61, 60, 59, 58, 57, 56, 56, 56, 57, 56, 56, 55, 55, 54, 54, 53, 53, 52, 51, 50, 49, 49, 48, 48, 49, 49, 50, 51, 52, 54, 56, 58, 61, 64, 67, 70, 73, 76, 80, 85, 90, 95, 99, 102, 104, 104, 106, 109, 112, 114, 116, 117, 119, 119, 120, 115, 111, 110, 113, 116, 118, 120]\nconst ACTIVE_DESKTOP = [79, 80, 79, 81, 79, 76, 72, 74, 72, 69, 66, 62, 57, 54, 50, 46, 41, 39, 37, 36, 34, 32, 30, 28, 26, 26, 27, 27, 27, 27, 27, 28, 29, 29, 30, 31, 33, 34, 35, 36, 38, 39, 40, 40, 41, 41, 41, 40, 40, 39, 39, 38, 37, 36, 35, 35, 34, 33, 33, 32, 31, 30, 29, 28, 27, 26, 26, 27, 28, 30, 33, 37, 43, 49, 55, 61, 66, 69, 72, 72, 74, 76, 78, 80, 81, 81, 83, 82, 83, 78, 74, 73, 76, 79, 81, 83]\nconst ACTIVE_MOBILE = [37, 38, 38, 38, 37, 37, 37, 37, 38, 38, 38, 39, 43, 45, 47, 49, 52, 52, 52, 51, 51, 51, 50, 49, 49, 50, 49, 48, 46, 44, 42, 39, 36, 34, 31, 29, 26, 24, 22, 20, 18, 17, 17, 16, 15, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 15, 16, 17, 19, 21, 23, 26, 29, 32, 35, 37, 39, 40, 40, 39, 37, 36, 35, 34, 33, 33, 32, 32, 32, 33, 34, 34, 35, 36, 36, 37, 37, 37, 37, 37, 37, 37, 37, 37]\n\nconst ACTIVE_SERIES = [\n { key: 'allUsers', color: CHART_COLORS.allUsers, name: 'All Users', values: ACTIVE_ALL },\n { key: 'desktop', color: CHART_COLORS.desktop, name: 'Desktop', values: ACTIVE_DESKTOP },\n { key: 'mobile', color: CHART_COLORS.mobile, name: 'Mobile', values: ACTIVE_MOBILE },\n]\n\n/** 图表画布:800×300,留出左侧刻度与底部时间轴的边距。 */\nconst CHART = { h: 300, padLeft: 30, padRight: 10, padTop: 5, padBottom: 35 }\n\n/** 图表宽度跟着容器走,viewBox 和像素一比一,线和文字都不会被拉伸。 */\nconst chartEl = ref<SVGSVGElement | null>(null)\nconst chartWidth = ref(800)\nconst chartInnerWidth = computed(() => chartWidth.value - CHART.padLeft - CHART.padRight)\nlet chartObserver: ResizeObserver | null = null\nonMounted(() => {\n if (!chartEl.value || typeof ResizeObserver === 'undefined')\n return\n chartObserver = new ResizeObserver(([entry]) => {\n if (entry && entry.contentRect.width > 0)\n chartWidth.value = entry.contentRect.width\n })\n chartObserver.observe(chartEl.value)\n})\nonBeforeUnmount(() => chartObserver?.disconnect())\nconst CHART_INNER_H = CHART.h - CHART.padTop - CHART.padBottom\n\n/** 把一个数组折成 polyline 的 points 字符串。 */\nfunction pointsFor(values: number[], min: number, max: number): string {\n return values.map((value, i) => {\n const x = CHART.padLeft + (i / (values.length - 1)) * chartInnerWidth.value\n const y = CHART.padTop + CHART_INNER_H - ((value - min) / (max - min)) * CHART_INNER_H\n return `${x.toFixed(1)},${y.toFixed(1)}`\n }).join(' ')\n}\n\nfunction tickY(value: number, min: number, max: number): number {\n return CHART.padTop + CHART_INNER_H - ((value - min) / (max - min)) * CHART_INNER_H\n}\n\nfunction tickX(index: number): number {\n return CHART.padLeft + (index / (ACTIVE_ALL.length - 1)) * chartInnerWidth.value\n}\n\nconst Y_AXIS = [0, 20, 40, 60, 80, 100, 120]\nconst Y_MIN = 0\nconst Y_MAX = 120\n\n/** 横轴标 3 个时间点(曲线上的 32 / 64 / 95),起点不标,和原版一样。 */\nconst X_TICKS = [32, 64, 95]\nconst X_LABELS: Record<number, string> = {\n 0: 'Apr 1 14:00',\n 32: 'Apr 1 22:00',\n 64: 'Apr 2 06:00',\n 95: 'Apr 2 14:00',\n}\n\nconst METRICS = [\n { label: 'Monthly Visitors', value: '27.3 k', change: '+18.2%', positive: true },\n { label: 'Monthly Page Views', value: '48.2 k', change: '+12.5%', positive: true },\n { label: 'Avg. Session', value: '4.5 min', change: '-14.3%', positive: false },\n { label: 'Bounce Rate', value: '42.3%', change: '-8.7%', positive: false },\n]\n\nconst SPARKLINES = [\n [48, 46, 44, 42, 40, 18, 16, 38, 36, 34, 32, 30, 12, 10, 28, 26, 28, 32, 36, 14, 12, 40, 44, 48, 52, 56, 28, 24, 58, 62],\n [36, 38, 35, 37, 36, 14, 12, 38, 36, 34, 37, 35, 12, 10, 36, 34, 36, 35, 38, 14, 12, 40, 44, 50, 54, 56, 26, 22, 58, 60],\n [58, 56, 60, 58, 62, 30, 26, 60, 58, 62, 60, 58, 28, 24, 56, 54, 50, 46, 42, 18, 14, 38, 36, 34, 32, 30, 10, 8, 28, 26],\n [52, 56, 50, 54, 58, 62, 60, 54, 52, 56, 50, 54, 60, 58, 50, 48, 46, 44, 40, 46, 44, 38, 36, 34, 36, 32, 38, 36, 30, 28],\n]\n\nconst REGION_DATA = [\n { label: 'NORAM', value: 38, color: 'var(--astryx-color-data-categorical-blue, #0171E3)' },\n { label: 'EMEA', value: 28, color: 'var(--astryx-color-data-categorical-orange, #EB6E00)' },\n { label: 'APAC', value: 22, color: 'var(--astryx-color-data-categorical-green, #0B991F)' },\n { label: 'LATAM', value: 8, color: 'var(--astryx-color-data-categorical-purple, #6B1EFD)' },\n { label: 'Other', value: 4, color: 'var(--astryx-color-data-neutral, #8494A3)' },\n]\n\nconst ROLE_DATA = [\n { label: 'Engineer', value: 45, color: 'var(--astryx-color-data-categorical-blue, #0171E3)' },\n { label: 'Manager', value: 20, color: 'var(--astryx-color-data-categorical-orange, #EB6E00)' },\n { label: 'Designer', value: 15, color: 'var(--astryx-color-data-categorical-green, #0B991F)' },\n { label: 'Data Scientist', value: 12, color: 'var(--astryx-color-data-categorical-purple, #6B1EFD)' },\n { label: 'Other', value: 8, color: 'var(--astryx-color-data-neutral, #8494A3)' },\n]\n\nconst TOP_PAGES: PageRow[] = [\n { id: '1', page: '/home', views: 8420, newUsers: '62.3%', avgTime: '3:42', exits: '18.5%' },\n { id: '2', page: '/products', views: 6150, newUsers: '45.1%', avgTime: '4:15', exits: '22.8%' },\n { id: '3', page: '/pricing', views: 4830, newUsers: '38.7%', avgTime: '2:58', exits: '35.2%' },\n { id: '4', page: '/blog', views: 3920, newUsers: '71.4%', avgTime: '5:30', exits: '12.1%' },\n { id: '5', page: '/docs', views: 3410, newUsers: '29.8%', avgTime: '6:12', exits: '8.4%' },\n { id: '6', page: '/about', views: 2980, newUsers: '55.6%', avgTime: '2:15', exits: '28.3%' },\n { id: '7', page: '/contact', views: 2540, newUsers: '48.2%', avgTime: '1:48', exits: '41.7%' },\n { id: '8', page: '/changelog', views: 2210, newUsers: '22.1%', avgTime: '4:55', exits: '15.6%' },\n { id: '9', page: '/support', views: 1870, newUsers: '59.3%', avgTime: '3:22', exits: '30.9%' },\n { id: '10', page: '/careers', views: 1520, newUsers: '83.1%', avgTime: '2:34', exits: '45.2%' },\n]\n\nconst TOP_EVENTS: EventRow[] = [\n { id: '1', event: 'page_view', count: 18420, users: 12300, newUsers: 4920 },\n { id: '2', event: 'session_start', count: 14850, users: 9870, newUsers: 3950 },\n { id: '3', event: 'first_visit', count: 8230, users: 8230, newUsers: 8230 },\n { id: '4', event: 'user_engagement', count: 6120, users: 4510, newUsers: 1580 },\n { id: '5', event: 'click', count: 3540, users: 2680, newUsers: 940 },\n { id: '6', event: 'scroll', count: 2910, users: 2140, newUsers: 750 },\n { id: '7', event: 'form_submit', count: 1870, users: 1350, newUsers: 540 },\n { id: '8', event: 'video_play', count: 1240, users: 980, newUsers: 390 },\n { id: '9', event: 'search', count: 960, users: 720, newUsers: 290 },\n { id: '10', event: 'share', count: 580, users: 410, newUsers: 160 },\n]\n\nconst TOP_PAGES_MAX = Math.max(...TOP_PAGES.map(row => row.views))\nconst TOP_EVENTS_MAX = Math.max(...TOP_EVENTS.map(row => row.count))\n\n/** 数值列里画一条进度条,条下再写原始数字。 */\nfunction barCell(value: number, max: number, label: string, suffix = '') {\n return h(VStack, { gap: 1 }, () => [\n h(ProgressBar, { value, max, label, isLabelHidden: true }),\n h(Text, { type: 'supporting' }, () => `${value.toLocaleString()}${suffix}`),\n ])\n}\n\nconst TOP_PAGES_COLUMNS: TableColumn<PageRow>[] = [\n { key: 'page', header: 'Page', width: pixel(160) },\n { key: 'views', header: 'Views', width: proportional(1), renderCell: (row: PageRow) => barCell(row.views, TOP_PAGES_MAX, `${row.page} views`, ' views') },\n { key: 'newUsers', header: 'New Users', width: pixel(120) },\n { key: 'avgTime', header: 'Avg. Time', width: pixel(120) },\n]\n\nconst TOP_EVENTS_COLUMNS: TableColumn<EventRow>[] = [\n { key: 'event', header: 'Event', width: pixel(160) },\n { key: 'count', header: 'Count', width: proportional(1), renderCell: (row: EventRow) => barCell(row.count, TOP_EVENTS_MAX, `${row.count}`) },\n { key: 'users', header: 'Users', width: pixel(120), renderCell: (row: EventRow) => row.users.toLocaleString() },\n { key: 'newUsers', header: 'New Users', width: pixel(120), renderCell: (row: EventRow) => row.newUsers.toLocaleString() },\n]\n\nfunction tickLabel(value: number): string {\n return X_LABELS[value] ?? ''\n}\n\n/**\n * 指标卡里的迷你折线:不带坐标轴,纵轴从 0 起,上界取到下一个整刻度(和原版的图表默认一样),\n * 上下各留 5px、左右各留 5px。宽度跟着卡片走,viewBox 和像素一比一。\n */\nconst SPARK = { h: 40, pad: 5 }\nconst sparkWidth = ref(398)\nlet sparkObserver: ResizeObserver | null = null\n\n/** 第一张迷你图量宽,四张同宽,共用这个宽度。 */\nfunction bindSpark(element: unknown): void {\n sparkObserver?.disconnect()\n sparkObserver = null\n if (!(element instanceof SVGSVGElement) || typeof ResizeObserver === 'undefined')\n return\n sparkObserver = new ResizeObserver(([entry]) => {\n if (entry && entry.contentRect.width > 0)\n sparkWidth.value = entry.contentRect.width\n })\n sparkObserver.observe(element)\n}\nonBeforeUnmount(() => sparkObserver?.disconnect())\n\nfunction sparkTop(values: number[]): number {\n const max = Math.max(...values)\n const step = max > 50 ? 20 : max > 20 ? 10 : 5\n return Math.ceil(max / step) * step\n}\n\nfunction sparklinePoints(values: number[]): string {\n const top = sparkTop(values)\n const inner = SPARK.h - SPARK.pad * 2\n const width = sparkWidth.value - SPARK.pad * 2\n return values.map((value, i) => {\n const x = SPARK.pad + (i / (values.length - 1)) * width\n const y = SPARK.pad + inner - (value / top) * inner\n return `${x.toFixed(1)},${y.toFixed(1)}`\n }).join(' ')\n}\n<\/script>\n\n<template>\n <Layout height=\"fill\">\n <LayoutContent :padding=\"6\">\n <VStack :gap=\"6\">\n <VStack :gap=\"6\">\n <HStack h-align=\"between\" align=\"center\">\n <Heading :level=\"3\">\n Active users\n </Heading>\n <Button label=\"Reload\" variant=\"secondary\">\n <template #icon>\n <Icon name=\"heroicons:arrow-path\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n <VStack :gap=\"3\">\n <svg ref=\"chartEl\" class=\"active-users-chart\" :viewBox=\"`0 0 ${chartWidth} ${CHART.h}`\" role=\"img\" aria-label=\"Active users\">\n <g>\n <line\n v-for=\"tick in Y_AXIS\"\n :key=\"`grid-${tick}`\"\n class=\"chart-grid\"\n :x1=\"CHART.padLeft\"\n :x2=\"chartWidth - CHART.padRight\"\n :y1=\"tickY(tick, Y_MIN, Y_MAX)\"\n :y2=\"tickY(tick, Y_MIN, Y_MAX)\"\n />\n <text\n v-for=\"tick in Y_AXIS\"\n :key=\"`ylabel-${tick}`\"\n class=\"chart-axis\"\n :x=\"CHART.padLeft - 8\"\n :y=\"Math.max(tickY(tick, Y_MIN, Y_MAX), 9)\"\n text-anchor=\"end\"\n dominant-baseline=\"central\"\n >{{ tick }}</text>\n <text\n v-for=\"index in X_TICKS\"\n :key=\"`xlabel-${index}`\"\n class=\"chart-axis\"\n :x=\"Math.min(tickX(index), chartWidth - CHART.padRight - 23)\"\n :y=\"CHART.h - CHART.padBottom + 8\"\n text-anchor=\"middle\"\n >{{ tickLabel(index) }}</text>\n <polyline\n v-for=\"series in ACTIVE_SERIES\"\n :key=\"series.key\"\n fill=\"none\"\n :stroke=\"series.color\"\n stroke-width=\"2\"\n :points=\"pointsFor(series.values, Y_MIN, Y_MAX)\"\n />\n </g>\n </svg>\n <HStack :gap=\"6\" align=\"center\">\n <HStack v-for=\"series in ACTIVE_SERIES\" :key=\"series.key\" :gap=\"2\" align=\"center\">\n <Icon name=\"stop\" size=\"xsm\" color=\"inherit\" :style=\"{ color: series.color }\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ series.name }}\n </Text>\n </HStack>\n </HStack>\n </VStack>\n </VStack>\n\n <Grid :columns=\"{ minWidth: 320, repeat: 'fit' }\" :gap=\"4\">\n <Grid v-for=\"(start, group) in [0, 2]\" :key=\"group\" :columns=\"{ minWidth: 240, repeat: 'fit' }\" :gap=\"4\">\n <Card v-for=\"(metric, i) in METRICS.slice(start, start + 2)\" :key=\"metric.label\">\n <VStack :gap=\"2\">\n <Heading :level=\"4\">\n {{ metric.label }}\n </Heading>\n <HStack :gap=\"2\" align=\"center\">\n <Heading :level=\"2\">\n {{ metric.value }}\n </Heading>\n <HStack :gap=\"1\" align=\"center\">\n <Icon :icon=\"metric.positive ? 'arrowUp' : 'arrowDown'\" size=\"xsm\" :color=\"metric.positive ? 'success' : 'error'\" />\n <Text type=\"body\" color=\"secondary\">\n {{ metric.change }}\n </Text>\n </HStack>\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n Last 30 days vs. Previous\n </Text>\n <svg :ref=\"i === 0 && start === 0 ? bindSpark : undefined\" class=\"sparkline\" :viewBox=\"`0 0 ${sparkWidth} ${SPARK.h}`\" aria-hidden=\"true\">\n <polyline fill=\"none\" :stroke=\"CHART_COLORS.allUsers\" stroke-width=\"1.5\" :points=\"sparklinePoints(SPARKLINES[start + i])\" />\n </svg>\n </VStack>\n </Card>\n </Grid>\n </Grid>\n\n <Divider />\n\n <HStack h-align=\"between\" align=\"center\">\n <Heading :level=\"3\">\n Demographics\n </Heading>\n <Button label=\"View more\" variant=\"secondary\" />\n </HStack>\n <Grid :columns=\"{ minWidth: 320, repeat: 'fit' }\" :gap=\"4\">\n <Card v-for=\"group in [{ title: 'Region', data: REGION_DATA }, { title: 'Role', data: ROLE_DATA }]\" :key=\"group.title\">\n <VStack :gap=\"4\">\n <Heading :level=\"4\">\n {{ group.title }}\n </Heading>\n <div class=\"stacked-bar\" role=\"img\" :aria-label=\"group.title\">\n <span\n v-for=\"segment in group.data\"\n :key=\"segment.label\"\n class=\"stacked-bar__segment\"\n :style=\"{ backgroundColor: segment.color, flexGrow: segment.value }\"\n />\n </div>\n <HStack :gap=\"4\" wrap=\"wrap\">\n <VStack v-for=\"segment in group.data\" :key=\"segment.label\" :gap=\"0\">\n <HStack :gap=\"2\" align=\"center\">\n <Icon name=\"stop\" size=\"xsm\" color=\"inherit\" :style=\"{ color: segment.color }\" />\n <Text type=\"supporting\">\n {{ segment.label }}\n </Text>\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n {{ segment.value }} - {{ ((segment.value / group.data.reduce((sum, s) => sum + s.value, 0)) * 100).toFixed(2) }}%\n </Text>\n </VStack>\n </HStack>\n </VStack>\n </Card>\n </Grid>\n\n <Divider />\n\n <HStack h-align=\"between\" align=\"center\">\n <Heading :level=\"3\">\n Engagement\n </Heading>\n <Button label=\"View more\" variant=\"secondary\" />\n </HStack>\n <Grid :columns=\"{ minWidth: 320, repeat: 'fit' }\" :gap=\"4\">\n <Card>\n <VStack :gap=\"6\">\n <HStack h-align=\"between\" align=\"center\">\n <Heading :level=\"4\">\n Top pages\n </Heading>\n <Text type=\"body\">\n <Link href=\"#\">\n All pages\n </Link>\n </Text>\n </HStack>\n <Table :data=\"TOP_PAGES\" :columns=\"TOP_PAGES_COLUMNS\" id-key=\"id\" density=\"compact\" dividers=\"rows\" has-hover />\n </VStack>\n </Card>\n <Card>\n <VStack :gap=\"6\">\n <HStack h-align=\"between\" align=\"center\">\n <Heading :level=\"4\">\n Top events\n </Heading>\n <Text type=\"body\">\n <Link href=\"#\">\n All events\n </Link>\n </Text>\n </HStack>\n <Table :data=\"TOP_EVENTS\" :columns=\"TOP_EVENTS_COLUMNS\" id-key=\"id\" density=\"compact\" dividers=\"rows\" has-hover />\n </VStack>\n </Card>\n </Grid>\n </VStack>\n </LayoutContent>\n </Layout>\n</template>\n\n<style scoped>\n.stacked-bar {\n display: flex;\n height: 24px;\n overflow: clip;\n border-radius: 4px;\n}\n.stacked-bar__segment {\n flex-basis: 0;\n}\n.active-users-chart {\n width: 100%;\n height: 300px;\n display: block;\n}\n.chart-grid {\n stroke: var(--astryx-color-border);\n stroke-width: 1;\n}\n.chart-axis {\n fill: var(--astryx-color-text-secondary);\n font-size: 12px;\n}\n.sparkline {\n width: 100%;\n height: 40px;\n display: block;\n}\n</style>\n",
76960
77312
  "docsUrl": {
76961
77313
  "en": "https://astryx-vue.pages.dev/en/playground/?template=dashboard",
76962
77314
  "zh": "https://astryx-vue.pages.dev/playground/?template=dashboard"
@@ -77023,7 +77375,7 @@ const catalog = {
77023
77375
  ],
77024
77376
  "dependencies": ["@astryx-vue/core", "vue"],
77025
77377
  "isReady": true,
77026
- "source": "<script setup lang=\"ts\">\nimport type { TableColumn } from '@astryx-vue/core'\nimport {\n Badge,\n Button,\n Card,\n Divider,\n Grid,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n LayoutHeader,\n pixel,\n proportional,\n ProgressBar,\n Selector,\n Table,\n Text,\n VStack,\n} from '@astryx-vue/core'\nimport { computed, h, onBeforeUnmount, onMounted, reactive, ref } from 'vue'\n\n// ── 数据 ──────────────────────────────────────────────────────────────────────\n\nconst COLORS = {\n blue: 'var(--astryx-color-data-categorical-blue, #0171E3)',\n green: 'var(--astryx-color-data-categorical-green, #0B991F)',\n orange: 'var(--astryx-color-data-categorical-orange, #EB6E00)',\n purple: 'var(--astryx-color-data-categorical-purple, #6B1EFD)',\n teal: 'var(--astryx-color-data-categorical-teal, #08A3A3)',\n neutral: 'var(--astryx-color-data-neutral, #8494A3)',\n}\n\nconst TREND_DAYS = 90\n\n/** 稳定的伪随机,保证每次渲染的曲线一样。 */\nfunction noise(i: number, seed: number) {\n const x = Math.sin(i * 127.1 + seed * 311.7) * 43758.5453\n return x - Math.floor(x)\n}\n\n/** 迷你走势:基线 + 周节律 + 随机游走 + 抖动,偶尔来一个尖峰。 */\nfunction spark(seed: number, trend: number) {\n const out: number[] = []\n let drift = 0\n for (let i = 0; i < TREND_DAYS; i++) {\n const base = 50 + (trend * i * 24) / TREND_DAYS\n const weekly = Math.sin((i * Math.PI * 2) / 7 + seed) * 2.5\n drift = drift * 0.72 + (noise(i, seed) - 0.5) * 7\n const jitter = (noise(i, seed + 17) - 0.5) * 8\n const burst = noise(i, seed + 91) > 0.9 ? (noise(i, seed + 43) - 0.4) * 20 : 0\n out.push(Math.round(base + weekly + drift + jitter + burst))\n }\n return out\n}\n\ninterface Metric {\n key: string\n label: string\n value: string\n higherIsBetter: boolean\n deltas: { dd: number, ww: number, mm: number, yy: number }\n spark: number[]\n}\n\nconst OVERVIEW_METRICS: Metric[] = [\n { key: 'active-users', label: 'Active users', value: '1.28M', higherIsBetter: true, deltas: { dd: 0.4, ww: 2.1, mm: 6.4, yy: 18.2 }, spark: spark(1, 1.1) },\n { key: 'new-users', label: 'New users', value: '184.2K', higherIsBetter: true, deltas: { dd: 1.2, ww: 3.8, mm: 9.1, yy: 24.6 }, spark: spark(2, 1.4) },\n { key: 'sessions', label: 'Sessions', value: '4.62M', higherIsBetter: true, deltas: { dd: 0.2, ww: 1.4, mm: 5.2, yy: 15.9 }, spark: spark(3, 0.9) },\n { key: 'avg-session', label: 'Avg. session', value: '6m 42s', higherIsBetter: true, deltas: { dd: -0.3, ww: 0.8, mm: 2.1, yy: -4.3 }, spark: spark(4, 0.3) },\n { key: 'pages-session', label: 'Pages / session', value: '5.3', higherIsBetter: true, deltas: { dd: 0.1, ww: -0.6, mm: 1.2, yy: 3.7 }, spark: spark(5, 0.4) },\n { key: 'bounce', label: 'Bounce rate', value: '38.6%', higherIsBetter: false, deltas: { dd: -0.2, ww: -1.1, mm: -2.4, yy: -6.8 }, spark: spark(6, -0.5) },\n { key: 'dau-mau', label: 'DAU / MAU', value: '52.1%', higherIsBetter: true, deltas: { dd: 0.3, ww: 1, mm: 3.2, yy: 8.4 }, spark: spark(7, 0.7) },\n { key: 'conversion', label: 'Conversion rate', value: '3.84%', higherIsBetter: true, deltas: { dd: -0.1, ww: 0.9, mm: 2.6, yy: 11.2 }, spark: spark(8, 0.6) },\n { key: 'activation', label: 'Activation rate', value: '61.4%', higherIsBetter: true, deltas: { dd: 0.2, ww: 1.3, mm: 4.1, yy: 12.7 }, spark: spark(9, 0.8) },\n { key: 'retention-30', label: '30-day retention', value: '27.4%', higherIsBetter: true, deltas: { dd: 0.1, ww: -0.4, mm: 1.8, yy: 5.3 }, spark: spark(10, -0.3) },\n { key: 'churn', label: 'Churn rate', value: '4.7%', higherIsBetter: false, deltas: { dd: -0.1, ww: -0.5, mm: -1.3, yy: -3.9 }, spark: spark(11, -0.6) },\n { key: 'load-time', label: 'Avg. load time', value: '1.4s', higherIsBetter: false, deltas: { dd: 0.2, ww: 0.6, mm: -2.2, yy: -8.1 }, spark: spark(12, 0.35) },\n]\n\n/** 活跃用户趋势:周末流量从桌面端挪到移动端,每天各自抖动,偶尔尖峰。 */\nconst ACTIVE_TREND = Array.from({ length: TREND_DAYS }, (_, i) => {\n const weekend = i % 7 === 5 || i % 7 === 6\n const spike = noise(i, 61) > 0.88 ? 1 : 0\n return {\n label: `Day ${i + 1}`,\n desktop: Math.round(520 + (i * 90) / TREND_DAYS + (weekend ? -110 : 20) + (noise(i, 11) - 0.5) * 80),\n mobile: Math.round(680 + (i * 150) / TREND_DAYS + (weekend ? 85 : -15) + (noise(i, 23) - 0.5) * 125 + spike * 130),\n tablet: Math.round(140 + (weekend ? 24 : -5) + (noise(i, 37) - 0.5) * 40 + spike * 18),\n }\n})\n\nconst ACTIVE_SERIES = [\n { key: 'mobile', label: 'Mobile', color: COLORS.blue, hasFill: true },\n { key: 'desktop', label: 'Desktop', color: COLORS.teal, hasFill: true },\n { key: 'tablet', label: 'Tablet', color: COLORS.orange, hasFill: false },\n] as const\n\n/** 和原版一样高 280;`width` 跟着 svg 的实际宽度走,viewBox 和像素一比一,图表撑满卡片。 */\nconst CHART = reactive({ width: 720, height: 280, left: 52, right: 12, top: 5, bottom: 26 })\nconst chartEl = ref<SVGSVGElement | null>(null)\nlet chartObserver: ResizeObserver | null = null\nonMounted(() => {\n if (!chartEl.value || typeof ResizeObserver === 'undefined')\n return\n chartObserver = new ResizeObserver(([entry]) => {\n if (entry && entry.contentRect.width > 0)\n CHART.width = entry.contentRect.width\n })\n chartObserver.observe(chartEl.value)\n})\nonBeforeUnmount(() => chartObserver?.disconnect())\nconst CHART_Y_MAX = 1200\n\nfunction chartX(i: number) {\n return CHART.left + (i / (TREND_DAYS - 1)) * (CHART.width - CHART.left - CHART.right)\n}\n\nfunction chartY(value: number) {\n const inner = CHART.height - CHART.top - CHART.bottom\n return CHART.top + (1 - value / CHART_Y_MAX) * inner\n}\n\nfunction linePath(pick: (row: typeof ACTIVE_TREND[number]) => number) {\n return ACTIVE_TREND.map((row, i) => `${i === 0 ? 'M' : 'L'}${chartX(i).toFixed(1)},${chartY(pick(row)).toFixed(1)}`).join(' ')\n}\n\nfunction areaPath(pick: (row: typeof ACTIVE_TREND[number]) => number) {\n const baseline = chartY(0).toFixed(1)\n const last = ACTIVE_TREND.length - 1\n return `${linePath(pick)} L${chartX(last).toFixed(1)},${baseline} L${chartX(0).toFixed(1)},${baseline} Z`\n}\n\nconst Y_TICKS = [0, 300, 600, 900, 1200]\n/** 和原版一样,4 个刻度平均分布在 90 天里。 */\nconst X_TICKS = [0, 1, 2, 3].map(i => Math.round((i * (TREND_DAYS - 1)) / 3))\n\nconst AGE_DATA = [\n { label: '18–24', value: 26, color: COLORS.blue },\n { label: '25–34', value: 34, color: COLORS.teal },\n { label: '35–44', value: 21, color: COLORS.green },\n { label: '45–54', value: 12, color: COLORS.orange },\n { label: '55+', value: 7, color: COLORS.neutral },\n]\n\nconst GENDER_DATA = [\n { label: 'Female', value: 48, color: COLORS.purple },\n { label: 'Male', value: 46, color: COLORS.blue },\n { label: 'Other / undisclosed', value: 6, color: COLORS.neutral },\n]\n\nconst DEVICE_DATA = [\n { label: 'Mobile', value: 58, color: COLORS.blue },\n { label: 'Desktop', value: 34, color: COLORS.teal },\n { label: 'Tablet', value: 8, color: COLORS.orange },\n]\n\ninterface AcqRow extends Record<string, unknown> {\n id: string\n channel: string\n users: number\n share: number\n delta: number\n}\n\nconst ACQUISITION: AcqRow[] = [\n { id: 'a1', channel: 'Organic search', users: 486_200, share: 38, delta: 4.2 },\n { id: 'a2', channel: 'Direct', users: 295_400, share: 23, delta: 1.1 },\n { id: 'a3', channel: 'Social', users: 217_800, share: 17, delta: 8.6 },\n { id: 'a4', channel: 'Referral', users: 141_900, share: 11, delta: -2.3 },\n { id: 'a5', channel: 'Email', users: 89_200, share: 7, delta: 3.4 },\n { id: 'a6', channel: 'Paid ads', users: 51_300, share: 4, delta: -1.8 },\n]\n\nconst ACQ_MAX = Math.max(...ACQUISITION.map(a => a.users))\n\nconst RANGE_OPTIONS = [\n { value: '7d', label: 'Last 7 days' },\n { value: '30d', label: 'Last 30 days' },\n { value: '90d', label: 'Last 90 days' },\n]\nconst SEGMENT_OPTIONS = [\n { value: 'all', label: 'All users' },\n { value: 'new', label: 'New users' },\n { value: 'returning', label: 'Returning users' },\n]\nconst DEVICE_OPTIONS = [\n { value: 'all', label: 'All devices' },\n { value: 'mobile', label: 'Mobile' },\n { value: 'desktop', label: 'Desktop' },\n { value: 'tablet', label: 'Tablet' },\n]\n\n// ── 状态 ──────────────────────────────────────────────────────────────────────\n\nconst range = ref('90d')\nconst segment = ref('all')\nconst device = ref('all')\n\nfunction labelFor(options: { value: string, label: string }[], value: string) {\n return options.find(option => option.value === value)?.label ?? ''\n}\n\nconst summary = computed(() =>\n [labelFor(SEGMENT_OPTIONS, segment.value), labelFor(DEVICE_OPTIONS, device.value), labelFor(RANGE_OPTIONS, range.value)].join(' · '),\n)\n\n// ── 表格列 ────────────────────────────────────────────────────────────────────\n\nfunction formatNumber(n: number) {\n return n.toLocaleString('en-US')\n}\n\nfunction deltaVariant(value: number) {\n return value === 0 ? 'neutral' : value > 0 ? 'green' : 'red'\n}\n\nfunction deltaLabel(value: number) {\n return `${value > 0 ? '+' : ''}${value.toFixed(1)}%`\n}\n\nconst acquisitionColumns: TableColumn<AcqRow>[] = [\n {\n key: 'channel',\n header: 'Channel',\n width: pixel(160),\n renderCell: row => h(Text, { type: 'body', weight: 'semibold' }, () => row.channel),\n },\n {\n key: 'users',\n header: 'Users',\n width: proportional(1),\n renderCell: row => h(VStack, { gap: 1 }, () => [\n h(ProgressBar, { value: row.users, max: ACQ_MAX, label: `${row.channel} users`, isLabelHidden: true }),\n h(Text, { type: 'supporting', color: 'secondary', hasTabularNumbers: true }, () => formatNumber(row.users)),\n ]),\n },\n {\n key: 'share',\n header: 'Share',\n width: pixel(90),\n renderCell: row => h(Text, { type: 'body', hasTabularNumbers: true }, () => `${row.share}%`),\n },\n {\n key: 'delta',\n header: 'w/w',\n width: pixel(100),\n renderCell: row => h(Badge, { variant: deltaVariant(row.delta), label: deltaLabel(row.delta) }),\n },\n]\n\nconst BREAKDOWNS = [\n { title: 'Age', data: AGE_DATA },\n { title: 'Gender', data: GENDER_DATA },\n { title: 'Device', data: DEVICE_DATA },\n]\n\nfunction sparkPath(data: number[]) {\n const max = Math.max(...data)\n const min = Math.min(...data)\n const span = max - min || 1\n return data.map((value, i) => {\n const x = (i / (data.length - 1)) * 100\n const y = 32 - ((value - min) / span) * 32\n return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`\n }).join(' ')\n}\n\nfunction deltaColor(metric: Metric) {\n const trendUp = metric.deltas.ww >= 0\n const favorable = metric.higherIsBetter ? trendUp : !trendUp\n return favorable ? COLORS.green : COLORS.orange\n}\n\nfunction directionColor(metric: Metric) {\n const trendUp = metric.deltas.ww >= 0\n const favorable = metric.higherIsBetter ? trendUp : !trendUp\n return favorable ? 'success' : 'error'\n}\n<\/script>\n\n<template>\n <Layout height=\"fill\" :content-width=\"1440\">\n <template #header>\n <LayoutHeader has-divider :padding=\"6\">\n <HStack :gap=\"3\" align=\"center\" justify=\"between\" wrap=\"wrap\">\n <VStack :gap=\"0\">\n <Heading :level=\"1\">\n Data Dashboard\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n User analytics overview · {{ summary }}\n </Text>\n </VStack>\n <HStack :gap=\"1\">\n <Selector v-model=\"range\" label=\"Date range\" is-label-hidden :options=\"RANGE_OPTIONS\" />\n <Selector v-model=\"segment\" label=\"Segment\" is-label-hidden :options=\"SEGMENT_OPTIONS\" />\n <Selector v-model=\"device\" label=\"Device\" is-label-hidden :options=\"DEVICE_OPTIONS\" />\n <Button label=\"Export\" variant=\"secondary\">\n <template #icon>\n <Icon name=\"heroicons:arrow-down-tray\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n </HStack>\n </LayoutHeader>\n </template>\n\n <LayoutContent :padding=\"6\">\n <VStack :gap=\"10\">\n <VStack :gap=\"6\">\n <HStack :gap=\"3\" align=\"center\" justify=\"between\">\n <HStack :gap=\"3\" align=\"center\">\n <span class=\"section-tile\">\n <Icon icon=\"i-carbon-user\" size=\"sm\" color=\"secondary\" />\n </span>\n <Heading :level=\"2\">\n Key metrics\n </Heading>\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n Change vs. prior day, week, month, and year\n </Text>\n </HStack>\n <Grid :columns=\"{ minWidth: 240, repeat: 'fit' }\" :gap=\"3\" class=\"metrics-grid\">\n <Card v-for=\"metric in OVERVIEW_METRICS\" :key=\"metric.key\" :padding=\"4\">\n <VStack :gap=\"4\">\n <VStack :gap=\"1\">\n <Text type=\"label\" color=\"secondary\">\n {{ metric.label }}\n </Text>\n <HStack :gap=\"2\" align=\"center\">\n <Heading :level=\"1\" :accessibility-level=\"3\">\n {{ metric.value }}\n </Heading>\n <Icon\n :icon=\"metric.deltas.ww >= 0 ? 'arrowUp' : 'arrowDown'\"\n size=\"sm\"\n :color=\"directionColor(metric)\"\n />\n </HStack>\n </VStack>\n <svg class=\"spark\" viewBox=\"0 0 100 32\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n <path\n :d=\"sparkPath(metric.spark)\"\n fill=\"none\"\n :stroke=\"deltaColor(metric)\"\n stroke-width=\"1.25\"\n vector-effect=\"non-scaling-stroke\"\n />\n </svg>\n <Grid :columns=\"2\" :gap=\"2\">\n <Text type=\"body\" weight=\"bold\">\n {{ metric.deltas.dd > 0 ? '+' : '' }}{{ metric.deltas.dd.toFixed(1) }}%\n <Text type=\"body\" display=\"inline\" weight=\"normal\" color=\"secondary\">\n d/d\n </Text>\n </Text>\n <Text type=\"body\" weight=\"bold\">\n {{ metric.deltas.ww > 0 ? '+' : '' }}{{ metric.deltas.ww.toFixed(1) }}%\n <Text type=\"body\" display=\"inline\" weight=\"normal\" color=\"secondary\">\n w/w\n </Text>\n </Text>\n <Text type=\"body\" weight=\"bold\">\n {{ metric.deltas.mm > 0 ? '+' : '' }}{{ metric.deltas.mm.toFixed(1) }}%\n <Text type=\"body\" display=\"inline\" weight=\"normal\" color=\"secondary\">\n m/m\n </Text>\n </Text>\n <Text type=\"body\" weight=\"bold\">\n {{ metric.deltas.yy > 0 ? '+' : '' }}{{ metric.deltas.yy.toFixed(1) }}%\n <Text type=\"body\" display=\"inline\" weight=\"normal\" color=\"secondary\">\n y/y\n </Text>\n </Text>\n </Grid>\n </VStack>\n </Card>\n </Grid>\n </VStack>\n\n <VStack :gap=\"6\">\n <HStack :gap=\"3\" align=\"center\" justify=\"between\">\n <HStack :gap=\"3\" align=\"center\">\n <span class=\"section-tile\">\n <Icon icon=\"i-carbon-cursor-1\" size=\"sm\" color=\"secondary\" />\n </span>\n <Heading :level=\"2\">\n Devices\n </Heading>\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n Daily active users (thousands)\n </Text>\n </HStack>\n <Card :padding=\"6\">\n <VStack :gap=\"6\">\n <Text type=\"label\">\n Daily active users by device type\n </Text>\n <svg ref=\"chartEl\" class=\"trend-chart\" :viewBox=\"`0 0 ${CHART.width} ${CHART.height}`\" role=\"img\" aria-label=\"Daily active users by device type\">\n <defs>\n <linearGradient id=\"au-mobile\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"5%\" :stop-color=\"COLORS.blue\" stop-opacity=\"0.3\" />\n <stop offset=\"95%\" :stop-color=\"COLORS.blue\" stop-opacity=\"0.04\" />\n </linearGradient>\n <linearGradient id=\"au-desktop\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"5%\" :stop-color=\"COLORS.teal\" stop-opacity=\"0.3\" />\n <stop offset=\"95%\" :stop-color=\"COLORS.teal\" stop-opacity=\"0.04\" />\n </linearGradient>\n </defs>\n\n <line\n v-for=\"tick in Y_TICKS\"\n :key=\"`grid-${tick}`\"\n :x1=\"CHART.left\"\n :x2=\"CHART.width - CHART.right\"\n :y1=\"chartY(tick)\"\n :y2=\"chartY(tick)\"\n class=\"chart-grid\"\n />\n <text\n v-for=\"tick in Y_TICKS\"\n :key=\"`y-${tick}`\"\n :x=\"CHART.left - 8\"\n :y=\"chartY(tick) + 4\"\n text-anchor=\"end\"\n class=\"chart-tick\"\n >\n {{ tick }}K\n </text>\n <text\n v-for=\"tick in X_TICKS\"\n :key=\"`x-${tick}`\"\n :x=\"chartX(tick)\"\n :y=\"CHART.height - 6\"\n text-anchor=\"middle\"\n class=\"chart-tick\"\n >\n {{ ACTIVE_TREND[tick].label }}\n </text>\n\n <path :d=\"areaPath(row => row.mobile)\" fill=\"url(#au-mobile)\" />\n <path :d=\"areaPath(row => row.desktop)\" fill=\"url(#au-desktop)\" />\n <path :d=\"linePath(row => row.tablet)\" fill=\"none\" :stroke=\"COLORS.orange\" stroke-width=\"1.5\" />\n <path :d=\"linePath(row => row.mobile)\" fill=\"none\" :stroke=\"COLORS.blue\" stroke-width=\"1.5\" />\n <path :d=\"linePath(row => row.desktop)\" fill=\"none\" :stroke=\"COLORS.teal\" stroke-width=\"1.5\" />\n </svg>\n <HStack :gap=\"5\" align=\"center\" wrap=\"wrap\">\n <HStack v-for=\"series in ACTIVE_SERIES\" :key=\"series.key\" :gap=\"2\" align=\"center\">\n <span class=\"legend-dot\" :style=\"{ background: series.color }\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ series.label }}\n </Text>\n </HStack>\n </HStack>\n </VStack>\n </Card>\n </VStack>\n\n <Divider />\n\n <VStack :gap=\"6\">\n <HStack :gap=\"3\" align=\"center\" justify=\"between\">\n <HStack :gap=\"3\" align=\"center\">\n <span class=\"section-tile\">\n <Icon icon=\"i-carbon-user-multiple\" size=\"sm\" color=\"secondary\" />\n </span>\n <Heading :level=\"2\">\n Audience demographics\n </Heading>\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n Share of active users\n </Text>\n </HStack>\n <Grid :columns=\"{ minWidth: 320, repeat: 'fit' }\" :gap=\"4\">\n <Card v-for=\"breakdown in BREAKDOWNS\" :key=\"breakdown.title\" :padding=\"4\">\n <VStack :gap=\"4\">\n <Heading :level=\"4\" :accessibility-level=\"3\">\n {{ breakdown.title }}\n </Heading>\n <div class=\"breakdown-bar\">\n <div\n v-for=\"seg in breakdown.data\"\n :key=\"seg.label\"\n class=\"breakdown-bar__seg\"\n :style=\"{ width: `${seg.value}%`, background: seg.color }\"\n />\n </div>\n <VStack :gap=\"2\">\n <HStack v-for=\"seg in breakdown.data\" :key=\"seg.label\" justify=\"between\" align=\"center\">\n <HStack :gap=\"2\" align=\"center\">\n <span class=\"legend-dot\" :style=\"{ background: seg.color }\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ seg.label }}\n </Text>\n </HStack>\n <Text type=\"supporting\" weight=\"semibold\">\n {{ seg.value }}%\n </Text>\n </HStack>\n </VStack>\n </VStack>\n </Card>\n </Grid>\n </VStack>\n\n <VStack :gap=\"6\">\n <HStack :gap=\"3\" align=\"center\" justify=\"between\">\n <Heading :level=\"2\">\n Acquisition channels\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n How users find the product\n </Text>\n </HStack>\n <Card>\n <Table :data=\"ACQUISITION\" :columns=\"acquisitionColumns\" id-key=\"id\" density=\"balanced\" dividers=\"rows\" has-hover />\n </Card>\n </VStack>\n </VStack>\n </LayoutContent>\n </Layout>\n</template>\n\n<style scoped>\n.metrics-grid {\n align-items: stretch;\n grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));\n}\n.section-tile {\n display: grid;\n place-items: center;\n width: 32px;\n height: 32px;\n border-radius: var(--astryx-radius-element);\n background: var(--astryx-color-background-muted);\n}\n.spark { width: 100%; height: 48px; display: block; }\n.trend-chart { width: 100%; height: 280px; display: block; overflow: visible; }\n.chart-grid { stroke: var(--astryx-color-border, rgb(5 54 89 / 10%)); stroke-width: 1; }\n.chart-tick { fill: var(--astryx-color-text-secondary, #4E606F); font-size: 12px; }\n.legend-dot { width: 8px; height: 8px; border-radius: 2px; flex: none; }\n.breakdown-bar { display: flex; width: 100%; height: 12px; border-radius: 4px; overflow: hidden; }\n.breakdown-bar__seg { height: 100%; }\n</style>\n",
77378
+ "source": "<script setup lang=\"ts\">\nimport type { TableColumn } from '@astryx-vue/core'\nimport {\n Badge,\n Button,\n Card,\n Divider,\n Grid,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n LayoutHeader,\n pixel,\n proportional,\n ProgressBar,\n Selector,\n Table,\n Text,\n VStack,\n} from '@astryx-vue/core'\nimport { computed, h, onBeforeUnmount, onMounted, reactive, ref } from 'vue'\n\n// ── 数据 ──────────────────────────────────────────────────────────────────────\n\nconst COLORS = {\n blue: 'var(--astryx-color-data-categorical-blue, #0171E3)',\n green: 'var(--astryx-color-data-categorical-green, #0B991F)',\n orange: 'var(--astryx-color-data-categorical-orange, #EB6E00)',\n purple: 'var(--astryx-color-data-categorical-purple, #6B1EFD)',\n teal: 'var(--astryx-color-data-categorical-teal, #08A3A3)',\n neutral: 'var(--astryx-color-data-neutral, #8494A3)',\n}\n\nconst TREND_DAYS = 90\n\n/** 稳定的伪随机,保证每次渲染的曲线一样。 */\nfunction noise(i: number, seed: number) {\n const x = Math.sin(i * 127.1 + seed * 311.7) * 43758.5453\n return x - Math.floor(x)\n}\n\n/** 迷你走势:基线 + 周节律 + 随机游走 + 抖动,偶尔来一个尖峰。 */\nfunction spark(seed: number, trend: number) {\n const out: number[] = []\n let drift = 0\n for (let i = 0; i < TREND_DAYS; i++) {\n const base = 50 + (trend * i * 24) / TREND_DAYS\n const weekly = Math.sin((i * Math.PI * 2) / 7 + seed) * 2.5\n drift = drift * 0.72 + (noise(i, seed) - 0.5) * 7\n const jitter = (noise(i, seed + 17) - 0.5) * 8\n const burst = noise(i, seed + 91) > 0.9 ? (noise(i, seed + 43) - 0.4) * 20 : 0\n out.push(Math.round(base + weekly + drift + jitter + burst))\n }\n return out\n}\n\ninterface Metric {\n key: string\n label: string\n value: string\n higherIsBetter: boolean\n deltas: { dd: number, ww: number, mm: number, yy: number }\n spark: number[]\n}\n\nconst OVERVIEW_METRICS: Metric[] = [\n { key: 'active-users', label: 'Active users', value: '1.28M', higherIsBetter: true, deltas: { dd: 0.4, ww: 2.1, mm: 6.4, yy: 18.2 }, spark: spark(1, 1.1) },\n { key: 'new-users', label: 'New users', value: '184.2K', higherIsBetter: true, deltas: { dd: 1.2, ww: 3.8, mm: 9.1, yy: 24.6 }, spark: spark(2, 1.4) },\n { key: 'sessions', label: 'Sessions', value: '4.62M', higherIsBetter: true, deltas: { dd: 0.2, ww: 1.4, mm: 5.2, yy: 15.9 }, spark: spark(3, 0.9) },\n { key: 'avg-session', label: 'Avg. session', value: '6m 42s', higherIsBetter: true, deltas: { dd: -0.3, ww: 0.8, mm: 2.1, yy: -4.3 }, spark: spark(4, 0.3) },\n { key: 'pages-session', label: 'Pages / session', value: '5.3', higherIsBetter: true, deltas: { dd: 0.1, ww: -0.6, mm: 1.2, yy: 3.7 }, spark: spark(5, 0.4) },\n { key: 'bounce', label: 'Bounce rate', value: '38.6%', higherIsBetter: false, deltas: { dd: -0.2, ww: -1.1, mm: -2.4, yy: -6.8 }, spark: spark(6, -0.5) },\n { key: 'dau-mau', label: 'DAU / MAU', value: '52.1%', higherIsBetter: true, deltas: { dd: 0.3, ww: 1, mm: 3.2, yy: 8.4 }, spark: spark(7, 0.7) },\n { key: 'conversion', label: 'Conversion rate', value: '3.84%', higherIsBetter: true, deltas: { dd: -0.1, ww: 0.9, mm: 2.6, yy: 11.2 }, spark: spark(8, 0.6) },\n { key: 'activation', label: 'Activation rate', value: '61.4%', higherIsBetter: true, deltas: { dd: 0.2, ww: 1.3, mm: 4.1, yy: 12.7 }, spark: spark(9, 0.8) },\n { key: 'retention-30', label: '30-day retention', value: '27.4%', higherIsBetter: true, deltas: { dd: 0.1, ww: -0.4, mm: 1.8, yy: 5.3 }, spark: spark(10, -0.3) },\n { key: 'churn', label: 'Churn rate', value: '4.7%', higherIsBetter: false, deltas: { dd: -0.1, ww: -0.5, mm: -1.3, yy: -3.9 }, spark: spark(11, -0.6) },\n { key: 'load-time', label: 'Avg. load time', value: '1.4s', higherIsBetter: false, deltas: { dd: 0.2, ww: 0.6, mm: -2.2, yy: -8.1 }, spark: spark(12, 0.35) },\n]\n\n/** 活跃用户趋势:周末流量从桌面端挪到移动端,每天各自抖动,偶尔尖峰。 */\nconst ACTIVE_TREND = Array.from({ length: TREND_DAYS }, (_, i) => {\n const weekend = i % 7 === 5 || i % 7 === 6\n const spike = noise(i, 61) > 0.88 ? 1 : 0\n return {\n label: `Day ${i + 1}`,\n desktop: Math.round(520 + (i * 90) / TREND_DAYS + (weekend ? -110 : 20) + (noise(i, 11) - 0.5) * 80),\n mobile: Math.round(680 + (i * 150) / TREND_DAYS + (weekend ? 85 : -15) + (noise(i, 23) - 0.5) * 125 + spike * 130),\n tablet: Math.round(140 + (weekend ? 24 : -5) + (noise(i, 37) - 0.5) * 40 + spike * 18),\n }\n})\n\nconst ACTIVE_SERIES = [\n { key: 'mobile', label: 'Mobile', color: COLORS.blue, hasFill: true },\n { key: 'desktop', label: 'Desktop', color: COLORS.teal, hasFill: true },\n { key: 'tablet', label: 'Tablet', color: COLORS.orange, hasFill: false },\n] as const\n\n/** 和原版一样高 280;`width` 跟着 svg 的实际宽度走,viewBox 和像素一比一,图表撑满卡片。 */\nconst CHART = reactive({ width: 720, height: 280, left: 40, right: 12, top: 5, bottom: 35 })\nconst chartEl = ref<SVGSVGElement | null>(null)\nlet chartObserver: ResizeObserver | null = null\nonMounted(() => {\n if (!chartEl.value || typeof ResizeObserver === 'undefined')\n return\n chartObserver = new ResizeObserver(([entry]) => {\n if (entry && entry.contentRect.width > 0)\n CHART.width = entry.contentRect.width\n })\n chartObserver.observe(chartEl.value)\n})\nonBeforeUnmount(() => chartObserver?.disconnect())\nconst CHART_Y_MAX = 1200\n\nfunction chartX(i: number) {\n return CHART.left + (i / (TREND_DAYS - 1)) * (CHART.width - CHART.left - CHART.right)\n}\n\nfunction chartY(value: number) {\n const inner = CHART.height - CHART.top - CHART.bottom\n return CHART.top + (1 - value / CHART_Y_MAX) * inner\n}\n\nfunction linePath(pick: (row: typeof ACTIVE_TREND[number]) => number) {\n return ACTIVE_TREND.map((row, i) => `${i === 0 ? 'M' : 'L'}${chartX(i).toFixed(1)},${chartY(pick(row)).toFixed(1)}`).join(' ')\n}\n\nfunction areaPath(pick: (row: typeof ACTIVE_TREND[number]) => number) {\n const baseline = chartY(0).toFixed(1)\n const last = ACTIVE_TREND.length - 1\n return `${linePath(pick)} L${chartX(last).toFixed(1)},${baseline} L${chartX(0).toFixed(1)},${baseline} Z`\n}\n\nconst Y_TICKS = [0, 300, 600, 900, 1200]\n/** 和原版一样,4 个刻度平均分布在 90 天里。 */\nconst X_TICKS = [0, 1, 2, 3].map(i => Math.round((i * (TREND_DAYS - 1)) / 3))\n\nconst AGE_DATA = [\n { label: '18–24', value: 26, color: COLORS.blue },\n { label: '25–34', value: 34, color: COLORS.teal },\n { label: '35–44', value: 21, color: COLORS.green },\n { label: '45–54', value: 12, color: COLORS.orange },\n { label: '55+', value: 7, color: COLORS.neutral },\n]\n\nconst GENDER_DATA = [\n { label: 'Female', value: 48, color: COLORS.purple },\n { label: 'Male', value: 46, color: COLORS.blue },\n { label: 'Other / undisclosed', value: 6, color: COLORS.neutral },\n]\n\nconst DEVICE_DATA = [\n { label: 'Mobile', value: 58, color: COLORS.blue },\n { label: 'Desktop', value: 34, color: COLORS.teal },\n { label: 'Tablet', value: 8, color: COLORS.orange },\n]\n\ninterface AcqRow extends Record<string, unknown> {\n id: string\n channel: string\n users: number\n share: number\n delta: number\n}\n\nconst ACQUISITION: AcqRow[] = [\n { id: 'a1', channel: 'Organic search', users: 486_200, share: 38, delta: 4.2 },\n { id: 'a2', channel: 'Direct', users: 295_400, share: 23, delta: 1.1 },\n { id: 'a3', channel: 'Social', users: 217_800, share: 17, delta: 8.6 },\n { id: 'a4', channel: 'Referral', users: 141_900, share: 11, delta: -2.3 },\n { id: 'a5', channel: 'Email', users: 89_200, share: 7, delta: 3.4 },\n { id: 'a6', channel: 'Paid ads', users: 51_300, share: 4, delta: -1.8 },\n]\n\nconst ACQ_MAX = Math.max(...ACQUISITION.map(a => a.users))\n\nconst RANGE_OPTIONS = [\n { value: '7d', label: 'Last 7 days' },\n { value: '30d', label: 'Last 30 days' },\n { value: '90d', label: 'Last 90 days' },\n]\nconst SEGMENT_OPTIONS = [\n { value: 'all', label: 'All users' },\n { value: 'new', label: 'New users' },\n { value: 'returning', label: 'Returning users' },\n]\nconst DEVICE_OPTIONS = [\n { value: 'all', label: 'All devices' },\n { value: 'mobile', label: 'Mobile' },\n { value: 'desktop', label: 'Desktop' },\n { value: 'tablet', label: 'Tablet' },\n]\n\n// ── 状态 ──────────────────────────────────────────────────────────────────────\n\nconst range = ref('90d')\nconst segment = ref('all')\nconst device = ref('all')\n\nfunction labelFor(options: { value: string, label: string }[], value: string) {\n return options.find(option => option.value === value)?.label ?? ''\n}\n\nconst summary = computed(() =>\n [labelFor(SEGMENT_OPTIONS, segment.value), labelFor(DEVICE_OPTIONS, device.value), labelFor(RANGE_OPTIONS, range.value)].join(' · '),\n)\n\n// ── 表格列 ────────────────────────────────────────────────────────────────────\n\nfunction formatNumber(n: number) {\n return n.toLocaleString('en-US')\n}\n\nfunction deltaVariant(value: number) {\n return value === 0 ? 'neutral' : value > 0 ? 'green' : 'red'\n}\n\nfunction deltaLabel(value: number) {\n return `${value > 0 ? '+' : ''}${value.toFixed(1)}%`\n}\n\nconst acquisitionColumns: TableColumn<AcqRow>[] = [\n {\n key: 'channel',\n header: 'Channel',\n width: pixel(160),\n renderCell: row => h(Text, { type: 'body', weight: 'semibold' }, () => row.channel),\n },\n {\n key: 'users',\n header: 'Users',\n width: proportional(1),\n renderCell: row => h(VStack, { gap: 1 }, () => [\n h(ProgressBar, { value: row.users, max: ACQ_MAX, label: `${row.channel} users`, isLabelHidden: true }),\n h(Text, { type: 'supporting', color: 'secondary', hasTabularNumbers: true }, () => formatNumber(row.users)),\n ]),\n },\n {\n key: 'share',\n header: 'Share',\n width: pixel(90),\n renderCell: row => h(Text, { type: 'body', hasTabularNumbers: true }, () => `${row.share}%`),\n },\n {\n key: 'delta',\n header: 'w/w',\n width: pixel(100),\n renderCell: row => h(Badge, { variant: deltaVariant(row.delta), label: deltaLabel(row.delta) }),\n },\n]\n\nconst BREAKDOWNS = [\n { title: 'Age', data: AGE_DATA },\n { title: 'Gender', data: GENDER_DATA },\n { title: 'Device', data: DEVICE_DATA },\n]\n\n/** 迷你折线:高 48,上留 4;纵轴从 0 起、上界取整到下一个整百(和原版的图表默认一致);宽度量真实像素。 */\nconst SPARK = { h: 48, top: 4 }\nconst sparkWidth = ref(392)\nlet sparkObserver: ResizeObserver | null = null\n\nfunction bindSpark(element: unknown): void {\n sparkObserver?.disconnect()\n sparkObserver = null\n if (!(element instanceof SVGSVGElement) || typeof ResizeObserver === 'undefined')\n return\n sparkObserver = new ResizeObserver(([entry]) => {\n if (entry && entry.contentRect.width > 0)\n sparkWidth.value = entry.contentRect.width\n })\n sparkObserver.observe(element)\n}\nonBeforeUnmount(() => sparkObserver?.disconnect())\n\nfunction sparkPath(data: number[]) {\n const top = Math.ceil(Math.max(...data) / 100) * 100\n const inner = SPARK.h - SPARK.top\n return data.map((value, i) => {\n const x = (i / (data.length - 1)) * sparkWidth.value\n const y = SPARK.top + inner * (1 - value / top)\n return `${i === 0 ? 'M' : 'L'}${x.toFixed(3)},${y.toFixed(3)}`\n }).join('')\n}\n\nfunction deltaColor(metric: Metric) {\n const trendUp = metric.deltas.ww >= 0\n const favorable = metric.higherIsBetter ? trendUp : !trendUp\n return favorable ? COLORS.green : COLORS.orange\n}\n\nfunction directionColor(metric: Metric) {\n const trendUp = metric.deltas.ww >= 0\n const favorable = metric.higherIsBetter ? trendUp : !trendUp\n return favorable ? 'success' : 'error'\n}\n<\/script>\n\n<template>\n <Layout height=\"fill\" :content-width=\"1440\">\n <template #header>\n <LayoutHeader has-divider>\n <HStack :gap=\"3\" align=\"center\" justify=\"between\" wrap=\"wrap\">\n <VStack :gap=\"0\">\n <Heading :level=\"1\">\n Data Dashboard\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n User analytics overview · {{ summary }}\n </Text>\n </VStack>\n <HStack :gap=\"1\">\n <Selector v-model=\"range\" label=\"Date range\" is-label-hidden :options=\"RANGE_OPTIONS\" />\n <Selector v-model=\"segment\" label=\"Segment\" is-label-hidden :options=\"SEGMENT_OPTIONS\" />\n <Selector v-model=\"device\" label=\"Device\" is-label-hidden :options=\"DEVICE_OPTIONS\" />\n <Button label=\"Export\" variant=\"secondary\">\n <template #icon>\n <Icon name=\"heroicons:arrow-down-tray\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n </HStack>\n </LayoutHeader>\n </template>\n\n <LayoutContent :padding=\"6\">\n <VStack :gap=\"10\">\n <VStack :gap=\"6\">\n <HStack :gap=\"3\" align=\"center\" justify=\"between\">\n <HStack :gap=\"3\" align=\"center\">\n <span class=\"section-tile\">\n <Icon name=\"heroicons:users\" size=\"sm\" color=\"secondary\" />\n </span>\n <Heading :level=\"2\">\n Key metrics\n </Heading>\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n Change vs. prior day, week, month, and year\n </Text>\n </HStack>\n <Grid :columns=\"{ minWidth: 240, repeat: 'fit' }\" :gap=\"3\" class=\"metrics-grid\">\n <Card v-for=\"(metric, index) in OVERVIEW_METRICS\" :key=\"metric.key\" :padding=\"4\">\n <VStack :gap=\"4\">\n <VStack :gap=\"1\">\n <Text type=\"label\" color=\"secondary\">\n {{ metric.label }}\n </Text>\n <HStack :gap=\"2\" align=\"center\">\n <Heading :level=\"1\" :accessibility-level=\"3\">\n {{ metric.value }}\n </Heading>\n <Icon\n :icon=\"metric.deltas.ww >= 0 ? 'arrowUp' : 'arrowDown'\"\n size=\"sm\"\n :color=\"directionColor(metric)\"\n />\n </HStack>\n </VStack>\n <svg :ref=\"index === 0 ? bindSpark : undefined\" class=\"spark\" :viewBox=\"`0 0 ${sparkWidth} ${SPARK.h}`\" aria-hidden=\"true\">\n <path\n :d=\"sparkPath(metric.spark)\"\n fill=\"none\"\n :stroke=\"deltaColor(metric)\"\n stroke-width=\"1.25\"\n />\n </svg>\n <Grid :columns=\"2\" :gap=\"2\">\n <Text type=\"body\" weight=\"bold\">\n {{ metric.deltas.dd > 0 ? '+' : '' }}{{ metric.deltas.dd.toFixed(1) }}%\n <Text type=\"body\" display=\"inline\" weight=\"normal\" color=\"secondary\">\n d/d\n </Text>\n </Text>\n <Text type=\"body\" weight=\"bold\">\n {{ metric.deltas.ww > 0 ? '+' : '' }}{{ metric.deltas.ww.toFixed(1) }}%\n <Text type=\"body\" display=\"inline\" weight=\"normal\" color=\"secondary\">\n w/w\n </Text>\n </Text>\n <Text type=\"body\" weight=\"bold\">\n {{ metric.deltas.mm > 0 ? '+' : '' }}{{ metric.deltas.mm.toFixed(1) }}%\n <Text type=\"body\" display=\"inline\" weight=\"normal\" color=\"secondary\">\n m/m\n </Text>\n </Text>\n <Text type=\"body\" weight=\"bold\">\n {{ metric.deltas.yy > 0 ? '+' : '' }}{{ metric.deltas.yy.toFixed(1) }}%\n <Text type=\"body\" display=\"inline\" weight=\"normal\" color=\"secondary\">\n y/y\n </Text>\n </Text>\n </Grid>\n </VStack>\n </Card>\n </Grid>\n </VStack>\n\n <VStack :gap=\"6\">\n <HStack :gap=\"3\" align=\"center\" justify=\"between\">\n <HStack :gap=\"3\" align=\"center\">\n <span class=\"section-tile\">\n <Icon name=\"heroicons:cursor-arrow-rays\" size=\"sm\" color=\"secondary\" />\n </span>\n <Heading :level=\"2\">\n Devices\n </Heading>\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n Daily active users (thousands)\n </Text>\n </HStack>\n <Card :padding=\"6\">\n <VStack :gap=\"6\">\n <Text type=\"label\">\n Daily active users by device type\n </Text>\n <VStack :gap=\"3\">\n <svg ref=\"chartEl\" class=\"trend-chart\" :viewBox=\"`0 0 ${CHART.width} ${CHART.height}`\" role=\"img\" aria-label=\"Daily active users by device type\">\n <defs>\n <linearGradient id=\"au-mobile\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"5%\" :stop-color=\"COLORS.blue\" stop-opacity=\"0.3\" />\n <stop offset=\"95%\" :stop-color=\"COLORS.blue\" stop-opacity=\"0.04\" />\n </linearGradient>\n <linearGradient id=\"au-desktop\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"5%\" :stop-color=\"COLORS.teal\" stop-opacity=\"0.3\" />\n <stop offset=\"95%\" :stop-color=\"COLORS.teal\" stop-opacity=\"0.04\" />\n </linearGradient>\n </defs>\n\n <line\n v-for=\"tick in Y_TICKS\"\n :key=\"`grid-${tick}`\"\n :x1=\"CHART.left\"\n :x2=\"CHART.width - CHART.right\"\n :y1=\"chartY(tick)\"\n :y2=\"chartY(tick)\"\n class=\"chart-grid\"\n />\n <text\n v-for=\"tick in Y_TICKS\"\n :key=\"`y-${tick}`\"\n :x=\"CHART.left - 8\"\n :y=\"Math.max(chartY(tick), 9)\"\n text-anchor=\"end\"\n dominant-baseline=\"central\"\n class=\"chart-tick\"\n >\n {{ tick }}K\n </text>\n <text\n v-for=\"tick in X_TICKS\"\n :key=\"`x-${tick}`\"\n :x=\"Math.min(chartX(tick), CHART.width - CHART.right - 20)\"\n :y=\"CHART.height - CHART.bottom + 13\"\n text-anchor=\"middle\"\n dominant-baseline=\"central\"\n class=\"chart-tick\"\n >\n {{ ACTIVE_TREND[tick].label }}\n </text>\n\n <path :d=\"areaPath(row => row.mobile)\" fill=\"url(#au-mobile)\" />\n <path :d=\"linePath(row => row.mobile)\" fill=\"none\" :stroke=\"COLORS.blue\" stroke-width=\"1.5\" />\n <path :d=\"areaPath(row => row.desktop)\" fill=\"url(#au-desktop)\" />\n <path :d=\"linePath(row => row.desktop)\" fill=\"none\" :stroke=\"COLORS.teal\" stroke-width=\"1.5\" />\n <path :d=\"linePath(row => row.tablet)\" fill=\"none\" :stroke=\"COLORS.orange\" stroke-width=\"1.5\" />\n </svg>\n <HStack :gap=\"5\" align=\"center\" wrap=\"wrap\">\n <HStack v-for=\"series in ACTIVE_SERIES\" :key=\"series.key\" :gap=\"2\" align=\"center\">\n <span class=\"legend-dot\" :style=\"{ background: series.color }\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ series.label }}\n </Text>\n </HStack>\n </HStack>\n </VStack>\n </VStack>\n </Card>\n </VStack>\n\n <Divider />\n\n <VStack :gap=\"6\">\n <HStack :gap=\"3\" align=\"center\" justify=\"between\">\n <HStack :gap=\"3\" align=\"center\">\n <span class=\"section-tile\">\n <Icon name=\"heroicons:users\" size=\"sm\" color=\"secondary\" />\n </span>\n <Heading :level=\"2\">\n Audience demographics\n </Heading>\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n Share of active users\n </Text>\n </HStack>\n <Grid :columns=\"{ minWidth: 320, repeat: 'fit' }\" :gap=\"4\">\n <Card v-for=\"breakdown in BREAKDOWNS\" :key=\"breakdown.title\" :padding=\"3\">\n <VStack :gap=\"4\">\n <Heading :level=\"4\" :accessibility-level=\"3\">\n {{ breakdown.title }}\n </Heading>\n <div class=\"breakdown-bar\">\n <div\n v-for=\"seg in breakdown.data\"\n :key=\"seg.label\"\n class=\"breakdown-bar__seg\"\n :style=\"{ width: `${seg.value}%`, background: seg.color }\"\n />\n </div>\n <VStack :gap=\"2\">\n <HStack v-for=\"seg in breakdown.data\" :key=\"seg.label\" justify=\"between\" align=\"center\">\n <HStack :gap=\"2\" align=\"center\">\n <span class=\"legend-dot\" :style=\"{ background: seg.color }\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ seg.label }}\n </Text>\n </HStack>\n <Text type=\"supporting\" weight=\"semibold\">\n {{ seg.value }}%\n </Text>\n </HStack>\n </VStack>\n </VStack>\n </Card>\n </Grid>\n </VStack>\n\n <VStack :gap=\"6\">\n <HStack :gap=\"3\" align=\"center\" justify=\"between\">\n <Heading :level=\"2\">\n Acquisition channels\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n How users find the product\n </Text>\n </HStack>\n <Card>\n <Table :data=\"ACQUISITION\" :columns=\"acquisitionColumns\" id-key=\"id\" density=\"balanced\" dividers=\"rows\" has-hover />\n </Card>\n </VStack>\n </VStack>\n </LayoutContent>\n </Layout>\n</template>\n\n<style scoped>\n.metrics-grid {\n align-items: stretch;\n grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));\n}\n.section-tile {\n display: grid;\n place-items: center;\n width: 32px;\n height: 32px;\n border-radius: var(--astryx-radius-element);\n background: var(--astryx-color-background-muted);\n}\n.spark { width: 100%; height: 48px; display: block; }\n.trend-chart { width: 100%; height: 280px; display: block; overflow: visible; }\n.chart-grid { stroke: var(--astryx-color-border, rgb(5 54 89 / 10%)); stroke-width: 1; }\n.chart-tick { fill: var(--astryx-color-text-secondary, #4E606F); font-size: 12px; }\n.legend-dot { width: 12px; height: 12px; border-radius: 0; flex: none; }\n.breakdown-bar { display: flex; width: 100%; height: 24px; border-radius: 4px; overflow: hidden; }\n.breakdown-bar__seg { height: 100%; }\n</style>\n",
77027
77379
  "docsUrl": {
77028
77380
  "en": "https://astryx-vue.pages.dev/en/playground/?template=dashboard-comparison",
77029
77381
  "zh": "https://astryx-vue.pages.dev/playground/?template=dashboard-comparison"
@@ -77092,7 +77444,7 @@ const catalog = {
77092
77444
  ],
77093
77445
  "dependencies": ["@astryx-vue/core", "vue"],
77094
77446
  "isReady": true,
77095
- "source": "<script setup lang=\"ts\">\nimport {\n Avatar,\n Button,\n Card,\n Divider,\n Grid,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n LayoutHeader,\n LayoutPanel,\n ProgressBar,\n SegmentedControl,\n SegmentedControlItem,\n StackItem,\n Text,\n Timestamp,\n useMediaQuery,\n VStack,\n} from '@astryx-vue/core'\nimport { computed, onBeforeUnmount, ref } from 'vue'\n\ntype Period = 'WoW' | 'MoM' | 'QoQ'\ntype Rag = 'green' | 'amber' | 'red'\n\ninterface Kpi {\n key: string\n label: string\n value: string\n delta: Record<Period, number>\n higherIsBetter: boolean\n}\n\ninterface Okr {\n objective: string\n owner: string\n actual: number\n target: number\n unit: string\n}\n\ninterface TrendSeries {\n key: string\n title: string\n unit: string\n color: string\n kind: 'area' | 'line'\n data: { t: number, label: string, current: number, prior: number }[]\n}\n\ninterface Callout {\n status: 'success' | 'warning' | 'info'\n title: string\n detail: string\n}\n\nconst COLORS = {\n blue: 'var(--astryx-color-data-categorical-blue, #0171E3)',\n green: 'var(--astryx-color-data-categorical-green, #0B991F)',\n orange: 'var(--astryx-color-data-categorical-orange, #EB6E00)',\n purple: 'var(--astryx-color-data-categorical-purple, #6B1EFD)',\n prior: 'var(--astryx-color-text-secondary, #737373)',\n}\n\nconst PERIOD_SUBTITLE: Record<Period, string> = {\n WoW: 'Week of Jun 23 – Jun 29, 2026 vs. prior week',\n MoM: 'June 2026 vs. May 2026',\n QoQ: 'Q2 2026 vs. Q1 2026',\n}\n\nconst KPIS: Kpi[] = [\n { key: 'revenue', label: 'Net revenue', value: '$4.82M', delta: { WoW: 2.4, MoM: 6.1, QoQ: 14.3 }, higherIsBetter: true },\n { key: 'arr', label: 'ARR', value: '$58.1M', delta: { WoW: 1.1, MoM: 3.8, QoQ: 11.2 }, higherIsBetter: true },\n { key: 'nrr', label: 'Net revenue retention', value: '112%', delta: { WoW: 0.3, MoM: -0.8, QoQ: 2.1 }, higherIsBetter: true },\n { key: 'churn', label: 'Gross churn', value: '1.9%', delta: { WoW: 0.4, MoM: 1.6, QoQ: -0.7 }, higherIsBetter: false },\n { key: 'cac', label: 'CAC payback', value: '14.2 mo', delta: { WoW: -0.2, MoM: -3.4, QoQ: -8.1 }, higherIsBetter: false },\n { key: 'nps', label: 'NPS', value: '48', delta: { WoW: 1.0, MoM: 4.0, QoQ: 6.0 }, higherIsBetter: true },\n]\n\nconst OKRS: Okr[] = [\n { objective: 'Reach $60M ARR', owner: 'Dana Whitfield · CRO', actual: 58.1, target: 60, unit: 'M' },\n { objective: 'Expand into 3 new enterprise verticals', owner: 'Marcus Lin · VP Sales', actual: 2, target: 3, unit: '' },\n { objective: 'Ship AI copilot GA', owner: 'Priya Raman · VP Product', actual: 82, target: 100, unit: '%' },\n { objective: 'Improve gross margin to 78%', owner: 'Tom Okafor · CFO', actual: 75.4, target: 78, unit: '%' },\n]\n\nconst WEEK_LABELS = ['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W8', 'W9', 'W10', 'W11', 'W12']\n\nfunction makeTrend(base: number, growth: number, priorGap: number, wobble: number) {\n return WEEK_LABELS.map((label, i) => {\n const drift = base * (1 + (growth * i) / 11)\n const current = Math.round(drift + Math.sin(i * 1.3) * base * wobble)\n return { t: i, label, current, prior: Math.round(current * (1 - priorGap)) }\n })\n}\n\nconst TRENDS: TrendSeries[] = [\n { key: 'revenue', title: 'Net revenue', unit: 'k', color: COLORS.blue, kind: 'area', data: makeTrend(980, 0.24, 0.12, 0.03) },\n { key: 'pipeline', title: 'Qualified pipeline', unit: 'k', color: COLORS.green, kind: 'area', data: makeTrend(3200, 0.31, 0.18, 0.05) },\n { key: 'activation', title: 'Activation rate', unit: '%', color: COLORS.purple, kind: 'line', data: makeTrend(42, 0.28, 0.09, 0.04) },\n { key: 'support', title: 'Support CSAT', unit: '%', color: COLORS.orange, kind: 'line', data: makeTrend(88, 0.06, 0.03, 0.02) },\n]\n\nconst NARRATIVE: Record<Period, Callout[]> = {\n WoW: [\n { status: 'success', title: 'Net revenue up 2.4% week-over-week', detail: 'Driven by three enterprise renewals closing early in the East region; expansion ARR contributed 60% of the lift.' },\n { status: 'warning', title: 'Gross churn ticked up 0.4 points', detail: 'Two mid-market logos churned on price; both flagged in QBRs last month. Save-play in motion for the remaining at-risk cohort.' },\n { status: 'info', title: 'CAC payback holding at 14.2 months', detail: 'Paid acquisition efficiency steady; the shift toward partner-sourced pipeline is starting to show in blended CAC.' },\n ],\n MoM: [\n { status: 'success', title: 'Revenue accelerating: +6.1% month-over-month', detail: 'Second consecutive month of accelerating growth. New-logo bookings and expansion both above plan; net-new pipeline covers 3.2x of next quarter target.' },\n { status: 'warning', title: 'NRR dipped 0.8 points to 112%', detail: 'A seasonal downgrade cycle in the SMB segment pulled retention down slightly. Enterprise NRR remains above 120%.' },\n { status: 'success', title: 'CAC payback improved 3.4 points', detail: 'Sales efficiency gains from the new SDR playbook are compounding; payback now inside the 15-month board target.' },\n ],\n QoQ: [\n { status: 'success', title: 'Strong quarter: revenue +14.3%, ARR +11.2%', detail: 'Best quarter on record for net-new ARR. Two of three new verticals landed anchor customers; the AI copilot beta is converting at 2x the baseline trial rate.' },\n { status: 'success', title: 'Churn down 0.7 points quarter-over-quarter', detail: 'Retention initiatives launched in Q1 are paying off; gross churn at a two-year low and NRR up 2.1 points.' },\n { status: 'warning', title: 'Gross margin at 75.4%, short of the 78% goal', detail: 'Infrastructure cost from the AI copilot GA ramp is the primary drag. Finance projects margin recovery once usage-based pricing lands next quarter.' },\n ],\n}\n\nconst CALLOUT_ICON: Record<Callout['status'], string> = {\n success: 'success',\n warning: 'warning',\n info: 'info',\n}\n\nconst CALLOUT_ICON_COLOR: Record<Callout['status'], 'success' | 'warning' | 'accent'> = {\n success: 'success',\n warning: 'warning',\n info: 'accent',\n}\n\nconst CALLOUT_LABEL: Record<Callout['status'], string> = {\n success: 'Positive',\n warning: 'Needs attention',\n info: 'Context',\n}\n\n/** Delta 的方向和幅度决定图标与配色:方向有利且幅度大是绿,方向不利且幅度大是红。 */\nfunction ragFor(deltaPct: number, higherIsBetter: boolean): Rag {\n const favorable = higherIsBetter ? deltaPct >= 0 : deltaPct <= 0\n const magnitude = Math.abs(deltaPct)\n if (favorable)\n return magnitude >= 1 ? 'green' : 'amber'\n return magnitude >= 3 ? 'red' : 'amber'\n}\n\nfunction okrRag(pct: number): Rag {\n if (pct >= 90)\n return 'green'\n if (pct >= 70)\n return 'amber'\n return 'red'\n}\n\nconst RAG_ICON = { green: 'success', amber: 'warning', red: 'error' } as const\nconst RAG_PROGRESS = { green: 'success', amber: 'warning', red: 'error' } as const\n\nconst period = ref<Period>('WoW')\nconst isNarrow = useMediaQuery('(max-width: 1024px)')\n\nconst subtitle = computed(() => PERIOD_SUBTITLE[period.value])\nconst callouts = computed(() => NARRATIVE[period.value])\nconst okrSummary = computed(() => {\n const onTrack = OKRS.filter(okr => (okr.actual / okr.target) * 100 >= 90).length\n return `${onTrack} of ${OKRS.length} on track`\n})\n\nfunction deltaText(delta: number): string {\n return `${delta > 0 ? '+' : ''}${delta.toFixed(1)}%`\n}\n\nfunction deltaIcon(delta: number): string {\n if (delta === 0)\n return 'chevronRight'\n return delta < 0 ? 'arrowDown' : 'arrowUp'\n}\n\nfunction okrPercent(okr: Okr): number {\n return Math.round((okr.actual / okr.target) * 100)\n}\n\nfunction ownerName(owner: string): string {\n return owner.split('·')[0].trim()\n}\n\nfunction trendDelta(trend: TrendSeries): number {\n const latest = trend.data[trend.data.length - 1]\n return Math.round(((latest.current - latest.prior) / latest.prior) * 100)\n}\n\n/** 图表画布高度 200,左侧留给 y 轴刻度,底部留给 x 轴标签。 */\nconst CHART = { h: 200, padLeft: 40, padRight: 8, padTop: 5, padBottom: 30 }\nconst CHART_INNER_H = CHART.h - CHART.padTop - CHART.padBottom\nconst X_TICKS = [0, 3, 7, 11]\n\n/** 四张卡片宽度相同,只量第一张;viewBox 和像素一比一,图撑满卡片也不会被拉伸。 */\nconst chartWidth = ref(360)\nconst chartInnerW = computed(() => chartWidth.value - CHART.padLeft - CHART.padRight)\nlet chartObserver: ResizeObserver | null = null\nfunction bindChart(el: unknown, index: number) {\n if (index !== 0 || !(el instanceof SVGSVGElement) || chartObserver || typeof ResizeObserver === 'undefined')\n return\n chartObserver = new ResizeObserver(([entry]) => {\n if (entry && entry.contentRect.width > 0)\n chartWidth.value = entry.contentRect.width\n })\n chartObserver.observe(el)\n}\nonBeforeUnmount(() => chartObserver?.disconnect())\n\n/**\n * 0 起步、一共 5 个刻度的 y 轴,步长和 recharts 默认的取整规则一致:\n * 原始步长按数量级取 0.05(个位数时取 0.1)的整数倍再向上取整。\n */\nfunction niceTicks(max: number): number[] {\n const raw = max / 4\n const digits = Math.floor(Math.log10(raw)) + 1\n const unit = 10 ** digits * (digits === 1 ? 0.1 : 0.05)\n const step = Math.ceil(raw / unit) * unit\n return [0, 1, 2, 3, 4].map(i => Math.round(i * step * 100) / 100)\n}\n\n/** 单调三次样条(等价于 recharts 的 monotone 曲线),返回 SVG 路径。 */\nfunction monotonePath(pts: { x: number, y: number }[]): string {\n const n = pts.length\n const dx: number[] = []\n const slope: number[] = []\n for (let i = 0; i < n - 1; i++) {\n dx.push(pts[i + 1].x - pts[i].x)\n slope.push((pts[i + 1].y - pts[i].y) / dx[i])\n }\n const m: number[] = [slope[0]]\n for (let i = 1; i < n - 1; i++)\n m.push(slope[i - 1] * slope[i] <= 0 ? 0 : (slope[i - 1] + slope[i]) / 2)\n m.push(slope[n - 2])\n for (let i = 0; i < n - 1; i++) {\n if (slope[i] === 0) {\n m[i] = 0\n m[i + 1] = 0\n continue\n }\n const a = m[i] / slope[i]\n const b = m[i + 1] / slope[i]\n const h = Math.hypot(a, b)\n if (h > 3) {\n m[i] = (3 * a / h) * slope[i]\n m[i + 1] = (3 * b / h) * slope[i]\n }\n }\n let d = `M${pts[0].x.toFixed(1)},${pts[0].y.toFixed(1)}`\n for (let i = 0; i < n - 1; i++) {\n const w = dx[i] / 3\n d += `C${(pts[i].x + w).toFixed(1)},${(pts[i].y + m[i] * w).toFixed(1)},${(pts[i + 1].x - w).toFixed(1)},${(pts[i + 1].y - m[i + 1] * w).toFixed(1)},${pts[i + 1].x.toFixed(1)},${pts[i + 1].y.toFixed(1)}`\n }\n return d\n}\n\n/** 一张趋势卡的几何:网格刻度、x 轴标签、当前期曲线(含面积)和上期虚线。 */\nfunction trendGeometry(trend: TrendSeries) {\n const max = Math.max(...trend.data.flatMap(row => [row.current, row.prior]))\n const ticks = niceTicks(max)\n const top = ticks[ticks.length - 1]\n const x = (i: number) => CHART.padLeft + (i / 11) * chartInnerW.value\n const y = (v: number) => CHART.padTop + CHART_INNER_H - (v / top) * CHART_INNER_H\n const curve = (key: 'current' | 'prior') => monotonePath(trend.data.map((row, i) => ({ x: x(i), y: y(row[key]) })))\n const current = curve('current')\n const baseline = CHART.padTop + CHART_INNER_H\n return {\n yTicks: ticks.map(value => ({ value, y: y(value) })),\n xTicks: X_TICKS.map(i => ({ label: WEEK_LABELS[i], x: x(i) })),\n current,\n prior: curve('prior'),\n area: `${current}L${x(11).toFixed(1)},${baseline}L${x(0).toFixed(1)},${baseline}Z`,\n baseline,\n }\n}\n<\/script>\n\n<template>\n <Layout height=\"fill\" :content-width=\"1440\">\n <template #header>\n <LayoutHeader :padding=\"6\" has-divider>\n <HStack :gap=\"3\" align=\"center\" h-align=\"between\" wrap=\"wrap\">\n <VStack :gap=\"1\">\n <Heading :level=\"1\">\n Weekly Business Review\n </Heading>\n <HStack :gap=\"2\" align=\"center\">\n <Text type=\"body\" color=\"secondary\">\n {{ subtitle }}\n </Text>\n <Text type=\"body\" color=\"secondary\">\n · Generated\n </Text>\n <Timestamp value=\"2026-06-30T08:00:00Z\" format=\"date\" type=\"body\" color=\"secondary\" />\n </HStack>\n </VStack>\n <HStack :gap=\"3\" align=\"center\">\n <SegmentedControl v-model=\"period\" label=\"Comparison period\">\n <SegmentedControlItem label=\"WoW\" value=\"WoW\" />\n <SegmentedControlItem label=\"MoM\" value=\"MoM\" />\n <SegmentedControlItem label=\"QoQ\" value=\"QoQ\" />\n </SegmentedControl>\n <Button label=\"Export\" variant=\"secondary\">\n <template #icon>\n <Icon name=\"heroicons:arrow-down-tray\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n </HStack>\n </LayoutHeader>\n </template>\n\n <LayoutContent :padding=\"6\" class=\"page-content\">\n <VStack :gap=\"10\">\n <VStack v-if=\"isNarrow\" :gap=\"6\">\n <HStack :gap=\"3\" align=\"center\" h-align=\"between\">\n <Heading :level=\"2\">\n What changed &amp; why\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n AI summary\n </Text>\n </HStack>\n <VStack :gap=\"2\">\n <Card v-for=\"callout in callouts\" :key=\"callout.title\" :padding=\"5\">\n <HStack :gap=\"3\" align=\"start\">\n <Icon\n :icon=\"CALLOUT_ICON[callout.status]\"\n :color=\"CALLOUT_ICON_COLOR[callout.status]\"\n size=\"md\"\n :label=\"CALLOUT_LABEL[callout.status]\"\n />\n <StackItem size=\"fill\">\n <VStack :gap=\"1\">\n <Heading :level=\"4\">\n {{ callout.title }}\n </Heading>\n <Text type=\"body\" color=\"secondary\">\n {{ callout.detail }}\n </Text>\n </VStack>\n </StackItem>\n </HStack>\n </Card>\n </VStack>\n </VStack>\n\n <VStack :gap=\"6\">\n <HStack :gap=\"3\" align=\"center\" h-align=\"between\">\n <Heading :level=\"2\">\n Scorecards\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Performance metrics\n </Text>\n </HStack>\n <Grid :gap=\"2\" class=\"scorecard-grid\">\n <Card v-for=\"kpi in KPIS\" :key=\"kpi.key\" :padding=\"5\">\n <VStack :gap=\"1\">\n <Text type=\"label\" color=\"secondary\">\n {{ kpi.label }}\n </Text>\n <HStack :gap=\"4\" align=\"center\">\n <Heading :level=\"2\">\n {{ kpi.value }}\n </Heading>\n <HStack :gap=\"2\" align=\"center\">\n <Text type=\"body\" color=\"secondary\">\n {{ deltaText(kpi.delta[period]) }}\n </Text>\n <Icon\n :icon=\"deltaIcon(kpi.delta[period])\"\n size=\"xsm\"\n :color=\"RAG_ICON[ragFor(kpi.delta[period], kpi.higherIsBetter)]\"\n />\n </HStack>\n </HStack>\n </VStack>\n </Card>\n </Grid>\n </VStack>\n\n <VStack :gap=\"6\">\n <HStack :gap=\"3\" align=\"center\" h-align=\"between\">\n <Heading :level=\"2\">\n Goal attainment\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n {{ okrSummary }}\n </Text>\n </HStack>\n <Card :padding=\"6\">\n <VStack :gap=\"6\">\n <template v-for=\"(okr, i) in OKRS\" :key=\"okr.objective\">\n <Divider v-if=\"i > 0\" />\n <VStack :gap=\"2\">\n <HStack h-align=\"between\" align=\"center\" :gap=\"3\">\n <StackItem size=\"fill\">\n <Heading :level=\"4\">\n {{ okr.objective }}\n </Heading>\n </StackItem>\n <Text type=\"supporting\" color=\"secondary\">\n {{ okrPercent(okr) }}%\n </Text>\n </HStack>\n <ProgressBar\n :value=\"okr.actual\"\n :max=\"okr.target\"\n :variant=\"RAG_PROGRESS[okrRag(okrPercent(okr))]\"\n :label=\"okr.objective\"\n is-label-hidden\n />\n <HStack h-align=\"between\" align=\"center\" :gap=\"4\">\n <HStack :gap=\"2\" align=\"center\">\n <Avatar size=\"sm\" :name=\"ownerName(okr.owner)\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ okr.owner }}\n </Text>\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n {{ okr.actual }}{{ okr.unit }} / {{ okr.target }}{{ okr.unit }} target\n </Text>\n </HStack>\n </VStack>\n </template>\n </VStack>\n </Card>\n </VStack>\n\n <VStack :gap=\"6\">\n <HStack :gap=\"3\" align=\"center\" h-align=\"between\">\n <Heading :level=\"2\">\n Trends\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Current vs. prior period · trailing 12 weeks\n </Text>\n </HStack>\n <Grid :columns=\"{ minWidth: 330, repeat: 'fit' }\" :gap=\"3\">\n <Card v-for=\"(trend, index) in TRENDS\" :key=\"trend.key\" :padding=\"5\">\n <VStack :gap=\"6\">\n <HStack h-align=\"between\" align=\"center\">\n <HStack :gap=\"2\">\n <Heading :level=\"3\">\n {{ trend.title }}\n </Heading>\n <Heading :level=\"3\" color=\"secondary\">\n {{ trend.data[trend.data.length - 1].current.toLocaleString('en-US') }}{{ trend.unit }}\n </Heading>\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n {{ trendDelta(trend) >= 0 ? '+' : '' }}{{ trendDelta(trend) }}% vs prior\n </Text>\n </HStack>\n <svg\n :ref=\"(el: unknown) => bindChart(el, index)\"\n class=\"trend-chart\"\n :viewBox=\"`0 0 ${chartWidth} ${CHART.h}`\"\n role=\"img\"\n :aria-label=\"trend.title\"\n >\n <defs>\n <linearGradient :id=\"`grad-${trend.key}`\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"5%\" :stop-color=\"trend.color\" stop-opacity=\"0.3\" />\n <stop offset=\"95%\" :stop-color=\"trend.color\" stop-opacity=\"0.04\" />\n </linearGradient>\n </defs>\n <g v-for=\"tick in trendGeometry(trend).yTicks\" :key=\"tick.value\">\n <line :x1=\"CHART.padLeft\" :x2=\"chartWidth - CHART.padRight\" :y1=\"tick.y\" :y2=\"tick.y\" class=\"chart-grid\" />\n <text :x=\"CHART.padLeft - 8\" :y=\"tick.y\" text-anchor=\"end\" dominant-baseline=\"middle\" class=\"chart-tick\">{{ tick.value }}</text>\n </g>\n <text\n v-for=\"tick in trendGeometry(trend).xTicks\"\n :key=\"tick.label\"\n :x=\"tick.x\"\n :y=\"CHART.h - 8\"\n :text-anchor=\"tick.label === 'W1' ? 'start' : tick.label === 'W12' ? 'end' : 'middle'\"\n class=\"chart-tick\"\n >{{ tick.label }}</text>\n <path v-if=\"trend.kind === 'area'\" :d=\"trendGeometry(trend).area\" :fill=\"`url(#grad-${trend.key})`\" />\n <path :d=\"trendGeometry(trend).prior\" fill=\"none\" :stroke=\"COLORS.prior\" stroke-width=\"1\" stroke-dasharray=\"4 4\" />\n <path :d=\"trendGeometry(trend).current\" fill=\"none\" :stroke=\"trend.color\" stroke-width=\"2\" />\n </svg>\n <HStack :gap=\"5\" align=\"center\">\n <HStack :gap=\"2\" align=\"center\">\n <span class=\"legend-swatch\" :style=\"{ backgroundColor: trend.color }\" aria-hidden=\"true\" />\n <Text type=\"supporting\" color=\"secondary\">\n Current\n </Text>\n </HStack>\n <HStack :gap=\"2\" align=\"center\">\n <span class=\"legend-swatch\" :style=\"{ backgroundColor: COLORS.prior }\" aria-hidden=\"true\" />\n <Text type=\"supporting\" color=\"secondary\">\n Prior\n </Text>\n </HStack>\n </HStack>\n </VStack>\n </Card>\n </Grid>\n </VStack>\n </VStack>\n </LayoutContent>\n\n <template v-if=\"!isNarrow\" #end>\n <LayoutPanel :width=\"400\" :padding=\"6\" has-divider role=\"complementary\" label=\"What changed and why\">\n <VStack :gap=\"6\">\n <HStack :gap=\"3\" align=\"center\" h-align=\"between\">\n <Heading :level=\"2\">\n What changed &amp; why\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n AI summary\n </Text>\n </HStack>\n <VStack :gap=\"2\">\n <Card v-for=\"callout in callouts\" :key=\"callout.title\" :padding=\"5\">\n <HStack :gap=\"3\" align=\"start\">\n <Icon\n :icon=\"CALLOUT_ICON[callout.status]\"\n :color=\"CALLOUT_ICON_COLOR[callout.status]\"\n size=\"md\"\n :label=\"CALLOUT_LABEL[callout.status]\"\n />\n <StackItem size=\"fill\">\n <VStack :gap=\"1\">\n <Heading :level=\"4\">\n {{ callout.title }}\n </Heading>\n <Text type=\"body\" color=\"secondary\">\n {{ callout.detail }}\n </Text>\n </VStack>\n </StackItem>\n </HStack>\n </Card>\n </VStack>\n </VStack>\n </LayoutPanel>\n </template>\n </Layout>\n</template>\n\n<style scoped>\n.legend-swatch {\n flex-shrink: 0;\n width: 8px;\n height: 8px;\n border-radius: 2px;\n}\n\n.scorecard-grid {\n grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));\n}\n.page-content {\n padding-bottom: var(--astryx-spacing-10);\n}\n.trend-chart {\n width: 100%;\n height: 200px;\n display: block;\n}\n.chart-grid {\n stroke: var(--astryx-color-border, rgba(5, 54, 89, 0.1));\n stroke-width: 1;\n}\n.chart-tick {\n fill: var(--astryx-color-text-secondary, #4E606F);\n font-size: 12px;\n}\n</style>\n",
77447
+ "source": "<script setup lang=\"ts\">\nimport {\n Avatar,\n Button,\n Card,\n Divider,\n Grid,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n LayoutHeader,\n LayoutPanel,\n ProgressBar,\n SegmentedControl,\n SegmentedControlItem,\n StackItem,\n Text,\n Timestamp,\n useMediaQuery,\n VStack,\n} from '@astryx-vue/core'\nimport { computed, onBeforeUnmount, ref } from 'vue'\n\ntype Period = 'WoW' | 'MoM' | 'QoQ'\ntype Rag = 'green' | 'amber' | 'red'\n\ninterface Kpi {\n key: string\n label: string\n value: string\n delta: Record<Period, number>\n higherIsBetter: boolean\n}\n\ninterface Okr {\n objective: string\n owner: string\n actual: number\n target: number\n unit: string\n}\n\ninterface TrendSeries {\n key: string\n title: string\n unit: string\n color: string\n kind: 'area' | 'line'\n data: { t: number, label: string, current: number, prior: number }[]\n}\n\ninterface Callout {\n status: 'success' | 'warning' | 'info'\n title: string\n detail: string\n}\n\nconst COLORS = {\n blue: 'var(--astryx-color-data-categorical-blue, #0171E3)',\n green: 'var(--astryx-color-data-categorical-green, #0B991F)',\n orange: 'var(--astryx-color-data-categorical-orange, #EB6E00)',\n purple: 'var(--astryx-color-data-categorical-purple, #6B1EFD)',\n prior: 'var(--astryx-color-text-secondary, #737373)',\n}\n\nconst PERIOD_SUBTITLE: Record<Period, string> = {\n WoW: 'Week of Jun 23 – Jun 29, 2026 vs. prior week',\n MoM: 'June 2026 vs. May 2026',\n QoQ: 'Q2 2026 vs. Q1 2026',\n}\n\nconst KPIS: Kpi[] = [\n { key: 'revenue', label: 'Net revenue', value: '$4.82M', delta: { WoW: 2.4, MoM: 6.1, QoQ: 14.3 }, higherIsBetter: true },\n { key: 'arr', label: 'ARR', value: '$58.1M', delta: { WoW: 1.1, MoM: 3.8, QoQ: 11.2 }, higherIsBetter: true },\n { key: 'nrr', label: 'Net revenue retention', value: '112%', delta: { WoW: 0.3, MoM: -0.8, QoQ: 2.1 }, higherIsBetter: true },\n { key: 'churn', label: 'Gross churn', value: '1.9%', delta: { WoW: 0.4, MoM: 1.6, QoQ: -0.7 }, higherIsBetter: false },\n { key: 'cac', label: 'CAC payback', value: '14.2 mo', delta: { WoW: -0.2, MoM: -3.4, QoQ: -8.1 }, higherIsBetter: false },\n { key: 'nps', label: 'NPS', value: '48', delta: { WoW: 1.0, MoM: 4.0, QoQ: 6.0 }, higherIsBetter: true },\n]\n\nconst OKRS: Okr[] = [\n { objective: 'Reach $60M ARR', owner: 'Dana Whitfield · CRO', actual: 58.1, target: 60, unit: 'M' },\n { objective: 'Expand into 3 new enterprise verticals', owner: 'Marcus Lin · VP Sales', actual: 2, target: 3, unit: '' },\n { objective: 'Ship AI copilot GA', owner: 'Priya Raman · VP Product', actual: 82, target: 100, unit: '%' },\n { objective: 'Improve gross margin to 78%', owner: 'Tom Okafor · CFO', actual: 75.4, target: 78, unit: '%' },\n]\n\nconst WEEK_LABELS = ['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W8', 'W9', 'W10', 'W11', 'W12']\n\nfunction makeTrend(base: number, growth: number, priorGap: number, wobble: number) {\n return WEEK_LABELS.map((label, i) => {\n const drift = base * (1 + (growth * i) / 11)\n const current = Math.round(drift + Math.sin(i * 1.3) * base * wobble)\n return { t: i, label, current, prior: Math.round(current * (1 - priorGap)) }\n })\n}\n\nconst TRENDS: TrendSeries[] = [\n { key: 'revenue', title: 'Net revenue', unit: 'k', color: COLORS.blue, kind: 'area', data: makeTrend(980, 0.24, 0.12, 0.03) },\n { key: 'pipeline', title: 'Qualified pipeline', unit: 'k', color: COLORS.green, kind: 'area', data: makeTrend(3200, 0.31, 0.18, 0.05) },\n { key: 'activation', title: 'Activation rate', unit: '%', color: COLORS.purple, kind: 'line', data: makeTrend(42, 0.28, 0.09, 0.04) },\n { key: 'support', title: 'Support CSAT', unit: '%', color: COLORS.orange, kind: 'line', data: makeTrend(88, 0.06, 0.03, 0.02) },\n]\n\nconst NARRATIVE: Record<Period, Callout[]> = {\n WoW: [\n { status: 'success', title: 'Net revenue up 2.4% week-over-week', detail: 'Driven by three enterprise renewals closing early in the East region; expansion ARR contributed 60% of the lift.' },\n { status: 'warning', title: 'Gross churn ticked up 0.4 points', detail: 'Two mid-market logos churned on price; both flagged in QBRs last month. Save-play in motion for the remaining at-risk cohort.' },\n { status: 'info', title: 'CAC payback holding at 14.2 months', detail: 'Paid acquisition efficiency steady; the shift toward partner-sourced pipeline is starting to show in blended CAC.' },\n ],\n MoM: [\n { status: 'success', title: 'Revenue accelerating: +6.1% month-over-month', detail: 'Second consecutive month of accelerating growth. New-logo bookings and expansion both above plan; net-new pipeline covers 3.2x of next quarter target.' },\n { status: 'warning', title: 'NRR dipped 0.8 points to 112%', detail: 'A seasonal downgrade cycle in the SMB segment pulled retention down slightly. Enterprise NRR remains above 120%.' },\n { status: 'success', title: 'CAC payback improved 3.4 points', detail: 'Sales efficiency gains from the new SDR playbook are compounding; payback now inside the 15-month board target.' },\n ],\n QoQ: [\n { status: 'success', title: 'Strong quarter: revenue +14.3%, ARR +11.2%', detail: 'Best quarter on record for net-new ARR. Two of three new verticals landed anchor customers; the AI copilot beta is converting at 2x the baseline trial rate.' },\n { status: 'success', title: 'Churn down 0.7 points quarter-over-quarter', detail: 'Retention initiatives launched in Q1 are paying off; gross churn at a two-year low and NRR up 2.1 points.' },\n { status: 'warning', title: 'Gross margin at 75.4%, short of the 78% goal', detail: 'Infrastructure cost from the AI copilot GA ramp is the primary drag. Finance projects margin recovery once usage-based pricing lands next quarter.' },\n ],\n}\n\nconst CALLOUT_ICON: Record<Callout['status'], string> = {\n success: 'success',\n warning: 'warning',\n info: 'info',\n}\n\nconst CALLOUT_ICON_COLOR: Record<Callout['status'], 'success' | 'warning' | 'accent'> = {\n success: 'success',\n warning: 'warning',\n info: 'accent',\n}\n\nconst CALLOUT_LABEL: Record<Callout['status'], string> = {\n success: 'Positive',\n warning: 'Needs attention',\n info: 'Context',\n}\n\n/** Delta 的方向和幅度决定图标与配色:方向有利且幅度大是绿,方向不利且幅度大是红。 */\nfunction ragFor(deltaPct: number, higherIsBetter: boolean): Rag {\n const favorable = higherIsBetter ? deltaPct >= 0 : deltaPct <= 0\n const magnitude = Math.abs(deltaPct)\n if (favorable)\n return magnitude >= 1 ? 'green' : 'amber'\n return magnitude >= 3 ? 'red' : 'amber'\n}\n\nfunction okrRag(pct: number): Rag {\n if (pct >= 90)\n return 'green'\n if (pct >= 70)\n return 'amber'\n return 'red'\n}\n\nconst RAG_ICON = { green: 'success', amber: 'warning', red: 'error' } as const\nconst RAG_PROGRESS = { green: 'success', amber: 'warning', red: 'error' } as const\n\nconst period = ref<Period>('WoW')\nconst isNarrow = useMediaQuery('(max-width: 1024px)')\n\nconst subtitle = computed(() => PERIOD_SUBTITLE[period.value])\nconst callouts = computed(() => NARRATIVE[period.value])\nconst okrSummary = computed(() => {\n const onTrack = OKRS.filter(okr => (okr.actual / okr.target) * 100 >= 90).length\n return `${onTrack} of ${OKRS.length} on track`\n})\n\nfunction deltaText(delta: number): string {\n return `${delta > 0 ? '+' : ''}${delta.toFixed(1)}%`\n}\n\nfunction deltaIcon(delta: number): string {\n if (delta === 0)\n return 'chevronRight'\n return delta < 0 ? 'arrowDown' : 'arrowUp'\n}\n\nfunction okrPercent(okr: Okr): number {\n return Math.round((okr.actual / okr.target) * 100)\n}\n\nfunction ownerName(owner: string): string {\n return owner.split('·')[0].trim()\n}\n\nfunction trendDelta(trend: TrendSeries): number {\n const latest = trend.data[trend.data.length - 1]\n return Math.round(((latest.current - latest.prior) / latest.prior) * 100)\n}\n\n/** 图表画布高度 200,左侧留给 y 轴刻度,底部留给 x 轴标签。 */\nconst CHART = { h: 200, padLeft: 40, padRight: 8, padTop: 5, padBottom: 30 }\nconst CHART_INNER_H = CHART.h - CHART.padTop - CHART.padBottom\nconst X_TICKS = [0, 3, 7, 11]\n\n/** 四张卡片宽度相同,只量第一张;viewBox 和像素一比一,图撑满卡片也不会被拉伸。 */\nconst chartWidth = ref(360)\nconst chartInnerW = computed(() => chartWidth.value - CHART.padLeft - CHART.padRight)\nlet chartObserver: ResizeObserver | null = null\nfunction bindChart(el: unknown, index: number) {\n if (index !== 0 || !(el instanceof SVGSVGElement) || chartObserver || typeof ResizeObserver === 'undefined')\n return\n chartObserver = new ResizeObserver(([entry]) => {\n if (entry && entry.contentRect.width > 0)\n chartWidth.value = entry.contentRect.width\n })\n chartObserver.observe(el)\n}\nonBeforeUnmount(() => chartObserver?.disconnect())\n\n/**\n * 0 起步、一共 5 个刻度的 y 轴,步长和 recharts 默认的取整规则一致:\n * 原始步长按数量级取 0.05(个位数时取 0.1)的整数倍再向上取整。\n */\nfunction niceTicks(max: number): number[] {\n const raw = max / 4\n const digits = Math.floor(Math.log10(raw)) + 1\n const unit = 10 ** digits * (digits === 1 ? 0.1 : 0.05)\n const step = Math.ceil(raw / unit) * unit\n return [0, 1, 2, 3, 4].map(i => Math.round(i * step * 100) / 100)\n}\n\n/** 单调三次样条(等价于 recharts 的 monotone 曲线),返回 SVG 路径。 */\nfunction monotonePath(pts: { x: number, y: number }[]): string {\n const n = pts.length\n const dx: number[] = []\n const slope: number[] = []\n for (let i = 0; i < n - 1; i++) {\n dx.push(pts[i + 1].x - pts[i].x)\n slope.push((pts[i + 1].y - pts[i].y) / dx[i])\n }\n const m: number[] = [slope[0]]\n for (let i = 1; i < n - 1; i++)\n m.push(slope[i - 1] * slope[i] <= 0 ? 0 : (slope[i - 1] + slope[i]) / 2)\n m.push(slope[n - 2])\n for (let i = 0; i < n - 1; i++) {\n if (slope[i] === 0) {\n m[i] = 0\n m[i + 1] = 0\n continue\n }\n const a = m[i] / slope[i]\n const b = m[i + 1] / slope[i]\n const h = Math.hypot(a, b)\n if (h > 3) {\n m[i] = (3 * a / h) * slope[i]\n m[i + 1] = (3 * b / h) * slope[i]\n }\n }\n let d = `M${pts[0].x.toFixed(1)},${pts[0].y.toFixed(1)}`\n for (let i = 0; i < n - 1; i++) {\n const w = dx[i] / 3\n d += `C${(pts[i].x + w).toFixed(1)},${(pts[i].y + m[i] * w).toFixed(1)},${(pts[i + 1].x - w).toFixed(1)},${(pts[i + 1].y - m[i + 1] * w).toFixed(1)},${pts[i + 1].x.toFixed(1)},${pts[i + 1].y.toFixed(1)}`\n }\n return d\n}\n\n/** 一张趋势卡的几何:网格刻度、x 轴标签、当前期曲线(含面积)和上期虚线。 */\nfunction trendGeometry(trend: TrendSeries) {\n const max = Math.max(...trend.data.flatMap(row => [row.current, row.prior]))\n const ticks = niceTicks(max)\n const top = ticks[ticks.length - 1]\n const x = (i: number) => CHART.padLeft + (i / 11) * chartInnerW.value\n const y = (v: number) => CHART.padTop + CHART_INNER_H - (v / top) * CHART_INNER_H\n const curve = (key: 'current' | 'prior') => monotonePath(trend.data.map((row, i) => ({ x: x(i), y: y(row[key]) })))\n const current = curve('current')\n const baseline = CHART.padTop + CHART_INNER_H\n return {\n yTicks: ticks.map(value => ({ value, y: y(value) })),\n xTicks: X_TICKS.map(i => ({ label: WEEK_LABELS[i], x: x(i) })),\n current,\n prior: curve('prior'),\n area: `${current}L${x(11).toFixed(1)},${baseline}L${x(0).toFixed(1)},${baseline}Z`,\n baseline,\n }\n}\n<\/script>\n\n<template>\n <Layout height=\"fill\" :content-width=\"1440\">\n <template #header>\n <LayoutHeader :padding=\"6\" has-divider>\n <HStack :gap=\"3\" align=\"center\" h-align=\"between\" wrap=\"wrap\">\n <VStack :gap=\"1\">\n <Heading :level=\"1\">\n Weekly Business Review\n </Heading>\n <HStack :gap=\"2\" align=\"center\">\n <Text type=\"body\" color=\"secondary\">\n {{ subtitle }}\n </Text>\n <Text type=\"body\" color=\"secondary\">\n · Generated\n </Text>\n <Timestamp value=\"2026-06-30T08:00:00Z\" format=\"date\" type=\"body\" color=\"secondary\" />\n </HStack>\n </VStack>\n <HStack :gap=\"3\" align=\"center\">\n <SegmentedControl v-model=\"period\" label=\"Comparison period\">\n <SegmentedControlItem label=\"WoW\" value=\"WoW\" />\n <SegmentedControlItem label=\"MoM\" value=\"MoM\" />\n <SegmentedControlItem label=\"QoQ\" value=\"QoQ\" />\n </SegmentedControl>\n <Button label=\"Export\" variant=\"secondary\">\n <template #icon>\n <Icon name=\"heroicons:arrow-down-tray\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n </HStack>\n </LayoutHeader>\n </template>\n\n <LayoutContent :padding=\"6\" class=\"page-content\">\n <VStack :gap=\"10\">\n <VStack v-if=\"isNarrow\" :gap=\"6\">\n <HStack :gap=\"3\" align=\"center\" h-align=\"between\">\n <Heading :level=\"2\">\n What changed &amp; why\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n AI summary\n </Text>\n </HStack>\n <VStack :gap=\"2\">\n <Card v-for=\"callout in callouts\" :key=\"callout.title\" :padding=\"5\">\n <HStack :gap=\"3\" align=\"start\">\n <Icon\n :icon=\"CALLOUT_ICON[callout.status]\"\n :color=\"CALLOUT_ICON_COLOR[callout.status]\"\n size=\"md\"\n :label=\"CALLOUT_LABEL[callout.status]\"\n />\n <StackItem size=\"fill\">\n <VStack :gap=\"1\">\n <Heading :level=\"4\">\n {{ callout.title }}\n </Heading>\n <Text type=\"body\" color=\"secondary\">\n {{ callout.detail }}\n </Text>\n </VStack>\n </StackItem>\n </HStack>\n </Card>\n </VStack>\n </VStack>\n\n <VStack :gap=\"6\">\n <HStack :gap=\"3\" align=\"center\" h-align=\"between\">\n <Heading :level=\"2\">\n Scorecards\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Performance metrics\n </Text>\n </HStack>\n <Grid :gap=\"2\" class=\"scorecard-grid\">\n <Card v-for=\"kpi in KPIS\" :key=\"kpi.key\" :padding=\"5\">\n <VStack :gap=\"1\">\n <Text type=\"label\" color=\"secondary\">\n {{ kpi.label }}\n </Text>\n <HStack :gap=\"4\" align=\"center\">\n <Heading :level=\"2\">\n {{ kpi.value }}\n </Heading>\n <HStack :gap=\"2\" align=\"center\">\n <Text type=\"body\" color=\"secondary\">\n {{ deltaText(kpi.delta[period]) }}\n </Text>\n <Icon\n :icon=\"deltaIcon(kpi.delta[period])\"\n size=\"xsm\"\n :color=\"RAG_ICON[ragFor(kpi.delta[period], kpi.higherIsBetter)]\"\n />\n </HStack>\n </HStack>\n </VStack>\n </Card>\n </Grid>\n </VStack>\n\n <VStack :gap=\"6\">\n <HStack :gap=\"3\" align=\"center\" h-align=\"between\">\n <Heading :level=\"2\">\n Goal attainment\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n {{ okrSummary }}\n </Text>\n </HStack>\n <Card :padding=\"6\">\n <VStack :gap=\"6\">\n <template v-for=\"(okr, i) in OKRS\" :key=\"okr.objective\">\n <Divider v-if=\"i > 0\" />\n <VStack :gap=\"2\">\n <HStack h-align=\"between\" align=\"center\" :gap=\"3\">\n <StackItem size=\"fill\">\n <Heading :level=\"4\">\n {{ okr.objective }}\n </Heading>\n </StackItem>\n <Text type=\"supporting\" color=\"secondary\">\n {{ okrPercent(okr) }}%\n </Text>\n </HStack>\n <ProgressBar\n :value=\"okr.actual\"\n :max=\"okr.target\"\n :variant=\"RAG_PROGRESS[okrRag(okrPercent(okr))]\"\n :label=\"okr.objective\"\n is-label-hidden\n />\n <HStack h-align=\"between\" align=\"center\" :gap=\"4\">\n <HStack :gap=\"2\" align=\"center\">\n <Avatar size=\"sm\" :name=\"ownerName(okr.owner)\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ okr.owner }}\n </Text>\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n {{ okr.actual }}{{ okr.unit }} / {{ okr.target }}{{ okr.unit }} target\n </Text>\n </HStack>\n </VStack>\n </template>\n </VStack>\n </Card>\n </VStack>\n\n <VStack :gap=\"6\">\n <HStack :gap=\"3\" align=\"center\" h-align=\"between\">\n <Heading :level=\"2\">\n Trends\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Current vs. prior period · trailing 12 weeks\n </Text>\n </HStack>\n <Grid :columns=\"{ minWidth: 330, repeat: 'fit' }\" :gap=\"3\">\n <Card v-for=\"(trend, index) in TRENDS\" :key=\"trend.key\" :padding=\"5\">\n <VStack :gap=\"6\">\n <HStack h-align=\"between\" align=\"center\">\n <HStack :gap=\"2\">\n <Heading :level=\"3\">\n {{ trend.title }}\n </Heading>\n <Heading :level=\"3\" color=\"secondary\">\n {{ trend.data[trend.data.length - 1].current.toLocaleString('en-US') }}{{ trend.unit }}\n </Heading>\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n {{ trendDelta(trend) >= 0 ? '+' : '' }}{{ trendDelta(trend) }}% vs prior\n </Text>\n </HStack>\n <svg\n :ref=\"(el: unknown) => bindChart(el, index)\"\n class=\"trend-chart\"\n :viewBox=\"`0 0 ${chartWidth} ${CHART.h}`\"\n role=\"img\"\n :aria-label=\"trend.title\"\n >\n <defs>\n <linearGradient :id=\"`grad-${trend.key}`\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"5%\" :stop-color=\"trend.color\" stop-opacity=\"0.3\" />\n <stop offset=\"95%\" :stop-color=\"trend.color\" stop-opacity=\"0.04\" />\n </linearGradient>\n </defs>\n <g v-for=\"tick in trendGeometry(trend).yTicks\" :key=\"tick.value\">\n <line :x1=\"CHART.padLeft\" :x2=\"chartWidth - CHART.padRight\" :y1=\"tick.y\" :y2=\"tick.y\" class=\"chart-grid\" />\n <text :x=\"CHART.padLeft - 8\" :y=\"tick.y\" text-anchor=\"end\" dominant-baseline=\"middle\" class=\"chart-tick\">{{ tick.value }}</text>\n </g>\n <text\n v-for=\"(tick, tickIndex) in trendGeometry(trend).xTicks\"\n :key=\"tick.label\"\n :x=\"tick.x\"\n :y=\"CHART.h - 8\"\n :text-anchor=\"tickIndex === trendGeometry(trend).xTicks.length - 1 ? 'end' : 'middle'\"\n class=\"chart-tick\"\n >{{ tick.label }}</text>\n <path v-if=\"trend.kind === 'area'\" :d=\"trendGeometry(trend).area\" :fill=\"`url(#grad-${trend.key})`\" />\n <path :d=\"trendGeometry(trend).prior\" fill=\"none\" :stroke=\"COLORS.prior\" stroke-width=\"1\" stroke-dasharray=\"4 4\" />\n <path :d=\"trendGeometry(trend).current\" fill=\"none\" :stroke=\"trend.color\" stroke-width=\"2\" />\n </svg>\n <HStack :gap=\"5\" align=\"center\">\n <HStack :gap=\"2\" align=\"center\">\n <span class=\"legend-swatch\" :style=\"{ backgroundColor: trend.color }\" aria-hidden=\"true\" />\n <Text type=\"supporting\" color=\"secondary\">\n Current\n </Text>\n </HStack>\n <HStack :gap=\"2\" align=\"center\">\n <span class=\"legend-swatch\" :style=\"{ backgroundColor: COLORS.prior }\" aria-hidden=\"true\" />\n <Text type=\"supporting\" color=\"secondary\">\n Prior\n </Text>\n </HStack>\n </HStack>\n </VStack>\n </Card>\n </Grid>\n </VStack>\n </VStack>\n </LayoutContent>\n\n <template v-if=\"!isNarrow\" #end>\n <LayoutPanel :width=\"400\" :padding=\"6\" has-divider role=\"complementary\" label=\"What changed and why\">\n <VStack :gap=\"6\">\n <HStack :gap=\"3\" align=\"center\" h-align=\"between\">\n <Heading :level=\"2\">\n What changed &amp; why\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n AI summary\n </Text>\n </HStack>\n <VStack :gap=\"2\">\n <Card v-for=\"callout in callouts\" :key=\"callout.title\" :padding=\"5\">\n <HStack :gap=\"3\" align=\"start\">\n <Icon\n :icon=\"CALLOUT_ICON[callout.status]\"\n :color=\"CALLOUT_ICON_COLOR[callout.status]\"\n size=\"md\"\n :label=\"CALLOUT_LABEL[callout.status]\"\n />\n <StackItem size=\"fill\">\n <VStack :gap=\"1\">\n <Heading :level=\"4\">\n {{ callout.title }}\n </Heading>\n <Text type=\"body\" color=\"secondary\">\n {{ callout.detail }}\n </Text>\n </VStack>\n </StackItem>\n </HStack>\n </Card>\n </VStack>\n </VStack>\n </LayoutPanel>\n </template>\n </Layout>\n</template>\n\n<style scoped>\n.legend-swatch {\n flex-shrink: 0;\n width: 8px;\n height: 8px;\n border-radius: 2px;\n}\n\n/* 指标卡按视口宽度分三档:1 列、2 列(768–1199)、3 列(≥1200),档位互不重叠。 */\n.scorecard-grid {\n grid-template-columns: 1fr;\n}\n\n@media (min-width: 768px) and (max-width: 1199.98px) {\n .scorecard-grid {\n grid-template-columns: repeat(2, 1fr);\n }\n}\n\n@media (min-width: 1200px) {\n .scorecard-grid {\n grid-template-columns: repeat(3, 1fr);\n }\n}\n.page-content {\n padding-bottom: var(--astryx-spacing-10);\n}\n.trend-chart {\n width: 100%;\n height: 200px;\n display: block;\n}\n.chart-grid {\n stroke: var(--astryx-color-border, rgba(5, 54, 89, 0.1));\n stroke-width: 1;\n}\n.chart-tick {\n fill: var(--astryx-color-text-secondary, #4E606F);\n font-size: 12px;\n}\n</style>\n",
77096
77448
  "docsUrl": {
77097
77449
  "en": "https://astryx-vue.pages.dev/en/playground/?template=dashboard-scorecard",
77098
77450
  "zh": "https://astryx-vue.pages.dev/playground/?template=dashboard-scorecard"
@@ -77168,7 +77520,7 @@ const catalog = {
77168
77520
  "en": "It imports @astryx-vue/playground-charts, which exists only inside the docs playground and is not a published package.",
77169
77521
  "zh": "它引用了 @astryx-vue/playground-charts,这个包只存在于本站演练场,没有发布到 npm。"
77170
77522
  },
77171
- "source": "<script setup lang=\"ts\">\nimport type { TableColumn } from '@astryx-vue/core'\nimport { computed, h, ref } from 'vue'\nimport {\n Button,\n Card,\n Divider,\n Grid,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n LayoutHeader,\n SegmentedControl,\n SegmentedControlItem,\n Selector,\n Table,\n Text,\n Token,\n VStack,\n pixel,\n} from '@astryx-vue/core'\nimport {\n PlaygroundHorizontalBarChart,\n PlaygroundLineChart,\n PlaygroundResponsive,\n} from '@astryx-vue/playground-charts'\n\ntype Segment = 'all' | 'web' | 'ios' | 'android'\ntype FunnelWindow = '7d' | '30d' | '90d'\n\nconst COLORS = {\n blue: 'var(--astryx-color-data-categorical-blue, #0171E3)',\n green: 'var(--astryx-color-data-categorical-green, #0B991F)',\n orange: 'var(--astryx-color-data-categorical-orange, #EB6E00)',\n purple: 'var(--astryx-color-data-categorical-purple, #6B1EFD)',\n teal: 'var(--astryx-color-data-categorical-teal, #08A3A3)',\n}\n\nconst FUNNEL_COLORS = [COLORS.blue, COLORS.teal, COLORS.green, COLORS.orange, COLORS.purple]\n\nconst WINDOW_FACTOR: Record<FunnelWindow, number> = { '7d': 0.24, '30d': 1, '90d': 3.1 }\n\nconst FUNNEL_STAGES = [\n { key: 'visit', label: 'Visited site', users: { all: 128400, web: 82600, ios: 27300, android: 18500 } },\n { key: 'signup', label: 'Signed up', users: { all: 41200, web: 24800, ios: 9600, android: 6800 } },\n { key: 'activate', label: 'Activated', users: { all: 22600, web: 12900, ios: 5900, android: 3800 } },\n { key: 'subscribe', label: 'Subscribed', users: { all: 9100, web: 5100, ios: 2600, android: 1400 } },\n { key: 'retain', label: 'Retained 30d', users: { all: 6300, web: 3600, ios: 1850, android: 850 } },\n]\n\nconst TREND = [\n {t: 0, label: 'W1', signup: 30.1, activate: 52.4, subscribe: 38.2},\n {t: 1, label: 'W2', signup: 31.4, activate: 54.1, subscribe: 36.9},\n {t: 2, label: 'W3', signup: 29.2, activate: 51.2, subscribe: 39.8},\n {t: 3, label: 'W4', signup: 32.6, activate: 55.8, subscribe: 37.1},\n {t: 4, label: 'W5', signup: 28.4, activate: 53.6, subscribe: 40.4},\n {t: 5, label: 'W6', signup: 31.8, activate: 56.2, subscribe: 38.3},\n {t: 6, label: 'W7', signup: 33.5, activate: 54.3, subscribe: 44.6},\n {t: 7, label: 'W8', signup: 30.2, activate: 57.1, subscribe: 42.1},\n {t: 8, label: 'W9', signup: 34.1, activate: 48.9, subscribe: 37.4},\n {t: 9, label: 'W10', signup: 29.6, activate: 46.2, subscribe: 35.2},\n {t: 10, label: 'W11', signup: 32.9, activate: 50.4, subscribe: 38.9},\n {t: 11, label: 'W12', signup: 35.4, activate: 55.6, subscribe: 41.3},\n {t: 12, label: 'W13', signup: 31.1, activate: 57.3, subscribe: 39.2},\n {t: 13, label: 'W14', signup: 33.8, activate: 54.8, subscribe: 42.8},\n {t: 14, label: 'W15', signup: 41.2, activate: 49.6, subscribe: 40.1},\n {t: 15, label: 'W16', signup: 38.6, activate: 47.8, subscribe: 36.4},\n {t: 16, label: 'W17', signup: 30.4, activate: 53.2, subscribe: 39.7},\n {t: 17, label: 'W18', signup: 26.8, activate: 44.1, subscribe: 34.2},\n {t: 18, label: 'W19', signup: 31.9, activate: 51.7, subscribe: 38.1},\n {t: 19, label: 'W20', signup: 34.7, activate: 56.4, subscribe: 41.9},\n {t: 20, label: 'W21', signup: 32.2, activate: 58.2, subscribe: 44.2},\n {t: 21, label: 'W22', signup: 36.1, activate: 55.1, subscribe: 40.6},\n {t: 22, label: 'W23', signup: 33.4, activate: 59.4, subscribe: 43.8},\n {t: 23, label: 'W24', signup: 37.2, activate: 56.8, subscribe: 46.1},\n {t: 24, label: 'W25', signup: 34.9, activate: 60.1, subscribe: 42.7},\n {t: 25, label: 'W26', signup: 38.4, activate: 58.6, subscribe: 45.3},\n]\n\nconst TREND_SERIES = [\n { key: 'signup', name: 'Visit → signup', color: COLORS.blue },\n { key: 'activate', name: 'Signup → activation', color: COLORS.green },\n { key: 'subscribe', name: 'Activation → paid', color: COLORS.orange },\n]\n\nconst TREND_TICK_STEP = 5\nconst TREND_TICKS = Array.from({ length: Math.ceil(TREND.length / TREND_TICK_STEP) }, (_, i) => i * TREND_TICK_STEP)\nconst formatTrendTick = (v: number) => TREND[v]?.label ?? ''\n\nconst COHORTS = [\n { label: 'Apr 6 – Apr 12', size: 4820, retention: [100, 62, 48, 41, 37, 34, 32, 31] },\n { label: 'Apr 13 – Apr 19', size: 5140, retention: [100, 64, 50, 43, 39, 36, 34] },\n { label: 'Apr 20 – Apr 26', size: 5390, retention: [100, 66, 52, 45, 41, 38] },\n { label: 'Apr 27 – May 3', size: 5610, retention: [100, 63, 49, 42, 38] },\n { label: 'May 4 – May 10', size: 5980, retention: [100, 67, 54, 47] },\n { label: 'May 11 – May 17', size: 6240, retention: [100, 69, 56] },\n { label: 'May 18 – May 24', size: 6510, retention: [100, 71] },\n { label: 'May 25 – May 31', size: 6720, retention: [100] },\n]\n\nconst MAX_WEEKS = 8\n\nconst KPIS: Record<Segment, Array<{ key: string, label: string, value: string, delta: number, higherIsBetter: boolean }>> = {\n all: [\n { key: 'v2s', label: 'Visit → signup', value: '32.1%', delta: 1.8, higherIsBetter: true },\n { key: 's2a', label: 'Signup → activation', value: '54.9%', delta: 2.4, higherIsBetter: true },\n { key: 'a2s', label: 'Activation → paid', value: '40.3%', delta: -1.1, higherIsBetter: true },\n { key: 'w4', label: 'Week-4 retention', value: '43.2%', delta: 3.6, higherIsBetter: true },\n ],\n web: [\n { key: 'v2s', label: 'Visit → signup', value: '30.0%', delta: 1.2, higherIsBetter: true },\n { key: 's2a', label: 'Signup → activation', value: '52.0%', delta: 1.9, higherIsBetter: true },\n { key: 'a2s', label: 'Activation → paid', value: '39.5%', delta: -0.6, higherIsBetter: true },\n { key: 'w4', label: 'Week-4 retention', value: '41.8%', delta: 2.9, higherIsBetter: true },\n ],\n ios: [\n { key: 'v2s', label: 'Visit → signup', value: '35.2%', delta: 2.6, higherIsBetter: true },\n { key: 's2a', label: 'Signup → activation', value: '61.5%', delta: 3.1, higherIsBetter: true },\n { key: 'a2s', label: 'Activation → paid', value: '44.1%', delta: 0.8, higherIsBetter: true },\n { key: 'w4', label: 'Week-4 retention', value: '47.5%', delta: 4.2, higherIsBetter: true },\n ],\n android: [\n { key: 'v2s', label: 'Visit → signup', value: '36.8%', delta: 2.1, higherIsBetter: true },\n { key: 's2a', label: 'Signup → activation', value: '55.9%', delta: 1.4, higherIsBetter: true },\n { key: 'a2s', label: 'Activation → paid', value: '36.8%', delta: -2.3, higherIsBetter: true },\n { key: 'w4', label: 'Week-4 retention', value: '38.4%', delta: -0.9, higherIsBetter: true },\n ],\n}\n\nconst SEGMENT_OPTIONS = [\n { value: 'all', label: 'All platforms' },\n { value: 'web', label: 'Web' },\n { value: 'ios', label: 'iOS' },\n { value: 'android', label: 'Android' },\n]\n\nfunction nf(n: number) {\n return n.toLocaleString()\n}\n\nfunction retentionColor(pct: number): 'green' | 'teal' | 'yellow' | 'orange' | 'red' {\n if (pct >= 70) return 'green'\n if (pct >= 50) return 'teal'\n if (pct >= 40) return 'yellow'\n if (pct >= 32) return 'orange'\n return 'red'\n}\n\ninterface CohortRow extends Record<string, unknown> {\n id: string\n label: string\n size: number\n retention: number[]\n}\n\nconst cohortRows: CohortRow[] = COHORTS.map((c, i) => ({\n id: `c${i}`,\n label: c.label,\n size: c.size,\n retention: c.retention,\n}))\n\nconst cohortColumns: TableColumn<CohortRow>[] = [\n {\n key: 'label',\n header: 'Cohort',\n width: pixel(150),\n renderCell: item => h(Text, { type: 'body', weight: 'semibold' }, () => item.label),\n },\n {\n key: 'size',\n header: 'Users',\n width: pixel(90),\n renderCell: item => h(Text, { type: 'body', color: 'secondary' }, () => nf(item.size)),\n },\n ...Array.from({ length: MAX_WEEKS }, (_, week) => ({\n key: `w${week}`,\n header: `W${week}`,\n width: pixel(72),\n renderCell: (item: CohortRow) => {\n const pct = item.retention[week]\n if (pct == null)\n return h(Text, { type: 'supporting', color: 'secondary' }, () => '—')\n return h(Token, { size: 'sm', color: retentionColor(pct), label: `${pct}%` })\n },\n })),\n]\n\nconst segment = ref<Segment>('all')\nconst funnelWindow = ref<FunnelWindow>('30d')\n\nconst kpis = computed(() => KPIS[segment.value])\n\nconst topOfFunnel = computed(() => {\n const base = FUNNEL_STAGES[0].users[segment.value]\n return Math.round(base * WINDOW_FACTOR[funnelWindow.value])\n})\n\nconst overallConversion = computed(() => {\n const top = FUNNEL_STAGES[0].users[segment.value]\n const bottom = FUNNEL_STAGES[FUNNEL_STAGES.length - 1].users[segment.value]\n return ((bottom / top) * 100).toFixed(1)\n})\n\nconst funnelBars = computed(() => {\n const top = FUNNEL_STAGES[0].users[segment.value]\n return FUNNEL_STAGES.map((stage, i) => {\n const users = stage.users[segment.value]\n const prev = i > 0 ? FUNNEL_STAGES[i - 1].users[segment.value] : null\n return {\n label: stage.label,\n value: users,\n color: FUNNEL_COLORS[i % FUNNEL_COLORS.length],\n pctOfTop: (users / top) * 100,\n stepPct: prev != null ? (users / prev) * 100 : null,\n }\n })\n})\n\nconst funnelChartData = computed(() => funnelBars.value.map(b => ({\n label: b.label,\n value: b.value,\n color: b.color,\n})))\n\nconst funnelSteps = computed(() => FUNNEL_STAGES.slice(1).map((stage, i) => {\n const prev = FUNNEL_STAGES[i]\n const from = prev.users[segment.value]\n const to = stage.users[segment.value]\n const conv = (to / from) * 100\n const drop = 100 - conv\n return {\n key: stage.key,\n label: `${prev.label} → ${stage.label}`,\n conv,\n drop,\n }\n}))\n\nconst segmentLabel = computed(() => SEGMENT_OPTIONS.find(o => o.value === segment.value)?.label ?? '')\n<\/script>\n\n<template>\n <Layout height=\"fill\" :padding=\"4\" :content-width=\"1440\">\n <template #header>\n <LayoutHeader has-divider>\n <HStack :gap=\"3\" align=\"center\" justify=\"between\" wrap=\"wrap\">\n <VStack :gap=\"0\">\n <Heading :level=\"1\">\n Funnel & cohort analytics\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n {{ nf(topOfFunnel) }} visitors · {{ overallConversion }}% end-to-end conversion\n </Text>\n </VStack>\n <HStack :gap=\"2\">\n <SegmentedControl v-model=\"funnelWindow\" label=\"Funnel window\">\n <SegmentedControlItem label=\"7d\" value=\"7d\" />\n <SegmentedControlItem label=\"30d\" value=\"30d\" />\n <SegmentedControlItem label=\"90d\" value=\"90d\" />\n </SegmentedControl>\n <Selector\n v-model=\"segment\"\n label=\"Segment\"\n is-label-hidden\n :options=\"SEGMENT_OPTIONS\"\n />\n <Button label=\"Export\" variant=\"secondary\">\n <template #icon>\n <Icon icon=\"i-heroicons-arrow-down-tray\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n </HStack>\n </LayoutHeader>\n </template>\n <template #default>\n <LayoutContent>\n <VStack :gap=\"10\">\n <VStack :gap=\"4\">\n <Grid :columns=\"{ minWidth: 240, repeat: 'fit' }\" :gap=\"3\">\n <Card v-for=\"kpi in kpis\" :key=\"kpi.key\" :padding=\"4\">\n <VStack :gap=\"1\">\n <Text type=\"label\" color=\"secondary\">\n {{ kpi.label }}\n </Text>\n <HStack :gap=\"3\">\n <Heading :level=\"1\">\n {{ kpi.value }}\n </Heading>\n <HStack :gap=\"1\" align=\"center\">\n <Icon\n :icon=\"kpi.delta >= 0 ? 'arrowUp' : 'arrowDown'\"\n size=\"xsm\"\n :color=\"(kpi.higherIsBetter ? kpi.delta >= 0 : kpi.delta <= 0) ? 'success' : 'error'\"\n />\n <Text type=\"supporting\" color=\"secondary\">\n {{ kpi.delta >= 0 ? '+' : '' }}{{ kpi.delta.toFixed(1) }} pts vs. prior\n </Text>\n </HStack>\n </HStack>\n </VStack>\n </Card>\n </Grid>\n\n <Card :padding=\"6\">\n <VStack :gap=\"6\">\n <VStack>\n <Heading :level=\"2\">\n Conversion funnel\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n {{ segmentLabel }} · {{ funnelWindow }}\n </Text>\n </VStack>\n <PlaygroundResponsive :height=\"260\">\n <PlaygroundHorizontalBarChart :data=\"funnelChartData\" :height=\"260\" />\n </PlaygroundResponsive>\n <Divider />\n <Grid :columns=\"{ minWidth: 200, repeat: 'fit' }\" :gap=\"3\" class=\"step-grid\">\n <VStack v-for=\"step in funnelSteps\" :key=\"step.key\" :gap=\"1\" class=\"step\">\n <Text type=\"label\" color=\"secondary\">\n {{ step.label }}\n </Text>\n <HStack :gap=\"2\" align=\"center\">\n <Heading :level=\"2\">\n {{ step.conv.toFixed(1) }}%\n </Heading>\n <Token\n size=\"sm\"\n :color=\"step.drop >= 55 ? 'red' : 'green'\"\n :label=\"`-${step.drop.toFixed(0)}%`\"\n >\n <template #icon>\n <Icon icon=\"i-heroicons-arrow-trending-down\" size=\"xsm\" color=\"inherit\" />\n </template>\n </Token>\n </HStack>\n </VStack>\n </Grid>\n </VStack>\n </Card>\n\n <Card :padding=\"6\">\n <VStack :gap=\"6\">\n <VStack>\n <Heading :level=\"2\">\n Conversion over time\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Stage conversion rates · trailing {{ TREND.length }} weeks\n </Text>\n </VStack>\n <PlaygroundResponsive :height=\"260\">\n <PlaygroundLineChart\n :data=\"TREND\"\n x-key=\"t\"\n :series=\"TREND_SERIES\"\n :height=\"260\"\n y-unit=\"%\"\n :x-ticks=\"TREND_TICKS\"\n :x-tick-format=\"formatTrendTick\"\n />\n </PlaygroundResponsive>\n <HStack :gap=\"5\" align=\"center\" wrap=\"wrap\">\n <HStack v-for=\"series in TREND_SERIES\" :key=\"series.key\" :gap=\"2\" align=\"center\">\n <span class=\"legend-dot\" :style=\"{ background: series.color }\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ series.name }}\n </Text>\n </HStack>\n </HStack>\n </VStack>\n </Card>\n </VStack>\n\n <Divider />\n\n <VStack :gap=\"6\">\n <VStack>\n <Heading :level=\"2\">\n Weekly retention cohorts\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Signup week × weeks since signup\n </Text>\n </VStack>\n <Card :padding=\"6\">\n <VStack :gap=\"8\">\n <Table :data=\"cohortRows\" :columns=\"cohortColumns\" id-key=\"id\" density=\"balanced\" dividers=\"rows\" />\n <HStack :gap=\"4\" align=\"center\" wrap=\"wrap\">\n <Text type=\"supporting\" color=\"secondary\">\n Retention:\n </Text>\n <HStack :gap=\"2\" align=\"center\">\n <Token size=\"sm\" color=\"red\" label=\"< 32%\" />\n <Token size=\"sm\" color=\"orange\" label=\"32–40%\" />\n <Token size=\"sm\" color=\"yellow\" label=\"40–50%\" />\n <Token size=\"sm\" color=\"teal\" label=\"50–70%\" />\n <Token size=\"sm\" color=\"green\" label=\"≥ 70%\" />\n </HStack>\n </HStack>\n </VStack>\n </Card>\n </VStack>\n </VStack>\n </LayoutContent>\n </template>\n </Layout>\n</template>\n\n<style scoped>\n.step-grid {\n overflow-x: clip;\n}\n.step {\n border-inline-start-width: 1px;\n border-inline-start-style: solid;\n border-inline-start-color: var(--astryx-color-border, rgba(5, 54, 89, 0.1));\n margin-inline-start: calc(var(--astryx-spacing-3, 12px) / -2);\n padding-inline-start: calc(var(--astryx-spacing-3, 12px) / 2);\n}\n.legend-dot {\n width: 8px;\n height: 8px;\n border-radius: 999px;\n flex-shrink: 0;\n}\n</style>\n",
77523
+ "source": "<script setup lang=\"ts\">\nimport type { TableColumn } from '@astryx-vue/core'\nimport { computed, h, ref } from 'vue'\nimport {\n Button,\n Card,\n Divider,\n Grid,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n LayoutHeader,\n SegmentedControl,\n SegmentedControlItem,\n Selector,\n Table,\n Text,\n Token,\n VStack,\n pixel,\n} from '@astryx-vue/core'\nimport {\n PlaygroundHorizontalBarChart,\n PlaygroundLineChart,\n PlaygroundResponsive,\n} from '@astryx-vue/playground-charts'\n\ntype Segment = 'all' | 'web' | 'ios' | 'android'\ntype FunnelWindow = '7d' | '30d' | '90d'\n\nconst COLORS = {\n blue: 'var(--astryx-color-data-categorical-blue, #0171E3)',\n green: 'var(--astryx-color-data-categorical-green, #0B991F)',\n orange: 'var(--astryx-color-data-categorical-orange, #EB6E00)',\n purple: 'var(--astryx-color-data-categorical-purple, #6B1EFD)',\n teal: 'var(--astryx-color-data-categorical-teal, #08A3A3)',\n}\n\nconst FUNNEL_COLORS = [COLORS.blue, COLORS.teal, COLORS.green, COLORS.orange, COLORS.purple]\n\nconst WINDOW_FACTOR: Record<FunnelWindow, number> = { '7d': 0.24, '30d': 1, '90d': 3.1 }\n\nconst FUNNEL_STAGES = [\n { key: 'visit', label: 'Visited site', users: { all: 128400, web: 82600, ios: 27300, android: 18500 } },\n { key: 'signup', label: 'Signed up', users: { all: 41200, web: 24800, ios: 9600, android: 6800 } },\n { key: 'activate', label: 'Activated', users: { all: 22600, web: 12900, ios: 5900, android: 3800 } },\n { key: 'subscribe', label: 'Subscribed', users: { all: 9100, web: 5100, ios: 2600, android: 1400 } },\n { key: 'retain', label: 'Retained 30d', users: { all: 6300, web: 3600, ios: 1850, android: 850 } },\n]\n\nconst TREND = [\n {t: 0, label: 'W1', signup: 30.1, activate: 52.4, subscribe: 38.2},\n {t: 1, label: 'W2', signup: 31.4, activate: 54.1, subscribe: 36.9},\n {t: 2, label: 'W3', signup: 29.2, activate: 51.2, subscribe: 39.8},\n {t: 3, label: 'W4', signup: 32.6, activate: 55.8, subscribe: 37.1},\n {t: 4, label: 'W5', signup: 28.4, activate: 53.6, subscribe: 40.4},\n {t: 5, label: 'W6', signup: 31.8, activate: 56.2, subscribe: 38.3},\n {t: 6, label: 'W7', signup: 33.5, activate: 54.3, subscribe: 44.6},\n {t: 7, label: 'W8', signup: 30.2, activate: 57.1, subscribe: 42.1},\n {t: 8, label: 'W9', signup: 34.1, activate: 48.9, subscribe: 37.4},\n {t: 9, label: 'W10', signup: 29.6, activate: 46.2, subscribe: 35.2},\n {t: 10, label: 'W11', signup: 32.9, activate: 50.4, subscribe: 38.9},\n {t: 11, label: 'W12', signup: 35.4, activate: 55.6, subscribe: 41.3},\n {t: 12, label: 'W13', signup: 31.1, activate: 57.3, subscribe: 39.2},\n {t: 13, label: 'W14', signup: 33.8, activate: 54.8, subscribe: 42.8},\n {t: 14, label: 'W15', signup: 41.2, activate: 49.6, subscribe: 40.1},\n {t: 15, label: 'W16', signup: 38.6, activate: 47.8, subscribe: 36.4},\n {t: 16, label: 'W17', signup: 30.4, activate: 53.2, subscribe: 39.7},\n {t: 17, label: 'W18', signup: 26.8, activate: 44.1, subscribe: 34.2},\n {t: 18, label: 'W19', signup: 31.9, activate: 51.7, subscribe: 38.1},\n {t: 19, label: 'W20', signup: 34.7, activate: 56.4, subscribe: 41.9},\n {t: 20, label: 'W21', signup: 32.2, activate: 58.2, subscribe: 44.2},\n {t: 21, label: 'W22', signup: 36.1, activate: 55.1, subscribe: 40.6},\n {t: 22, label: 'W23', signup: 33.4, activate: 59.4, subscribe: 43.8},\n {t: 23, label: 'W24', signup: 37.2, activate: 56.8, subscribe: 46.1},\n {t: 24, label: 'W25', signup: 34.9, activate: 60.1, subscribe: 42.7},\n {t: 25, label: 'W26', signup: 38.4, activate: 58.6, subscribe: 45.3},\n]\n\nconst TREND_SERIES = [\n { key: 'signup', name: 'Visit → signup', color: COLORS.blue },\n { key: 'activate', name: 'Signup → activation', color: COLORS.green },\n { key: 'subscribe', name: 'Activation → paid', color: COLORS.orange },\n]\n\nconst TREND_TICK_STEP = 5\nconst TREND_TICKS = Array.from({ length: Math.ceil(TREND.length / TREND_TICK_STEP) }, (_, i) => i * TREND_TICK_STEP)\nconst formatTrendTick = (v: number) => TREND[v]?.label ?? ''\n\nconst COHORTS = [\n { label: 'Apr 6 – Apr 12', size: 4820, retention: [100, 62, 48, 41, 37, 34, 32, 31] },\n { label: 'Apr 13 – Apr 19', size: 5140, retention: [100, 64, 50, 43, 39, 36, 34] },\n { label: 'Apr 20 – Apr 26', size: 5390, retention: [100, 66, 52, 45, 41, 38] },\n { label: 'Apr 27 – May 3', size: 5610, retention: [100, 63, 49, 42, 38] },\n { label: 'May 4 – May 10', size: 5980, retention: [100, 67, 54, 47] },\n { label: 'May 11 – May 17', size: 6240, retention: [100, 69, 56] },\n { label: 'May 18 – May 24', size: 6510, retention: [100, 71] },\n { label: 'May 25 – May 31', size: 6720, retention: [100] },\n]\n\nconst MAX_WEEKS = 8\n\nconst KPIS: Record<Segment, Array<{ key: string, label: string, value: string, delta: number, higherIsBetter: boolean }>> = {\n all: [\n { key: 'v2s', label: 'Visit → signup', value: '32.1%', delta: 1.8, higherIsBetter: true },\n { key: 's2a', label: 'Signup → activation', value: '54.9%', delta: 2.4, higherIsBetter: true },\n { key: 'a2s', label: 'Activation → paid', value: '40.3%', delta: -1.1, higherIsBetter: true },\n { key: 'w4', label: 'Week-4 retention', value: '43.2%', delta: 3.6, higherIsBetter: true },\n ],\n web: [\n { key: 'v2s', label: 'Visit → signup', value: '30.0%', delta: 1.2, higherIsBetter: true },\n { key: 's2a', label: 'Signup → activation', value: '52.0%', delta: 1.9, higherIsBetter: true },\n { key: 'a2s', label: 'Activation → paid', value: '39.5%', delta: -0.6, higherIsBetter: true },\n { key: 'w4', label: 'Week-4 retention', value: '41.8%', delta: 2.9, higherIsBetter: true },\n ],\n ios: [\n { key: 'v2s', label: 'Visit → signup', value: '35.2%', delta: 2.6, higherIsBetter: true },\n { key: 's2a', label: 'Signup → activation', value: '61.5%', delta: 3.1, higherIsBetter: true },\n { key: 'a2s', label: 'Activation → paid', value: '44.1%', delta: 0.8, higherIsBetter: true },\n { key: 'w4', label: 'Week-4 retention', value: '47.5%', delta: 4.2, higherIsBetter: true },\n ],\n android: [\n { key: 'v2s', label: 'Visit → signup', value: '36.8%', delta: 2.1, higherIsBetter: true },\n { key: 's2a', label: 'Signup → activation', value: '55.9%', delta: 1.4, higherIsBetter: true },\n { key: 'a2s', label: 'Activation → paid', value: '36.8%', delta: -2.3, higherIsBetter: true },\n { key: 'w4', label: 'Week-4 retention', value: '38.4%', delta: -0.9, higherIsBetter: true },\n ],\n}\n\nconst SEGMENT_OPTIONS = [\n { value: 'all', label: 'All platforms' },\n { value: 'web', label: 'Web' },\n { value: 'ios', label: 'iOS' },\n { value: 'android', label: 'Android' },\n]\n\nfunction nf(n: number) {\n return n.toLocaleString()\n}\n\nfunction retentionColor(pct: number): 'green' | 'teal' | 'yellow' | 'orange' | 'red' {\n if (pct >= 70) return 'green'\n if (pct >= 50) return 'teal'\n if (pct >= 40) return 'yellow'\n if (pct >= 32) return 'orange'\n return 'red'\n}\n\ninterface CohortRow extends Record<string, unknown> {\n id: string\n label: string\n size: number\n retention: number[]\n}\n\nconst cohortRows: CohortRow[] = COHORTS.map((c, i) => ({\n id: `c${i}`,\n label: c.label,\n size: c.size,\n retention: c.retention,\n}))\n\nconst cohortColumns: TableColumn<CohortRow>[] = [\n {\n key: 'label',\n header: 'Cohort',\n width: pixel(150),\n renderCell: item => h(Text, { type: 'body', weight: 'semibold' }, () => item.label),\n },\n {\n key: 'size',\n header: 'Users',\n width: pixel(90),\n renderCell: item => h(Text, { type: 'body', color: 'secondary' }, () => nf(item.size)),\n },\n ...Array.from({ length: MAX_WEEKS }, (_, week) => ({\n key: `w${week}`,\n header: `W${week}`,\n width: pixel(72),\n renderCell: (item: CohortRow) => {\n const pct = item.retention[week]\n if (pct == null)\n return h(Text, { type: 'supporting', color: 'secondary' }, () => '—')\n return h(Token, { size: 'sm', color: retentionColor(pct), label: `${pct}%` })\n },\n })),\n]\n\nconst segment = ref<Segment>('all')\nconst funnelWindow = ref<FunnelWindow>('30d')\n\nconst kpis = computed(() => KPIS[segment.value])\n\nconst topOfFunnel = computed(() => {\n const base = FUNNEL_STAGES[0].users[segment.value]\n return Math.round(base * WINDOW_FACTOR[funnelWindow.value])\n})\n\nconst overallConversion = computed(() => {\n const top = FUNNEL_STAGES[0].users[segment.value]\n const bottom = FUNNEL_STAGES[FUNNEL_STAGES.length - 1].users[segment.value]\n return ((bottom / top) * 100).toFixed(1)\n})\n\nconst funnelBars = computed(() => {\n const top = FUNNEL_STAGES[0].users[segment.value]\n return FUNNEL_STAGES.map((stage, i) => {\n const users = stage.users[segment.value]\n const prev = i > 0 ? FUNNEL_STAGES[i - 1].users[segment.value] : null\n return {\n label: stage.label,\n value: users,\n color: FUNNEL_COLORS[i % FUNNEL_COLORS.length],\n pctOfTop: (users / top) * 100,\n stepPct: prev != null ? (users / prev) * 100 : null,\n }\n })\n})\n\nconst funnelChartData = computed(() => funnelBars.value.map(b => ({\n label: b.label,\n value: b.value,\n color: b.color,\n})))\n\nconst funnelSteps = computed(() => FUNNEL_STAGES.slice(1).map((stage, i) => {\n const prev = FUNNEL_STAGES[i]\n const from = prev.users[segment.value]\n const to = stage.users[segment.value]\n const conv = (to / from) * 100\n const drop = 100 - conv\n return {\n key: stage.key,\n label: `${prev.label} → ${stage.label}`,\n conv,\n drop,\n }\n}))\n\nconst segmentLabel = computed(() => SEGMENT_OPTIONS.find(o => o.value === segment.value)?.label ?? '')\n<\/script>\n\n<template>\n <Layout height=\"fill\" :padding=\"4\" :content-width=\"1440\">\n <template #header>\n <LayoutHeader has-divider>\n <HStack :gap=\"3\" align=\"center\" justify=\"between\" wrap=\"wrap\">\n <VStack :gap=\"0\">\n <Heading :level=\"1\">\n Funnel & cohort analytics\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n {{ nf(topOfFunnel) }} visitors · {{ overallConversion }}% end-to-end conversion\n </Text>\n </VStack>\n <HStack :gap=\"2\">\n <SegmentedControl v-model=\"funnelWindow\" label=\"Funnel window\">\n <SegmentedControlItem label=\"7d\" value=\"7d\" />\n <SegmentedControlItem label=\"30d\" value=\"30d\" />\n <SegmentedControlItem label=\"90d\" value=\"90d\" />\n </SegmentedControl>\n <Selector\n v-model=\"segment\"\n label=\"Segment\"\n is-label-hidden\n :options=\"SEGMENT_OPTIONS\"\n />\n <Button label=\"Export\" variant=\"secondary\">\n <template #icon>\n <Icon icon=\"i-heroicons-arrow-down-tray\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n </HStack>\n </LayoutHeader>\n </template>\n <template #default>\n <LayoutContent>\n <VStack :gap=\"10\">\n <VStack :gap=\"4\">\n <Grid :columns=\"{ minWidth: 240, repeat: 'fit' }\" :gap=\"3\">\n <Card v-for=\"kpi in kpis\" :key=\"kpi.key\" :padding=\"4\">\n <VStack :gap=\"1\">\n <Text type=\"label\" color=\"secondary\">\n {{ kpi.label }}\n </Text>\n <HStack :gap=\"3\">\n <Heading :level=\"1\">\n {{ kpi.value }}\n </Heading>\n <HStack :gap=\"1\" align=\"center\">\n <Icon\n :icon=\"kpi.delta >= 0 ? 'arrowUp' : 'arrowDown'\"\n size=\"xsm\"\n :color=\"(kpi.higherIsBetter ? kpi.delta >= 0 : kpi.delta <= 0) ? 'success' : 'error'\"\n />\n <Text type=\"supporting\" color=\"secondary\">\n {{ kpi.delta >= 0 ? '+' : '' }}{{ kpi.delta.toFixed(1) }} pts vs. prior\n </Text>\n </HStack>\n </HStack>\n </VStack>\n </Card>\n </Grid>\n\n <Card :padding=\"6\">\n <VStack :gap=\"6\">\n <VStack>\n <Heading :level=\"2\">\n Conversion funnel\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n {{ segmentLabel }} · {{ funnelWindow }}\n </Text>\n </VStack>\n <PlaygroundResponsive :height=\"260\">\n <PlaygroundHorizontalBarChart :data=\"funnelChartData\" :height=\"260\" has-axis />\n </PlaygroundResponsive>\n <Divider />\n <Grid :columns=\"{ minWidth: 200, repeat: 'fit' }\" :gap=\"3\" class=\"step-grid\">\n <VStack v-for=\"step in funnelSteps\" :key=\"step.key\" :gap=\"1\" class=\"step\">\n <Text type=\"label\" color=\"secondary\">\n {{ step.label }}\n </Text>\n <HStack :gap=\"2\" align=\"center\">\n <Heading :level=\"2\">\n {{ step.conv.toFixed(1) }}%\n </Heading>\n <Token\n size=\"sm\"\n :color=\"step.drop >= 55 ? 'red' : 'green'\"\n :label=\"`-${step.drop.toFixed(0)}%`\"\n >\n <template #icon>\n <Icon icon=\"i-heroicons-arrow-trending-down\" size=\"xsm\" color=\"inherit\" />\n </template>\n </Token>\n </HStack>\n </VStack>\n </Grid>\n </VStack>\n </Card>\n\n <Card :padding=\"6\">\n <VStack :gap=\"6\">\n <VStack>\n <Heading :level=\"2\">\n Conversion over time\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Stage conversion rates · trailing {{ TREND.length }} weeks\n </Text>\n </VStack>\n <VStack :gap=\"3\">\n <PlaygroundResponsive :height=\"260\">\n <PlaygroundLineChart\n :data=\"TREND\"\n x-key=\"t\"\n :series=\"TREND_SERIES\"\n :height=\"260\"\n y-unit=\"%\"\n :x-ticks=\"TREND_TICKS\"\n :x-tick-format=\"formatTrendTick\"\n :pad=\"{ top: 5, right: 12, bottom: 35, left: 40 }\"\n center-ticks\n />\n </PlaygroundResponsive>\n <HStack :gap=\"5\" align=\"center\" wrap=\"wrap\">\n <HStack v-for=\"series in TREND_SERIES\" :key=\"series.key\" :gap=\"2\" align=\"center\">\n <span class=\"legend-dot\" :style=\"{ background: series.color }\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ series.name }}\n </Text>\n </HStack>\n </HStack>\n </VStack>\n </VStack>\n </Card>\n </VStack>\n\n <Divider />\n\n <VStack :gap=\"6\">\n <VStack>\n <Heading :level=\"2\">\n Weekly retention cohorts\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Signup week × weeks since signup\n </Text>\n </VStack>\n <Card :padding=\"6\">\n <VStack :gap=\"8\">\n <div class=\"fixed-table\"><Table :data=\"cohortRows\" :columns=\"cohortColumns\" id-key=\"id\" density=\"balanced\" dividers=\"rows\" /></div>\n <HStack :gap=\"4\" align=\"center\" wrap=\"wrap\">\n <Text type=\"supporting\" color=\"secondary\">\n Retention:\n </Text>\n <HStack :gap=\"2\" align=\"center\">\n <Token size=\"sm\" color=\"red\" label=\"< 32%\" />\n <Token size=\"sm\" color=\"orange\" label=\"32–40%\" />\n <Token size=\"sm\" color=\"yellow\" label=\"40–50%\" />\n <Token size=\"sm\" color=\"teal\" label=\"50–70%\" />\n <Token size=\"sm\" color=\"green\" label=\"≥ 70%\" />\n </HStack>\n </HStack>\n </VStack>\n </Card>\n </VStack>\n </VStack>\n </LayoutContent>\n </template>\n </Layout>\n</template>\n\n<style scoped>\n/* 原版 Table 的数据模式默认 `table-layout: fixed`,列宽严格按声明的比例分;core 目前是 auto,列宽按内容算、行高有 0.5px 的小数。 */\n.fixed-table :deep(.astryx-table) {\n table-layout: fixed;\n}\n\n.step-grid {\n overflow-x: clip;\n}\n.step {\n border-inline-start-width: 1px;\n border-inline-start-style: solid;\n border-inline-start-color: var(--astryx-color-border, rgba(5, 54, 89, 0.1));\n margin-inline-start: calc(var(--astryx-spacing-3, 12px) / -2);\n padding-inline-start: calc(var(--astryx-spacing-3, 12px) / 2);\n}\n.legend-dot {\n width: 12px;\n height: 12px;\n border-radius: 0;\n flex-shrink: 0;\n}\n</style>\n",
77172
77524
  "docsUrl": {
77173
77525
  "en": "https://astryx-vue.pages.dev/en/playground/?template=dashboard-cohort-funnel",
77174
77526
  "zh": "https://astryx-vue.pages.dev/playground/?template=dashboard-cohort-funnel"
@@ -77244,7 +77596,7 @@ const catalog = {
77244
77596
  "en": "It imports @astryx-vue/playground-charts, which exists only inside the docs playground and is not a published package.",
77245
77597
  "zh": "它引用了 @astryx-vue/playground-charts,这个包只存在于本站演练场,没有发布到 npm。"
77246
77598
  },
77247
- "source": "<script setup lang=\"ts\">\nimport type { TableColumn } from '@astryx-vue/core'\nimport { h, ref } from 'vue'\nimport {\n Avatar,\n Badge,\n Button,\n Card,\n Divider,\n DropdownMenu,\n Grid,\n GridSpan,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n Link,\n List,\n ListItem,\n Table,\n Text,\n VStack,\n proportional,\n} from '@astryx-vue/core'\nimport { PlaygroundAreaChart, PlaygroundResponsive, PlaygroundSparkline } from '@astryx-vue/playground-charts'\n\ninterface StockRow extends Record<string, unknown> {\n id: string\n ticker: string\n price: string\n dailyPts: number\n dailyPct: number\n weekChg: number\n spark: number[]\n}\n\nfunction genSpark(\n seed: number,\n start: number,\n end: number,\n volatility: number,\n N: number = 96,\n): number[] {\n let s = seed >>> 0;\n const rand = () => {\n s = (Math.imul(s, 1664525) + 1013904223) >>> 0;\n return s / 0x100000000;\n };\n const points: number[] = [];\n let drift = 0;\n for (let i = 0; i < N; i++) {\n const t = i / (N - 1);\n const trend = start + (end - start) * t;\n // Multiple overlaid waves at different frequencies for session-like rhythm\n // plus high-frequency chop.\n const wave =\n Math.sin(t * Math.PI * 2.3 + seed * 0.13) * volatility * 1.4 +\n Math.sin(t * Math.PI * 5.7 + seed * 0.41) * volatility * 0.9 +\n Math.sin(t * Math.PI * 13.1 + seed * 0.07) * volatility * 0.5;\n // Loosely-correlated random walk so adjacent ticks jitter rather than glide.\n drift = drift * 0.55 + (rand() - 0.5) * volatility * 2.2;\n // Occasional sharper spike to mimic news-driven moves.\n const spike = rand() < 0.04 ? (rand() - 0.5) * volatility * 4 : 0;\n points.push(trend + wave + drift + spike);\n }\n return points;\n}\n\nconst portfolioData = (() => {\n const anchors: Array<[number, number]> = [\n [0, 230000],\n [14, 238000],\n [28, 245000],\n [42, 250000],\n [56, 245000],\n [70, 258000],\n [84, 252000],\n [98, 260000],\n [112, 255000],\n [126, 245000],\n [140, 222000],\n [154, 218000],\n [168, 225000],\n [182, 232000],\n [196, 225000],\n [210, 235000],\n [224, 240000],\n [238, 245000],\n [252, 235000],\n [266, 248000],\n [280, 255000],\n [294, 260000],\n [308, 268000],\n [322, 275000],\n [336, 278000],\n [350, 285000],\n [364, 288000],\n [378, 290000],\n [392, 292000],\n [406, 294200],\n ];\n const totalDays = anchors[anchors.length - 1][0];\n const monthPerDay = 12 / totalDays;\n const out: Array<{month: number; label: string; value: number}> = [];\n let ai = 0;\n for (let day = 0; day <= totalDays; day++) {\n while (ai < anchors.length - 2 && day >= anchors[ai + 1][0]) {\n ai++;\n }\n const [d0, v0] = anchors[ai];\n const [d1, v1] = anchors[ai + 1];\n const t = (day - d0) / (d1 - d0);\n const base = v0 + (v1 - v0) * t;\n const seed = Math.sin(day * 12.9898 + 78.233) * 43758.5453;\n const noise = (seed - Math.floor(seed) - 0.5) * 3600;\n out.push({\n month: day * monthPerDay,\n label: `Day ${day + 1}`,\n value: Math.round(base + noise),\n });\n }\n return out;\n})();\n\nconst xAxisTicks = [0, 3, 6, 9, 12]\nconst xAxisLabels: Record<number, string> = { 0: 'Oct', 3: 'Jan', 6: 'Apr', 9: 'Jul', 12: 'Oct' }\n\nconst metrics = [\n {value: '$294,200', change: '+14.8%', label: 'Total value'},\n {value: '14.8%', change: '+2.1%', label: 'Annual return'},\n {value: '2.8%', change: '$2,060/qtr', label: 'Dividend yield'},\n {value: '23', change: '+4 YTD', label: 'Total asset holdings'},\n];\n\nconst topAssets = [\n {ticker: 'AAPL', name: 'Apple Inc.', value: '$87,200', change: '+18.4%'},\n {ticker: 'MSFT', name: 'Microsoft Corp.', value: '$72,500', change: '+14.7%'},\n {ticker: 'NVDA', name: 'NVIDIA Corp.', value: '$63,800', change: '+31.2%'},\n {\n ticker: 'VTI',\n name: 'Vanguard Total Stock',\n value: '$58,400',\n change: '+11.3%',\n },\n {\n ticker: 'BND',\n name: 'Vanguard Total Bond',\n value: '$45,600',\n change: '+4.2%',\n },\n];\n\nconst marketIndices = [\n {\n name: 'Dow Jones',\n ticker: 'DJI',\n price: '43,821.67',\n change: '+0.42%',\n positive: true,\n spark: genSpark(1337, 78, 84, 3.2),\n },\n {\n name: 'NASDAQ',\n ticker: 'IXIC',\n price: '18,942.18',\n change: '-0.50%',\n positive: false,\n spark: genSpark(2042, 86, 78, 3.8),\n },\n {\n name: 'S&P 500',\n ticker: 'SPX',\n price: '5,918.33',\n change: '+0.21%',\n positive: true,\n spark: genSpark(3155, 71, 75, 2.8),\n },\n {\n name: 'NYSE Composite',\n ticker: 'NYA',\n price: '19,752.41',\n change: '-0.20%',\n positive: false,\n spark: genSpark(4287, 70, 65, 3.0),\n },\n {\n name: 'NVIDIA Corp.',\n ticker: 'NVDA',\n price: '$177.39',\n change: '+0.93%',\n positive: true,\n spark: genSpark(5519, 60, 67, 3.6),\n },\n {\n name: 'Intel Corp.',\n ticker: 'INTC',\n price: '$50.38',\n change: '+4.89%',\n positive: true,\n spark: genSpark(6701, 40, 51, 2.6),\n },\n {\n name: 'Nokia Corp.',\n ticker: 'NOK',\n price: '$8.82',\n change: '+6.65%',\n positive: true,\n spark: genSpark(7823, 30, 41, 2.4),\n },\n {\n name: 'Tesla, Inc.',\n ticker: 'TSLA',\n price: '$360.59',\n change: '-5.42%',\n positive: false,\n spark: genSpark(8945, 90, 76, 4.2),\n },\n];\n\nconst trendingStocks: StockRow[] = [\n {\n id: '1',\n ticker: 'AAPL',\n price: '$188.72',\n dailyPts: 1.35,\n dailyPct: 0.72,\n weekChg: 22.4,\n spark: genSpark(11023, 60, 70, 2.6, 48),\n },\n {\n id: '2',\n ticker: 'MSFT',\n price: '$415.6',\n dailyPts: 3.2,\n dailyPct: 0.78,\n weekChg: 18.6,\n spark: genSpark(12591, 55, 65, 2.4, 48),\n },\n {\n id: '3',\n ticker: 'NVDA',\n price: '$177.39',\n dailyPts: 1.65,\n dailyPct: 0.93,\n weekChg: 45.2,\n spark: genSpark(13744, 40, 68, 3.8, 48),\n },\n {\n id: '4',\n ticker: 'AMZN',\n price: '$186.5',\n dailyPts: -0.8,\n dailyPct: -0.43,\n weekChg: 15.3,\n spark: genSpark(14882, 70, 63, 2.5, 48),\n },\n {\n id: '5',\n ticker: 'GOOGL',\n price: '$155.72',\n dailyPts: 2.1,\n dailyPct: 1.37,\n weekChg: 12.8,\n spark: genSpark(15967, 50, 60, 2.7, 48),\n },\n {\n id: '6',\n ticker: 'META',\n price: '$505.3',\n dailyPts: 4.5,\n dailyPct: 0.9,\n weekChg: 35.1,\n spark: genSpark(17105, 45, 61, 3.0, 48),\n },\n {\n id: '7',\n ticker: 'TSLA',\n price: '$360.59',\n dailyPts: -20.67,\n dailyPct: -5.42,\n weekChg: -8.3,\n spark: genSpark(18249, 90, 64, 4.4, 48),\n },\n {\n id: '8',\n ticker: 'INTC',\n price: '$50.38',\n dailyPts: 2.35,\n dailyPct: 4.89,\n weekChg: -12.5,\n spark: genSpark(19388, 65, 57, 2.8, 48),\n },\n {\n id: '9',\n ticker: 'AMD',\n price: '$162.45',\n dailyPts: -1.2,\n dailyPct: -0.73,\n weekChg: 28.7,\n spark: genSpark(20471, 50, 63, 2.9, 48),\n },\n {\n id: '10',\n ticker: 'NFLX',\n price: '$628.9',\n dailyPts: 5.4,\n dailyPct: 0.87,\n weekChg: 42.1,\n spark: genSpark(21556, 42, 58, 2.6, 48),\n },\n];\n\nconst trendingColumns: TableColumn<StockRow>[] = [\n { key: 'ticker', header: 'Ticker', width: proportional(1), renderCell: row => h(Text, { type: 'body', weight: 'bold' }, () => row.ticker) },\n { key: 'price', header: 'Price', width: proportional(1) },\n { key: 'dailyPts', header: 'Daily Chg (pts)', width: proportional(1), renderCell: row => h(Badge, { variant: row.dailyPts >= 0 ? 'green' : 'red', label: (row.dailyPts >= 0 ? '+' : '') + row.dailyPts.toFixed(2) }) },\n { key: 'dailyPct', header: 'Daily Chg (%)', width: proportional(1), renderCell: row => h(Badge, { variant: row.dailyPct >= 0 ? 'green' : 'red', label: (row.dailyPct >= 0 ? '+' : '') + row.dailyPct.toFixed(2) + '%' }) },\n { key: 'weekChg', header: '52W Chg (%)', width: proportional(1), renderCell: row => h(Badge, { variant: row.weekChg >= 0 ? 'green' : 'red', label: (row.weekChg >= 0 ? '+' : '') + row.weekChg.toFixed(1) + '%' }) },\n { key: 'spark', header: '24h Trend', width: proportional(1), renderCell: row => h(PlaygroundSparkline, { data: row.spark, color: row.dailyPct >= 0 ? 'var(--astryx-color-data-categorical-green, #0B991F)' : 'var(--astryx-color-data-categorical-red, #F5394F)', height: 24, strokeWidth: 1 }) },\n]\n\nconst timeRange = ref('1 year')\n<\/script>\n\n<template>\n <Layout height=\"fill\">\n <template #default>\n <LayoutContent :padding=\"6\">\n <VStack :gap=\"6\">\n <HStack justify=\"between\" align=\"center\">\n <Heading :level=\"1\">My Portfolio</Heading>\n <DropdownMenu\n :button=\"{ label: timeRange, variant: 'secondary', size: 'lg' }\"\n has-chevron\n :items=\"[\n { label: '1 month', onClick: () => timeRange = '1 month' },\n { label: '3 months', onClick: () => timeRange = '3 months' },\n { label: '6 months', onClick: () => timeRange = '6 months' },\n { label: '1 year', onClick: () => timeRange = '1 year' },\n { label: '5 years', onClick: () => timeRange = '5 years' },\n { label: 'All time', onClick: () => timeRange = 'All time' },\n ]\"\n />\n </HStack>\n\n <Grid :columns=\"{ minWidth: 280, repeat: 'fit' }\" :gap=\"4\">\n <Card v-for=\"m in metrics\" :key=\"m.label\">\n <VStack :gap=\"1\">\n <HStack :gap=\"3\" align=\"center\">\n <Text type=\"display-3\" weight=\"bold\">{{ m.value }}</Text>\n <HStack :gap=\"1\" align=\"center\">\n <Icon :icon=\"!m.change.startsWith('-') ? 'arrowUp' : 'arrowDown'\" size=\"xsm\" :color=\"!m.change.startsWith('-') ? 'success' : 'error'\" />\n <Text type=\"body\" color=\"secondary\">{{ m.change }}</Text>\n </HStack>\n </HStack>\n <Text type=\"body\" color=\"secondary\">{{ m.label }}</Text>\n </VStack>\n </Card>\n </Grid>\n\n <Grid :columns=\"{ minWidth: 280, max: 4 }\" :gap=\"4\">\n <GridSpan :columns=\"3\">\n <Card>\n <VStack :gap=\"4\">\n <HStack justify=\"between\" align=\"center\">\n <Heading :level=\"3\">Portfolio Value</Heading>\n <Link href=\"#\">View details</Link>\n </HStack>\n <PlaygroundResponsive :height=\"340\">\n <PlaygroundAreaChart\n :data=\"portfolioData\"\n x-key=\"month\"\n y-key=\"value\"\n color=\"var(--astryx-color-data-categorical-green, #0B991F)\"\n :height=\"340\"\n :y-domain=\"[200000, 320000]\"\n :x-domain=\"[0, 12]\"\n :x-ticks=\"xAxisTicks\"\n :x-tick-format=\"(v: number) => xAxisLabels[v] ?? ''\"\n :y-tick-format=\"(v: number) => `$${(v / 1000).toFixed(0)}k`\"\n />\n </PlaygroundResponsive>\n </VStack>\n </Card>\n </GridSpan>\n <GridSpan :columns=\"1\">\n <Card>\n <VStack :gap=\"4\">\n <HStack justify=\"between\" align=\"center\">\n <Heading :level=\"3\">Top Assets</Heading>\n <Link href=\"#\">View all</Link>\n </HStack>\n <List density=\"spacious\">\n <ListItem\n v-for=\"asset in topAssets\"\n :key=\"asset.ticker\"\n :label=\"asset.ticker\"\n :description=\"asset.name\"\n href=\"#\"\n >\n <template #startContent>\n <Avatar :name=\"asset.ticker\" size=\"md\" />\n </template>\n <template #endContent>\n <VStack :gap=\"0\" justify=\"end\">\n <Text type=\"body\">{{ asset.value }}</Text>\n <Badge :label=\"asset.change\" :variant=\"asset.change.startsWith('-') ? 'red' : 'green'\" />\n </VStack>\n </template>\n </ListItem>\n </List>\n </VStack>\n </Card>\n </GridSpan>\n </Grid>\n\n <Divider />\n\n <HStack justify=\"between\" align=\"start\">\n <VStack :gap=\"1\">\n <Heading :level=\"1\">Market Today</Heading>\n <Text type=\"body\" color=\"secondary\">Past 24 hours</Text>\n </VStack>\n <Button label=\"View more\" variant=\"secondary\" size=\"lg\" />\n </HStack>\n\n <Grid :columns=\"{ minWidth: 320, repeat: 'fit' }\" :gap=\"4\">\n <Card v-for=\"m in marketIndices\" :key=\"m.ticker\">\n <VStack :gap=\"4\">\n <VStack :gap=\"0\">\n <Heading :level=\"3\">{{ m.name }}</Heading>\n <Text type=\"supporting\" color=\"secondary\">{{ m.ticker }}</Text>\n </VStack>\n <PlaygroundSparkline :data=\"m.spark\" :color=\"m.positive ? 'var(--astryx-color-data-categorical-green, #0B991F)' : 'var(--astryx-color-data-categorical-red, #F5394F)'\" />\n <HStack :gap=\"3\" align=\"center\">\n <Text type=\"display-3\" weight=\"bold\">{{ m.price }}</Text>\n <HStack :gap=\"1\" align=\"center\">\n <Icon :icon=\"m.positive ? 'arrowUp' : 'arrowDown'\" size=\"xsm\" :color=\"m.positive ? 'success' : 'error'\" />\n <Text type=\"body\" color=\"secondary\">{{ m.change }}</Text>\n </HStack>\n </HStack>\n </VStack>\n </Card>\n </Grid>\n\n <Card>\n <VStack :gap=\"4\">\n <Heading :level=\"3\">Trending Stocks</Heading>\n <Table :data=\"trendingStocks\" :columns=\"trendingColumns\" id-key=\"id\" has-hover dividers=\"rows\" />\n </VStack>\n </Card>\n </VStack>\n </LayoutContent>\n </template>\n </Layout>\n</template>\n",
77599
+ "source": "<script setup lang=\"ts\">\nimport type { TableColumn } from '@astryx-vue/core'\nimport { h, ref } from 'vue'\nimport {\n Avatar,\n Badge,\n Button,\n Card,\n Divider,\n DropdownMenu,\n Grid,\n GridSpan,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n Link,\n List,\n ListItem,\n Table,\n Text,\n VStack,\n proportional,\n} from '@astryx-vue/core'\nimport { PlaygroundAreaChart, PlaygroundResponsive, PlaygroundSparkline } from '@astryx-vue/playground-charts'\n\ninterface StockRow extends Record<string, unknown> {\n id: string\n ticker: string\n price: string\n dailyPts: number\n dailyPct: number\n weekChg: number\n spark: number[]\n}\n\nfunction genSpark(\n seed: number,\n start: number,\n end: number,\n volatility: number,\n N: number = 96,\n): number[] {\n let s = seed >>> 0;\n const rand = () => {\n s = (Math.imul(s, 1664525) + 1013904223) >>> 0;\n return s / 0x100000000;\n };\n const points: number[] = [];\n let drift = 0;\n for (let i = 0; i < N; i++) {\n const t = i / (N - 1);\n const trend = start + (end - start) * t;\n // Multiple overlaid waves at different frequencies for session-like rhythm\n // plus high-frequency chop.\n const wave =\n Math.sin(t * Math.PI * 2.3 + seed * 0.13) * volatility * 1.4 +\n Math.sin(t * Math.PI * 5.7 + seed * 0.41) * volatility * 0.9 +\n Math.sin(t * Math.PI * 13.1 + seed * 0.07) * volatility * 0.5;\n // Loosely-correlated random walk so adjacent ticks jitter rather than glide.\n drift = drift * 0.55 + (rand() - 0.5) * volatility * 2.2;\n // Occasional sharper spike to mimic news-driven moves.\n const spike = rand() < 0.04 ? (rand() - 0.5) * volatility * 4 : 0;\n points.push(trend + wave + drift + spike);\n }\n return points;\n}\n\nconst portfolioData = (() => {\n const anchors: Array<[number, number]> = [\n [0, 230000],\n [14, 238000],\n [28, 245000],\n [42, 250000],\n [56, 245000],\n [70, 258000],\n [84, 252000],\n [98, 260000],\n [112, 255000],\n [126, 245000],\n [140, 222000],\n [154, 218000],\n [168, 225000],\n [182, 232000],\n [196, 225000],\n [210, 235000],\n [224, 240000],\n [238, 245000],\n [252, 235000],\n [266, 248000],\n [280, 255000],\n [294, 260000],\n [308, 268000],\n [322, 275000],\n [336, 278000],\n [350, 285000],\n [364, 288000],\n [378, 290000],\n [392, 292000],\n [406, 294200],\n ];\n const totalDays = anchors[anchors.length - 1][0];\n const monthPerDay = 12 / totalDays;\n const out: Array<{month: number; label: string; value: number}> = [];\n let ai = 0;\n for (let day = 0; day <= totalDays; day++) {\n while (ai < anchors.length - 2 && day >= anchors[ai + 1][0]) {\n ai++;\n }\n const [d0, v0] = anchors[ai];\n const [d1, v1] = anchors[ai + 1];\n const t = (day - d0) / (d1 - d0);\n const base = v0 + (v1 - v0) * t;\n const seed = Math.sin(day * 12.9898 + 78.233) * 43758.5453;\n const noise = (seed - Math.floor(seed) - 0.5) * 3600;\n out.push({\n month: day * monthPerDay,\n label: `Day ${day + 1}`,\n value: Math.round(base + noise),\n });\n }\n return out;\n})();\n\nconst xAxisTicks = [0, 3, 6, 9, 12]\nconst xAxisLabels: Record<number, string> = { 0: 'Oct', 3: 'Jan', 6: 'Apr', 9: 'Jul', 12: 'Oct' }\n\nconst metrics = [\n {value: '$294,200', change: '+14.8%', label: 'Total value'},\n {value: '14.8%', change: '+2.1%', label: 'Annual return'},\n {value: '2.8%', change: '$2,060/qtr', label: 'Dividend yield'},\n {value: '23', change: '+4 YTD', label: 'Total asset holdings'},\n];\n\nconst topAssets = [\n {ticker: 'AAPL', name: 'Apple Inc.', value: '$87,200', change: '+18.4%'},\n {ticker: 'MSFT', name: 'Microsoft Corp.', value: '$72,500', change: '+14.7%'},\n {ticker: 'NVDA', name: 'NVIDIA Corp.', value: '$63,800', change: '+31.2%'},\n {\n ticker: 'VTI',\n name: 'Vanguard Total Stock',\n value: '$58,400',\n change: '+11.3%',\n },\n {\n ticker: 'BND',\n name: 'Vanguard Total Bond',\n value: '$45,600',\n change: '+4.2%',\n },\n];\n\nconst marketIndices = [\n {\n name: 'Dow Jones',\n ticker: 'DJI',\n price: '43,821.67',\n change: '+0.42%',\n positive: true,\n spark: genSpark(1337, 78, 84, 3.2),\n },\n {\n name: 'NASDAQ',\n ticker: 'IXIC',\n price: '18,942.18',\n change: '-0.50%',\n positive: false,\n spark: genSpark(2042, 86, 78, 3.8),\n },\n {\n name: 'S&P 500',\n ticker: 'SPX',\n price: '5,918.33',\n change: '+0.21%',\n positive: true,\n spark: genSpark(3155, 71, 75, 2.8),\n },\n {\n name: 'NYSE Composite',\n ticker: 'NYA',\n price: '19,752.41',\n change: '-0.20%',\n positive: false,\n spark: genSpark(4287, 70, 65, 3.0),\n },\n {\n name: 'NVIDIA Corp.',\n ticker: 'NVDA',\n price: '$177.39',\n change: '+0.93%',\n positive: true,\n spark: genSpark(5519, 60, 67, 3.6),\n },\n {\n name: 'Intel Corp.',\n ticker: 'INTC',\n price: '$50.38',\n change: '+4.89%',\n positive: true,\n spark: genSpark(6701, 40, 51, 2.6),\n },\n {\n name: 'Nokia Corp.',\n ticker: 'NOK',\n price: '$8.82',\n change: '+6.65%',\n positive: true,\n spark: genSpark(7823, 30, 41, 2.4),\n },\n {\n name: 'Tesla, Inc.',\n ticker: 'TSLA',\n price: '$360.59',\n change: '-5.42%',\n positive: false,\n spark: genSpark(8945, 90, 76, 4.2),\n },\n];\n\nconst trendingStocks: StockRow[] = [\n {\n id: '1',\n ticker: 'AAPL',\n price: '$188.72',\n dailyPts: 1.35,\n dailyPct: 0.72,\n weekChg: 22.4,\n spark: genSpark(11023, 60, 70, 2.6, 48),\n },\n {\n id: '2',\n ticker: 'MSFT',\n price: '$415.6',\n dailyPts: 3.2,\n dailyPct: 0.78,\n weekChg: 18.6,\n spark: genSpark(12591, 55, 65, 2.4, 48),\n },\n {\n id: '3',\n ticker: 'NVDA',\n price: '$177.39',\n dailyPts: 1.65,\n dailyPct: 0.93,\n weekChg: 45.2,\n spark: genSpark(13744, 40, 68, 3.8, 48),\n },\n {\n id: '4',\n ticker: 'AMZN',\n price: '$186.5',\n dailyPts: -0.8,\n dailyPct: -0.43,\n weekChg: 15.3,\n spark: genSpark(14882, 70, 63, 2.5, 48),\n },\n {\n id: '5',\n ticker: 'GOOGL',\n price: '$155.72',\n dailyPts: 2.1,\n dailyPct: 1.37,\n weekChg: 12.8,\n spark: genSpark(15967, 50, 60, 2.7, 48),\n },\n {\n id: '6',\n ticker: 'META',\n price: '$505.3',\n dailyPts: 4.5,\n dailyPct: 0.9,\n weekChg: 35.1,\n spark: genSpark(17105, 45, 61, 3.0, 48),\n },\n {\n id: '7',\n ticker: 'TSLA',\n price: '$360.59',\n dailyPts: -20.67,\n dailyPct: -5.42,\n weekChg: -8.3,\n spark: genSpark(18249, 90, 64, 4.4, 48),\n },\n {\n id: '8',\n ticker: 'INTC',\n price: '$50.38',\n dailyPts: 2.35,\n dailyPct: 4.89,\n weekChg: -12.5,\n spark: genSpark(19388, 65, 57, 2.8, 48),\n },\n {\n id: '9',\n ticker: 'AMD',\n price: '$162.45',\n dailyPts: -1.2,\n dailyPct: -0.73,\n weekChg: 28.7,\n spark: genSpark(20471, 50, 63, 2.9, 48),\n },\n {\n id: '10',\n ticker: 'NFLX',\n price: '$628.9',\n dailyPts: 5.4,\n dailyPct: 0.87,\n weekChg: 42.1,\n spark: genSpark(21556, 42, 58, 2.6, 48),\n },\n];\n\nconst trendingColumns: TableColumn<StockRow>[] = [\n { key: 'ticker', header: 'Ticker', width: proportional(1), renderCell: row => h(Text, { type: 'body', weight: 'bold' }, () => row.ticker) },\n { key: 'price', header: 'Price', width: proportional(1) },\n { key: 'dailyPts', header: 'Daily Chg (pts)', width: proportional(1), renderCell: row => h(Badge, { variant: row.dailyPts >= 0 ? 'green' : 'red', label: (row.dailyPts >= 0 ? '+' : '') + row.dailyPts.toFixed(2) }) },\n { key: 'dailyPct', header: 'Daily Chg (%)', width: proportional(1), renderCell: row => h(Badge, { variant: row.dailyPct >= 0 ? 'green' : 'red', label: (row.dailyPct >= 0 ? '+' : '') + row.dailyPct.toFixed(2) + '%' }) },\n { key: 'weekChg', header: '52W Chg (%)', width: proportional(1), renderCell: row => h(Badge, { variant: row.weekChg >= 0 ? 'green' : 'red', label: (row.weekChg >= 0 ? '+' : '') + row.weekChg.toFixed(1) + '%' }) },\n { key: 'spark', header: '24h Trend', width: proportional(1), renderCell: row => h(PlaygroundSparkline, { data: row.spark, color: row.dailyPct >= 0 ? 'var(--astryx-color-data-categorical-green, #0B991F)' : 'var(--astryx-color-data-categorical-red, #F5394F)', height: 24, strokeWidth: 1, isMeasured: true, padTop: 2, padBottom: 2 }) },\n]\n\nconst timeRange = ref('1 year')\n<\/script>\n\n<template>\n <Layout height=\"fill\">\n <template #default>\n <LayoutContent :padding=\"6\">\n <VStack :gap=\"6\">\n <HStack justify=\"between\" align=\"center\">\n <Heading :level=\"1\">My Portfolio</Heading>\n <DropdownMenu\n :button=\"{ label: timeRange, variant: 'secondary', size: 'lg' }\"\n has-chevron\n :items=\"[\n { label: '1 month', onClick: () => timeRange = '1 month' },\n { label: '3 months', onClick: () => timeRange = '3 months' },\n { label: '6 months', onClick: () => timeRange = '6 months' },\n { label: '1 year', onClick: () => timeRange = '1 year' },\n { label: '5 years', onClick: () => timeRange = '5 years' },\n { label: 'All time', onClick: () => timeRange = 'All time' },\n ]\"\n />\n </HStack>\n\n <Grid :columns=\"{ minWidth: 280, repeat: 'fit' }\" :gap=\"4\">\n <Grid v-for=\"group in [0, 1]\" :key=\"group\" :columns=\"{ minWidth: 280, repeat: 'fit' }\" :gap=\"4\">\n <Card v-for=\"m in metrics.slice(group * 2, group * 2 + 2)\" :key=\"m.label\">\n <VStack :gap=\"1\">\n <HStack :gap=\"3\" align=\"center\">\n <Text type=\"display-3\" weight=\"bold\">{{ m.value }}</Text>\n <HStack :gap=\"1\" align=\"center\">\n <Icon :icon=\"!m.change.startsWith('-') ? 'arrowUp' : 'arrowDown'\" size=\"xsm\" :color=\"!m.change.startsWith('-') ? 'success' : 'error'\" />\n <Text type=\"body\" color=\"secondary\">{{ m.change }}</Text>\n </HStack>\n </HStack>\n <Text type=\"body\" color=\"secondary\">{{ m.label }}</Text>\n </VStack>\n </Card>\n </Grid>\n </Grid>\n\n <Grid :columns=\"{ minWidth: 280, max: 4 }\" :gap=\"4\">\n <GridSpan :columns=\"3\">\n <Card>\n <VStack :gap=\"4\">\n <HStack justify=\"between\" align=\"center\">\n <Heading :level=\"3\">Portfolio Value</Heading>\n <Link href=\"#\">View details</Link>\n </HStack>\n <PlaygroundResponsive :height=\"340\">\n <PlaygroundAreaChart\n :data=\"portfolioData\"\n x-key=\"month\"\n y-key=\"value\"\n color=\"var(--astryx-color-data-categorical-green, #0B991F)\"\n :height=\"340\"\n :y-domain=\"[200000, 320000]\"\n :y-ticks=\"[200000, 240000, 280000, 320000]\"\n :pad=\"{ top: 10, right: 10, bottom: 35, left: 50 }\"\n center-ticks\n :x-domain=\"[0, 12]\"\n :x-ticks=\"xAxisTicks\"\n :x-tick-format=\"(v: number) => xAxisLabels[v] ?? ''\"\n :y-tick-format=\"(v: number) => `$${(v / 1000).toFixed(0)}k`\"\n />\n </PlaygroundResponsive>\n </VStack>\n </Card>\n </GridSpan>\n <GridSpan :columns=\"1\">\n <Card>\n <VStack :gap=\"4\">\n <HStack justify=\"between\" align=\"center\">\n <Heading :level=\"3\">Top Assets</Heading>\n <Link href=\"#\">View all</Link>\n </HStack>\n <List density=\"spacious\">\n <ListItem\n v-for=\"asset in topAssets\"\n :key=\"asset.ticker\"\n :label=\"asset.ticker\"\n :description=\"asset.name\"\n href=\"#\"\n >\n <template #label>\n <Text weight=\"bold\">\n {{ asset.ticker }}\n </Text>\n </template>\n <template #startContent>\n <Avatar :name=\"asset.ticker\" size=\"md\" />\n </template>\n <template #endContent>\n <VStack :gap=\"0\" hAlign=\"end\">\n <Text type=\"body\">{{ asset.value }}</Text>\n <Badge :label=\"asset.change\" :variant=\"asset.change.startsWith('-') ? 'red' : 'green'\" />\n </VStack>\n </template>\n </ListItem>\n </List>\n </VStack>\n </Card>\n </GridSpan>\n </Grid>\n\n <Divider />\n\n <HStack justify=\"between\" align=\"start\">\n <VStack :gap=\"1\">\n <Heading :level=\"1\">Market Today</Heading>\n <Text type=\"body\" color=\"secondary\">Past 24 hours</Text>\n </VStack>\n <Button label=\"View more\" variant=\"secondary\" size=\"lg\" />\n </HStack>\n\n <Grid :columns=\"{ minWidth: 320, repeat: 'fit' }\" :gap=\"4\">\n <Card v-for=\"m in marketIndices\" :key=\"m.ticker\">\n <VStack :gap=\"4\">\n <VStack :gap=\"0\">\n <Heading :level=\"3\">{{ m.name }}</Heading>\n <Text type=\"supporting\" color=\"secondary\">{{ m.ticker }}</Text>\n </VStack>\n <PlaygroundSparkline :data=\"m.spark\" :color=\"m.positive ? 'var(--astryx-color-data-categorical-green, #0B991F)' : 'var(--astryx-color-data-categorical-red, #F5394F)'\" is-measured :pad-top=\"2\" :pad-bottom=\"2\" />\n <HStack :gap=\"3\" align=\"center\">\n <Text type=\"display-3\" weight=\"bold\">{{ m.price }}</Text>\n <HStack :gap=\"1\" align=\"center\">\n <Icon :icon=\"m.positive ? 'arrowUp' : 'arrowDown'\" size=\"xsm\" :color=\"m.positive ? 'success' : 'error'\" />\n <Text type=\"body\" color=\"secondary\">{{ m.change }}</Text>\n </HStack>\n </HStack>\n </VStack>\n </Card>\n </Grid>\n\n <Card>\n <VStack :gap=\"4\">\n <Heading :level=\"3\">Trending Stocks</Heading>\n <Table :data=\"trendingStocks\" :columns=\"trendingColumns\" id-key=\"id\" has-hover dividers=\"rows\" />\n </VStack>\n </Card>\n </VStack>\n </LayoutContent>\n </template>\n </Layout>\n</template>\n",
77248
77600
  "docsUrl": {
77249
77601
  "en": "https://astryx-vue.pages.dev/en/playground/?template=dashboard-composition",
77250
77602
  "zh": "https://astryx-vue.pages.dev/playground/?template=dashboard-composition"
@@ -77326,7 +77678,7 @@ const catalog = {
77326
77678
  "en": "It imports @astryx-vue/playground-charts, which exists only inside the docs playground and is not a published package.",
77327
77679
  "zh": "它引用了 @astryx-vue/playground-charts,这个包只存在于本站演练场,没有发布到 npm。"
77328
77680
  },
77329
- "source": "<script setup lang=\"ts\">\nimport type { TableColumn } from '@astryx-vue/core'\nimport { computed, h, ref } from 'vue'\nimport {\n Avatar,\n Badge,\n Button,\n Card,\n EmptyState,\n Grid,\n Heading,\n HStack,\n HoverCard,\n Icon,\n Layout,\n LayoutContent,\n LayoutHeader,\n List,\n ListItem,\n ProgressBar,\n Section,\n SegmentedControl,\n SegmentedControlItem,\n Table,\n Text,\n Timestamp,\n VStack,\n proportional,\n} from '@astryx-vue/core'\nimport {\n PlaygroundDonutChart,\n PlaygroundGanttChart,\n PlaygroundLineChart,\n PlaygroundResponsive,\n} from '@astryx-vue/playground-charts'\n\ntype Rag = 'onTrack' | 'atRisk' | 'blocked'\ntype Phase = 'all' | 'build' | 'beta' | 'ga'\ntype ReleasePhase = Exclude<Phase, 'all'>\n\nconst PHASE_LABEL: Record<ReleasePhase, string> = { build: 'Build', beta: 'Beta', ga: 'GA' }\nconst RAG_LABEL: Record<Rag, string> = { onTrack: 'On track', atRisk: 'At risk', blocked: 'Blocked' }\nconst RAG_BADGE: Record<Rag, 'green' | 'yellow' | 'red'> = { onTrack: 'green', atRisk: 'yellow', blocked: 'red' }\nconst RAG_FILL: Record<Rag, string> = {\n onTrack: 'var(--astryx-color-data-categorical-green, #0B991F)',\n atRisk: 'var(--astryx-color-data-categorical-orange, #EB6E00)',\n blocked: 'var(--astryx-color-data-categorical-red, #F5394F)',\n}\n\nconst TIMELINE_DAYS = 90\nconst TIMELINE_TICKS = Array.from({ length: TIMELINE_DAYS / 15 + 1 }, (_, i) => i * 15)\nconst PROJECT_DAY = 58\n\ninterface Milestone {\n id: string\n name: string\n phase: ReleasePhase\n start: number\n end: number\n status: Rag\n dateLabel: string\n}\n\nconst MILESTONES: Milestone[] = [\n { id: 'm1', name: 'Kickoff & scoping', phase: 'build', start: 0, end: 8, status: 'onTrack', dateLabel: 'Apr 1 – Apr 9' },\n { id: 'm2', name: 'Architecture sign-off', phase: 'build', start: 8, end: 20, status: 'onTrack', dateLabel: 'Apr 9 – Apr 21' },\n { id: 'm3', name: 'Feature complete', phase: 'build', start: 20, end: 44, status: 'atRisk', dateLabel: 'Apr 21 – May 15' },\n { id: 'm4', name: 'Private beta', phase: 'beta', start: 44, end: 62, status: 'atRisk', dateLabel: 'May 15 – Jun 2' },\n { id: 'm5', name: 'Bug bash & hardening', phase: 'beta', start: 58, end: 72, status: 'blocked', dateLabel: 'May 29 – Jun 12' },\n { id: 'm6', name: 'GA readiness review', phase: 'ga', start: 72, end: 82, status: 'onTrack', dateLabel: 'Jun 12 – Jun 22' },\n { id: 'm7', name: 'Public launch', phase: 'ga', start: 82, end: 90, status: 'onTrack', dateLabel: 'Jun 22 – Jun 30' },\n]\n\nconst TASK_STATUS = [\n { key: 'closed', tasks: 86, label: 'Closed', points: 190, color: 'var(--astryx-color-data-categorical-blue, #0171E3)' },\n { key: 'inProgress', tasks: 24, label: 'In progress', points: 58, color: 'var(--astryx-color-data-categorical-green, #0B991F)' },\n { key: 'inReview', tasks: 12, label: 'In review', points: 24, color: 'var(--astryx-color-data-categorical-orange, #EB6E00)' },\n { key: 'blocked', tasks: 5, label: 'Blocked', points: 22, color: 'var(--astryx-color-data-categorical-red, #F5394F)' },\n { key: 'planned', tasks: 21, label: 'Planned', points: 26, color: 'var(--astryx-color-data-neutral, #8494A3)' },\n]\n\ninterface Workstream extends Record<string, unknown> {\n id: string\n name: string\n owner: string\n phase: ReleasePhase\n percent: number\n due: string\n status: Rag\n}\n\nconst WORKSTREAMS: Workstream[] = [\n { id: 'w1', name: 'Core platform', owner: 'Priya Raman', phase: 'build', percent: 100, due: '2026-04-21', status: 'onTrack' },\n { id: 'w2', name: 'Data pipeline', owner: 'Marcus Webb', phase: 'build', percent: 92, due: '2026-05-15', status: 'atRisk' },\n { id: 'w3', name: 'Web client', owner: 'Ana Duarte', phase: 'build', percent: 88, due: '2026-05-15', status: 'onTrack' },\n { id: 'w4', name: 'Beta onboarding', owner: 'Tom Okafor', phase: 'beta', percent: 64, due: '2026-06-02', status: 'atRisk' },\n { id: 'w5', name: 'Load & reliability', owner: 'Dana Whitfield', phase: 'beta', percent: 38, due: '2026-06-12', status: 'blocked' },\n { id: 'w6', name: 'Docs & enablement', owner: 'Sofia Marín', phase: 'ga', percent: 55, due: '2026-06-22', status: 'onTrack' },\n { id: 'w7', name: 'Launch marketing', owner: 'Kenji Watanabe', phase: 'ga', percent: 41, due: '2026-06-22', status: 'onTrack' },\n { id: 'w8', name: 'Go-to-market ops', owner: 'Lena Fischer', phase: 'ga', percent: 30, due: '2026-06-30', status: 'atRisk' },\n]\n\ninterface Risk {\n id: string\n title: string\n workstream: string\n phase: ReleasePhase\n severity: Rag\n owner: string\n detail: string\n}\n\nconst RISKS: Risk[] = [\n { id: 'r1', title: 'Load tests failing at 3x target concurrency', workstream: 'Load & reliability', phase: 'beta', severity: 'blocked', owner: 'Dana Whitfield', detail: 'p99 latency degrades past SLO above 12k concurrent sessions.' },\n { id: 'r2', title: 'Data pipeline backfill slower than planned', workstream: 'Data pipeline', phase: 'build', severity: 'atRisk', owner: 'Marcus Webb', detail: 'Historical backfill is tracking two days behind.' },\n { id: 'r3', title: 'Beta onboarding email deliverability', workstream: 'Beta onboarding', phase: 'beta', severity: 'atRisk', owner: 'Tom Okafor', detail: 'Invite emails landing in spam for two enterprise domains.' },\n]\n\nconst BURNDOWN = [320, 300, 286, 262, 240, 226, 210, 182, 150, 132, 96, 54, 20].map((actual, i) => ({\n week: i,\n label: `W${i + 1}`,\n ideal: Math.max(0, Math.round(320 - (320 / 12) * i)),\n actual,\n}))\n\nconst PROGRAM_STATUS: Rag = WORKSTREAMS.some(w => w.status === 'blocked')\n ? 'blocked'\n : WORKSTREAMS.some(w => w.status === 'atRisk')\n ? 'atRisk'\n : 'onTrack'\n\nfunction byPhase<T extends { phase: ReleasePhase }>(items: T[], phase: Phase) {\n return phase === 'all' ? items : items.filter(item => item.phase === phase)\n}\n\nconst phase = ref<Phase>('all')\nconst milestones = computed(() => byPhase(MILESTONES, phase.value))\nconst workstreams = computed(() => byPhase(WORKSTREAMS, phase.value))\nconst risks = computed(() => byPhase(RISKS, phase.value))\n\nconst RAG_ORDER: Rag[] = ['onTrack', 'atRisk', 'blocked']\n\nconst ganttItems = computed(() => milestones.value.map(m => ({\n name: m.name,\n start: m.start,\n end: m.end,\n color: RAG_FILL[m.status],\n})))\n\nconst TASK_POINTS_TOTAL = TASK_STATUS.reduce((sum, s) => sum + s.points, 0)\nconst TASK_COUNT_TOTAL = TASK_STATUS.reduce((sum, s) => sum + s.tasks, 0)\nconst ACTUAL_COLOR = 'var(--astryx-color-data-categorical-blue, #0171E3)'\nconst IDEAL_COLOR = 'var(--astryx-color-data-neutral, #8494A3)'\n\nconst donutSegments = computed(() => TASK_STATUS.map(s => ({ value: s.points, color: s.color })))\n\nconst WORKSTREAM_COLUMNS: TableColumn<Workstream>[] = [\n {\n key: 'name',\n header: 'Workstream',\n width: proportional(1, { minWidth: 160 }),\n renderCell: item => h(VStack, { gap: 0 }, () => [\n h(Text, { type: 'body', weight: 'semibold' }, () => item.name),\n h(Text, { type: 'supporting', color: 'secondary' }, () => PHASE_LABEL[item.phase]),\n ]),\n },\n {\n key: 'owner',\n header: 'Owner',\n width: proportional(1, { minWidth: 160 }),\n renderCell: item => h(HStack, { gap: 2, vAlign: 'center' }, () => [\n h(Avatar, { name: item.owner, size: 'sm' }),\n h(Text, { type: 'body' }, () => item.owner),\n ]),\n },\n {\n key: 'percent',\n header: '% complete',\n width: proportional(2, { minWidth: 200 }),\n renderCell: item => h(VStack, { gap: 1 }, () => [\n h(ProgressBar, { value: item.percent, max: 100, label: `${item.name} progress`, isLabelHidden: true }),\n h(Text, { type: 'supporting', color: 'secondary' }, () => `${item.percent}%`),\n ]),\n },\n {\n key: 'due',\n header: 'Due',\n width: proportional(1, { minWidth: 120 }),\n renderCell: item => h(Timestamp, { value: item.due, format: 'date' }),\n },\n {\n key: 'status',\n header: 'Status',\n width: proportional(1, { minWidth: 120 }),\n renderCell: item => h(Badge, { variant: RAG_BADGE[item.status], label: RAG_LABEL[item.status] }),\n },\n]\n<\/script>\n\n<template>\n <Layout height=\"fill\">\n <template #header>\n <LayoutHeader :padding=\"6\" has-divider>\n <HStack :gap=\"3\" align=\"center\" justify=\"between\" wrap=\"wrap\" class=\"content-column\">\n <VStack :gap=\"1\">\n <Heading :level=\"1\">\n Aurora Launch\n </Heading>\n <HStack :gap=\"2\" align=\"center\">\n <Text type=\"body\" color=\"secondary\">\n Target GA ·\n </Text>\n <Timestamp value=\"2026-06-30T00:00:00Z\" format=\"date\" type=\"body\" color=\"secondary\" />\n <Badge :variant=\"RAG_BADGE[PROGRAM_STATUS]\" :label=\"RAG_LABEL[PROGRAM_STATUS]\" />\n </HStack>\n </VStack>\n <HStack :gap=\"2\">\n <SegmentedControl v-model=\"phase\" label=\"Release phase\">\n <SegmentedControlItem label=\"All\" value=\"all\" />\n <SegmentedControlItem label=\"Build\" value=\"build\" />\n <SegmentedControlItem label=\"Beta\" value=\"beta\" />\n <SegmentedControlItem label=\"GA\" value=\"ga\" />\n </SegmentedControl>\n <Button label=\"Export status\" variant=\"secondary\">\n <template #icon>\n <Icon icon=\"i-heroicons-arrow-down-tray\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n </HStack>\n </LayoutHeader>\n </template>\n <template #default>\n <LayoutContent :padding=\"0\" class=\"content-bottom-pad\">\n <Section :padding=\"6\" :dividers=\"['bottom']\">\n <VStack :gap=\"6\" class=\"content-column\">\n <Heading :level=\"2\">\n Milestone timeline\n </Heading>\n <EmptyState\n v-if=\"milestones.length === 0\"\n title=\"No milestones in this phase\"\n description=\"Switch phases to see other milestones.\"\n is-compact\n />\n <template v-else>\n <PlaygroundGanttChart\n :items=\"ganttItems\"\n :domain-max=\"TIMELINE_DAYS\"\n :today=\"PROJECT_DAY\"\n :ticks=\"TIMELINE_TICKS\"\n />\n <HStack :gap=\"4\" align=\"center\" wrap=\"wrap\">\n <HStack v-for=\"status in RAG_ORDER\" :key=\"status\" :gap=\"2\" align=\"center\">\n <span class=\"legend-swatch\" :style=\"{ backgroundColor: RAG_FILL[status] }\" aria-hidden=\"true\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ RAG_LABEL[status] }}\n </Text>\n </HStack>\n </HStack>\n </template>\n </VStack>\n </Section>\n <Section :padding=\"6\" :padding-block=\"10\">\n <VStack :gap=\"6\" class=\"content-column\">\n <Grid :gap=\"4\" :columns=\"{ minWidth: 320, max: 3 }\">\n <Card :padding=\"6\">\n <VStack :gap=\"5\">\n <VStack>\n <Heading :level=\"3\" :accessibility-level=\"2\">\n Overall progress\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n {{ TASK_COUNT_TOTAL }} tasks · {{ TASK_POINTS_TOTAL }} story points\n </Text>\n </VStack>\n <HStack :gap=\"8\" align=\"center\" wrap=\"wrap\">\n <PlaygroundDonutChart :segments=\"donutSegments\" />\n <VStack :gap=\"2\">\n <HStack v-for=\"s in TASK_STATUS\" :key=\"s.key\" :gap=\"2\" align=\"center\">\n <span class=\"legend-swatch\" :style=\"{ backgroundColor: s.color }\" aria-hidden=\"true\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ s.label }}\n </Text>\n </HStack>\n </VStack>\n </HStack>\n </VStack>\n </Card>\n <Card :padding=\"6\">\n <VStack :gap=\"5\">\n <VStack>\n <Heading :level=\"3\" :accessibility-level=\"2\">\n Scope burndown\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Remaining story points · {{ BURNDOWN.length }} weeks\n </Text>\n </VStack>\n <VStack :gap=\"3\">\n <PlaygroundResponsive :height=\"208\">\n <PlaygroundLineChart\n :data=\"BURNDOWN\"\n x-key=\"week\"\n :series=\"[\n { key: 'ideal', name: 'Ideal', color: IDEAL_COLOR },\n { key: 'actual', name: 'Actual', color: ACTUAL_COLOR },\n ]\"\n :height=\"208\"\n y-unit=\" pts\"\n :x-ticks=\"[0, 3, 6, 9, 12]\"\n :x-tick-format=\"(v: number) => BURNDOWN[v]?.label ?? ''\"\n monotone\n />\n </PlaygroundResponsive>\n <HStack :gap=\"4\" align=\"center\" wrap=\"wrap\">\n <HStack v-for=\"item in [{ label: 'Actual remaining', color: ACTUAL_COLOR }, { label: 'Ideal burndown', color: IDEAL_COLOR }]\" :key=\"item.label\" :gap=\"2\" align=\"center\">\n <span class=\"legend-swatch\" :style=\"{ backgroundColor: item.color }\" aria-hidden=\"true\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ item.label }}\n </Text>\n </HStack>\n </HStack>\n </VStack>\n </VStack>\n </Card>\n <Card :padding=\"6\">\n <VStack :gap=\"4\">\n <VStack>\n <Heading :level=\"3\" :accessibility-level=\"2\">\n Blockers & risks\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Hover or focus a row for detail\n </Text>\n </VStack>\n <List v-if=\"risks.length\" has-dividers>\n <HoverCard v-for=\"risk in risks\" :key=\"risk.id\" placement=\"below\" alignment=\"start\" focus-trigger=\"always\">\n <ListItem\n :label=\"risk.title\"\n :description=\"`${risk.workstream} · ${PHASE_LABEL[risk.phase]} · ${risk.owner}`\"\n class=\"risk-row\"\n >\n <template #endContent>\n <Badge :variant=\"RAG_BADGE[risk.severity]\" :label=\"RAG_LABEL[risk.severity]\" />\n </template>\n </ListItem>\n <template #content>\n <VStack :gap=\"2\" class=\"risk-detail\">\n <Text type=\"body\" weight=\"semibold\">\n {{ risk.title }}\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n {{ risk.detail }}\n </Text>\n </VStack>\n </template>\n </HoverCard>\n </List>\n <EmptyState\n v-else\n title=\"No open blockers or risks\"\n description=\"Nothing flagged for the selected phase.\"\n is-compact\n />\n </VStack>\n </Card>\n </Grid>\n <Card :padding=\"6\">\n <VStack :gap=\"4\">\n <Heading :level=\"3\">\n Workstreams\n </Heading>\n <Table\n v-if=\"workstreams.length\"\n :data=\"workstreams\"\n :columns=\"WORKSTREAM_COLUMNS\"\n id-key=\"id\"\n density=\"balanced\"\n dividers=\"rows\"\n has-hover\n />\n <EmptyState v-else title=\"No workstreams in this phase\" description=\"Switch phases to see other workstreams.\" is-compact />\n </VStack>\n </Card>\n </VStack>\n </Section>\n </LayoutContent>\n </template>\n </Layout>\n</template>\n\n<style scoped>\n.legend-swatch {\n flex-shrink: 0;\n width: 8px;\n height: 8px;\n border-radius: 2px;\n}\n\n.content-column {\n width: 100%;\n max-width: 1440px;\n margin-inline: auto;\n}\n.content-bottom-pad {\n padding-block-end: var(--astryx-spacing-10);\n}\n.risk-row {\n transition: background-color var(--astryx-duration-fast-min) var(--astryx-ease-standard);\n}\n.risk-row:hover {\n background-color: var(--astryx-color-overlay-hover);\n}\n.risk-detail {\n max-width: 320px;\n}\n</style>\n",
77681
+ "source": "<script setup lang=\"ts\">\nimport type { TableColumn } from '@astryx-vue/core'\nimport { computed, h, ref } from 'vue'\nimport {\n Avatar,\n Badge,\n Button,\n Card,\n EmptyState,\n Grid,\n Heading,\n HStack,\n HoverCard,\n Icon,\n Layout,\n LayoutContent,\n LayoutHeader,\n List,\n ListItem,\n ProgressBar,\n Section,\n SegmentedControl,\n SegmentedControlItem,\n Table,\n Text,\n Timestamp,\n VStack,\n proportional,\n} from '@astryx-vue/core'\nimport {\n PlaygroundDonutChart,\n PlaygroundGanttChart,\n PlaygroundLineChart,\n PlaygroundResponsive,\n} from '@astryx-vue/playground-charts'\n\ntype Rag = 'onTrack' | 'atRisk' | 'blocked'\ntype Phase = 'all' | 'build' | 'beta' | 'ga'\ntype ReleasePhase = Exclude<Phase, 'all'>\n\nconst PHASE_LABEL: Record<ReleasePhase, string> = { build: 'Build', beta: 'Beta', ga: 'GA' }\nconst RAG_LABEL: Record<Rag, string> = { onTrack: 'On track', atRisk: 'At risk', blocked: 'Blocked' }\nconst RAG_BADGE: Record<Rag, 'green' | 'yellow' | 'red'> = { onTrack: 'green', atRisk: 'yellow', blocked: 'red' }\nconst RAG_FILL: Record<Rag, string> = {\n onTrack: 'var(--astryx-color-data-categorical-green, #0B991F)',\n atRisk: 'var(--astryx-color-data-categorical-orange, #EB6E00)',\n blocked: 'var(--astryx-color-data-categorical-red, #F5394F)',\n}\n\nconst TIMELINE_DAYS = 90\nconst TIMELINE_TICKS = Array.from({ length: TIMELINE_DAYS / 15 + 1 }, (_, i) => i * 15)\nconst PROJECT_DAY = 58\n\ninterface Milestone {\n id: string\n name: string\n phase: ReleasePhase\n start: number\n end: number\n status: Rag\n dateLabel: string\n}\n\nconst MILESTONES: Milestone[] = [\n { id: 'm1', name: 'Kickoff & scoping', phase: 'build', start: 0, end: 8, status: 'onTrack', dateLabel: 'Apr 1 – Apr 9' },\n { id: 'm2', name: 'Architecture sign-off', phase: 'build', start: 8, end: 20, status: 'onTrack', dateLabel: 'Apr 9 – Apr 21' },\n { id: 'm3', name: 'Feature complete', phase: 'build', start: 20, end: 44, status: 'atRisk', dateLabel: 'Apr 21 – May 15' },\n { id: 'm4', name: 'Private beta', phase: 'beta', start: 44, end: 62, status: 'atRisk', dateLabel: 'May 15 – Jun 2' },\n { id: 'm5', name: 'Bug bash & hardening', phase: 'beta', start: 58, end: 72, status: 'blocked', dateLabel: 'May 29 – Jun 12' },\n { id: 'm6', name: 'GA readiness review', phase: 'ga', start: 72, end: 82, status: 'onTrack', dateLabel: 'Jun 12 – Jun 22' },\n { id: 'm7', name: 'Public launch', phase: 'ga', start: 82, end: 90, status: 'onTrack', dateLabel: 'Jun 22 – Jun 30' },\n]\n\nconst TASK_STATUS = [\n { key: 'closed', tasks: 86, label: 'Closed', points: 190, color: 'var(--astryx-color-data-categorical-blue, #0171E3)' },\n { key: 'inProgress', tasks: 24, label: 'In progress', points: 58, color: 'var(--astryx-color-data-categorical-green, #0B991F)' },\n { key: 'inReview', tasks: 12, label: 'In review', points: 24, color: 'var(--astryx-color-data-categorical-orange, #EB6E00)' },\n { key: 'blocked', tasks: 5, label: 'Blocked', points: 22, color: 'var(--astryx-color-data-categorical-red, #F5394F)' },\n { key: 'planned', tasks: 21, label: 'Planned', points: 26, color: 'var(--astryx-color-data-neutral, #8494A3)' },\n]\n\ninterface Workstream extends Record<string, unknown> {\n id: string\n name: string\n owner: string\n phase: ReleasePhase\n percent: number\n due: string\n status: Rag\n}\n\nconst WORKSTREAMS: Workstream[] = [\n { id: 'w1', name: 'Core platform', owner: 'Priya Raman', phase: 'build', percent: 100, due: '2026-04-21', status: 'onTrack' },\n { id: 'w2', name: 'Data pipeline', owner: 'Marcus Webb', phase: 'build', percent: 92, due: '2026-05-15', status: 'atRisk' },\n { id: 'w3', name: 'Web client', owner: 'Ana Duarte', phase: 'build', percent: 88, due: '2026-05-15', status: 'onTrack' },\n { id: 'w4', name: 'Beta onboarding', owner: 'Tom Okafor', phase: 'beta', percent: 64, due: '2026-06-02', status: 'atRisk' },\n { id: 'w5', name: 'Load & reliability', owner: 'Dana Whitfield', phase: 'beta', percent: 38, due: '2026-06-12', status: 'blocked' },\n { id: 'w6', name: 'Docs & enablement', owner: 'Sofia Marín', phase: 'ga', percent: 55, due: '2026-06-22', status: 'onTrack' },\n { id: 'w7', name: 'Launch marketing', owner: 'Kenji Watanabe', phase: 'ga', percent: 41, due: '2026-06-22', status: 'onTrack' },\n { id: 'w8', name: 'Go-to-market ops', owner: 'Lena Fischer', phase: 'ga', percent: 30, due: '2026-06-30', status: 'atRisk' },\n]\n\ninterface Risk {\n id: string\n title: string\n workstream: string\n phase: ReleasePhase\n severity: Rag\n owner: string\n detail: string\n}\n\nconst RISKS: Risk[] = [\n { id: 'r1', title: 'Load tests failing at 3x target concurrency', workstream: 'Load & reliability', phase: 'beta', severity: 'blocked', owner: 'Dana Whitfield', detail: 'p99 latency degrades past SLO above 12k concurrent sessions.' },\n { id: 'r2', title: 'Data pipeline backfill slower than planned', workstream: 'Data pipeline', phase: 'build', severity: 'atRisk', owner: 'Marcus Webb', detail: 'Historical backfill is tracking two days behind.' },\n { id: 'r3', title: 'Beta onboarding email deliverability', workstream: 'Beta onboarding', phase: 'beta', severity: 'atRisk', owner: 'Tom Okafor', detail: 'Invite emails landing in spam for two enterprise domains.' },\n]\n\nconst BURNDOWN = [320, 300, 286, 262, 240, 226, 210, 182, 150, 132, 96, 54, 20].map((actual, i) => ({\n week: i,\n label: `W${i + 1}`,\n ideal: Math.max(0, Math.round(320 - (320 / 12) * i)),\n actual,\n}))\n\nconst PROGRAM_STATUS: Rag = WORKSTREAMS.some(w => w.status === 'blocked')\n ? 'blocked'\n : WORKSTREAMS.some(w => w.status === 'atRisk')\n ? 'atRisk'\n : 'onTrack'\n\nfunction byPhase<T extends { phase: ReleasePhase }>(items: T[], phase: Phase) {\n return phase === 'all' ? items : items.filter(item => item.phase === phase)\n}\n\nconst phase = ref<Phase>('all')\nconst milestones = computed(() => byPhase(MILESTONES, phase.value))\nconst workstreams = computed(() => byPhase(WORKSTREAMS, phase.value))\nconst risks = computed(() => byPhase(RISKS, phase.value))\n\nconst RAG_ORDER: Rag[] = ['onTrack', 'atRisk', 'blocked']\n\nconst ganttItems = computed(() => milestones.value.map(m => ({\n name: m.name,\n start: m.start,\n end: m.end,\n color: RAG_FILL[m.status],\n})))\n\nconst TASK_POINTS_TOTAL = TASK_STATUS.reduce((sum, s) => sum + s.points, 0)\nconst TASK_COUNT_TOTAL = TASK_STATUS.reduce((sum, s) => sum + s.tasks, 0)\nconst ACTUAL_COLOR = 'var(--astryx-color-data-categorical-blue, #0171E3)'\nconst IDEAL_COLOR = 'var(--astryx-color-data-neutral, #8494A3)'\n\nconst donutSegments = computed(() => TASK_STATUS.map(s => ({ value: s.points, color: s.color })))\n\nconst WORKSTREAM_COLUMNS: TableColumn<Workstream>[] = [\n {\n key: 'name',\n header: 'Workstream',\n width: proportional(1, { minWidth: 160 }),\n renderCell: item => h(VStack, { gap: 0 }, () => [\n h(Text, { type: 'body', weight: 'semibold' }, () => item.name),\n h(Text, { type: 'supporting', color: 'secondary' }, () => PHASE_LABEL[item.phase]),\n ]),\n },\n {\n key: 'owner',\n header: 'Owner',\n width: proportional(1, { minWidth: 160 }),\n renderCell: item => h(HStack, { gap: 2, vAlign: 'center' }, () => [\n h(Avatar, { name: item.owner, size: 'sm' }),\n h(Text, { type: 'body' }, () => item.owner),\n ]),\n },\n {\n key: 'percent',\n header: '% complete',\n width: proportional(2, { minWidth: 200 }),\n renderCell: item => h(VStack, { gap: 1 }, () => [\n h(ProgressBar, { value: item.percent, max: 100, label: `${item.name} progress`, isLabelHidden: true }),\n h(Text, { type: 'supporting', color: 'secondary' }, () => `${item.percent}%`),\n ]),\n },\n {\n key: 'due',\n header: 'Due',\n width: proportional(1, { minWidth: 120 }),\n renderCell: item => h(Timestamp, { value: item.due, format: 'date' }),\n },\n {\n key: 'status',\n header: 'Status',\n width: proportional(1, { minWidth: 120 }),\n renderCell: item => h(Badge, { variant: RAG_BADGE[item.status], label: RAG_LABEL[item.status] }),\n },\n]\n<\/script>\n\n<template>\n <Layout height=\"fill\">\n <template #header>\n <LayoutHeader :padding=\"6\" has-divider>\n <HStack :gap=\"3\" align=\"center\" justify=\"between\" wrap=\"wrap\" class=\"content-column\">\n <VStack :gap=\"1\">\n <Heading :level=\"1\">\n Aurora Launch\n </Heading>\n <HStack :gap=\"2\" align=\"center\">\n <Text type=\"body\" color=\"secondary\">\n Target GA ·\n </Text>\n <Timestamp value=\"2026-06-30T00:00:00Z\" format=\"date\" type=\"body\" color=\"secondary\" />\n <Badge :variant=\"RAG_BADGE[PROGRAM_STATUS]\" :label=\"RAG_LABEL[PROGRAM_STATUS]\">\n <template #icon>\n <Icon :name=\"PROGRAM_STATUS === 'onTrack' ? 'heroicons:check-circle' : 'heroicons:exclamation-triangle'\" size=\"sm\" />\n </template>\n </Badge>\n </HStack>\n </VStack>\n <HStack :gap=\"2\">\n <SegmentedControl v-model=\"phase\" label=\"Release phase\">\n <SegmentedControlItem label=\"All\" value=\"all\" />\n <SegmentedControlItem label=\"Build\" value=\"build\" />\n <SegmentedControlItem label=\"Beta\" value=\"beta\" />\n <SegmentedControlItem label=\"GA\" value=\"ga\" />\n </SegmentedControl>\n <Button label=\"Export status\" variant=\"secondary\">\n <template #icon>\n <Icon icon=\"i-heroicons-arrow-down-tray\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n </HStack>\n </LayoutHeader>\n </template>\n <template #default>\n <LayoutContent :padding=\"0\" class=\"content-bottom-pad\">\n <Section :padding=\"6\" :dividers=\"['bottom']\">\n <VStack :gap=\"6\" class=\"content-column\">\n <HStack :gap=\"3\" h-align=\"between\" align=\"center\">\n <Heading :level=\"2\">\n Milestone timeline\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Project days 0–{{ TIMELINE_DAYS }} · dashed line marks today\n </Text>\n </HStack>\n <EmptyState\n v-if=\"milestones.length === 0\"\n title=\"No milestones in this phase\"\n description=\"Switch phases to see other milestones.\"\n is-compact\n />\n <template v-else>\n <VStack :gap=\"3\">\n <PlaygroundGanttChart\n :items=\"ganttItems\"\n :domain-max=\"TIMELINE_DAYS\"\n :today=\"PROJECT_DAY\"\n :ticks=\"TIMELINE_TICKS\"\n is-recharts-layout\n />\n <HStack :gap=\"4\" align=\"center\" wrap=\"wrap\">\n <HStack v-for=\"status in RAG_ORDER\" :key=\"status\" :gap=\"2\" align=\"center\">\n <span class=\"legend-swatch\" :style=\"{ backgroundColor: RAG_FILL[status] }\" aria-hidden=\"true\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ RAG_LABEL[status] }}\n </Text>\n </HStack>\n </HStack>\n </VStack>\n </template>\n </VStack>\n </Section>\n <Section :padding=\"6\" :padding-block=\"10\">\n <VStack :gap=\"6\" class=\"content-column\">\n <Grid :gap=\"4\" :columns=\"{ minWidth: 320, max: 3 }\">\n <Card :padding=\"6\">\n <VStack :gap=\"5\">\n <VStack>\n <Heading :level=\"3\" :accessibility-level=\"2\">\n Overall progress\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n {{ TASK_COUNT_TOTAL }} tasks · {{ TASK_POINTS_TOTAL }} story points\n </Text>\n </VStack>\n <HStack :gap=\"8\" align=\"center\" wrap=\"wrap\">\n <PlaygroundDonutChart :segments=\"donutSegments\" :gap-angle=\"2.3\" />\n <VStack :gap=\"2\">\n <HStack v-for=\"s in TASK_STATUS\" :key=\"s.key\" :gap=\"2\" align=\"center\">\n <span class=\"legend-swatch\" :style=\"{ backgroundColor: s.color }\" aria-hidden=\"true\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ s.label }}\n </Text>\n </HStack>\n </VStack>\n </HStack>\n </VStack>\n </Card>\n <Card :padding=\"6\">\n <VStack :gap=\"5\">\n <VStack>\n <Heading :level=\"3\" :accessibility-level=\"2\">\n Scope burndown\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Remaining story points · {{ BURNDOWN.length }} weeks\n </Text>\n </VStack>\n <VStack :gap=\"3\">\n <PlaygroundResponsive :height=\"208\">\n <PlaygroundLineChart\n :data=\"BURNDOWN\"\n x-key=\"week\"\n :series=\"[\n { key: 'ideal', name: 'Ideal', color: IDEAL_COLOR },\n { key: 'actual', name: 'Actual', color: ACTUAL_COLOR },\n ]\"\n :height=\"208\"\n :y-ticks=\"[0, 80, 160, 240, 320]\"\n y-unit=\" pts\"\n is-y-unit-stacked\n :x-ticks=\"[0, 3, 6, 9, 12]\"\n :x-tick-format=\"(v: number) => BURNDOWN[v]?.label ?? ''\"\n :pad=\"{ top: 16, right: 4, bottom: 34, left: 44 }\"\n center-ticks\n monotone\n />\n </PlaygroundResponsive>\n <HStack :gap=\"4\" align=\"center\" wrap=\"wrap\">\n <HStack v-for=\"item in [{ label: 'Actual remaining', color: ACTUAL_COLOR }, { label: 'Ideal burndown', color: IDEAL_COLOR }]\" :key=\"item.label\" :gap=\"2\" align=\"center\">\n <span class=\"legend-swatch\" :style=\"{ backgroundColor: item.color }\" aria-hidden=\"true\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ item.label }}\n </Text>\n </HStack>\n </HStack>\n </VStack>\n </VStack>\n </Card>\n <Card :padding=\"6\">\n <VStack :gap=\"4\">\n <VStack>\n <Heading :level=\"3\" :accessibility-level=\"2\">\n Blockers & risks\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Hover or focus a row for detail\n </Text>\n </VStack>\n <List v-if=\"risks.length\" has-dividers class=\"risk-list\">\n <HoverCard v-for=\"risk in risks\" :key=\"risk.id\" placement=\"below\" alignment=\"start\" focus-trigger=\"always\">\n <ListItem\n :label=\"risk.title\"\n :description=\"`${risk.workstream} · ${PHASE_LABEL[risk.phase]} · ${risk.owner}`\"\n class=\"risk-row\"\n >\n <template #endContent>\n <Badge :variant=\"RAG_BADGE[risk.severity]\" :label=\"RAG_LABEL[risk.severity]\" />\n </template>\n </ListItem>\n <template #content>\n <VStack :gap=\"2\" class=\"risk-detail\">\n <Text type=\"body\" weight=\"semibold\">\n {{ risk.title }}\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n {{ risk.detail }}\n </Text>\n </VStack>\n </template>\n </HoverCard>\n </List>\n <EmptyState\n v-else\n title=\"No open blockers or risks\"\n description=\"Nothing flagged for the selected phase.\"\n is-compact\n />\n </VStack>\n </Card>\n </Grid>\n <Card :padding=\"6\">\n <VStack :gap=\"4\">\n <HStack :gap=\"3\" h-align=\"between\" align=\"center\" wrap=\"wrap\">\n <Heading :level=\"3\" :accessibility-level=\"2\">\n Workstreams\n </Heading>\n <Text type=\"body\" color=\"secondary\">\n {{ workstreams.length }} of {{ WORKSTREAMS.length }}\n </Text>\n </HStack>\n <Table\n v-if=\"workstreams.length\"\n :data=\"workstreams\"\n :columns=\"WORKSTREAM_COLUMNS\"\n id-key=\"id\"\n density=\"balanced\"\n dividers=\"rows\"\n has-hover\n />\n <EmptyState v-else title=\"No workstreams in this phase\" description=\"Switch phases to see other workstreams.\" is-compact />\n </VStack>\n </Card>\n </VStack>\n </Section>\n </LayoutContent>\n </template>\n </Layout>\n</template>\n\n<style scoped>\n.legend-swatch {\n flex-shrink: 0;\n width: 8px;\n height: 8px;\n border-radius: 2px;\n}\n\n.content-column {\n width: 100%;\n max-width: 1440px;\n margin-inline: auto;\n}\n.content-bottom-pad {\n padding-block-end: var(--astryx-spacing-10);\n}\n/* HoverCard 的包裹 span 夹在 ul 和 li 之间,List 的 `> .astryx-list-item` 分隔线选择器够不着,\n 这里在包裹 span 上按「除最后一个」补上同样的分隔线。 */\n.risk-list :deep(.astryx-hover-card__trigger:not(:last-of-type) > .astryx-list-item) {\n border-block-end: var(--astryx-border-width, 1px) solid var(--astryx-list-divider-color, light-dark(#05365919, #f2f4f619));\n}\n.risk-row {\n transition: background-color var(--astryx-duration-fast-min) var(--astryx-ease-standard);\n}\n.risk-row:hover {\n background-color: var(--astryx-color-overlay-hover);\n}\n.risk-detail {\n max-width: 320px;\n}\n</style>\n",
77330
77682
  "docsUrl": {
77331
77683
  "en": "https://astryx-vue.pages.dev/en/playground/?template=dashboard-progress",
77332
77684
  "zh": "https://astryx-vue.pages.dev/playground/?template=dashboard-progress"
@@ -77379,6 +77731,7 @@ const catalog = {
77379
77731
  "Badge",
77380
77732
  "Card",
77381
77733
  "Divider",
77734
+ "EmptyState",
77382
77735
  "Grid",
77383
77736
  "Heading",
77384
77737
  "HStack",
@@ -77392,6 +77745,7 @@ const catalog = {
77392
77745
  "SegmentedControl",
77393
77746
  "SegmentedControlItem",
77394
77747
  "Selector",
77748
+ "StackItem",
77395
77749
  "StatusDot",
77396
77750
  "Text",
77397
77751
  "Timestamp",
@@ -77408,7 +77762,7 @@ const catalog = {
77408
77762
  "en": "It imports @astryx-vue/playground-charts, which exists only inside the docs playground and is not a published package.",
77409
77763
  "zh": "它引用了 @astryx-vue/playground-charts,这个包只存在于本站演练场,没有发布到 npm。"
77410
77764
  },
77411
- "source": "<script setup lang=\"ts\">\nimport type { CSSProperties } from 'vue'\nimport { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'\nimport {\n Badge,\n Card,\n Divider,\n Grid,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n LayoutHeader,\n LayoutPanel,\n List,\n ListItem,\n SegmentedControl,\n SegmentedControlItem,\n Selector,\n StatusDot,\n Text,\n Timestamp,\n Token,\n VStack,\n useMediaQuery,\n} from '@astryx-vue/core'\nimport { PlaygroundSparkline } from '@astryx-vue/playground-charts'\n\ntype TimeWindow = '1h' | '1d' | '7d'\ntype Environment = 'production' | 'staging' | 'development'\ntype HealthStatus = 'healthy' | 'warning' | 'critical'\n\nconst pageStyle: CSSProperties = { height: '100dvh' }\n\nconst ENV_OPTIONS = [\n { value: 'production', label: 'Production' },\n { value: 'staging', label: 'Staging' },\n { value: 'development', label: 'Development' },\n]\n\nconst REGION_OPTIONS = [\n { value: 'all', label: 'All regions' },\n { value: 'us-east-1', label: 'us-east-1' },\n { value: 'us-west-2', label: 'us-west-2' },\n { value: 'eu-west-1', label: 'eu-west-1' },\n { value: 'ap-southeast-1', label: 'ap-southeast-1' },\n]\n\nconst KPI_BY_ENV: Record<Environment, Array<{ key: string, label: string, value: string, status: HealthStatus, delta: string, deltaPositive: boolean }>> = {\n production: [\n { key: 'health', label: 'Overall health', value: '98.6%', status: 'healthy', delta: '+0.3%', deltaPositive: true },\n { key: 'error', label: 'Error rate', value: '1.84%', status: 'warning', delta: '+0.9%', deltaPositive: false },\n { key: 'latency', label: 'p99 latency', value: '512 ms', status: 'warning', delta: '+84 ms', deltaPositive: false },\n { key: 'availability', label: 'Availability', value: '99.98%', status: 'healthy', delta: '+0.01%', deltaPositive: true },\n ],\n staging: [\n { key: 'health', label: 'Overall health', value: '99.4%', status: 'healthy', delta: '+0.1%', deltaPositive: true },\n { key: 'error', label: 'Error rate', value: '0.42%', status: 'healthy', delta: '-0.2%', deltaPositive: true },\n { key: 'latency', label: 'p99 latency', value: '338 ms', status: 'healthy', delta: '-12 ms', deltaPositive: true },\n { key: 'availability', label: 'Availability', value: '99.95%', status: 'healthy', delta: '0.00%', deltaPositive: true },\n ],\n development: [\n { key: 'health', label: 'Overall health', value: '96.1%', status: 'warning', delta: '-1.8%', deltaPositive: false },\n { key: 'error', label: 'Error rate', value: '3.10%', status: 'critical', delta: '+1.4%', deltaPositive: false },\n { key: 'latency', label: 'p99 latency', value: '274 ms', status: 'healthy', delta: '-30 ms', deltaPositive: true },\n { key: 'availability', label: 'Availability', value: '99.20%', status: 'warning', delta: '-0.6%', deltaPositive: false },\n ],\n}\n\nconst SPARKLINES: Record<string, number[]> = {\n health: [97.6, 97.7, 98, 96.5, 97.3, 97, 96.8, 97.2, 95, 96.3, 96.8, 97.1, 96.7, 97.3, 96.1, 96.9, 97.2, 96.5, 97.4, 98.6],\n error: [0.62, 0.98, 1.02, 2.41, 1.57, 0.96, 1.43, 1.36, 1.33, 1.48, 1.41, 1.36, 1.35, 1.19, 1.27, 1.2, 1.26, 1.09, 1.84, 1.75],\n latency: [322, 371, 313, 617, 377, 399, 421, 365, 797, 477, 380, 569, 389, 417, 376, 871, 411, 437, 512, 503],\n availability: [99.891, 99.956, 99.882, 99.696, 99.913, 99.948, 99.921, 99.94, 99.551, 99.891, 99.926, 99.912, 99.739, 99.912, 99.426, 99.928, 99.856, 99.98, 99.98, 99.98],\n}\n\nconst STATUS_STROKE: Record<HealthStatus, string> = {\n healthy: 'var(--astryx-color-success, #0B991F)',\n warning: 'var(--astryx-color-warning, #B25000)',\n critical: 'var(--astryx-color-error, #D6002A)',\n}\n\nconst ALERTS = [\n { id: 'AL-8842', severity: 'critical' as HealthStatus, title: 'search-indexer 5xx rate above 5%', service: 'search-indexer · us-east-1', at: '2026-06-30T21:14:00Z', state: 'firing' },\n { id: 'AL-8841', severity: 'warning' as HealthStatus, title: 'checkout-api p99 latency above 500ms', service: 'checkout-api · us-east-1', at: '2026-06-30T21:02:00Z', state: 'firing' },\n { id: 'AL-8839', severity: 'warning' as HealthStatus, title: 'notifications queue depth climbing', service: 'notifications · ap-southeast-1', at: '2026-06-30T20:58:00Z', state: 'firing' },\n { id: 'AL-8840', severity: 'warning' as HealthStatus, title: 'edge-cdn cache hit ratio degraded', service: 'edge-cdn · eu-west-1', at: '2026-06-30T20:41:00Z', state: 'acknowledged' },\n { id: 'AL-8838', severity: 'critical' as HealthStatus, title: 'auth error rate spike in dev cluster', service: 'auth · us-west-2', at: '2026-06-30T20:27:00Z', state: 'acknowledged' },\n { id: 'AL-8837', severity: 'warning' as HealthStatus, title: 'payments settlement job heartbeat missed', service: 'payments · us-east-1', at: '2026-06-30T20:12:00Z', state: 'resolved' },\n]\n\nconst HOST_ROWS = [\n { id: '1', service: 'checkout-api', environment: 'production' as Environment, region: 'us-east-1', instances: 24, rpm: 9240, errorRate: 2.1, p99: 512, status: 'warning' as HealthStatus },\n { id: '2', service: 'search-indexer', environment: 'production', region: 'us-east-1', instances: 16, rpm: 4120, errorRate: 5.8, p99: 1180, status: 'critical' },\n { id: '3', service: 'edge-cdn', environment: 'production', region: 'eu-west-1', instances: 30, rpm: 14800, errorRate: 1.6, p99: 402, status: 'warning' },\n { id: '4', service: 'search-indexer', environment: 'production', region: 'ap-southeast-1', instances: 8, rpm: 2000, errorRate: 0.9, p99: 342, status: 'healthy' },\n { id: '5', service: 'notifications', environment: 'production', region: 'ap-southeast-1', instances: 6, rpm: 1200, errorRate: 0.5, p99: 268, status: 'healthy' },\n { id: '6', service: 'checkout-api', environment: 'production', region: 'eu-west-1', instances: 12, rpm: 4000, errorRate: 0.4, p99: 236, status: 'healthy' },\n]\n\n// ── 图表 ──────────────────────────────────────────────────────────────────────\n// 内联 SVG:锯齿状时间序列 + 坐标轴 + 图例,形状由固定种子的伪随机生成。\n\n/** 两张图并排、一样宽;`width` 跟着第一张图的实际宽度走,viewBox 和像素一比一。 */\nconst CHART = reactive({ width: 640, height: 240, left: 52, right: 8, top: 8, bottom: 26 })\nconst chartEl = ref<SVGSVGElement | null>(null)\nlet chartObserver: ResizeObserver | null = null\nonMounted(() => {\n if (!chartEl.value || typeof ResizeObserver === 'undefined')\n return\n chartObserver = new ResizeObserver(([entry]) => {\n if (entry && entry.contentRect.width > 0)\n CHART.width = entry.contentRect.width\n })\n chartObserver.observe(chartEl.value)\n})\nonBeforeUnmount(() => chartObserver?.disconnect())\nconst POINTS = 96\nconst X_LABELS = ['00:00', '04:50', '09:30', '14:20', '19:00', '23:50']\n\nfunction noise(i: number, seed: number) {\n const x = Math.sin(i * 127.1 + seed * 311.7) * 43758.5453\n return x - Math.floor(x)\n}\n\n/** 基线 + 慢波 + 抖动,偶尔来一根尖峰,读起来像真实遥测。 */\nfunction telemetry(base: number, swing: number, jitter: number, seed: number) {\n return Array.from({ length: POINTS }, (_, i) => {\n const wave = Math.sin((i / POINTS) * Math.PI * 4 + seed) * swing\n const wobble = (noise(i, seed) - 0.5) * jitter\n const spike = noise(i, seed + 41) > 0.86 ? jitter * 2.4 : 0\n return Math.max(0, base + wave + wobble + spike)\n })\n}\n\nconst latencySeries = [\n { key: 'p50', label: 'p50', color: 'var(--astryx-color-data-categorical-blue, #0171E3)', values: telemetry(46, 10, 14, 3) },\n { key: 'p95', label: 'p95', color: 'var(--astryx-color-data-categorical-orange, #EB6E00)', values: telemetry(152, 40, 34, 7) },\n { key: 'p99', label: 'p99', color: 'var(--astryx-color-data-categorical-purple, #6B1EFD)', values: telemetry(330, 110, 90, 11) },\n]\n\nconst volumeSeries = [\n { key: 'useast', label: 'us-east-1', color: 'var(--astryx-color-data-categorical-blue, #0171E3)', values: telemetry(9400, 2600, 900, 5) },\n { key: 'uswest', label: 'us-west-2', color: 'var(--astryx-color-data-categorical-teal, #08A3A3)', values: telemetry(6400, 1500, 650, 9) },\n { key: 'euwest', label: 'eu-west-1', color: 'var(--astryx-color-data-categorical-orange, #EB6E00)', values: telemetry(4100, 900, 500, 13) },\n]\n\nconst LATENCY_Y_TICKS = [0, 200, 400, 600, 800]\nconst VOLUME_Y_TICKS = [0, 3500, 7000, 10500, 14000]\n\nfunction chartX(i: number) {\n return CHART.left + (i / (POINTS - 1)) * (CHART.width - CHART.left - CHART.right)\n}\n\nfunction chartY(value: number, max: number) {\n const inner = CHART.height - CHART.top - CHART.bottom\n return CHART.top + (1 - value / max) * inner\n}\n\nfunction chartPath(values: number[], max: number) {\n return values.map((v, i) => `${i === 0 ? 'M' : 'L'}${chartX(i).toFixed(1)},${chartY(v, max).toFixed(1)}`).join(' ')\n}\n\nfunction rpmLabel(rpm: number) {\n return `${(rpm / 1000).toFixed(1)}k rpm`\n}\n\nconst timeWindow = ref<TimeWindow>('1d')\nconst environment = ref<Environment>('production')\nconst region = ref('all')\nconst isNarrow = useMediaQuery('(max-width: 1024px)')\n\nconst kpis = computed(() => KPI_BY_ENV[environment.value])\nconst rows = computed(() => HOST_ROWS.filter(r => r.environment === environment.value && (region.value === 'all' || r.region === region.value)))\nconst breakdownContext = computed(() => `${ENV_OPTIONS.find(e => e.value === environment.value)?.label} · ${REGION_OPTIONS.find(r => r.value === region.value)?.label}`)\nconst firingCount = computed(() => ALERTS.filter(a => a.state === 'firing').length)\n<\/script>\n\n<template>\n <Layout height=\"fill\" :style=\"pageStyle\">\n <template #header>\n <LayoutHeader :padding=\"6\" has-divider>\n <HStack :gap=\"3\" align=\"center\" justify=\"between\" wrap=\"wrap\">\n <VStack :gap=\"0\" align=\"center\">\n <Heading :level=\"1\">\n Service Health Monitor\n </Heading>\n <HStack :gap=\"2\" align=\"center\">\n <StatusDot variant=\"success\" label=\"Live\" is-pulsing tooltip=\"Auto-refreshing every 30s\" />\n <Text type=\"supporting\" color=\"secondary\">\n Updated\n <Timestamp value=\"2026-06-30T21:15:00Z\" format=\"relative\" color=\"inherit\" />\n </Text>\n </HStack>\n </VStack>\n <HStack :gap=\"2\" align=\"center\">\n <SegmentedControl v-model=\"timeWindow\" label=\"Time window\" size=\"lg\">\n <SegmentedControlItem label=\"1h\" value=\"1h\" />\n <SegmentedControlItem label=\"1d\" value=\"1d\" />\n <SegmentedControlItem label=\"7d\" value=\"7d\" />\n </SegmentedControl>\n <Selector v-model=\"environment\" label=\"Environment\" is-label-hidden size=\"lg\" :options=\"ENV_OPTIONS\" />\n <Selector v-model=\"region\" label=\"Region\" is-label-hidden size=\"lg\" :options=\"REGION_OPTIONS\" />\n </HStack>\n </HStack>\n </LayoutHeader>\n </template>\n <template #default>\n <LayoutContent :padding=\"6\">\n <VStack :gap=\"10\">\n <VStack :gap=\"6\">\n <Grid :columns=\"{ minWidth: 240, repeat: 'fit' }\" :gap=\"3\">\n <Card v-for=\"kpi in kpis\" :key=\"kpi.key\" :padding=\"4\">\n <VStack :gap=\"2\">\n <HStack :gap=\"2\" align=\"center\">\n <StatusDot :variant=\"kpi.status === 'healthy' ? 'success' : kpi.status === 'warning' ? 'warning' : 'error'\" :label=\"kpi.label\" />\n <Text type=\"label\" color=\"secondary\">\n {{ kpi.label }}\n </Text>\n </HStack>\n <HStack :gap=\"2\" align=\"center\">\n <Heading :level=\"2\">\n {{ kpi.value }}\n </Heading>\n <HStack :gap=\"1\" align=\"center\">\n <Icon :icon=\"kpi.deltaPositive ? 'arrowUp' : 'arrowDown'\" size=\"xsm\" :color=\"kpi.deltaPositive ? 'success' : 'error'\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ kpi.delta }}\n </Text>\n </HStack>\n </HStack>\n <PlaygroundSparkline :data=\"SPARKLINES[kpi.key]\" :color=\"STATUS_STROKE[kpi.status]\" />\n </VStack>\n </Card>\n </Grid>\n <Grid :columns=\"{ minWidth: 320, repeat: 'fit' }\" :gap=\"3\">\n <Card :padding=\"6\">\n <VStack :gap=\"4\">\n <VStack :gap=\"1\">\n <Heading :level=\"3\">\n Response latency\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Percentile latency across all services (ms)\n </Text>\n </VStack>\n <svg ref=\"chartEl\" class=\"rail-chart\" :viewBox=\"`0 0 ${CHART.width} ${CHART.height}`\" role=\"img\" aria-label=\"Response latency percentiles\">\n <line\n v-for=\"tick in LATENCY_Y_TICKS\"\n :key=\"`ly-${tick}`\"\n :x1=\"CHART.left\"\n :x2=\"CHART.width - CHART.right\"\n :y1=\"chartY(tick, 800)\"\n :y2=\"chartY(tick, 800)\"\n class=\"chart-grid\"\n />\n <text\n v-for=\"tick in LATENCY_Y_TICKS\"\n :key=\"`lyt-${tick}`\"\n :x=\"CHART.left - 8\"\n :y=\"chartY(tick, 800) + 4\"\n text-anchor=\"end\"\n class=\"chart-tick\"\n >\n {{ tick }} ms\n </text>\n <text\n v-for=\"(label, i) in X_LABELS\"\n :key=\"`lx-${label}`\"\n :x=\"chartX((i / (X_LABELS.length - 1)) * (POINTS - 1))\"\n :y=\"CHART.height - 8\"\n :text-anchor=\"i === X_LABELS.length - 1 ? 'end' : i === 0 ? 'start' : 'middle'\"\n class=\"chart-tick\"\n >\n {{ label }}\n </text>\n <path\n v-for=\"series in latencySeries\"\n :key=\"series.key\"\n :d=\"chartPath(series.values, 800)\"\n fill=\"none\"\n :stroke=\"series.color\"\n stroke-width=\"1.5\"\n vector-effect=\"non-scaling-stroke\"\n />\n </svg>\n <HStack :gap=\"5\" align=\"center\" wrap=\"wrap\">\n <HStack v-for=\"series in latencySeries\" :key=\"series.key\" :gap=\"2\" align=\"center\">\n <span class=\"legend-dot\" :style=\"{ background: series.color }\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ series.label }}\n </Text>\n </HStack>\n </HStack>\n </VStack>\n </Card>\n <Card :padding=\"6\">\n <VStack :gap=\"4\">\n <VStack :gap=\"1\">\n <Heading :level=\"3\">\n Request volume by region\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Requests per minute (rpm)\n </Text>\n </VStack>\n <svg class=\"rail-chart\" :viewBox=\"`0 0 ${CHART.width} ${CHART.height}`\" role=\"img\" aria-label=\"Request volume by region\">\n <line\n v-for=\"tick in VOLUME_Y_TICKS\"\n :key=\"`vy-${tick}`\"\n :x1=\"CHART.left\"\n :x2=\"CHART.width - CHART.right\"\n :y1=\"chartY(tick, 14000)\"\n :y2=\"chartY(tick, 14000)\"\n class=\"chart-grid\"\n />\n <text\n v-for=\"tick in VOLUME_Y_TICKS\"\n :key=\"`vyt-${tick}`\"\n :x=\"CHART.left - 8\"\n :y=\"chartY(tick, 14000) + 4\"\n text-anchor=\"end\"\n class=\"chart-tick\"\n >\n {{ tick }}\n </text>\n <text\n v-for=\"(label, i) in X_LABELS\"\n :key=\"`vx-${label}`\"\n :x=\"chartX((i / (X_LABELS.length - 1)) * (POINTS - 1))\"\n :y=\"CHART.height - 8\"\n :text-anchor=\"i === X_LABELS.length - 1 ? 'end' : i === 0 ? 'start' : 'middle'\"\n class=\"chart-tick\"\n >\n {{ label }}\n </text>\n <path\n v-for=\"series in volumeSeries\"\n :key=\"series.key\"\n :d=\"chartPath(series.values, 14000)\"\n fill=\"none\"\n :stroke=\"series.color\"\n stroke-width=\"1.5\"\n vector-effect=\"non-scaling-stroke\"\n />\n </svg>\n <HStack :gap=\"5\" align=\"center\" wrap=\"wrap\">\n <HStack v-for=\"series in volumeSeries\" :key=\"series.key\" :gap=\"2\" align=\"center\">\n <span class=\"legend-dot\" :style=\"{ background: series.color }\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ series.label }}\n </Text>\n </HStack>\n </HStack>\n </VStack>\n </Card>\n </Grid>\n </VStack>\n <Divider />\n <VStack v-if=\"isNarrow\" :gap=\"4\">\n <Card :padding=\"0\">\n <VStack :gap=\"0\">\n <VStack :gap=\"1\" :padding=\"4\" class=\"rail-header\">\n <HStack :gap=\"2\" align=\"center\">\n <Icon icon=\"i-heroicons-bell-alert\" size=\"sm\" color=\"error\" />\n <Heading :level=\"3\">\n Active alerts\n </Heading>\n <Badge :label=\"`${firingCount} firing`\" variant=\"error\" />\n </HStack>\n </VStack>\n <List density=\"compact\" has-dividers>\n <ListItem\n v-for=\"alert in ALERTS\"\n :key=\"alert.id\"\n :label=\"alert.title\"\n :description=\"alert.service\"\n >\n <template #startContent>\n <StatusDot :variant=\"alert.severity === 'healthy' ? 'success' : alert.severity === 'warning' ? 'warning' : 'error'\" :label=\"alert.severity\" />\n </template>\n <template #endContent>\n <Timestamp :value=\"alert.at\" format=\"relative\" color=\"secondary\" />\n </template>\n </ListItem>\n </List>\n </VStack>\n </Card>\n <Card :padding=\"0\">\n <List density=\"compact\" has-dividers>\n <ListItem\n v-for=\"row in rows\"\n :key=\"row.id\"\n :label=\"row.service\"\n :description=\"`${row.region} · ${row.instances} hosts · ${row.rpm} rpm`\"\n >\n <template #endContent>\n <Text type=\"body\" weight=\"semibold\">\n {{ row.p99 }} ms\n </Text>\n </template>\n </ListItem>\n </List>\n </Card>\n </VStack>\n </VStack>\n </LayoutContent>\n </template>\n <template v-if=\"!isNarrow\" #end>\n <LayoutPanel :width=\"400\" :padding=\"0\" has-divider role=\"complementary\" label=\"Alerts and service breakdown\" is-scrollable>\n <VStack :gap=\"0\">\n <VStack :gap=\"1\" :padding=\"4\" class=\"rail-header sticky-header\">\n <HStack :gap=\"2\" align=\"center\">\n <Icon icon=\"i-heroicons-bell-alert\" size=\"sm\" :color=\"firingCount > 0 ? 'error' : 'secondary'\" />\n <Heading :level=\"3\">\n Active alerts\n </Heading>\n <Badge :label=\"`${firingCount} firing`\" :variant=\"firingCount > 0 ? 'error' : 'neutral'\" />\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n {{ ALERTS.length }} in the last 24h · newest first\n </Text>\n </VStack>\n <Divider />\n <List density=\"compact\" has-dividers>\n <ListItem v-for=\"alert in ALERTS\" :key=\"alert.id\" :label=\"alert.title\" :description=\"alert.service\">\n <template #startContent>\n <StatusDot :variant=\"alert.severity === 'critical' ? 'error' : 'warning'\" :label=\"alert.severity\" :is-pulsing=\"alert.state === 'firing' && alert.severity === 'critical'\" />\n </template>\n <template #endContent>\n <Token size=\"sm\" :color=\"alert.state === 'firing' ? 'red' : 'yellow'\" :label=\"alert.state === 'firing' ? 'Firing' : 'Ack'\" />\n </template>\n </ListItem>\n </List>\n <Divider />\n <VStack :gap=\"1\" :padding=\"4\" class=\"rail-header sticky-header\">\n <Heading :level=\"3\">\n Service breakdown\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n {{ breakdownContext }}\n </Text>\n </VStack>\n <List density=\"compact\" has-dividers>\n <ListItem\n v-for=\"row in rows\"\n :key=\"row.id\"\n :label=\"row.service\"\n :description=\"`${row.region} · ${row.instances} hosts · ${rpmLabel(row.rpm)}`\"\n >\n <template #startContent>\n <StatusDot :variant=\"row.status === 'healthy' ? 'success' : row.status === 'warning' ? 'warning' : 'error'\" :label=\"row.status\" />\n </template>\n <template #endContent>\n <HStack :gap=\"1\" align=\"center\">\n <Badge variant=\"neutral\" :label=\"`p99 ${row.p99} ms`\" />\n <Badge :variant=\"row.errorRate >= 2 ? 'error' : row.errorRate >= 1 ? 'warning' : 'success'\" :label=\"`${row.errorRate.toFixed(1)}% err`\" />\n </HStack>\n </template>\n </ListItem>\n </List>\n </VStack>\n </LayoutPanel>\n </template>\n </Layout>\n</template>\n\n<style scoped>\n.rail-header {\n background-color: var(--astryx-color-background-muted);\n}\n.rail-chart { width: 100%; height: 240px; display: block; }\n.chart-grid { stroke: var(--astryx-color-border, rgb(5 54 89 / 10%)); stroke-width: 1; }\n.chart-tick { fill: var(--astryx-color-text-secondary, #4E606F); font-size: 11px; }\n.legend-dot { width: 8px; height: 8px; border-radius: 2px; flex: none; }\n.sticky-header {\n position: sticky;\n inset-block-start: 0;\n z-index: 1;\n background-color: var(--astryx-color-background-surface);\n}\n</style>\n",
77765
+ "source": "<script setup lang=\"ts\">\nimport type { CSSProperties } from 'vue'\nimport { computed, defineComponent, h, onBeforeUnmount, onMounted, reactive, ref } from 'vue'\nimport {\n Badge,\n Card,\n Divider,\n EmptyState,\n Grid,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n LayoutHeader,\n LayoutPanel,\n List,\n ListItem,\n SegmentedControl,\n SegmentedControlItem,\n Selector,\n StackItem,\n StatusDot,\n Text,\n Timestamp,\n Token,\n VStack,\n useMediaQuery,\n} from '@astryx-vue/core'\nimport { PlaygroundSparkline } from '@astryx-vue/playground-charts'\n\ntype TimeWindow = '1h' | '1d' | '7d'\ntype Environment = 'production' | 'staging' | 'development'\ntype HealthStatus = 'healthy' | 'warning' | 'critical'\n\nconst pageStyle: CSSProperties = { height: '100dvh' }\n\nconst ENV_OPTIONS = [\n { value: 'production', label: 'Production' },\n { value: 'staging', label: 'Staging' },\n { value: 'development', label: 'Development' },\n]\n\nconst REGION_OPTIONS = [\n { value: 'all', label: 'All regions' },\n { value: 'us-east-1', label: 'us-east-1' },\n { value: 'us-west-2', label: 'us-west-2' },\n { value: 'eu-west-1', label: 'eu-west-1' },\n { value: 'ap-southeast-1', label: 'ap-southeast-1' },\n]\n\nconst KPI_BY_ENV: Record<Environment, Array<{ key: string, label: string, value: string, status: HealthStatus, delta: string, deltaPositive: boolean }>> = {\n production: [\n { key: 'health', label: 'Overall health', value: '98.6%', status: 'healthy', delta: '+0.3%', deltaPositive: true },\n { key: 'error', label: 'Error rate', value: '1.84%', status: 'warning', delta: '+0.9%', deltaPositive: false },\n { key: 'latency', label: 'p99 latency', value: '512 ms', status: 'warning', delta: '+84 ms', deltaPositive: false },\n { key: 'availability', label: 'Availability', value: '99.98%', status: 'healthy', delta: '+0.01%', deltaPositive: true },\n ],\n staging: [\n { key: 'health', label: 'Overall health', value: '99.4%', status: 'healthy', delta: '+0.1%', deltaPositive: true },\n { key: 'error', label: 'Error rate', value: '0.42%', status: 'healthy', delta: '-0.2%', deltaPositive: true },\n { key: 'latency', label: 'p99 latency', value: '338 ms', status: 'healthy', delta: '-12 ms', deltaPositive: true },\n { key: 'availability', label: 'Availability', value: '99.95%', status: 'healthy', delta: '0.00%', deltaPositive: true },\n ],\n development: [\n { key: 'health', label: 'Overall health', value: '96.1%', status: 'warning', delta: '-1.8%', deltaPositive: false },\n { key: 'error', label: 'Error rate', value: '3.10%', status: 'critical', delta: '+1.4%', deltaPositive: false },\n { key: 'latency', label: 'p99 latency', value: '274 ms', status: 'healthy', delta: '-30 ms', deltaPositive: true },\n { key: 'availability', label: 'Availability', value: '99.20%', status: 'warning', delta: '-0.6%', deltaPositive: false },\n ],\n}\n\nconst SPARKLINES: Record<string, number[]> = {\n health: [97.6, 97.6, 97.7, 97.6, 98, 97.4, 96.5, 97.3, 97.3, 97, 97, 97.6, 96.8, 97.2, 91.7, 92.2, 95, 96.3, 96.5, 96.4, 96.8, 95.9, 96.7, 96.7, 96.4, 97.1, 97, 97, 96.1, 96.7, 96, 95.7, 96.9, 97.3, 96.1, 95.8, 96.9, 96.2, 96.6, 96.7, 97.2, 92.7, 96.5, 97.2, 96.5, 95.8, 96, 97.4, 96.8, 96.9, 96.4, 96.9, 97.3, 96.2, 97.3, 96.2, 97, 97.3, 89.5, 90.1, 91.8, 95.1, 96.4, 97.7, 97.2, 97.8, 97, 97.5, 97.1, 98, 96.8, 96.8, 97.7, 97.7, 96.8, 97.2, 93.2, 95.3, 97.2, 97.7, 97.3, 98.2, 97.6, 97.3, 97, 97.4, 96.5, 98, 96.9, 97.1, 96.5, 96.4, 97.6, 87.4, 90, 94.1, 97.1, 97.1, 97.5, 97.6, 96.9, 96.9, 96.8, 96.5, 95.8, 96.7, 96.7, 97.2, 91.7, 91.5, 92.3, 95.3, 96.9, 97.4, 96.8, 97.9, 97.5, 98.3, 97.9, 98.6],\n error: [0.62, 0.64, 0.98, 0.82, 0.86, 0.83, 1.02, 1.29, 1.24, 2.41, 1.57, 0.96, 0.91, 1.43, 1.36, 1.16, 1.33, 0.95, 1.18, 1.3, 1.48, 1.05, 1.41, 1.06, 1.03, 1.36, 1, 3.71, 2.85, 1.7, 1.41, 1.29, 1.35, 1.35, 1.19, 1.27, 1.2, 0.91, 0.95, 1.06, 1.26, 1.28, 1.19, 1.09, 2.21, 1.52, 0.83, 0.94, 1.17, 0.81, 1.11, 1.24, 1.18, 0.76, 1.09, 1.08, 1.22, 1.02, 1, 5.18, 4.39, 3.74, 2.23, 1, 1.3, 1.53, 1.42, 1.16, 1.18, 1.52, 1.45, 1.34, 3.13, 2.1, 1.66, 1.74, 1.69, 1.33, 1.43, 1.73, 1.73, 1.51, 1.63, 1.55, 1.56, 1.74, 2, 1.46, 5.44, 4.33, 2.3, 1.99, 1.77, 1.82, 1.98, 1.86, 1.88, 1.51, 1.42, 1.82, 1.71, 3.88, 2.22, 1.72, 1.65, 1.64, 1.42, 1.69, 1.39, 1.81, 1.66, 1.3, 1.32, 1.75, 6.22, 5.03, 2.35, 1.39, 1.25, 1.84],\n latency: [322, 371, 287, 357, 313, 353, 320, 617, 421, 363, 377, 399, 350, 401, 421, 420, 430, 365, 401, 797, 635, 477, 365, 412, 380, 421, 380, 411, 393, 401, 436, 378, 376, 569, 389, 419, 417, 342, 347, 376, 385, 347, 393, 328, 328, 367, 304, 871, 812, 707, 411, 317, 316, 367, 331, 320, 354, 349, 326, 309, 323, 631, 396, 306, 295, 293, 355, 334, 324, 372, 1012, 850, 489, 355, 324, 346, 353, 406, 365, 362, 377, 348, 437, 423, 664, 437, 397, 413, 402, 427, 404, 438, 491, 479, 447, 500, 440, 903, 776, 555, 476, 506, 439, 461, 502, 496, 485, 474, 470, 503, 1198, 1080, 930, 609, 456, 507, 426, 451, 427, 512],\n availability: [99.891, 99.956, 99.885, 99.964, 99.882, 99.92, 99.696, 99.897, 99.913, 99.948, 99.918, 99.942, 99.921, 99.928, 99.94, 99.902, 99.551, 99.853, 99.853, 99.891, 99.887, 99.926, 99.912, 99.911, 99.739, 99.873, 99.877, 99.85, 99.844, 99.895, 99.854, 99.851, 99.912, 99.426, 99.593, 99.821, 99.851, 99.928, 99.767, 99.852, 99.854, 99.866, 99.87, 99.929, 99.856, 99.925, 99.672, 99.882, 99.926, 99.865, 99.944, 99.941, 99.807, 99.91, 99.892, 99.922, 99.937, 99.338, 99.491, 99.743, 99.925, 99.871, 99.95, 99.909, 99.949, 99.687, 99.844, 99.911, 99.93, 99.882, 99.894, 99.536, 99.833, 99.867, 99.927, 99.88, 99.883, 99.926, 99.895, 99.653, 99.827, 99.906, 99.941, 99.893, 99.863, 99.916, 99.781, 99.885, 99.846, 99.896, 99.876, 99.485, 99.534, 99.738, 99.832, 99.241, 99.321, 99.422, 99.698, 99.828, 99.824, 99.837, 99.828, 99.61, 99.761, 99.878, 99.843, 99.845, 99.529, 99.75, 99.838, 99.83, 99.423, 99.728, 99.817, 99.827, 99.811, 99.871, 99.925, 99.98],\n}\n\n/** 迷你折线只取最近一段,随时间窗口变:1h 取 60 点,1d 取 90 点,7d 取 120 点。 */\nconst SPARK_LEN: Record<TimeWindow, number> = { '1h': 60, '1d': 90, '7d': 120 }\n\n/** 迷你折线的颜色用原版的「文字前景」那一档(浅色深绿 / 深色浅绿),站点里没有对应变量,所以直接写 light-dark。 */\nconst STATUS_STROKE: Record<HealthStatus, string> = {\n healthy: 'light-dark(#09441F, #A5F690)',\n warning: 'light-dark(#753F07, #FBCE03)',\n critical: 'light-dark(#7B0210, #FFB2B8)',\n}\n\ntype AlertState = 'firing' | 'acknowledged' | 'resolved'\n\nconst ALERTS = [\n { id: 'AL-8842', severity: 'critical' as HealthStatus, title: 'search-indexer 5xx rate above 5%', service: 'search-indexer · us-east-1', at: '2026-06-30T21:14:00Z', state: 'firing' as AlertState },\n { id: 'AL-8841', severity: 'warning' as HealthStatus, title: 'checkout-api p99 latency above 500ms', service: 'checkout-api · us-east-1', at: '2026-06-30T21:02:00Z', state: 'firing' as AlertState },\n { id: 'AL-8840', severity: 'warning' as HealthStatus, title: 'edge-cdn cache hit ratio degraded', service: 'edge-cdn · eu-west-1', at: '2026-06-30T20:41:00Z', state: 'acknowledged' as AlertState },\n { id: 'AL-8839', severity: 'critical' as HealthStatus, title: 'auth error rate spike in dev cluster', service: 'auth · us-west-2', at: '2026-06-30T20:18:00Z', state: 'acknowledged' as AlertState },\n { id: 'AL-8838', severity: 'warning' as HealthStatus, title: 'notifications queue depth climbing', service: 'notifications · ap-southeast-1', at: '2026-06-30T19:55:00Z', state: 'firing' as AlertState },\n { id: 'AL-8837', severity: 'warning' as HealthStatus, title: 'payments settlement job heartbeat missed', service: 'payments · us-east-1', at: '2026-06-30T18:30:00Z', state: 'resolved' as AlertState },\n]\n\nconst ALERT_ORDER: AlertState[] = ['firing', 'acknowledged', 'resolved']\nconst ALERT_STATE_TOKEN: Record<AlertState, { label: string, color: 'red' | 'yellow' | 'green' }> = {\n firing: { label: 'Firing', color: 'red' },\n acknowledged: { label: 'Ack', color: 'yellow' },\n resolved: { label: 'Resolved', color: 'green' },\n}\n\nconst HOST_ROWS = [\n { id: '1', service: 'checkout-api', environment: 'production' as Environment, region: 'us-east-1', instances: 24, rpm: 9240, errorRate: 2.1, p99: 512, status: 'warning' as HealthStatus },\n { id: '2', service: 'checkout-api', environment: 'production' as Environment, region: 'us-west-2', instances: 18, rpm: 6110, errorRate: 0.3, p99: 214, status: 'healthy' as HealthStatus },\n { id: '3', service: 'checkout-api', environment: 'production' as Environment, region: 'eu-west-1', instances: 12, rpm: 3980, errorRate: 0.4, p99: 236, status: 'healthy' as HealthStatus },\n { id: '4', service: 'search-indexer', environment: 'production' as Environment, region: 'us-east-1', instances: 16, rpm: 4120, errorRate: 5.8, p99: 1180, status: 'critical' as HealthStatus },\n { id: '5', service: 'search-indexer', environment: 'production' as Environment, region: 'ap-southeast-1', instances: 8, rpm: 2040, errorRate: 0.9, p99: 342, status: 'healthy' as HealthStatus },\n { id: '6', service: 'payments', environment: 'production' as Environment, region: 'us-east-1', instances: 20, rpm: 5310, errorRate: 0.2, p99: 188, status: 'healthy' as HealthStatus },\n { id: '7', service: 'payments', environment: 'production' as Environment, region: 'eu-west-1', instances: 10, rpm: 2760, errorRate: 0.2, p99: 205, status: 'healthy' as HealthStatus },\n { id: '8', service: 'edge-cdn', environment: 'production' as Environment, region: 'eu-west-1', instances: 30, rpm: 14820, errorRate: 1.6, p99: 402, status: 'warning' as HealthStatus },\n { id: '9', service: 'auth', environment: 'production' as Environment, region: 'us-west-2', instances: 14, rpm: 4530, errorRate: 0.1, p99: 96, status: 'healthy' as HealthStatus },\n { id: '10', service: 'notifications', environment: 'production' as Environment, region: 'ap-southeast-1', instances: 6, rpm: 1210, errorRate: 0.5, p99: 268, status: 'healthy' as HealthStatus },\n { id: '11', service: 'checkout-api', environment: 'staging' as Environment, region: 'us-east-1', instances: 3, rpm: 420, errorRate: 0.4, p99: 198, status: 'healthy' as HealthStatus },\n { id: '12', service: 'search-indexer', environment: 'staging' as Environment, region: 'us-east-1', instances: 2, rpm: 180, errorRate: 1.1, p99: 356, status: 'warning' as HealthStatus },\n { id: '13', service: 'checkout-api', environment: 'development' as Environment, region: 'us-west-2', instances: 1, rpm: 44, errorRate: 3.2, p99: 274, status: 'warning' as HealthStatus },\n { id: '14', service: 'auth', environment: 'development' as Environment, region: 'us-west-2', instances: 1, rpm: 38, errorRate: 6.4, p99: 512, status: 'critical' as HealthStatus },\n]\n\n// ── 图表 ──────────────────────────────────────────────────────────────────────\n// 内联 SVG:锯齿状时间序列 + 坐标轴 + 图例,形状由固定种子的伪随机生成。\n\n/** 两张图并排、一样宽;`width` 跟着第一张图的实际宽度走,viewBox 和像素一比一。 */\nconst CHART = reactive({ width: 640, height: 280, left: 56, right: 16, top: 12, bottom: 34 })\nconst chartEl = ref<SVGSVGElement | null>(null)\nlet chartObserver: ResizeObserver | null = null\nonMounted(() => {\n if (!chartEl.value || typeof ResizeObserver === 'undefined')\n return\n chartObserver = new ResizeObserver(([entry]) => {\n if (entry && entry.contentRect.width > 0)\n CHART.width = entry.contentRect.width\n })\n chartObserver.observe(chartEl.value)\n})\nonBeforeUnmount(() => chartObserver?.disconnect())\nconst POINTS = 96\nconst X_LABELS = ['00:00', '04:50', '09:30', '14:20', '19:00', '23:50']\n\nfunction noise(i: number, seed: number) {\n const x = Math.sin(i * 127.1 + seed * 311.7) * 43758.5453\n return x - Math.floor(x)\n}\n\n/** 基线 + 慢波 + 抖动,偶尔来一根尖峰,读起来像真实遥测。 */\nfunction telemetry(base: number, swing: number, jitter: number, seed: number) {\n return Array.from({ length: POINTS }, (_, i) => {\n const wave = Math.sin((i / POINTS) * Math.PI * 4 + seed) * swing\n const wobble = (noise(i, seed) - 0.5) * jitter\n const spike = noise(i, seed + 41) > 0.86 ? jitter * 2.4 : 0\n return Math.max(0, base + wave + wobble + spike)\n })\n}\n\nconst latencySeries = [\n { key: 'p50', label: 'p50', color: 'var(--astryx-color-data-categorical-blue, #0171E3)', values: telemetry(46, 10, 14, 3) },\n { key: 'p95', label: 'p95', color: 'var(--astryx-color-data-categorical-orange, #EB6E00)', values: telemetry(152, 40, 34, 7) },\n { key: 'p99', label: 'p99', color: 'var(--astryx-color-data-categorical-purple, #6B1EFD)', values: telemetry(330, 110, 90, 11) },\n]\n\nconst volumeSeries = [\n { key: 'useast', label: 'us-east-1', color: 'var(--astryx-color-data-categorical-blue, #0171E3)', values: telemetry(9400, 2600, 900, 5) },\n { key: 'uswest', label: 'us-west-2', color: 'var(--astryx-color-data-categorical-teal, #08A3A3)', values: telemetry(6400, 1500, 650, 9) },\n { key: 'euwest', label: 'eu-west-1', color: 'var(--astryx-color-data-categorical-orange, #EB6E00)', values: telemetry(4100, 900, 500, 13) },\n]\n\nconst LATENCY_Y_TICKS = [0, 200, 400, 600, 800]\nconst VOLUME_Y_TICKS = [0, 3500, 7000, 10500, 14000]\n\nfunction chartX(i: number) {\n return CHART.left + (i / (POINTS - 1)) * (CHART.width - CHART.left - CHART.right)\n}\n\nfunction chartY(value: number, max: number) {\n const inner = CHART.height - CHART.top - CHART.bottom\n return CHART.top + (1 - value / max) * inner\n}\n\nfunction chartPath(values: number[], max: number) {\n return values.map((v, i) => `${i === 0 ? 'M' : 'L'}${chartX(i).toFixed(1)},${chartY(v, max).toFixed(1)}`).join(' ')\n}\n\nconst SEVERITY_RANK: Record<HealthStatus, number> = { critical: 0, warning: 1, healthy: 2 }\nconst STATUS_LABEL: Record<HealthStatus, string> = { healthy: 'Healthy', warning: 'Degraded', critical: 'Down' }\nconst STATUS_DOT: Record<HealthStatus, 'success' | 'warning' | 'error'> = { healthy: 'success', warning: 'warning', critical: 'error' }\n\nconst alertGroups = ALERT_ORDER\n .map(state => ({ state, items: ALERTS.filter(alert => alert.state === state) }))\n .filter(group => group.items.length > 0)\n\nfunction errorColor(rate: number): 'red' | 'yellow' | 'green' {\n if (rate >= 5)\n return 'red'\n return rate >= 1.5 ? 'yellow' : 'green'\n}\n\nfunction formatRpm(rpm: number): string {\n return rpm >= 1000 ? `${(rpm / 1000).toFixed(1)}k rpm` : `${rpm} rpm`\n}\n\n// ============= 90 天可用性 =============\n\nconst UPTIME_DAYS = 90\ntype UptimeStatus = 'operational' | 'degraded' | 'down'\n\n/** 可用性条的三种颜色:用状态点那套填充色(图例和条一致),不用文字前景色,那套在站点里偏深。 */\nconst UPTIME_COLOR: Record<UptimeStatus, string> = {\n operational: 'var(--astryx-status-dot-success-background, #198100)',\n degraded: 'var(--astryx-status-dot-warning-background, #ffce2f)',\n down: 'var(--astryx-status-dot-error-background, #c9303a)',\n}\nconst UPTIME_LABEL: Record<UptimeStatus, string> = {\n operational: 'Operational',\n degraded: 'Partial degradation',\n down: 'Downtime',\n}\nconst UPTIME_ORDER: UptimeStatus[] = ['operational', 'degraded', 'down']\nconst DAY_UPTIME: Record<UptimeStatus, number> = { operational: 100, degraded: 98.4, down: 82 }\nconst UPTIME_ENV_RISK: Record<Environment, number> = { production: 1, staging: 1.6, development: 3 }\nconst UPTIME_STATUS_RISK: Record<HealthStatus, number> = { healthy: 1, warning: 3.5, critical: 7 }\nconst UPTIME_TODAY: Record<HealthStatus, UptimeStatus> = { healthy: 'operational', warning: 'degraded', critical: 'down' }\n\ninterface ServiceUptime {\n service: string\n status: HealthStatus\n days: UptimeStatus[]\n uptime: number\n}\n\n/** 每个服务一串 90 天的状态:风险按环境和当前状态放大,种子固定所以每次渲染都一样。 */\nfunction buildUptime(service: string, environment: Environment, status: HealthStatus): ServiceUptime {\n let seed = 7\n for (let i = 0; i < service.length; i++)\n seed = (seed * 31 + service.charCodeAt(i)) % 9973\n const risk = UPTIME_ENV_RISK[environment] * UPTIME_STATUS_RISK[status]\n const downCount = Math.round(UPTIME_DAYS * Math.min(0.09, 0.005 * risk) * (0.6 + 0.8 * noise(seed, 3.7)))\n const degradedCount = Math.round(UPTIME_DAYS * Math.min(0.32, 0.02 * risk) * (0.6 + 0.8 * noise(seed, 5.9)))\n const ranked = [...Array.from({ length: UPTIME_DAYS }).keys()].sort((a, b) => noise(b, seed) - noise(a, seed))\n const days: UptimeStatus[] = Array.from({ length: UPTIME_DAYS }, () => 'operational')\n for (let k = 0; k < downCount; k++)\n days[ranked[k]] = 'down'\n for (let k = downCount; k < downCount + degradedCount; k++)\n days[ranked[k]] = 'degraded'\n days[UPTIME_DAYS - 1] = UPTIME_TODAY[status]\n const cleanDay = DAY_UPTIME.operational - 0.02 - noise(seed, 11.3) * 0.06\n let served = 0\n for (let i = 0; i < UPTIME_DAYS; i++)\n served += days[i] === 'operational' ? cleanDay - noise(i, seed + 1) * 0.02 : DAY_UPTIME[days[i]]\n return { service, status, days, uptime: served / UPTIME_DAYS }\n}\n\nfunction dayLabel(i: number): string {\n const ago = UPTIME_DAYS - 1 - i\n if (ago === 0)\n return 'Today'\n return ago === 1 ? 'Yesterday' : `${ago} days ago`\n}\n\nconst timeWindow = ref<TimeWindow>('1d')\nconst environment = ref<Environment>('production')\nconst region = ref('all')\nconst isNarrow = useMediaQuery('(max-width: 1024px)')\n\nconst kpis = computed(() => KPI_BY_ENV[environment.value])\nconst rows = computed(() => HOST_ROWS.filter(r => r.environment === environment.value && (region.value === 'all' || r.region === region.value)))\n/** 同一服务取最差状态,按名字排序。 */\nconst uptimeEntries = computed(() => {\n const worst = new Map<string, HealthStatus>()\n for (const row of rows.value) {\n const previous = worst.get(row.service)\n if (previous === undefined || SEVERITY_RANK[row.status] < SEVERITY_RANK[previous])\n worst.set(row.service, row.status)\n }\n return [...worst.entries()]\n .sort((a, b) => a[0].localeCompare(b[0]))\n .map(([service, status]) => buildUptime(service, environment.value, status))\n})\n\nconst sortedRows = computed(() => [...rows.value].sort((a, b) =>\n SEVERITY_RANK[a.status] - SEVERITY_RANK[b.status] || b.errorRate - a.errorRate,\n))\n\nconst breakdownContext = computed(() => `${ENV_OPTIONS.find(e => e.value === environment.value)?.label} · ${REGION_OPTIONS.find(r => r.value === region.value)?.label}`)\nconst firingCount = computed(() => ALERTS.filter(a => a.state === 'firing').length)\n\n/** 右栏的两个区块:Active alerts 和 Service breakdown。宽屏进 Layout 的 end 槽,窄屏放到主区底部。 */\n/** 渲染函数里造出来的节点拿不到 scoped 样式的属性,所以区块头的底色和吸顶都用内联样式。 */\nconst RAIL_HEADER_STYLE = { backgroundColor: 'var(--astryx-color-background-muted)' }\nconst RAIL_STICKY_STYLE = { position: 'sticky', insetBlockStart: 0, zIndex: 1, backgroundColor: 'var(--astryx-color-background-surface)' } as const\n\nconst AlertsFeed = defineComponent({\n props: { sticky: { type: Boolean, default: false } },\n setup(props) {\n return () => h(VStack, { gap: 0 }, () => [\n h(VStack, { gap: 0, style: props.sticky ? RAIL_STICKY_STYLE : undefined }, () => [\n h(VStack, { gap: 1, padding: 4, style: RAIL_HEADER_STYLE }, () => [\n h(HStack, { gap: 2, vAlign: 'center' }, () => [\n h(Icon, { name: 'heroicons:bell-alert', size: 'sm', color: firingCount.value > 0 ? 'error' : 'secondary' }),\n h(StackItem, { size: 'fill' }, () => h(Heading, { level: 3 }, () => 'Active alerts')),\n h(Badge, { label: `${firingCount.value} firing`, variant: firingCount.value > 0 ? 'error' : 'neutral' }),\n ]),\n h(Text, { type: 'supporting', color: 'secondary' }, () => `${ALERTS.length} in the last 24h · newest first`),\n ]),\n h(Divider),\n ]),\n ...alertGroups.map(group => h(List, { key: group.state, density: 'compact', hasDividers: true }, () => group.items.map(alert =>\n h(ListItem, { key: alert.id, label: alert.title, description: alert.service }, {\n startContent: () => h(StatusDot, { variant: STATUS_DOT[alert.severity], label: alert.severity, isPulsing: alert.state === 'firing' && alert.severity === 'critical' }),\n endContent: () => h(VStack, { gap: 1, hAlign: 'end' }, () => [\n h(Token, { size: 'sm', color: ALERT_STATE_TOKEN[alert.state].color, label: ALERT_STATE_TOKEN[alert.state].label }),\n h(Timestamp, { value: alert.at, format: 'relative', color: 'secondary' }),\n ]),\n }),\n ))),\n ])\n },\n})\n\nconst ServiceBreakdown = defineComponent({\n props: { sticky: { type: Boolean, default: false } },\n setup(props) {\n return () => h(VStack, { gap: 0 }, () => [\n h(VStack, { gap: 0, style: props.sticky ? RAIL_STICKY_STYLE : undefined }, () => [\n h(VStack, { gap: 1, padding: 4, style: RAIL_HEADER_STYLE }, () => [\n h(HStack, { gap: 2, vAlign: 'center' }, () => [\n h(Icon, { name: 'heroicons:server-stack', size: 'sm', color: 'secondary' }),\n h(StackItem, { size: 'fill' }, () => h(Heading, { level: 3 }, () => 'Service breakdown')),\n h(Badge, { label: String(rows.value.length), variant: 'neutral' }),\n ]),\n h(Text, { type: 'supporting', color: 'secondary' }, () => breakdownContext.value),\n ]),\n h(Divider),\n ]),\n h(List, { density: 'compact', hasDividers: true }, () => sortedRows.value.map(row =>\n h(ListItem, { key: row.id, label: row.service, description: `${row.region} · ${row.instances} hosts · ${formatRpm(row.rpm)}` }, {\n startContent: () => h(StatusDot, { variant: STATUS_DOT[row.status], label: STATUS_LABEL[row.status], isPulsing: row.status === 'critical' }),\n endContent: () => h(VStack, { gap: 1, hAlign: 'end' }, () => [\n h(HStack, { gap: 1, vAlign: 'center' }, () => [\n h(Text, { type: 'supporting', color: 'secondary' }, () => 'p99'),\n h(Text, { type: 'body', weight: 'semibold' }, () => `${row.p99} ms`),\n ]),\n h(Token, { size: 'sm', color: errorColor(row.errorRate), label: `${row.errorRate.toFixed(1)}% err` }),\n ]),\n }),\n )),\n ])\n },\n})\n\n<\/script>\n\n<template>\n <Layout height=\"fill\" :style=\"pageStyle\">\n <template #header>\n <LayoutHeader :padding=\"6\" has-divider>\n <HStack :gap=\"3\" align=\"center\" justify=\"between\" wrap=\"wrap\">\n <VStack :gap=\"0\" align=\"center\">\n <Heading :level=\"1\">\n Service Health Monitor\n </Heading>\n <HStack :gap=\"2\" align=\"center\">\n <StatusDot variant=\"success\" label=\"Live\" is-pulsing tooltip=\"Auto-refreshing every 30s\" />\n <Text type=\"supporting\" color=\"secondary\">\n Updated\n <Timestamp value=\"2026-06-30T21:15:00Z\" format=\"relative\" color=\"inherit\" />\n </Text>\n </HStack>\n </VStack>\n <HStack :gap=\"2\" align=\"center\">\n <SegmentedControl v-model=\"timeWindow\" label=\"Time window\" size=\"lg\">\n <SegmentedControlItem label=\"1h\" value=\"1h\" />\n <SegmentedControlItem label=\"1d\" value=\"1d\" />\n <SegmentedControlItem label=\"7d\" value=\"7d\" />\n </SegmentedControl>\n <Selector v-model=\"environment\" label=\"Environment\" is-label-hidden size=\"lg\" :options=\"ENV_OPTIONS\" />\n <Selector v-model=\"region\" label=\"Region\" is-label-hidden size=\"lg\" :options=\"REGION_OPTIONS\" />\n </HStack>\n </HStack>\n </LayoutHeader>\n </template>\n <template #default>\n <LayoutContent :padding=\"6\">\n <VStack :gap=\"10\">\n <VStack :gap=\"6\">\n <Grid :columns=\"{ minWidth: 240, repeat: 'fit' }\" :gap=\"3\">\n <Card v-for=\"kpi in kpis\" :key=\"kpi.key\" :padding=\"4\">\n <VStack :gap=\"2\">\n <HStack :gap=\"2\" align=\"center\">\n <StatusDot :variant=\"kpi.status === 'healthy' ? 'success' : kpi.status === 'warning' ? 'warning' : 'error'\" :label=\"kpi.label\" />\n <Text type=\"label\" color=\"secondary\">\n {{ kpi.label }}\n </Text>\n </HStack>\n <HStack :gap=\"2\" align=\"center\">\n <Heading :level=\"2\">\n {{ kpi.value }}\n </Heading>\n <HStack :gap=\"1\" align=\"center\">\n <Icon :icon=\"kpi.deltaPositive ? 'arrowUp' : 'arrowDown'\" size=\"xsm\" :color=\"kpi.deltaPositive ? 'success' : 'error'\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ kpi.delta }}\n </Text>\n </HStack>\n </HStack>\n <PlaygroundSparkline :data=\"SPARKLINES[kpi.key].slice(-SPARK_LEN[timeWindow])\" :color=\"STATUS_STROKE[kpi.status]\" :stroke-width=\"1.25\" is-measured />\n </VStack>\n </Card>\n </Grid>\n <Grid :columns=\"{ minWidth: 320, repeat: 'fit' }\" :gap=\"3\">\n <Card :padding=\"6\">\n <VStack :gap=\"4\">\n <VStack :gap=\"1\">\n <Heading :level=\"3\">\n Response latency\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Percentile latency across all services (ms)\n </Text>\n </VStack>\n <svg ref=\"chartEl\" class=\"rail-chart\" :viewBox=\"`0 0 ${CHART.width} ${CHART.height}`\" role=\"img\" aria-label=\"Response latency percentiles\">\n <line\n v-for=\"tick in LATENCY_Y_TICKS\"\n :key=\"`ly-${tick}`\"\n :x1=\"CHART.left\"\n :x2=\"CHART.width - CHART.right\"\n :y1=\"chartY(tick, 800)\"\n :y2=\"chartY(tick, 800)\"\n class=\"chart-grid\"\n />\n <text\n v-for=\"tick in LATENCY_Y_TICKS\"\n :key=\"`lyt-${tick}`\"\n :x=\"CHART.left - 8\"\n :y=\"chartY(tick, 800)\"\n text-anchor=\"end\"\n dominant-baseline=\"central\"\n class=\"chart-tick\"\n >\n {{ tick }} ms\n </text>\n <text\n v-for=\"(label, i) in X_LABELS\"\n :key=\"`lx-${label}`\"\n :x=\"chartX((i / (X_LABELS.length - 1)) * (POINTS - 1))\"\n :y=\"CHART.height - CHART.bottom + 13\"\n dominant-baseline=\"central\"\n text-anchor=\"middle\"\n class=\"chart-tick\"\n >\n {{ label }}\n </text>\n <path\n v-for=\"series in latencySeries\"\n :key=\"series.key\"\n :d=\"chartPath(series.values, 800)\"\n fill=\"none\"\n :stroke=\"series.color\"\n stroke-width=\"1.5\"\n vector-effect=\"non-scaling-stroke\"\n />\n </svg>\n <HStack :gap=\"5\" align=\"center\" wrap=\"wrap\">\n <HStack v-for=\"series in latencySeries\" :key=\"series.key\" :gap=\"2\" align=\"center\">\n <span class=\"legend-dot\" :style=\"{ background: series.color }\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ series.label }}\n </Text>\n </HStack>\n </HStack>\n </VStack>\n </Card>\n <Card :padding=\"6\">\n <VStack :gap=\"4\">\n <VStack :gap=\"1\">\n <Heading :level=\"3\">\n Request volume by region\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Requests per minute (rpm)\n </Text>\n </VStack>\n <svg class=\"rail-chart\" :viewBox=\"`0 0 ${CHART.width} ${CHART.height}`\" role=\"img\" aria-label=\"Request volume by region\">\n <line\n v-for=\"tick in VOLUME_Y_TICKS\"\n :key=\"`vy-${tick}`\"\n :x1=\"CHART.left\"\n :x2=\"CHART.width - CHART.right\"\n :y1=\"chartY(tick, 14000)\"\n :y2=\"chartY(tick, 14000)\"\n class=\"chart-grid\"\n />\n <text\n v-for=\"tick in VOLUME_Y_TICKS\"\n :key=\"`vyt-${tick}`\"\n :x=\"CHART.left - 8\"\n :y=\"chartY(tick, 14000)\"\n text-anchor=\"end\"\n dominant-baseline=\"central\"\n class=\"chart-tick\"\n >\n {{ tick }}\n </text>\n <text\n v-for=\"(label, i) in X_LABELS\"\n :key=\"`vx-${label}`\"\n :x=\"chartX((i / (X_LABELS.length - 1)) * (POINTS - 1))\"\n :y=\"CHART.height - CHART.bottom + 13\"\n dominant-baseline=\"central\"\n text-anchor=\"middle\"\n class=\"chart-tick\"\n >\n {{ label }}\n </text>\n <path\n v-for=\"series in volumeSeries\"\n :key=\"series.key\"\n :d=\"chartPath(series.values, 14000)\"\n fill=\"none\"\n :stroke=\"series.color\"\n stroke-width=\"1.5\"\n vector-effect=\"non-scaling-stroke\"\n />\n </svg>\n <HStack :gap=\"5\" align=\"center\" wrap=\"wrap\">\n <HStack v-for=\"series in volumeSeries\" :key=\"series.key\" :gap=\"2\" align=\"center\">\n <span class=\"legend-dot\" :style=\"{ background: series.color }\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ series.label }}\n </Text>\n </HStack>\n </HStack>\n </VStack>\n </Card>\n </Grid>\n </VStack>\n <Divider />\n <VStack :gap=\"6\">\n <HStack :gap=\"3\" align=\"center\" h-align=\"between\" wrap=\"wrap\">\n <VStack>\n <Heading :level=\"2\">\n All services\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Daily uptime over the last {{ UPTIME_DAYS }} days · {{ breakdownContext }}\n </Text>\n </VStack>\n <HStack :gap=\"4\" align=\"center\" wrap=\"wrap\">\n <HStack v-for=\"state in UPTIME_ORDER\" :key=\"state\" :gap=\"2\" align=\"center\">\n <span class=\"legend-dot\" :style=\"{ background: UPTIME_COLOR[state] }\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ UPTIME_LABEL[state] }}\n </Text>\n </HStack>\n </HStack>\n </HStack>\n <Card :padding=\"6\">\n <EmptyState\n v-if=\"uptimeEntries.length === 0\"\n title=\"No services in this view\"\n description=\"Nothing reports in the selected environment and region. Try widening the region filter.\"\n />\n <VStack v-else :gap=\"6\">\n <template v-for=\"(entry, index) in uptimeEntries\" :key=\"entry.service\">\n <Divider v-if=\"index > 0\" />\n <VStack :gap=\"2\">\n <HStack :gap=\"3\" align=\"center\" h-align=\"between\">\n <HStack :gap=\"2\" align=\"center\">\n <StatusDot :variant=\"STATUS_DOT[entry.status]\" :label=\"STATUS_LABEL[entry.status]\" :is-pulsing=\"entry.status === 'critical'\" />\n <Heading :level=\"4\">\n {{ entry.service }}\n </Heading>\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n {{ entry.uptime.toFixed(2) }}% uptime\n </Text>\n </HStack>\n <div class=\"uptime-strip\" role=\"img\" :aria-label=\"`${UPTIME_DAYS}-day uptime for ${entry.service}`\">\n <div\n v-for=\"(day, i) in entry.days\"\n :key=\"i\"\n class=\"uptime-bar\"\n :style=\"{ background: UPTIME_COLOR[day] }\"\n :title=\"`${dayLabel(i)} — ${UPTIME_LABEL[day]}`\"\n />\n </div>\n </VStack>\n </template>\n </VStack>\n </Card>\n </VStack>\n <VStack v-if=\"isNarrow\" :gap=\"4\">\n <Card :padding=\"0\">\n <AlertsFeed />\n </Card>\n <Card :padding=\"0\">\n <ServiceBreakdown />\n </Card>\n </VStack>\n </VStack>\n </LayoutContent>\n </template>\n <template v-if=\"!isNarrow\" #end>\n <LayoutPanel :width=\"400\" :padding=\"0\" has-divider role=\"complementary\" label=\"Alerts and service breakdown\" is-scrollable>\n <VStack :gap=\"0\">\n <AlertsFeed sticky />\n <Divider />\n <ServiceBreakdown sticky />\n </VStack>\n </LayoutPanel>\n </template>\n </Layout>\n</template>\n\n<style scoped>\n.rail-header {\n background-color: var(--astryx-color-background-muted);\n}\n.rail-chart { width: 100%; height: 280px; display: block; }\n.chart-grid { stroke: var(--astryx-color-border, rgb(5 54 89 / 10%)); stroke-width: 1; }\n.chart-tick { fill: var(--astryx-color-text-secondary, #4E606F); font-size: 12px; }\n.uptime-strip {\n display: flex;\n gap: 2px;\n overflow-x: auto;\n overscroll-behavior-x: contain;\n scrollbar-width: thin;\n}\n.uptime-bar {\n flex: 1 1 0;\n min-width: 3px;\n height: 30px;\n}\n.legend-dot { width: 8px; height: 8px; border-radius: 2px; flex: none; }\n.sticky-header {\n position: sticky;\n inset-block-start: 0;\n z-index: 1;\n background-color: var(--astryx-color-background-surface);\n}\n</style>\n",
77412
77766
  "docsUrl": {
77413
77767
  "en": "https://astryx-vue.pages.dev/en/playground/?template=dashboard-alert-rail",
77414
77768
  "zh": "https://astryx-vue.pages.dev/playground/?template=dashboard-alert-rail"
@@ -77481,7 +77835,7 @@ const catalog = {
77481
77835
  ],
77482
77836
  "dependencies": ["@astryx-vue/core", "vue"],
77483
77837
  "isReady": true,
77484
- "source": "<script setup lang=\"ts\">\nimport type { CSSProperties } from 'vue'\nimport { computed, ref } from 'vue'\nimport {\n Badge,\n Banner,\n Button,\n Card,\n Center,\n CheckboxInput,\n Collapsible,\n Divider,\n Grid,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n Link,\n NumberInput,\n RadioList,\n RadioListItem,\n Section,\n Selector,\n Stack,\n StackItem,\n Text,\n TextArea,\n TextInput,\n Thumbnail,\n useMediaQuery,\n VStack,\n} from '@astryx-vue/core'\n\n// ── Constants ─────────────────────────────────────────────────────────────────\n\nconst MONTHS = [\n '01',\n '02',\n '03',\n '04',\n '05',\n '06',\n '07',\n '08',\n '09',\n '10',\n '11',\n '12',\n];\nconst YEARS = Array.from({length: 12}, (_, i) => String(2025 + i));\n\nconst US_STATES = [\n 'Alabama',\n 'Alaska',\n 'Arizona',\n 'Arkansas',\n 'California',\n 'Colorado',\n 'Connecticut',\n 'Delaware',\n 'Florida',\n 'Georgia',\n 'Hawaii',\n 'Idaho',\n 'Illinois',\n 'Indiana',\n 'Iowa',\n 'Kansas',\n 'Kentucky',\n 'Louisiana',\n 'Maine',\n 'Maryland',\n 'Massachusetts',\n 'Michigan',\n 'Minnesota',\n 'Mississippi',\n 'Missouri',\n 'Montana',\n 'Nebraska',\n 'Nevada',\n 'New Hampshire',\n 'New Jersey',\n 'New Mexico',\n 'New York',\n 'North Carolina',\n 'North Dakota',\n 'Ohio',\n 'Oklahoma',\n 'Oregon',\n 'Pennsylvania',\n 'Rhode Island',\n 'South Carolina',\n 'South Dakota',\n 'Tennessee',\n 'Texas',\n 'Utah',\n 'Vermont',\n 'Virginia',\n 'Washington',\n 'West Virginia',\n 'Wisconsin',\n 'Wyoming',\n];\n\n// Product photos from the local template-assets set (committed to the\n// docsite; the CLI swaps these for an inline placeholder on scaffold).\nconst ITEM_IMAGES: Record<string, {src: string}> = {\n '1': {\n src: '/template-assets/light-product-1.png',\n },\n '2': {\n src: '/template-assets/light-product-4.png',\n },\n '3': {\n src: '/template-assets/light-product-5.png',\n },\n};\n\nconst ORDER_ITEMS = [\n {\n id: '1',\n name: 'Speckled Stoneware Mug',\n variant: 'Hand-thrown · 12 oz',\n price: 78,\n qty: 1,\n limited: false,\n },\n {\n id: '2',\n name: 'Stoneware Dinner Plate',\n variant: 'Reactive glaze · 10 in',\n price: 72,\n qty: 1,\n limited: false,\n },\n {\n id: '3',\n name: 'Cereal Bowl',\n variant: 'Speckled clay · 6 in',\n price: 80,\n qty: 1,\n limited: true,\n },\n];\n\nconst SUBTOTAL = 230;\n// SHIPPING is now computed from deliveryMethod state\nconst TAX = 18.4;\n// TOTAL is computed dynamically based on delivery selection\nconst fmt = (n: number) => `$${n.toFixed(2)}`;\n\nconst CARD_LOGOS = [\n { src: 'https://raw.githubusercontent.com/aaronfagan/svg-credit-card-payment-icons/main/flat/visa.svg', alt: 'Visa' },\n { src: 'https://raw.githubusercontent.com/aaronfagan/svg-credit-card-payment-icons/main/flat/mastercard.svg', alt: 'Mastercard' },\n { src: 'https://raw.githubusercontent.com/aaronfagan/svg-credit-card-payment-icons/main/flat/amex.svg', alt: 'Amex' },\n]\n\nconst POLICIES = ['Refund policy', 'Privacy policy', 'Terms of service', 'Cancellations']\n\n// ── Styles ────────────────────────────────────────────────────────────────────\n// Plain inline styles using Astryx design-token CSS variables (declared at\n// :root by `@astryxdesign/core/astryx.css`). No StyleX compiler required.\n\nconst fullWidth: CSSProperties = {width: '100%'};\n// Form column flex-basis so the two checkout columns share width evenly.\nconst formColBasis: CSSProperties = {flexBasis: 0};\n// Space the Order Summary content below its collapsible trigger title.\nconst summaryContent: CSSProperties = {paddingBlockStart: 'var(--astryx-spacing-2)'};\n// Order-summary column: sticky beside the form on desktop.\nconst summarySticky: CSSProperties = {\n flexBasis: 0,\n position: 'sticky',\n top: 'var(--astryx-spacing-4)',\n alignSelf: 'flex-start',\n};\n// On mobile the summary moves above the form.\nconst summaryMobileOrder: CSSProperties = {order: -1};\n// 快捷支付按钮里的品牌图形按字高缩放。\nconst brandLogo: CSSProperties = {height: 'var(--astryx-spacing-5)', width: 'auto'};\n// 卡组织图标是带描边的圆角小牌子。\nconst cardLogo: CSSProperties = {\n height: 'var(--astryx-spacing-7)',\n width: 'auto',\n borderRadius: 'var(--astryx-radius-element)',\n borderWidth: 'var(--astryx-border-width)',\n borderStyle: 'solid',\n borderColor: 'var(--astryx-color-border)',\n backgroundColor: 'var(--astryx-color-background-surface)',\n};\nconst isMobile = useMediaQuery('(max-width: 767px)')\nconst firstName = ref('')\nconst lastName = ref('')\nconst address = ref('')\nconst city = ref('')\nconst zip = ref('')\nconst state = ref('')\nconst phone = ref('')\nconst saveInfo = ref(false)\nconst email = ref('')\nconst emailOffers = ref(false)\nconst deliveryMethod = ref('standard')\nconst paymentMethod = ref('card')\nconst cardNumber = ref('')\nconst expiry = ref('')\nconst expYear = ref('')\nconst billingMatchesShipping = ref(true)\nconst billingAddress = ref('')\nconst billingCity = ref('')\nconst billingZip = ref('')\nconst billingState = ref('')\nconst cvc = ref('')\nconst cardName = ref('')\nconst promo = ref('')\nconst addGiftMessage = ref(false)\nconst giftTo = ref('')\nconst giftFrom = ref('')\nconst giftMessage = ref('')\nconst quantities = ref<Record<string, number>>({ '1': 1, '2': 1, '3': 1 })\nconst submitted = ref(false)\n\nconst shippingCost = computed(() => deliveryMethod.value === 'expedited' ? 9.95 : 4.95)\nconst orderTotal = computed(() => SUBTOTAL + shippingCost.value + TAX)\n\nconst errors = computed(() => submitted.value ? {\n firstName: !firstName.value.trim() ? 'Required' : undefined,\n lastName: !lastName.value.trim() ? 'Required' : undefined,\n address: !address.value.trim() ? 'Required' : undefined,\n city: !city.value.trim() ? 'Required' : undefined,\n zip: !zip.value.trim() ? 'Required' : undefined,\n state: !state.value ? 'Required' : undefined,\n email: !email.value.trim() ? 'Required' : undefined,\n phone: !phone.value.trim() ? 'Required' : undefined,\n expiry: !expiry.value ? 'Required' : undefined,\n expYear: !expYear.value ? 'Required' : undefined,\n cvc: !cvc.value.trim() ? 'Required' : undefined,\n cardNumber: paymentMethod.value === 'card' && !cardNumber.value.trim() ? 'Required' : undefined,\n cardName: paymentMethod.value === 'card' && !cardName.value.trim() ? 'Required' : undefined,\n billingAddress: !billingMatchesShipping.value && !billingAddress.value.trim() ? 'Required' : undefined,\n billingCity: !billingMatchesShipping.value && !billingCity.value.trim() ? 'Required' : undefined,\n billingZip: !billingMatchesShipping.value && !billingZip.value.trim() ? 'Required' : undefined,\n billingState: !billingMatchesShipping.value && !billingState.value ? 'Required' : undefined,\n} : {})\n<\/script>\n\n<template>\n <Layout height=\"fill\">\n <template #default>\n <LayoutContent :padding=\"0\">\n <Center axis=\"horizontal\">\n <Section variant=\"transparent\" :max-width=\"1100\" width=\"100%\" :padding=\"6\">\n <VStack :gap=\"5\">\n <VStack :gap=\"6\">\n <VStack :gap=\"2\">\n <Text type=\"display-1\" as=\"h1\">Payment Request</Text>\n <Text type=\"body\" color=\"secondary\">Review your order and complete your purchase. All transactions are secured with 256-bit SSL encryption.</Text>\n </VStack>\n <Divider />\n </VStack>\n <Stack :direction=\"isMobile ? 'vertical' : 'horizontal'\" :gap=\"8\" align=\"start\">\n <StackItem size=\"fill\" :style=\"isMobile ? undefined : formColBasis\">\n <VStack :gap=\"8\">\n <VStack :gap=\"1\">\n <HStack :gap=\"2\" justify=\"between\" align=\"center\">\n <Text type=\"large\" weight=\"bold\">Sign in to check out</Text>\n <Button label=\"Sign In\" variant=\"secondary\" size=\"sm\" />\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">Sign in to track your order and save your information for faster checkout.</Text>\n </VStack>\n <VStack :gap=\"3\">\n <Text type=\"large\" weight=\"bold\">Contact Information</Text>\n <TextInput v-model=\"email\" size=\"lg\" label=\"Email\" placeholder=\"you@example.com\" :status=\"errors.email ? { type: 'error', message: errors.email } : undefined\" />\n <CheckboxInput v-model=\"emailOffers\" label=\"Email me with news and offers\" />\n </VStack>\n <VStack :gap=\"3\">\n <Text type=\"large\" weight=\"bold\">Shipping Information</Text>\n <Grid :columns=\"2\" :gap=\"3\">\n <TextInput v-model=\"firstName\" size=\"lg\" label=\"First Name\" placeholder=\"John\" :status=\"errors.firstName ? { type: 'error', message: errors.firstName } : undefined\" />\n <TextInput v-model=\"lastName\" size=\"lg\" label=\"Last Name\" placeholder=\"Doe\" :status=\"errors.lastName ? { type: 'error', message: errors.lastName } : undefined\" />\n </Grid>\n <TextInput v-model=\"address\" size=\"lg\" label=\"Address\" placeholder=\"123 Main Street\" :status=\"errors.address ? { type: 'error', message: errors.address } : undefined\" />\n <Grid :columns=\"2\" :gap=\"3\">\n <TextInput v-model=\"city\" size=\"lg\" label=\"City\" placeholder=\"New York\" :status=\"errors.city ? { type: 'error', message: errors.city } : undefined\" />\n <TextInput v-model=\"zip\" size=\"lg\" label=\"ZIP Code\" placeholder=\"10001\" :status=\"errors.zip ? { type: 'error', message: errors.zip } : undefined\" />\n </Grid>\n <Selector v-model=\"state\" size=\"lg\" label=\"State\" placeholder=\"Select state\" :options=\"US_STATES\" :status=\"errors.state ? { type: 'error', message: errors.state } : undefined\" />\n <TextInput v-model=\"phone\" size=\"lg\" label=\"Phone Number\" placeholder=\"+1 (555) 123-4567\" label-tooltip=\"We use your phone number to provide shipping updates and contact you about your order if needed.\" :status=\"errors.phone ? { type: 'error', message: errors.phone } : undefined\" />\n <CheckboxInput v-model=\"saveInfo\" label=\"Save my information for a faster checkout\" />\n </VStack>\n <VStack :gap=\"3\">\n <VStack :gap=\"1\">\n <Text type=\"large\" weight=\"bold\">Delivery</Text>\n <Text type=\"supporting\" color=\"secondary\">Please allow 1–3 business days processing time before your order ships.</Text>\n </VStack>\n <RadioList v-model=\"deliveryMethod\" label=\"Delivery method\">\n <RadioListItem value=\"standard\" label=\"Standard (3–7 business days)\">\n <template #end><Text type=\"body\" weight=\"medium\">$4.95</Text></template>\n </RadioListItem>\n <RadioListItem value=\"expedited\" label=\"Expedited (1–2 business days)\">\n <template #end><Text type=\"body\" weight=\"medium\">$9.95</Text></template>\n </RadioListItem>\n </RadioList>\n </VStack>\n <VStack :gap=\"3\">\n <VStack :gap=\"1\">\n <Text type=\"large\" weight=\"bold\">Payment Method</Text>\n <Text type=\"supporting\" color=\"secondary\">All transactions are secure and encrypted.</Text>\n </VStack>\n <Grid :columns=\"2\" :gap=\"3\">\n <Button label=\"PayPal\" variant=\"primary\" size=\"lg\" :style=\"{ backgroundColor: '#FFC439', borderColor: '#FFC439' }\">\n <img src=\"https://www.paypalobjects.com/webstatic/mktg/Logo/pp-logo-100px.png\" alt=\"PayPal\" :style=\"brandLogo\">\n </Button>\n <Button label=\"Google Pay\" variant=\"primary\" size=\"lg\" :style=\"{ backgroundColor: '#000', borderColor: '#000' }\">\n <img src=\"https://pay.google.com/about/static_kcs/images/logos/google-pay-logo.svg\" alt=\"Google Pay\" :style=\"brandLogo\">\n </Button>\n </Grid>\n <HStack :gap=\"3\" align=\"center\">\n <StackItem size=\"fill\">\n <Divider />\n </StackItem>\n <Text type=\"supporting\" color=\"secondary\">OR</Text>\n <StackItem size=\"fill\">\n <Divider />\n </StackItem>\n </HStack>\n <HStack :gap=\"1.5\" align=\"center\">\n <img v-for=\"logo in CARD_LOGOS\" :key=\"logo.alt\" :src=\"logo.src\" :alt=\"logo.alt\" :style=\"cardLogo\">\n </HStack>\n <TextInput v-model=\"cardNumber\" size=\"lg\" label=\"Card Number\" placeholder=\"1234 5678 9012 3456\" :status=\"errors.cardNumber ? { type: 'error', message: errors.cardNumber } : undefined\" />\n <Grid :columns=\"3\" :gap=\"3\">\n <Selector v-model=\"expiry\" size=\"lg\" label=\"Expiry Month\" placeholder=\"MM\" :options=\"MONTHS\" :status=\"errors.expiry ? { type: 'error', message: errors.expiry } : undefined\" />\n <Selector v-model=\"expYear\" size=\"lg\" label=\"Expiry Year\" placeholder=\"YY\" :options=\"YEARS\" :status=\"errors.expYear ? { type: 'error', message: errors.expYear } : undefined\" />\n <TextInput v-model=\"cvc\" size=\"lg\" label=\"CVC\" placeholder=\"123\" label-tooltip=\"3-digit security code usually found on the back of your card. American Express cards have a 4-digit code located on the front.\" :status=\"errors.cvc ? { type: 'error', message: errors.cvc } : undefined\" />\n </Grid>\n <TextInput v-model=\"cardName\" size=\"lg\" label=\"Name on Card\" placeholder=\"John Doe\" :status=\"errors.cardName ? { type: 'error', message: errors.cardName } : undefined\" />\n <CheckboxInput v-model=\"billingMatchesShipping\" label=\"Use shipping address as billing address\" />\n <VStack v-if=\"!billingMatchesShipping\" :gap=\"3\">\n <TextInput v-model=\"billingAddress\" size=\"lg\" label=\"Address\" placeholder=\"123 Main Street\" :status=\"errors.billingAddress ? { type: 'error', message: errors.billingAddress } : undefined\" />\n <Grid :columns=\"2\" :gap=\"3\">\n <TextInput v-model=\"billingCity\" size=\"lg\" label=\"City\" placeholder=\"New York\" :status=\"errors.billingCity ? { type: 'error', message: errors.billingCity } : undefined\" />\n <TextInput v-model=\"billingZip\" size=\"lg\" label=\"ZIP Code\" placeholder=\"10001\" :status=\"errors.billingZip ? { type: 'error', message: errors.billingZip } : undefined\" />\n </Grid>\n <Selector v-model=\"billingState\" size=\"lg\" label=\"State\" placeholder=\"Select state\" :options=\"US_STATES\" :status=\"errors.billingState ? { type: 'error', message: errors.billingState } : undefined\" />\n </VStack>\n </VStack>\n <VStack :gap=\"3\">\n <Text type=\"large\" weight=\"bold\">Promo Code</Text>\n <HStack :gap=\"2\" align=\"center\">\n <StackItem size=\"fill\">\n <TextInput v-model=\"promo\" size=\"lg\" label=\"Promo code\" is-label-hidden placeholder=\"Enter promo code\" :style=\"fullWidth\" />\n </StackItem>\n <Button label=\"Apply\" variant=\"secondary\" size=\"lg\" />\n </HStack>\n </VStack>\n <VStack :gap=\"3\">\n <Text type=\"large\" weight=\"bold\">Gift Options</Text>\n <CheckboxInput v-model=\"addGiftMessage\" label=\"Add a gift message\" />\n <VStack v-if=\"addGiftMessage\" :gap=\"3\">\n <Grid :columns=\"2\" :gap=\"3\">\n <TextInput v-model=\"giftTo\" size=\"lg\" label=\"To\" is-label-hidden placeholder=\"To\" />\n <TextInput v-model=\"giftFrom\" size=\"lg\" label=\"From\" is-label-hidden placeholder=\"From\" />\n </Grid>\n <TextArea v-model=\"giftMessage\" label=\"Gift message\" is-label-hidden placeholder=\"Write something here\" />\n </VStack>\n </VStack>\n <VStack :gap=\"4\">\n <HStack :gap=\"5\" justify=\"center\" wrap=\"wrap\">\n <HStack :gap=\"1\" align=\"center\">\n <Icon icon=\"i-carbon-security\" size=\"sm\" color=\"secondary\" />\n <Text type=\"supporting\" color=\"secondary\">Secure Payment</Text>\n </HStack>\n <HStack :gap=\"1\" align=\"center\">\n <Icon icon=\"i-carbon-locked\" size=\"sm\" color=\"secondary\" />\n <Text type=\"supporting\" color=\"secondary\">SSL Encrypted</Text>\n </HStack>\n <HStack :gap=\"1\" align=\"center\">\n <Icon icon=\"i-carbon-checkmark-filled\" size=\"sm\" color=\"secondary\" />\n <Text type=\"supporting\" color=\"secondary\">Free Returns</Text>\n </HStack>\n </HStack>\n <VStack :gap=\"2\">\n <Button label=\"Place Order\" variant=\"primary\" size=\"lg\" :style=\"fullWidth\" @click=\"submitted = true\" />\n <Button label=\"Continue Shopping\" variant=\"secondary\" size=\"lg\" :style=\"fullWidth\" />\n </VStack>\n <Divider />\n <HStack :gap=\"4\" align=\"center\" wrap=\"wrap\">\n <Link v-for=\"policy in POLICIES\" :key=\"policy\" href=\"#\" type=\"supporting\">{{ policy }}</Link>\n </HStack>\n </VStack>\n </VStack>\n </StackItem>\n <StackItem size=\"fill\" :style=\"isMobile ? summaryMobileOrder : summarySticky\">\n <Card :padding=\"5\">\n <Collapsible trigger=\"Order Summary\" :default-is-open=\"true\">\n <VStack :gap=\"4\" :style=\"summaryContent\">\n <VStack v-for=\"item in ORDER_ITEMS\" :key=\"item.id\" :gap=\"3\">\n <HStack :gap=\"3\" align=\"start\">\n <Thumbnail :src=\"ITEM_IMAGES[item.id].src\" :alt=\"item.name\" />\n <StackItem size=\"fill\">\n <VStack :gap=\"1\">\n <HStack :gap=\"2\" justify=\"between\" align=\"start\">\n <HStack :gap=\"2\" align=\"center\">\n <Text type=\"body\" weight=\"medium\">{{ item.name }}</Text>\n <Badge v-if=\"item.limited\" variant=\"green\" label=\"LIMITED EDITION\" />\n </HStack>\n <Text type=\"body\" weight=\"bold\">{{ fmt(item.price) }}</Text>\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">{{ item.variant }}</Text>\n <HStack :gap=\"2\" align=\"center\">\n <span class=\"quantity-field\">\n <NumberInput v-model=\"quantities[item.id]\" label=\"Qty\" is-label-hidden :min=\"1\" :max=\"10\" is-integer-only />\n </span>\n <Link href=\"#\" @click.prevent=\"quantities[item.id] = 0\">Remove</Link>\n <Link href=\"#\">Save</Link>\n </HStack>\n </VStack>\n </StackItem>\n </HStack>\n <Divider />\n </VStack>\n <Text type=\"large\" weight=\"bold\">Order Total</Text>\n <HStack justify=\"between\"><Text color=\"secondary\">Subtotal</Text><Text>{{ fmt(SUBTOTAL) }}</Text></HStack>\n <HStack justify=\"between\"><Text color=\"secondary\">Shipping</Text><Text>{{ fmt(shippingCost) }}</Text></HStack>\n <HStack justify=\"between\"><Text color=\"secondary\">Tax</Text><Text>{{ fmt(TAX) }}</Text></HStack>\n <Divider />\n <HStack justify=\"between\"><Text type=\"large\" weight=\"bold\">Total</Text><Text type=\"large\" weight=\"bold\">{{ fmt(orderTotal) }}</Text></HStack>\n <Banner status=\"info\" title=\"Free shipping on orders over $300\">\n <template #icon><Icon icon=\"i-heroicons-truck\" size=\"sm\" /></template>\n </Banner>\n </VStack>\n </Collapsible>\n </Card>\n </StackItem>\n </Stack>\n </VStack>\n </Section>\n </Center>\n </LayoutContent>\n </template>\n </Layout>\n</template>\n\n<style scoped>\n.quantity-field {\n display: block;\n flex: none;\n width: 96px;\n}\n</style>\n",
77838
+ "source": "<script setup lang=\"ts\">\nimport type { CSSProperties } from 'vue'\nimport {\n Badge,\n Banner,\n Button,\n Card,\n Center,\n CheckboxInput,\n Collapsible,\n Divider,\n Grid,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n Link,\n NumberInput,\n RadioList,\n RadioListItem,\n Section,\n Selector,\n Stack,\n StackItem,\n Text,\n TextArea,\n TextInput,\n Thumbnail,\n useMediaQuery,\n VStack,\n} from '@astryx-vue/core'\nimport { computed, ref } from 'vue'\n\n// ── Constants ─────────────────────────────────────────────────────────────────\n\nconst MONTHS = [\n '01',\n '02',\n '03',\n '04',\n '05',\n '06',\n '07',\n '08',\n '09',\n '10',\n '11',\n '12',\n]\nconst YEARS = Array.from({ length: 12 }, (_, i) => String(2025 + i))\n\nconst US_STATES = [\n 'Alabama',\n 'Alaska',\n 'Arizona',\n 'Arkansas',\n 'California',\n 'Colorado',\n 'Connecticut',\n 'Delaware',\n 'Florida',\n 'Georgia',\n 'Hawaii',\n 'Idaho',\n 'Illinois',\n 'Indiana',\n 'Iowa',\n 'Kansas',\n 'Kentucky',\n 'Louisiana',\n 'Maine',\n 'Maryland',\n 'Massachusetts',\n 'Michigan',\n 'Minnesota',\n 'Mississippi',\n 'Missouri',\n 'Montana',\n 'Nebraska',\n 'Nevada',\n 'New Hampshire',\n 'New Jersey',\n 'New Mexico',\n 'New York',\n 'North Carolina',\n 'North Dakota',\n 'Ohio',\n 'Oklahoma',\n 'Oregon',\n 'Pennsylvania',\n 'Rhode Island',\n 'South Carolina',\n 'South Dakota',\n 'Tennessee',\n 'Texas',\n 'Utah',\n 'Vermont',\n 'Virginia',\n 'Washington',\n 'West Virginia',\n 'Wisconsin',\n 'Wyoming',\n]\n\n// Product photos from the local template-assets set (committed to the\n// docsite; the CLI swaps these for an inline placeholder on scaffold).\nconst ITEM_IMAGES: Record<string, { src: string }> = {\n 1: {\n src: '/template-assets/light-product-1.png',\n },\n 2: {\n src: '/template-assets/light-product-4.png',\n },\n 3: {\n src: '/template-assets/light-product-5.png',\n },\n}\n\nconst ORDER_ITEMS = [\n {\n id: '1',\n name: 'Speckled Stoneware Mug',\n variant: 'Hand-thrown · 12 oz',\n price: 78,\n qty: 1,\n limited: false,\n },\n {\n id: '2',\n name: 'Stoneware Dinner Plate',\n variant: 'Reactive glaze · 10 in',\n price: 72,\n qty: 1,\n limited: false,\n },\n {\n id: '3',\n name: 'Cereal Bowl',\n variant: 'Speckled clay · 6 in',\n price: 80,\n qty: 1,\n limited: true,\n },\n]\n\nconst SUBTOTAL = 230\n// SHIPPING is now computed from deliveryMethod state\nconst TAX = 18.4\n// TOTAL is computed dynamically based on delivery selection\nconst fmt = (n: number) => `$${n.toFixed(2)}`\n\nconst CARD_LOGOS = [\n { src: 'https://raw.githubusercontent.com/aaronfagan/svg-credit-card-payment-icons/main/flat/visa.svg', alt: 'Visa' },\n { src: 'https://raw.githubusercontent.com/aaronfagan/svg-credit-card-payment-icons/main/flat/mastercard.svg', alt: 'Mastercard' },\n { src: 'https://raw.githubusercontent.com/aaronfagan/svg-credit-card-payment-icons/main/flat/amex.svg', alt: 'Amex' },\n]\n\nconst POLICIES = ['Refund policy', 'Privacy policy', 'Terms of service', 'Cancellations']\n\n// ── Styles ────────────────────────────────────────────────────────────────────\n// Plain inline styles using Astryx design-token CSS variables (declared at\n// :root by `@astryxdesign/core/astryx.css`). No StyleX compiler required.\n\nconst fullWidth: CSSProperties = { width: '100%' }\n// Form column flex-basis so the two checkout columns share width evenly.\nconst formColBasis: CSSProperties = { flexBasis: 0 }\n// Space the Order Summary content below its collapsible trigger title.\nconst summaryContent: CSSProperties = { paddingBlockStart: 'var(--astryx-spacing-2)' }\n// Order-summary column: sticky beside the form on desktop.\nconst summarySticky: CSSProperties = {\n flexBasis: 0,\n position: 'sticky',\n top: 'var(--astryx-spacing-4)',\n alignSelf: 'flex-start',\n}\n// On mobile the summary moves above the form.\nconst summaryMobileOrder: CSSProperties = { order: -1 }\n// 快捷支付按钮里的品牌图形按字高缩放。\nconst brandLogo: CSSProperties = { height: 'var(--astryx-spacing-5)', width: 'auto' }\n// 卡组织图标是带描边的圆角小牌子。\nconst cardLogo: CSSProperties = {\n height: 'var(--astryx-spacing-7)',\n width: 'auto',\n borderRadius: 'var(--astryx-radius-element)',\n borderWidth: 'var(--astryx-border-width)',\n borderStyle: 'solid',\n borderColor: 'var(--astryx-color-border)',\n backgroundColor: 'var(--astryx-color-background-surface)',\n}\nconst isMobile = useMediaQuery('(max-width: 767px)')\nconst firstName = ref('')\nconst lastName = ref('')\nconst address = ref('')\nconst city = ref('')\nconst zip = ref('')\nconst state = ref('')\nconst phone = ref('')\nconst saveInfo = ref(false)\nconst email = ref('')\nconst emailOffers = ref(false)\nconst deliveryMethod = ref('standard')\nconst paymentMethod = ref('card')\nconst cardNumber = ref('')\nconst expiry = ref('')\nconst expYear = ref('')\nconst billingMatchesShipping = ref(true)\nconst billingAddress = ref('')\nconst billingCity = ref('')\nconst billingZip = ref('')\nconst billingState = ref('')\nconst cvc = ref('')\nconst cardName = ref('')\nconst promo = ref('')\nconst addGiftMessage = ref(false)\nconst giftTo = ref('')\nconst giftFrom = ref('')\nconst giftMessage = ref('')\nconst quantities = ref<Record<string, number>>({ 1: 1, 2: 1, 3: 1 })\nconst submitted = ref(false)\n\nconst shippingCost = computed(() => deliveryMethod.value === 'expedited' ? 9.95 : 4.95)\nconst orderTotal = computed(() => SUBTOTAL + shippingCost.value + TAX)\n\nconst errors = computed(() => submitted.value\n ? {\n firstName: !firstName.value.trim() ? 'Required' : undefined,\n lastName: !lastName.value.trim() ? 'Required' : undefined,\n address: !address.value.trim() ? 'Required' : undefined,\n city: !city.value.trim() ? 'Required' : undefined,\n zip: !zip.value.trim() ? 'Required' : undefined,\n state: !state.value ? 'Required' : undefined,\n email: !email.value.trim() ? 'Required' : undefined,\n phone: !phone.value.trim() ? 'Required' : undefined,\n expiry: !expiry.value ? 'Required' : undefined,\n expYear: !expYear.value ? 'Required' : undefined,\n cvc: !cvc.value.trim() ? 'Required' : undefined,\n cardNumber: paymentMethod.value === 'card' && !cardNumber.value.trim() ? 'Required' : undefined,\n cardName: paymentMethod.value === 'card' && !cardName.value.trim() ? 'Required' : undefined,\n billingAddress: !billingMatchesShipping.value && !billingAddress.value.trim() ? 'Required' : undefined,\n billingCity: !billingMatchesShipping.value && !billingCity.value.trim() ? 'Required' : undefined,\n billingZip: !billingMatchesShipping.value && !billingZip.value.trim() ? 'Required' : undefined,\n billingState: !billingMatchesShipping.value && !billingState.value ? 'Required' : undefined,\n }\n : {})\n<\/script>\n\n<template>\n <Layout height=\"fill\">\n <template #default>\n <LayoutContent :padding=\"0\">\n <Center axis=\"horizontal\">\n <Section variant=\"transparent\" :max-width=\"1100\" width=\"100%\" :padding=\"6\">\n <VStack :gap=\"5\">\n <VStack :gap=\"6\">\n <VStack :gap=\"2\">\n <Text type=\"display-1\" as=\"h1\">\n Payment Request\n </Text>\n <Text type=\"body\" color=\"secondary\">\n Review your order and complete your purchase. All transactions are secured with 256-bit SSL encryption.\n </Text>\n </VStack>\n <Divider />\n </VStack>\n <Stack :direction=\"isMobile ? 'vertical' : 'horizontal'\" :gap=\"8\" align=\"start\">\n <StackItem size=\"fill\" :style=\"isMobile ? undefined : formColBasis\">\n <VStack :gap=\"8\">\n <VStack :gap=\"1\">\n <HStack :gap=\"2\" justify=\"between\" align=\"center\">\n <Text type=\"large\" weight=\"bold\">\n Sign in to check out\n </Text>\n <Button label=\"Sign In\" variant=\"secondary\" size=\"sm\" />\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n Sign in to track your order and save your information for faster checkout.\n </Text>\n </VStack>\n <VStack :gap=\"3\">\n <Text type=\"large\" weight=\"bold\">\n Contact Information\n </Text>\n <TextInput v-model=\"email\" size=\"lg\" label=\"Email\" placeholder=\"you@example.com\" :status=\"errors.email ? { type: 'error', message: errors.email } : undefined\" />\n <CheckboxInput v-model=\"emailOffers\" label=\"Email me with news and offers\" />\n </VStack>\n <VStack :gap=\"3\">\n <Text type=\"large\" weight=\"bold\">\n Shipping Information\n </Text>\n <Grid :columns=\"2\" :gap=\"3\">\n <TextInput v-model=\"firstName\" size=\"lg\" label=\"First Name\" placeholder=\"John\" :status=\"errors.firstName ? { type: 'error', message: errors.firstName } : undefined\" />\n <TextInput v-model=\"lastName\" size=\"lg\" label=\"Last Name\" placeholder=\"Doe\" :status=\"errors.lastName ? { type: 'error', message: errors.lastName } : undefined\" />\n </Grid>\n <TextInput v-model=\"address\" size=\"lg\" label=\"Address\" placeholder=\"123 Main Street\" :status=\"errors.address ? { type: 'error', message: errors.address } : undefined\" />\n <Grid :columns=\"2\" :gap=\"3\">\n <TextInput v-model=\"city\" size=\"lg\" label=\"City\" placeholder=\"New York\" :status=\"errors.city ? { type: 'error', message: errors.city } : undefined\" />\n <TextInput v-model=\"zip\" size=\"lg\" label=\"ZIP Code\" placeholder=\"10001\" :status=\"errors.zip ? { type: 'error', message: errors.zip } : undefined\" />\n </Grid>\n <Selector v-model=\"state\" size=\"lg\" label=\"State\" placeholder=\"Select state\" :options=\"US_STATES\" :status=\"errors.state ? { type: 'error', message: errors.state } : undefined\" />\n <TextInput v-model=\"phone\" size=\"lg\" label=\"Phone Number\" placeholder=\"+1 (555) 123-4567\" label-tooltip=\"We use your phone number to provide shipping updates and contact you about your order if needed.\" :status=\"errors.phone ? { type: 'error', message: errors.phone } : undefined\" />\n <CheckboxInput v-model=\"saveInfo\" label=\"Save my information for a faster checkout\" />\n </VStack>\n <VStack :gap=\"3\">\n <VStack :gap=\"1\">\n <Text type=\"large\" weight=\"bold\">\n Delivery\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Please allow 1–3 business days processing time before your order ships.\n </Text>\n </VStack>\n <RadioList v-model=\"deliveryMethod\" label=\"Delivery method\">\n <RadioListItem value=\"standard\" label=\"Standard (3–7 business days)\">\n <template #end>\n <Text type=\"body\" weight=\"medium\">\n $4.95\n </Text>\n </template>\n </RadioListItem>\n <RadioListItem value=\"expedited\" label=\"Expedited (1–2 business days)\">\n <template #end>\n <Text type=\"body\" weight=\"medium\">\n $9.95\n </Text>\n </template>\n </RadioListItem>\n </RadioList>\n </VStack>\n <VStack :gap=\"3\">\n <VStack :gap=\"1\">\n <Text type=\"large\" weight=\"bold\">\n Payment Method\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n All transactions are secure and encrypted.\n </Text>\n </VStack>\n <Grid :columns=\"2\" :gap=\"3\">\n <Button label=\"PayPal\" variant=\"primary\" size=\"lg\" :style=\"{ backgroundColor: '#FFC439', borderColor: '#FFC439' }\">\n <img src=\"https://www.paypalobjects.com/webstatic/mktg/Logo/pp-logo-100px.png\" alt=\"PayPal\" :style=\"brandLogo\">\n </Button>\n <Button label=\"Google Pay\" variant=\"primary\" size=\"lg\" :style=\"{ backgroundColor: '#000', borderColor: '#000' }\">\n <img src=\"https://pay.google.com/about/static_kcs/images/logos/google-pay-logo.svg\" alt=\"Google Pay\" :style=\"brandLogo\">\n </Button>\n </Grid>\n <HStack :gap=\"3\" align=\"center\">\n <StackItem size=\"fill\">\n <Divider />\n </StackItem>\n <Text type=\"supporting\" color=\"secondary\">\n OR\n </Text>\n <StackItem size=\"fill\">\n <Divider />\n </StackItem>\n </HStack>\n <HStack :gap=\"1.5\" align=\"center\">\n <img v-for=\"logo in CARD_LOGOS\" :key=\"logo.alt\" :src=\"logo.src\" :alt=\"logo.alt\" :style=\"cardLogo\">\n </HStack>\n <TextInput v-model=\"cardNumber\" size=\"lg\" label=\"Card Number\" placeholder=\"1234 5678 9012 3456\" :status=\"errors.cardNumber ? { type: 'error', message: errors.cardNumber } : undefined\" />\n <Grid :columns=\"3\" :gap=\"3\">\n <Selector v-model=\"expiry\" size=\"lg\" label=\"Expiry Month\" placeholder=\"MM\" :options=\"MONTHS\" :status=\"errors.expiry ? { type: 'error', message: errors.expiry } : undefined\" />\n <Selector v-model=\"expYear\" size=\"lg\" label=\"Expiry Year\" placeholder=\"YY\" :options=\"YEARS\" :status=\"errors.expYear ? { type: 'error', message: errors.expYear } : undefined\" />\n <TextInput v-model=\"cvc\" size=\"lg\" label=\"CVC\" placeholder=\"123\" label-tooltip=\"3-digit security code usually found on the back of your card. American Express cards have a 4-digit code located on the front.\" :status=\"errors.cvc ? { type: 'error', message: errors.cvc } : undefined\" />\n </Grid>\n <TextInput v-model=\"cardName\" size=\"lg\" label=\"Name on Card\" placeholder=\"John Doe\" :status=\"errors.cardName ? { type: 'error', message: errors.cardName } : undefined\" />\n <CheckboxInput v-model=\"billingMatchesShipping\" label=\"Use shipping address as billing address\" />\n <VStack v-if=\"!billingMatchesShipping\" :gap=\"3\">\n <TextInput v-model=\"billingAddress\" size=\"lg\" label=\"Address\" placeholder=\"123 Main Street\" :status=\"errors.billingAddress ? { type: 'error', message: errors.billingAddress } : undefined\" />\n <Grid :columns=\"2\" :gap=\"3\">\n <TextInput v-model=\"billingCity\" size=\"lg\" label=\"City\" placeholder=\"New York\" :status=\"errors.billingCity ? { type: 'error', message: errors.billingCity } : undefined\" />\n <TextInput v-model=\"billingZip\" size=\"lg\" label=\"ZIP Code\" placeholder=\"10001\" :status=\"errors.billingZip ? { type: 'error', message: errors.billingZip } : undefined\" />\n </Grid>\n <Selector v-model=\"billingState\" size=\"lg\" label=\"State\" placeholder=\"Select state\" :options=\"US_STATES\" :status=\"errors.billingState ? { type: 'error', message: errors.billingState } : undefined\" />\n </VStack>\n </VStack>\n <VStack :gap=\"3\">\n <Text type=\"large\" weight=\"bold\">\n Promo Code\n </Text>\n <HStack :gap=\"2\" align=\"center\">\n <StackItem size=\"fill\">\n <TextInput v-model=\"promo\" size=\"lg\" label=\"Promo code\" is-label-hidden placeholder=\"Enter promo code\" :style=\"fullWidth\" />\n </StackItem>\n <Button label=\"Apply\" variant=\"secondary\" size=\"lg\" />\n </HStack>\n </VStack>\n <VStack :gap=\"3\">\n <Text type=\"large\" weight=\"bold\">\n Gift Options\n </Text>\n <CheckboxInput v-model=\"addGiftMessage\" label=\"Add a gift message\" />\n <VStack v-if=\"addGiftMessage\" :gap=\"3\">\n <Grid :columns=\"2\" :gap=\"3\">\n <TextInput v-model=\"giftTo\" size=\"lg\" label=\"To\" is-label-hidden placeholder=\"To\" />\n <TextInput v-model=\"giftFrom\" size=\"lg\" label=\"From\" is-label-hidden placeholder=\"From\" />\n </Grid>\n <TextArea v-model=\"giftMessage\" label=\"Gift message\" is-label-hidden placeholder=\"Write something here\" />\n </VStack>\n </VStack>\n <VStack :gap=\"4\">\n <HStack :gap=\"5\" justify=\"center\" wrap=\"wrap\">\n <HStack :gap=\"1\" align=\"center\">\n <Icon name=\"heroicons:shield-check\" size=\"sm\" color=\"secondary\" />\n <Text type=\"supporting\" color=\"secondary\">\n Secure Payment\n </Text>\n </HStack>\n <HStack :gap=\"1\" align=\"center\">\n <Icon name=\"heroicons:lock-closed\" size=\"sm\" color=\"secondary\" />\n <Text type=\"supporting\" color=\"secondary\">\n SSL Encrypted\n </Text>\n </HStack>\n <HStack :gap=\"1\" align=\"center\">\n <Icon name=\"heroicons:check-circle\" size=\"sm\" color=\"secondary\" />\n <Text type=\"supporting\" color=\"secondary\">\n Free Returns\n </Text>\n </HStack>\n </HStack>\n <VStack :gap=\"2\">\n <Button label=\"Place Order\" variant=\"primary\" size=\"lg\" :style=\"fullWidth\" @click=\"submitted = true\" />\n <Button label=\"Continue Shopping\" variant=\"secondary\" size=\"lg\" :style=\"fullWidth\" />\n </VStack>\n <Divider />\n <HStack :gap=\"4\" align=\"center\" wrap=\"wrap\">\n <Link v-for=\"policy in POLICIES\" :key=\"policy\" href=\"#\" size=\"sm\">\n {{ policy }}\n </Link>\n </HStack>\n </VStack>\n </VStack>\n </StackItem>\n <StackItem size=\"fill\" :style=\"isMobile ? summaryMobileOrder : summarySticky\">\n <Card :padding=\"5\">\n <Collapsible trigger=\"Order Summary\" :default-is-open=\"true\">\n <VStack :gap=\"4\" :style=\"summaryContent\">\n <VStack v-for=\"item in ORDER_ITEMS\" :key=\"item.id\" :gap=\"3\">\n <HStack :gap=\"3\" align=\"start\">\n <Thumbnail :src=\"ITEM_IMAGES[item.id].src\" :alt=\"item.name\" />\n <StackItem size=\"fill\">\n <VStack :gap=\"1\">\n <HStack :gap=\"2\" justify=\"between\" align=\"start\">\n <HStack :gap=\"2\" align=\"center\">\n <Text type=\"body\" weight=\"medium\">\n {{ item.name }}\n </Text>\n <Badge v-if=\"item.limited\" variant=\"green\" label=\"LIMITED EDITION\" />\n </HStack>\n <Text type=\"body\" weight=\"bold\">\n {{ fmt(item.price) }}\n </Text>\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n {{ item.variant }}\n </Text>\n <HStack :gap=\"2\" align=\"center\">\n <span class=\"quantity-field\">\n <NumberInput v-model=\"quantities[item.id]\" label=\"Qty\" is-label-hidden :min=\"1\" :max=\"10\" is-integer-only />\n </span>\n <Link size=\"sm\" href=\"#\" @click.prevent=\"quantities[item.id] = 0\">\n Remove\n </Link>\n <Link size=\"sm\" href=\"#\">\n Save\n </Link>\n </HStack>\n </VStack>\n </StackItem>\n </HStack>\n <Divider />\n </VStack>\n <VStack :gap=\"3\">\n <Text type=\"large\" weight=\"bold\">\n Order Total\n </Text>\n <VStack :gap=\"2\">\n <HStack justify=\"between\" align=\"center\">\n <Text type=\"body\" color=\"secondary\">\n Subtotal\n </Text>\n <Text type=\"body\">\n {{ fmt(SUBTOTAL) }}\n </Text>\n </HStack>\n <HStack justify=\"between\" align=\"center\">\n <Text type=\"body\" color=\"secondary\">\n Shipping\n </Text>\n <Text type=\"body\">\n {{ fmt(shippingCost) }}\n </Text>\n </HStack>\n <HStack justify=\"between\" align=\"center\">\n <Text type=\"body\" color=\"secondary\">\n Tax\n </Text>\n <Text type=\"body\">\n {{ fmt(TAX) }}\n </Text>\n </HStack>\n </VStack>\n <Divider />\n <HStack justify=\"between\" align=\"center\">\n <Text type=\"large\" weight=\"bold\">\n Total\n </Text>\n <Text type=\"large\" weight=\"bold\">\n {{ fmt(orderTotal) }}\n </Text>\n </HStack>\n <Banner status=\"info\" title=\"Free shipping on orders over $300\">\n <template #icon>\n <Icon name=\"heroicons:truck\" size=\"sm\" />\n </template>\n </Banner>\n </VStack>\n </VStack>\n </Collapsible>\n </Card>\n </StackItem>\n </Stack>\n </VStack>\n </Section>\n </Center>\n </LayoutContent>\n </template>\n </Layout>\n</template>\n\n<style scoped>\n.quantity-field {\n display: block;\n flex: none;\n /* 待 core 修复后去掉:NumberInput 原生 input 默认宽度比原版窄 52px,数量框整体补宽到原版的 164。 */\n width: 164px;\n}\n</style>\n",
77485
77839
  "docsUrl": {
77486
77840
  "en": "https://astryx-vue.pages.dev/en/playground/?template=payment-form",
77487
77841
  "zh": "https://astryx-vue.pages.dev/playground/?template=payment-form"
@@ -77530,6 +77884,7 @@ const catalog = {
77530
77884
  "Button",
77531
77885
  "Card",
77532
77886
  "CheckboxInput",
77887
+ "Collapsible",
77533
77888
  "Divider",
77534
77889
  "FieldStatus",
77535
77890
  "FormLayout",
@@ -77542,6 +77897,7 @@ const catalog = {
77542
77897
  "RadioList",
77543
77898
  "RadioListItem",
77544
77899
  "Selector",
77900
+ "Stack",
77545
77901
  "StackItem",
77546
77902
  "Step",
77547
77903
  "Stepper",
@@ -77553,7 +77909,7 @@ const catalog = {
77553
77909
  ],
77554
77910
  "dependencies": ["@astryx-vue/core", "vue"],
77555
77911
  "isReady": true,
77556
- "source": "<script setup lang=\"ts\">\nimport {\n Badge,\n Banner,\n Button,\n Card,\n CheckboxInput,\n Divider,\n FieldStatus,\n FormLayout,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n NumberInput,\n RadioList,\n RadioListItem,\n Selector,\n StackItem,\n Step,\n Stepper,\n Text,\n TextArea,\n TextInput,\n Thumbnail,\n useMediaQuery,\n VStack,\n} from '@astryx-vue/core'\nimport { computed, ref } from 'vue'\n\n// ── 数据 ──────────────────────────────────────────────────────────────────────\n\nconst STEPS = [\n { label: 'Cart', icon: 'i-carbon-shopping-cart' },\n { label: 'Shipping', icon: 'i-carbon-location' },\n { label: 'Delivery', icon: 'i-carbon-delivery-truck' },\n { label: 'Payment', icon: 'i-carbon-purchase' },\n]\n\nconst CART_ITEMS = [\n { id: 'mug', name: 'Speckled Stoneware Mug', variant: 'Hand-thrown · 12 oz', price: 78, qty: 2, src: '/template-assets/light-product-1.png', isLimited: false },\n { id: 'plate', name: 'Stoneware Dinner Plate', variant: 'Reactive glaze · 10 in', price: 72, qty: 1, src: '/template-assets/light-product-4.png', isLimited: false },\n { id: 'bowl', name: 'Cereal Bowl', variant: 'Speckled clay · 6 in', price: 80, qty: 1, src: '/template-assets/light-product-5.png', isLimited: true },\n]\n\nconst COUNTRIES = [\n { value: 'us', label: 'United States' },\n { value: 'ca', label: 'Canada' },\n { value: 'uk', label: 'United Kingdom' },\n]\n\nconst US_STATES = ['California', 'Illinois', 'Massachusetts', 'New York', 'Oregon', 'Texas', 'Washington']\n\nconst DELIVERY_OPTIONS = [\n { value: 'standard', label: 'Standard', window: '3–7 business days', price: 4.95 },\n { value: 'expedited', label: 'Expedited', window: '1–2 business days', price: 9.95 },\n { value: 'overnight', label: 'Overnight', window: 'Next business day by 5pm', price: 24.95 },\n]\n\nconst PROMOS: Record<string, { label: string, rate: number }> = {\n WELCOME10: { label: 'WELCOME10 · 10% off', rate: 0.1 },\n CLAY20: { label: 'CLAY20 · 20% off', rate: 0.2 },\n}\n\nconst TAX_RATE = 0.08\nconst FREE_SHIPPING_THRESHOLD = 300\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$/\n\nconst POSTAL_CODE_RULES: Record<string, { label: string, placeholder: string, pattern: RegExp, error: string }> = {\n us: { label: 'ZIP code', placeholder: '10001', pattern: /^\\d{5}(?:-\\d{4})?$/, error: 'Enter a 5-digit ZIP code, with an optional 4-digit suffix.' },\n ca: { label: 'Postal code', placeholder: 'K1A 0B1', pattern: /^[ABCEGHJ-NPRSTVXY]\\d[ABCEGHJ-NPRSTV-Z][ -]?\\d[ABCEGHJ-NPRSTV-Z]\\d$/i, error: 'Enter a Canadian postal code, for example K1A 0B1.' },\n uk: { label: 'Postcode', placeholder: 'SW1A 1AA', pattern: /^(?:GIR\\s?0AA|[A-Z]{1,2}\\d[A-Z\\d]?\\s*\\d[A-Z]{2})$/i, error: 'Enter a UK postcode, for example SW1A 1AA.' },\n}\n\nfunction money(n: number) {\n return `$${n.toFixed(2)}`\n}\n\n// ── 状态 ──────────────────────────────────────────────────────────────────────\n\nconst isNarrow = useMediaQuery('(max-width: 900px)')\n\nconst step = ref(0)\nconst attempted = ref<ReadonlySet<number>>(new Set())\nconst isComplete = ref(false)\n\nconst quantities = ref<Record<string, number>>(Object.fromEntries(CART_ITEMS.map(item => [item.id, item.qty])))\nconst promoInput = ref('')\nconst promoWasChecked = ref(false)\nconst appliedPromo = ref<string | null>(null)\n\nconst email = ref('')\nconst firstName = ref('')\nconst lastName = ref('')\nconst address = ref('')\nconst apartment = ref('')\nconst city = ref('')\nconst state = ref('')\nconst postalCode = ref('')\nconst country = ref('us')\n\nconst delivery = ref('standard')\nconst deliveryNote = ref('')\nconst isGift = ref(false)\n\nconst cardNumber = ref('')\nconst cardName = ref('')\nconst expiry = ref('')\nconst cvc = ref('')\nconst billingMatches = ref(true)\n\nconst items = computed(() =>\n CART_ITEMS\n .map(item => ({ ...item, qty: quantities.value[item.id] ?? item.qty }))\n .filter(item => item.qty > 0),\n)\n\nconst subtotal = computed(() => items.value.reduce((sum, item) => sum + item.price * item.qty, 0))\nconst promo = computed(() => (appliedPromo.value ? PROMOS[appliedPromo.value] : undefined))\nconst discount = computed(() => (promo.value ? subtotal.value * promo.value.rate : 0))\nconst deliveryOption = computed(() => DELIVERY_OPTIONS.find(o => o.value === delivery.value) ?? DELIVERY_OPTIONS[0])\nconst shipping = computed(() =>\n subtotal.value - discount.value >= FREE_SHIPPING_THRESHOLD && delivery.value === 'standard'\n ? 0\n : deliveryOption.value.price,\n)\nconst tax = computed(() => (subtotal.value - discount.value) * TAX_RATE)\nconst total = computed(() => subtotal.value - discount.value + shipping.value + tax.value)\nconst itemCount = computed(() => items.value.reduce((sum, item) => sum + item.qty, 0))\n\nconst postalRule = computed(() => POSTAL_CODE_RULES[country.value] ?? POSTAL_CODE_RULES.us)\n\nconst errorsByStep = computed<Array<Record<string, string>>>(() => {\n const cart: Record<string, string> = {}\n if (items.value.length === 0)\n cart.items = 'Your cart is empty.'\n\n const shippingStep: Record<string, string> = {}\n if (!EMAIL_PATTERN.test(email.value))\n shippingStep.email = 'Enter an email address for order updates.'\n if (!firstName.value.trim())\n shippingStep.firstName = 'Required'\n if (!lastName.value.trim())\n shippingStep.lastName = 'Required'\n if (!address.value.trim())\n shippingStep.address = 'Required'\n if (!city.value.trim())\n shippingStep.city = 'Required'\n if (country.value === 'us' && !state.value)\n shippingStep.state = 'Required'\n if (!postalRule.value.pattern.test(postalCode.value.trim()))\n shippingStep.postalCode = postalRule.value.error\n\n const deliveryStep: Record<string, string> = {}\n if (delivery.value === 'overnight' && country.value !== 'us')\n deliveryStep.delivery = 'Overnight delivery is only available in the US.'\n\n const payment: Record<string, string> = {}\n if (cardNumber.value.replace(/\\s/g, '').length < 15)\n payment.cardNumber = 'Enter the full card number.'\n if (!cardName.value.trim())\n payment.cardName = 'Enter the name printed on the card.'\n if (!/^\\d{2}\\s?\\/\\s?\\d{2}$/.test(expiry.value.trim()))\n payment.expiry = 'Use MM/YY.'\n if (!/^\\d{3,4}$/.test(cvc.value.trim()))\n payment.cvc = '3 or 4 digits.'\n\n return [cart, shippingStep, deliveryStep, payment]\n})\n\nfunction shownErrors(index: number): Record<string, string> {\n return attempted.value.has(index) ? errorsByStep.value[index] : {}\n}\n\nconst currentErrors = computed(() => shownErrors(step.value))\nconst isLastStep = computed(() => step.value === STEPS.length - 1)\n\nfunction markAttempted(indices: number[]) {\n const next = new Set(attempted.value)\n for (const index of indices) next.add(index)\n attempted.value = next\n}\n\nfunction goNext() {\n markAttempted([step.value])\n if (Object.keys(errorsByStep.value[step.value]).length > 0)\n return\n if (isLastStep.value) {\n isComplete.value = true\n return\n }\n step.value += 1\n}\n\n/** 往前跳时先检查中间的步骤,遇到第一个有问题的就停在那里。 */\nfunction goTo(index: number) {\n if (index <= step.value) {\n step.value = index\n return\n }\n const checked: number[] = []\n let firstInvalid: number | null = null\n for (let candidate = step.value; candidate < index; candidate += 1) {\n checked.push(candidate)\n if (Object.keys(errorsByStep.value[candidate]).length > 0) {\n firstInvalid = candidate\n break\n }\n }\n markAttempted(checked)\n step.value = firstInvalid ?? index\n}\n\nfunction onPromoInput(value: string) {\n promoInput.value = value\n promoWasChecked.value = false\n appliedPromo.value = null\n}\n\nfunction applyPromo() {\n const code = promoInput.value.trim().toUpperCase()\n promoWasChecked.value = code.length > 0\n appliedPromo.value = PROMOS[code] ? code : null\n}\n\nconst promoStatus = computed<{ type: 'success' | 'warning', message: string } | undefined>(() => {\n if (appliedPromo.value)\n return { type: 'success', message: `${PROMOS[appliedPromo.value].label} applied.` }\n if (promoInput.value.trim() && promoWasChecked.value)\n return { type: 'warning', message: 'That promo code is not valid.' }\n if (promoInput.value.trim())\n return { type: 'warning', message: 'Press Apply to check this code.' }\n return undefined\n})\n\nfunction blockedMessage(count: number) {\n return count === 1 ? 'One problem above needs fixing first.' : `${count} problems above need fixing first.`\n}\n\nfunction setQuantity(id: string, value: number | null) {\n quantities.value = { ...quantities.value, [id]: value ?? 0 }\n}\n\nfunction isFreeDelivery(optionValue: string) {\n return optionValue === 'standard' && subtotal.value - discount.value >= FREE_SHIPPING_THRESHOLD\n}\n<\/script>\n\n<template>\n <Layout height=\"fill\" :padding=\"6\" :content-width=\"1000\">\n <LayoutContent>\n <VStack :gap=\"6\">\n <HStack :gap=\"3\" align=\"center\">\n <StackItem size=\"fill\">\n <Heading :level=\"1\">\n Checkout\n </Heading>\n </StackItem>\n <HStack :gap=\"1.5\" align=\"center\">\n <Icon icon=\"i-carbon-locked\" size=\"sm\" color=\"secondary\" />\n <Text type=\"supporting\" color=\"secondary\">\n Secure checkout\n </Text>\n </HStack>\n </HStack>\n\n <HStack :gap=\"6\" align=\"start\" wrap=\"wrap\">\n <StackItem size=\"fill\">\n <VStack :gap=\"6\">\n <Banner\n v-if=\"isComplete\"\n status=\"success\"\n title=\"Order confirmed\"\n :description=\"`This local example completed an order for ${money(total)}. No payment was sent.`\"\n />\n <VStack v-if=\"isComplete\" :gap=\"4\">\n <Heading :level=\"2\">\n Thanks for your order\n </Heading>\n <Text type=\"body\" color=\"secondary\">\n The completion is stored in this page's component state so the template ends in a real, reviewable outcome.\n </Text>\n </VStack>\n\n <Stepper\n v-if=\"!isComplete\"\n :active-step=\"step\"\n orientation=\"horizontal\"\n density=\"balanced\"\n label=\"Checkout progress\"\n @step-click=\"goTo\"\n >\n <Step\n v-for=\"({ label, icon }, i) in STEPS\"\n :key=\"label\"\n :step=\"i\"\n :label=\"label\"\n :status=\"Object.keys(shownErrors(i)).length ? 'error' : undefined\"\n >\n <template #icon>\n <Icon :icon=\"Object.keys(shownErrors(i)).length ? 'error' : icon\" size=\"sm\" />\n </template>\n </Step>\n </Stepper>\n\n <VStack v-if=\"!isComplete && step === 0\" :gap=\"5\">\n <Heading :level=\"2\">\n Review your cart\n </Heading>\n <VStack :gap=\"4\">\n <VStack v-for=\"item in items\" :key=\"item.id\" :gap=\"3\">\n <HStack :gap=\"4\" align=\"start\" wrap=\"wrap\">\n <Thumbnail :src=\"item.src\" :alt=\"item.name\" />\n <StackItem size=\"fill\">\n <VStack :gap=\"1\">\n <HStack :gap=\"2\" align=\"start\" justify=\"between\">\n <HStack :gap=\"2\" align=\"center\" wrap=\"wrap\">\n <Text type=\"body\" weight=\"medium\">\n {{ item.name }}\n </Text>\n <Badge v-if=\"item.isLimited\" variant=\"green\" label=\"Limited\" />\n </HStack>\n <Text type=\"body\" weight=\"bold\">\n {{ money(item.price * item.qty) }}\n </Text>\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n {{ item.variant }} · {{ money(item.price) }} each\n </Text>\n <HStack :gap=\"2\" align=\"center\">\n <span class=\"quantity-field\">\n <NumberInput\n :model-value=\"item.qty\"\n :label=\"`Quantity of ${item.name}`\"\n is-label-hidden\n :min=\"0\"\n :max=\"10\"\n is-integer-only\n has-number-steppers\n @update:model-value=\"setQuantity(item.id, $event)\"\n />\n </span>\n <Button label=\"Remove\" variant=\"ghost\" @click=\"setQuantity(item.id, 0)\" />\n </HStack>\n </VStack>\n </StackItem>\n </HStack>\n <Divider />\n </VStack>\n </VStack>\n <FieldStatus\n v-if=\"currentErrors.items\"\n type=\"error\"\n variant=\"detached\"\n :message=\"currentErrors.items\"\n />\n <HStack :gap=\"2\" align=\"end\">\n <StackItem size=\"fill\">\n <TextInput\n :model-value=\"promoInput\"\n label=\"Promo code\"\n placeholder=\"WELCOME10\"\n :status=\"promoStatus\"\n @update:model-value=\"onPromoInput($event)\"\n />\n </StackItem>\n <Button label=\"Apply\" variant=\"secondary\" @click=\"applyPromo\" />\n </HStack>\n </VStack>\n\n <VStack v-if=\"!isComplete && step === 1\" :gap=\"5\">\n <VStack :gap=\"1\">\n <Heading :level=\"2\">\n Where should it go?\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Delivery options and tax are calculated from this address.\n </Text>\n </VStack>\n <FormLayout default-optionality=\"required\">\n <TextInput\n v-model=\"email\"\n label=\"Email\"\n placeholder=\"you@example.com\"\n description=\"Order confirmation and tracking go here.\"\n :status=\"currentErrors.email ? { type: 'error', message: currentErrors.email } : undefined\"\n />\n <FormLayout :direction=\"isNarrow ? 'vertical' : 'horizontal'\" default-optionality=\"required\">\n <TextInput\n v-model=\"firstName\"\n label=\"First name\"\n :status=\"currentErrors.firstName ? { type: 'error', message: currentErrors.firstName } : undefined\"\n />\n <TextInput\n v-model=\"lastName\"\n label=\"Last name\"\n :status=\"currentErrors.lastName ? { type: 'error', message: currentErrors.lastName } : undefined\"\n />\n </FormLayout>\n <Selector v-model=\"country\" label=\"Country\" :options=\"COUNTRIES\" />\n <TextInput\n v-model=\"address\"\n label=\"Street address\"\n placeholder=\"123 Main Street\"\n :status=\"currentErrors.address ? { type: 'error', message: currentErrors.address } : undefined\"\n />\n <TextInput v-model=\"apartment\" label=\"Apartment, suite, etc.\" is-optional />\n <FormLayout :direction=\"isNarrow ? 'vertical' : 'horizontal'\" default-optionality=\"required\">\n <TextInput\n v-model=\"city\"\n label=\"City\"\n :status=\"currentErrors.city ? { type: 'error', message: currentErrors.city } : undefined\"\n />\n <Selector\n v-if=\"country === 'us'\"\n v-model=\"state\"\n label=\"State\"\n placeholder=\"Select state\"\n :options=\"US_STATES\"\n searchable\n :status=\"currentErrors.state ? 'error' : undefined\"\n :status-message=\"currentErrors.state\"\n />\n <TextInput\n v-model=\"postalCode\"\n :label=\"postalRule.label\"\n :placeholder=\"postalRule.placeholder\"\n :status=\"currentErrors.postalCode ? { type: 'error', message: currentErrors.postalCode } : undefined\"\n />\n </FormLayout>\n </FormLayout>\n </VStack>\n\n <VStack v-if=\"!isComplete && step === 2\" :gap=\"5\">\n <VStack :gap=\"1\">\n <Heading :level=\"2\">\n How fast do you need it?\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Estimates run from the day the order ships, usually 1–3 business days after you place it.\n </Text>\n </VStack>\n <FormLayout default-optionality=\"required\">\n <RadioList\n v-model=\"delivery\"\n label=\"Delivery method\"\n :status=\"currentErrors.delivery ? { type: 'error', message: currentErrors.delivery } : undefined\"\n >\n <RadioListItem\n v-for=\"option in DELIVERY_OPTIONS\"\n :key=\"option.value\"\n :value=\"option.value\"\n :label=\"option.label\"\n :description=\"option.window\"\n >\n <template #end>\n <Text type=\"body\" weight=\"medium\">\n {{ isFreeDelivery(option.value) ? 'Free' : money(option.price) }}\n </Text>\n </template>\n </RadioListItem>\n </RadioList>\n <Divider />\n <CheckboxInput v-model=\"isGift\" label=\"This is a gift\" />\n <TextArea\n v-model=\"deliveryNote\"\n :label=\"isGift ? 'Gift message' : 'Delivery instructions'\"\n is-optional\n :rows=\"3\"\n :max-length=\"240\"\n :placeholder=\"isGift ? 'Happy birthday — hope these get plenty of use.' : 'Leave with the neighbour at number 14.'\"\n :description=\"isGift ? 'Printed on a card. Prices are left off the packing slip.' : 'Passed to the carrier where they support it.'\"\n />\n </FormLayout>\n </VStack>\n\n <VStack v-if=\"!isComplete && step === 3\" :gap=\"5\">\n <VStack :gap=\"1\">\n <Heading :level=\"2\">\n Payment\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n This template validates payment details and completes locally. No card is charged.\n </Text>\n </VStack>\n <FormLayout default-optionality=\"required\">\n <TextInput\n v-model=\"cardNumber\"\n label=\"Card number\"\n placeholder=\"4242 4242 4242 4242\"\n :status=\"currentErrors.cardNumber ? { type: 'error', message: currentErrors.cardNumber } : undefined\"\n />\n <TextInput\n v-model=\"cardName\"\n label=\"Name on card\"\n :status=\"currentErrors.cardName ? { type: 'error', message: currentErrors.cardName } : undefined\"\n />\n <HStack :gap=\"4\" wrap=\"wrap\">\n <TextInput\n v-model=\"expiry\"\n label=\"Expiry\"\n :width=\"140\"\n placeholder=\"MM/YY\"\n :status=\"currentErrors.expiry ? { type: 'error', message: currentErrors.expiry } : undefined\"\n />\n <TextInput\n v-model=\"cvc\"\n label=\"CVC\"\n :width=\"140\"\n placeholder=\"123\"\n label-tooltip=\"Three digits on the back of the card, or four on the front for American Express.\"\n :status=\"currentErrors.cvc ? { type: 'error', message: currentErrors.cvc } : undefined\"\n />\n </HStack>\n <Divider />\n <CheckboxInput v-model=\"billingMatches\" label=\"Billing address matches the shipping address\" />\n <Banner\n v-if=\"!billingMatches\"\n status=\"info\"\n title=\"Connect a billing-address step\"\n description=\"This local example does not collect a separate billing address or send payment. Add that flow with your payment provider.\"\n />\n </FormLayout>\n </VStack>\n\n <VStack v-if=\"!isComplete\" :gap=\"3\" h-align=\"start\">\n <FieldStatus\n v-if=\"Object.keys(currentErrors).length > 0\"\n type=\"error\"\n variant=\"detached\"\n :message=\"blockedMessage(Object.keys(currentErrors).length)\"\n />\n <HStack :gap=\"3\" align=\"center\" width=\"100%\">\n <Button\n :label=\"step === 0 ? 'Continue shopping' : 'Back'\"\n variant=\"secondary\"\n @click=\"step = Math.max(0, step - 1)\"\n />\n <StackItem size=\"fill\">\n <Button\n :label=\"isLastStep ? `Pay ${money(total)}` : 'Continue'\"\n variant=\"primary\"\n width=\"100%\"\n @click=\"goNext\"\n />\n </StackItem>\n </HStack>\n </VStack>\n </VStack>\n </StackItem>\n\n <StackItem v-if=\"!isNarrow\" :style=\"{ width: '360px', flexShrink: 0, alignSelf: 'flex-start' }\">\n <Card :padding=\"5\">\n <VStack :gap=\"4\">\n <HStack :gap=\"2\" align=\"center\" justify=\"between\">\n <Text type=\"label\">\n Order summary\n </Text>\n <Badge variant=\"neutral\" :label=\"`${itemCount} ${itemCount === 1 ? 'item' : 'items'}`\" />\n </HStack>\n <VStack :gap=\"3\">\n <HStack v-for=\"item in items\" :key=\"item.id\" :gap=\"3\" align=\"start\">\n <Thumbnail :src=\"item.src\" :alt=\"item.name\" />\n <StackItem size=\"fill\">\n <VStack :gap=\"0.5\">\n <HStack :gap=\"2\" align=\"start\" justify=\"between\">\n <Text type=\"body\" weight=\"medium\">\n {{ item.name }}\n </Text>\n <Text type=\"body\" weight=\"bold\">\n {{ money(item.price * item.qty) }}\n </Text>\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n {{ item.variant }} · Qty {{ item.qty }}\n </Text>\n </VStack>\n </StackItem>\n </HStack>\n </VStack>\n <Divider />\n <VStack :gap=\"2\">\n <HStack justify=\"between\" align=\"center\">\n <Text type=\"body\" color=\"secondary\">\n Subtotal\n </Text>\n <Text type=\"body\">\n {{ money(subtotal) }}\n </Text>\n </HStack>\n <HStack v-if=\"promo\" justify=\"between\" align=\"center\">\n <Text type=\"body\" color=\"secondary\">\n {{ promo.label }}\n </Text>\n <Text type=\"body\">\n −{{ money(discount) }}\n </Text>\n </HStack>\n <HStack justify=\"between\" align=\"center\">\n <Text type=\"body\" color=\"secondary\">\n {{ deliveryOption.label }} delivery\n </Text>\n <Text type=\"body\">\n {{ shipping === 0 ? 'Free' : money(shipping) }}\n </Text>\n </HStack>\n <HStack justify=\"between\" align=\"center\">\n <Text type=\"body\" color=\"secondary\">\n Estimated tax\n </Text>\n <Text type=\"body\">\n {{ money(tax) }}\n </Text>\n </HStack>\n <Divider />\n <HStack justify=\"between\" align=\"center\">\n <Text type=\"large\" weight=\"bold\">\n Total\n </Text>\n <Text type=\"large\" weight=\"bold\">\n {{ money(total) }}\n </Text>\n </HStack>\n </VStack>\n <Banner\n v-if=\"subtotal - discount < FREE_SHIPPING_THRESHOLD\"\n status=\"info\"\n :title=\"`${money(FREE_SHIPPING_THRESHOLD - (subtotal - discount))} away from free standard shipping`\"\n />\n </VStack>\n </Card>\n </StackItem>\n </HStack>\n </VStack>\n </LayoutContent>\n </Layout>\n</template>\n\n<style scoped>\n.quantity-field {\n display: block;\n flex: none;\n width: 96px;\n}\n</style>\n",
77912
+ "source": "<script setup lang=\"ts\">\nimport type { ComponentPublicInstance } from 'vue'\nimport {\n Badge,\n Banner,\n Button,\n Card,\n CheckboxInput,\n Collapsible,\n Divider,\n FieldStatus,\n FormLayout,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n NumberInput,\n RadioList,\n RadioListItem,\n Selector,\n Stack,\n StackItem,\n Step,\n Stepper,\n Text,\n TextArea,\n TextInput,\n Thumbnail,\n VStack,\n} from '@astryx-vue/core'\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\n\n// ── 数据 ──────────────────────────────────────────────────────────────────────\n\nconst STEPS = [\n { label: 'Cart', icon: 'heroicons:shopping-cart' },\n { label: 'Shipping', icon: 'heroicons:map-pin' },\n { label: 'Delivery', icon: 'heroicons:truck' },\n { label: 'Payment', icon: 'heroicons:credit-card' },\n]\n\nconst CART_ITEMS = [\n { id: 'mug', name: 'Speckled Stoneware Mug', variant: 'Hand-thrown · 12 oz', price: 78, qty: 2, src: '/template-assets/light-product-1.png', isLimited: false },\n { id: 'plate', name: 'Stoneware Dinner Plate', variant: 'Reactive glaze · 10 in', price: 72, qty: 1, src: '/template-assets/light-product-4.png', isLimited: false },\n { id: 'bowl', name: 'Cereal Bowl', variant: 'Speckled clay · 6 in', price: 80, qty: 1, src: '/template-assets/light-product-5.png', isLimited: true },\n]\n\nconst COUNTRIES = [\n { value: 'us', label: 'United States' },\n { value: 'ca', label: 'Canada' },\n { value: 'uk', label: 'United Kingdom' },\n]\n\nconst US_STATES = ['California', 'Illinois', 'Massachusetts', 'New York', 'Oregon', 'Texas', 'Washington']\n\nconst DELIVERY_OPTIONS = [\n { value: 'standard', label: 'Standard', window: '3–7 business days', price: 4.95 },\n { value: 'expedited', label: 'Expedited', window: '1–2 business days', price: 9.95 },\n { value: 'overnight', label: 'Overnight', window: 'Next business day by 5pm', price: 24.95 },\n]\n\nconst PROMOS: Record<string, { label: string, rate: number }> = {\n WELCOME10: { label: 'WELCOME10 · 10% off', rate: 0.1 },\n CLAY20: { label: 'CLAY20 · 20% off', rate: 0.2 },\n}\n\nconst TAX_RATE = 0.08\nconst FREE_SHIPPING_THRESHOLD = 300\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@][^\\s.@]*\\.[^\\s@]{2,}$/\n\nconst POSTAL_CODE_RULES: Record<string, { label: string, placeholder: string, pattern: RegExp, error: string }> = {\n us: { label: 'ZIP code', placeholder: '10001', pattern: /^\\d{5}(?:-\\d{4})?$/, error: 'Enter a 5-digit ZIP code, with an optional 4-digit suffix.' },\n ca: { label: 'Postal code', placeholder: 'K1A 0B1', pattern: /^[ABCEGHJ-NPRSTVXY]\\d[ABCEGHJ-NPRSTV-Z][ -]?\\d[ABCEGHJ-NPRSTV-Z]\\d$/i, error: 'Enter a Canadian postal code, for example K1A 0B1.' },\n uk: { label: 'Postcode', placeholder: 'SW1A 1AA', pattern: /^(?:GIR\\s?0AA|[A-Z]{1,2}\\d[A-Z\\d]?\\s*\\d[A-Z]{2})$/i, error: 'Enter a UK postcode, for example SW1A 1AA.' },\n}\n\nfunction money(n: number) {\n return `$${n.toFixed(2)}`\n}\n\n// ── 状态 ──────────────────────────────────────────────────────────────────────\n\nconst NARROW_HOST_WIDTH = 900\nconst hostRef = ref<ComponentPublicInstance | null>(null)\nconst isNarrow = ref(false)\nlet hostObserver: ResizeObserver | undefined\n\n// 窄版按预览容器的宽度判断,而不是浏览器窗口宽度:嵌在窄预览里的页面窗口再宽也要走窄版。\nwatch(hostRef, (instance) => {\n hostObserver?.disconnect()\n const host = instance?.$el as HTMLElement | undefined\n if (!host)\n return\n if (host.clientWidth > 0)\n isNarrow.value = host.clientWidth <= NARROW_HOST_WIDTH\n hostObserver = new ResizeObserver((entries) => {\n const width = entries[0]?.contentRect.width ?? host.clientWidth\n if (width > 0)\n isNarrow.value = width <= NARROW_HOST_WIDTH\n })\n hostObserver.observe(host)\n})\nonBeforeUnmount(() => hostObserver?.disconnect())\n\nconst step = ref(0)\nconst attempted = ref<ReadonlySet<number>>(new Set())\nconst isComplete = ref(false)\n\nconst quantities = ref<Record<string, number>>(Object.fromEntries(CART_ITEMS.map(item => [item.id, item.qty])))\nconst promoInput = ref('')\nconst promoWasChecked = ref(false)\nconst appliedPromo = ref<string | null>(null)\n\nconst email = ref('')\nconst firstName = ref('')\nconst lastName = ref('')\nconst address = ref('')\nconst apartment = ref('')\nconst city = ref('')\nconst state = ref('')\nconst postalCode = ref('')\nconst country = ref('us')\n\nconst delivery = ref('standard')\nconst deliveryNote = ref('')\nconst isGift = ref(false)\n\nconst cardNumber = ref('')\nconst cardName = ref('')\nconst expiry = ref('')\nconst cvc = ref('')\nconst billingMatches = ref(true)\n\nconst items = computed(() =>\n CART_ITEMS\n .map(item => ({ ...item, qty: quantities.value[item.id] ?? item.qty }))\n .filter(item => item.qty > 0),\n)\n\nconst subtotal = computed(() => items.value.reduce((sum, item) => sum + item.price * item.qty, 0))\nconst promo = computed(() => (appliedPromo.value ? PROMOS[appliedPromo.value] : undefined))\nconst discount = computed(() => (promo.value ? subtotal.value * promo.value.rate : 0))\nconst deliveryOption = computed(() => DELIVERY_OPTIONS.find(o => o.value === delivery.value) ?? DELIVERY_OPTIONS[0])\nconst shipping = computed(() =>\n subtotal.value - discount.value >= FREE_SHIPPING_THRESHOLD && delivery.value === 'standard'\n ? 0\n : deliveryOption.value.price,\n)\nconst tax = computed(() => (subtotal.value - discount.value) * TAX_RATE)\nconst total = computed(() => subtotal.value - discount.value + shipping.value + tax.value)\nconst itemCount = computed(() => items.value.reduce((sum, item) => sum + item.qty, 0))\n\nconst postalRule = computed(() => POSTAL_CODE_RULES[country.value] ?? POSTAL_CODE_RULES.us)\n\nconst errorsByStep = computed<Array<Record<string, string>>>(() => {\n const cart: Record<string, string> = {}\n if (items.value.length === 0)\n cart.items = 'Your cart is empty.'\n\n const shippingStep: Record<string, string> = {}\n if (!EMAIL_PATTERN.test(email.value))\n shippingStep.email = 'Enter an email address for order updates.'\n if (!firstName.value.trim())\n shippingStep.firstName = 'Required'\n if (!lastName.value.trim())\n shippingStep.lastName = 'Required'\n if (!address.value.trim())\n shippingStep.address = 'Required'\n if (!city.value.trim())\n shippingStep.city = 'Required'\n if (country.value === 'us' && !state.value)\n shippingStep.state = 'Required'\n if (!postalRule.value.pattern.test(postalCode.value.trim()))\n shippingStep.postalCode = postalRule.value.error\n\n const deliveryStep: Record<string, string> = {}\n if (delivery.value === 'overnight' && country.value !== 'us')\n deliveryStep.delivery = 'Overnight delivery is only available in the US.'\n\n const payment: Record<string, string> = {}\n if (cardNumber.value.replace(/\\s/g, '').length < 15)\n payment.cardNumber = 'Enter the full card number.'\n if (!cardName.value.trim())\n payment.cardName = 'Enter the name printed on the card.'\n if (!/^\\d{2}\\s?\\/\\s?\\d{2}$/.test(expiry.value.trim()))\n payment.expiry = 'Use MM/YY.'\n if (!/^\\d{3,4}$/.test(cvc.value.trim()))\n payment.cvc = '3 or 4 digits.'\n\n return [cart, shippingStep, deliveryStep, payment]\n})\n\nfunction shownErrors(index: number): Record<string, string> {\n return attempted.value.has(index) ? errorsByStep.value[index] : {}\n}\n\nconst currentErrors = computed(() => shownErrors(step.value))\nconst isLastStep = computed(() => step.value === STEPS.length - 1)\n\nfunction markAttempted(indices: number[]) {\n const next = new Set(attempted.value)\n for (const index of indices) next.add(index)\n attempted.value = next\n}\n\nfunction goNext() {\n markAttempted([step.value])\n if (Object.keys(errorsByStep.value[step.value]).length > 0)\n return\n if (isLastStep.value) {\n isComplete.value = true\n return\n }\n step.value += 1\n}\n\n/** 往前跳时先检查中间的步骤,遇到第一个有问题的就停在那里。 */\nfunction goTo(index: number) {\n if (index <= step.value) {\n step.value = index\n return\n }\n const checked: number[] = []\n let firstInvalid: number | null = null\n for (let candidate = step.value; candidate < index; candidate += 1) {\n checked.push(candidate)\n if (Object.keys(errorsByStep.value[candidate]).length > 0) {\n firstInvalid = candidate\n break\n }\n }\n markAttempted(checked)\n step.value = firstInvalid ?? index\n}\n\nfunction onPromoInput(value: string) {\n promoInput.value = value\n promoWasChecked.value = false\n appliedPromo.value = null\n}\n\nfunction applyPromo() {\n const code = promoInput.value.trim().toUpperCase()\n promoWasChecked.value = code.length > 0\n appliedPromo.value = PROMOS[code] ? code : null\n}\n\nconst promoStatus = computed<{ type: 'success' | 'warning', message: string } | undefined>(() => {\n if (appliedPromo.value)\n return { type: 'success', message: `${PROMOS[appliedPromo.value].label} applied.` }\n if (promoInput.value.trim() && promoWasChecked.value)\n return { type: 'warning', message: 'That promo code is not valid.' }\n if (promoInput.value.trim())\n return { type: 'warning', message: 'Press Apply to check this code.' }\n return undefined\n})\n\nfunction blockedMessage(count: number) {\n return count === 1 ? 'One problem above needs fixing first.' : `${count} problems above need fixing first.`\n}\n\nfunction setQuantity(id: string, value: number | null) {\n quantities.value = { ...quantities.value, [id]: value ?? 0 }\n}\n\nfunction isFreeDelivery(optionValue: string) {\n return optionValue === 'standard' && subtotal.value - discount.value >= FREE_SHIPPING_THRESHOLD\n}\n<\/script>\n\n<template>\n <Layout height=\"fill\" :padding=\"6\" :content-width=\"1000\">\n <LayoutContent>\n <VStack ref=\"hostRef\" :gap=\"6\">\n <HStack :gap=\"3\" align=\"center\">\n <StackItem size=\"fill\">\n <Heading :level=\"1\">\n Checkout\n </Heading>\n </StackItem>\n <HStack :gap=\"1.5\" align=\"center\">\n <Icon name=\"heroicons:lock-closed\" size=\"sm\" color=\"secondary\" />\n <Text type=\"supporting\" color=\"secondary\">\n Secure checkout\n </Text>\n </HStack>\n </HStack>\n\n <Stack :direction=\"isNarrow ? 'vertical' : 'horizontal'\" :gap=\"6\" align=\"start\">\n <StackItem size=\"fill\">\n <VStack :gap=\"6\">\n <Banner\n v-if=\"isComplete\"\n status=\"success\"\n title=\"Order confirmed\"\n :description=\"`This local example completed an order for ${money(total)}. No payment was sent.`\"\n />\n <VStack v-if=\"isComplete\" :gap=\"4\">\n <Heading :level=\"2\">\n Thanks for your order\n </Heading>\n <Text type=\"body\" color=\"secondary\">\n The completion is stored in this page's component state so the template ends in a real, reviewable outcome.\n </Text>\n </VStack>\n\n <Stepper\n v-if=\"!isComplete\"\n :active-step=\"step\"\n orientation=\"horizontal\"\n density=\"balanced\"\n label=\"Checkout progress\"\n @step-click=\"goTo\"\n >\n <Step\n v-for=\"({ label, icon }, i) in STEPS\"\n :key=\"label\"\n :step=\"i\"\n :label=\"label\"\n :status=\"Object.keys(shownErrors(i)).length ? 'error' : undefined\"\n >\n <template #icon>\n <Icon v-if=\"Object.keys(shownErrors(i)).length\" icon=\"error\" size=\"sm\" />\n <Icon v-else :name=\"icon\" size=\"sm\" />\n </template>\n </Step>\n </Stepper>\n\n <VStack v-if=\"!isComplete && step === 0\" :gap=\"5\">\n <Heading :level=\"2\">\n Review your cart\n </Heading>\n <VStack :gap=\"4\">\n <VStack v-for=\"item in items\" :key=\"item.id\" :gap=\"3\">\n <HStack :gap=\"4\" align=\"start\" wrap=\"wrap\">\n <Thumbnail :src=\"item.src\" :alt=\"item.name\" />\n <StackItem size=\"fill\">\n <VStack :gap=\"1\">\n <HStack :gap=\"2\" align=\"start\" justify=\"between\">\n <HStack :gap=\"2\" align=\"center\" wrap=\"wrap\">\n <Text type=\"body\" weight=\"medium\">\n {{ item.name }}\n </Text>\n <Badge v-if=\"item.isLimited\" variant=\"green\" label=\"Limited\" />\n </HStack>\n <Text type=\"body\" weight=\"bold\">\n {{ money(item.price * item.qty) }}\n </Text>\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n {{ item.variant }} · {{ money(item.price) }} each\n </Text>\n <HStack :gap=\"2\" align=\"center\">\n <span class=\"quantity-field\">\n <NumberInput\n :model-value=\"item.qty\"\n :label=\"`Quantity of ${item.name}`\"\n is-label-hidden\n :min=\"0\"\n :max=\"10\"\n is-integer-only\n has-number-steppers\n @update:model-value=\"setQuantity(item.id, $event)\"\n />\n </span>\n <Button label=\"Remove\" variant=\"ghost\" @click=\"setQuantity(item.id, 0)\" />\n </HStack>\n </VStack>\n </StackItem>\n </HStack>\n <Divider />\n </VStack>\n </VStack>\n <FieldStatus\n v-if=\"currentErrors.items\"\n type=\"error\"\n variant=\"detached\"\n :message=\"currentErrors.items\"\n />\n <HStack :gap=\"2\" align=\"end\">\n <StackItem size=\"fill\">\n <TextInput\n :model-value=\"promoInput\"\n label=\"Promo code\"\n placeholder=\"WELCOME10\"\n :status=\"promoStatus\"\n @update:model-value=\"onPromoInput($event)\"\n />\n </StackItem>\n <Button label=\"Apply\" variant=\"secondary\" @click=\"applyPromo\" />\n </HStack>\n </VStack>\n\n <VStack v-if=\"!isComplete && step === 1\" :gap=\"5\">\n <VStack :gap=\"1\">\n <Heading :level=\"2\">\n Where should it go?\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Delivery options and tax are calculated from this address.\n </Text>\n </VStack>\n <FormLayout default-optionality=\"required\">\n <TextInput\n v-model=\"email\"\n label=\"Email\"\n placeholder=\"you@example.com\"\n description=\"Order confirmation and tracking go here.\"\n :status=\"currentErrors.email ? { type: 'error', message: currentErrors.email } : undefined\"\n />\n <FormLayout :direction=\"isNarrow ? 'vertical' : 'horizontal'\" default-optionality=\"required\">\n <TextInput\n v-model=\"firstName\"\n label=\"First name\"\n :status=\"currentErrors.firstName ? { type: 'error', message: currentErrors.firstName } : undefined\"\n />\n <TextInput\n v-model=\"lastName\"\n label=\"Last name\"\n :status=\"currentErrors.lastName ? { type: 'error', message: currentErrors.lastName } : undefined\"\n />\n </FormLayout>\n <Selector v-model=\"country\" label=\"Country\" :options=\"COUNTRIES\" />\n <TextInput\n v-model=\"address\"\n label=\"Street address\"\n placeholder=\"123 Main Street\"\n :status=\"currentErrors.address ? { type: 'error', message: currentErrors.address } : undefined\"\n />\n <TextInput v-model=\"apartment\" label=\"Apartment, suite, etc.\" is-optional />\n <FormLayout :direction=\"isNarrow ? 'vertical' : 'horizontal'\" default-optionality=\"required\">\n <TextInput\n v-model=\"city\"\n label=\"City\"\n :status=\"currentErrors.city ? { type: 'error', message: currentErrors.city } : undefined\"\n />\n <Selector\n v-if=\"country === 'us'\"\n v-model=\"state\"\n label=\"State\"\n placeholder=\"Select state\"\n :options=\"US_STATES\"\n searchable\n :status=\"currentErrors.state ? 'error' : undefined\"\n :status-message=\"currentErrors.state\"\n />\n <TextInput\n v-model=\"postalCode\"\n :label=\"postalRule.label\"\n :placeholder=\"postalRule.placeholder\"\n :status=\"currentErrors.postalCode ? { type: 'error', message: currentErrors.postalCode } : undefined\"\n />\n </FormLayout>\n </FormLayout>\n </VStack>\n\n <VStack v-if=\"!isComplete && step === 2\" :gap=\"5\">\n <VStack :gap=\"1\">\n <Heading :level=\"2\">\n How fast do you need it?\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Estimates run from the day the order ships, usually 1–3 business days after you place it.\n </Text>\n </VStack>\n <FormLayout default-optionality=\"required\">\n <RadioList\n v-model=\"delivery\"\n label=\"Delivery method\"\n :status=\"currentErrors.delivery ? { type: 'error', message: currentErrors.delivery } : undefined\"\n >\n <RadioListItem\n v-for=\"option in DELIVERY_OPTIONS\"\n :key=\"option.value\"\n :value=\"option.value\"\n :label=\"option.label\"\n :description=\"option.window\"\n >\n <template #end>\n <Text type=\"body\" weight=\"medium\">\n {{ isFreeDelivery(option.value) ? 'Free' : money(option.price) }}\n </Text>\n </template>\n </RadioListItem>\n </RadioList>\n <Divider />\n <CheckboxInput v-model=\"isGift\" label=\"This is a gift\" />\n <TextArea\n v-model=\"deliveryNote\"\n :label=\"isGift ? 'Gift message' : 'Delivery instructions'\"\n is-optional\n :rows=\"3\"\n :max-length=\"240\"\n :placeholder=\"isGift ? 'Happy birthday — hope these get plenty of use.' : 'Leave with the neighbour at number 14.'\"\n :description=\"isGift ? 'Printed on a card. Prices are left off the packing slip.' : 'Passed to the carrier where they support it.'\"\n />\n </FormLayout>\n </VStack>\n\n <VStack v-if=\"!isComplete && step === 3\" :gap=\"5\">\n <VStack :gap=\"1\">\n <Heading :level=\"2\">\n Payment\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n This template validates payment details and completes locally. No card is charged.\n </Text>\n </VStack>\n <FormLayout default-optionality=\"required\">\n <TextInput\n v-model=\"cardNumber\"\n label=\"Card number\"\n placeholder=\"4242 4242 4242 4242\"\n :status=\"currentErrors.cardNumber ? { type: 'error', message: currentErrors.cardNumber } : undefined\"\n />\n <TextInput\n v-model=\"cardName\"\n label=\"Name on card\"\n :status=\"currentErrors.cardName ? { type: 'error', message: currentErrors.cardName } : undefined\"\n />\n <HStack :gap=\"4\" wrap=\"wrap\">\n <TextInput\n v-model=\"expiry\"\n label=\"Expiry\"\n :width=\"140\"\n placeholder=\"MM/YY\"\n :status=\"currentErrors.expiry ? { type: 'error', message: currentErrors.expiry } : undefined\"\n />\n <TextInput\n v-model=\"cvc\"\n label=\"CVC\"\n :width=\"140\"\n placeholder=\"123\"\n label-tooltip=\"Three digits on the back of the card, or four on the front for American Express.\"\n :status=\"currentErrors.cvc ? { type: 'error', message: currentErrors.cvc } : undefined\"\n />\n </HStack>\n <Divider />\n <CheckboxInput v-model=\"billingMatches\" label=\"Billing address matches the shipping address\" />\n <Banner\n v-if=\"!billingMatches\"\n status=\"info\"\n title=\"Connect a billing-address step\"\n description=\"This local example does not collect a separate billing address or send payment. Add that flow with your payment provider.\"\n />\n </FormLayout>\n </VStack>\n\n <VStack v-if=\"!isComplete\" :gap=\"3\" h-align=\"start\">\n <FieldStatus\n v-if=\"Object.keys(currentErrors).length > 0\"\n type=\"error\"\n variant=\"detached\"\n :message=\"blockedMessage(Object.keys(currentErrors).length)\"\n />\n <HStack :gap=\"3\" align=\"center\" width=\"100%\">\n <Button\n :label=\"step === 0 ? 'Continue shopping' : 'Back'\"\n variant=\"secondary\"\n @click=\"step = Math.max(0, step - 1)\"\n />\n <StackItem size=\"fill\">\n <Button\n :label=\"isLastStep ? `Pay ${money(total)}` : 'Continue'\"\n variant=\"primary\"\n width=\"100%\"\n @click=\"goNext\"\n />\n </StackItem>\n </HStack>\n </VStack>\n </VStack>\n </StackItem>\n\n <StackItem :class=\"isNarrow ? 'summary-column summary-column--narrow' : 'summary-column'\">\n <Card :padding=\"5\">\n <component\n :is=\"isNarrow ? Collapsible : VStack\"\n v-bind=\"isNarrow ? { trigger: `Order summary · ${money(total)}`, defaultIsOpen: false } : { gap: 4 }\"\n >\n <HStack v-if=\"!isNarrow\" :gap=\"2\" align=\"center\" justify=\"between\" class=\"summary-title\">\n <Text type=\"label\">\n Order summary\n </Text>\n <Badge variant=\"neutral\" :label=\"`${itemCount} ${itemCount === 1 ? 'item' : 'items'}`\" />\n </HStack>\n <VStack :gap=\"4\" :padding-block-start=\"isNarrow ? 3 : undefined\">\n <VStack :gap=\"3\">\n <HStack v-for=\"item in items\" :key=\"item.id\" :gap=\"3\" align=\"start\">\n <Thumbnail :src=\"item.src\" :alt=\"item.name\" />\n <StackItem size=\"fill\">\n <VStack :gap=\"0.5\">\n <HStack :gap=\"2\" align=\"start\" justify=\"between\">\n <Text type=\"body\" weight=\"medium\">\n {{ item.name }}\n </Text>\n <Text type=\"body\" weight=\"bold\">\n {{ money(item.price * item.qty) }}\n </Text>\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n {{ item.variant }} · Qty {{ item.qty }}\n </Text>\n </VStack>\n </StackItem>\n </HStack>\n </VStack>\n <Divider />\n <VStack :gap=\"2\">\n <HStack justify=\"between\" align=\"center\">\n <Text type=\"body\" color=\"secondary\">\n Subtotal\n </Text>\n <Text type=\"body\">\n {{ money(subtotal) }}\n </Text>\n </HStack>\n <HStack v-if=\"promo\" justify=\"between\" align=\"center\">\n <Text type=\"body\" color=\"secondary\">\n {{ promo.label }}\n </Text>\n <Text type=\"body\">\n −{{ money(discount) }}\n </Text>\n </HStack>\n <HStack justify=\"between\" align=\"center\">\n <Text type=\"body\" color=\"secondary\">\n {{ deliveryOption.label }} delivery\n </Text>\n <Text type=\"body\">\n {{ shipping === 0 ? 'Free' : money(shipping) }}\n </Text>\n </HStack>\n <HStack justify=\"between\" align=\"center\">\n <Text type=\"body\" color=\"secondary\">\n Estimated tax\n </Text>\n <Text type=\"body\">\n {{ money(tax) }}\n </Text>\n </HStack>\n <Divider />\n <HStack justify=\"between\" align=\"center\">\n <Text type=\"large\" weight=\"bold\">\n Total\n </Text>\n <Text type=\"large\" weight=\"bold\">\n {{ money(total) }}\n </Text>\n </HStack>\n </VStack>\n <Banner\n v-if=\"subtotal - discount < FREE_SHIPPING_THRESHOLD\"\n status=\"info\"\n :title=\"`${money(FREE_SHIPPING_THRESHOLD - (subtotal - discount))} away from free standard shipping`\"\n />\n </VStack>\n </component>\n </Card>\n </StackItem>\n </Stack>\n </VStack>\n </LayoutContent>\n </Layout>\n</template>\n\n<style scoped>\n/* 摘要列固定 360 宽,表单滚过时贴在页面内边距处;窄版时排到表单前面、占满一行。 */\n.summary-column {\n position: sticky;\n top: var(--astryx-spacing-6);\n flex-shrink: 0;\n align-self: flex-start;\n width: 360px;\n}\n.summary-column--narrow {\n position: static;\n order: -1;\n align-self: stretch;\n width: auto;\n}\n.quantity-field {\n display: block;\n flex: none;\n width: 96px;\n}\n</style>\n",
77557
77913
  "docsUrl": {
77558
77914
  "en": "https://astryx-vue.pages.dev/en/playground/?template=checkout-wizard",
77559
77915
  "zh": "https://astryx-vue.pages.dev/playground/?template=checkout-wizard"
@@ -77617,7 +77973,7 @@ const catalog = {
77617
77973
  ],
77618
77974
  "dependencies": ["@astryx-vue/core", "vue"],
77619
77975
  "isReady": true,
77620
- "source": "<script setup lang=\"ts\">\nimport type { Component } from 'vue'\nimport { computed, defineComponent, h, ref } from 'vue'\nimport {\n Banner,\n Button,\n Card,\n Center,\n CheckboxInput,\n Divider,\n Grid,\n HStack,\n Icon,\n Link,\n RadioList,\n RadioListItem,\n Section,\n Selector,\n Text,\n TextArea,\n TextInput,\n Token,\n VStack,\n} from '@astryx-vue/core'\n\nconst CAMPAIGN_GOALS = [\n 'Brand Awareness',\n 'Product Sampling',\n 'Product Launch',\n 'Event Promotion',\n 'Retail / In-Store',\n 'Trade Show',\n 'Influencer Activation',\n 'Community Building',\n 'Seasonal Campaign',\n 'Other',\n]\n\nconst LAUNCH_OPTIONS = [\n 'Within 30 days',\n '1–3 months',\n '3–6 months',\n '6–12 months',\n '12+ months',\n]\n\nconst BUDGET_OPTIONS = [\n 'Under $5K/mo',\n '$5K–$15K/mo',\n '$15K–$50K/mo',\n '$50K–$100K/mo',\n '$100K+/mo',\n]\n\nfunction iconComponent(className: string): Component {\n return defineComponent({\n render: () => h('span', { class: className, 'aria-hidden': 'true', style: 'width:1.25em;height:1.25em' }),\n })\n}\n\nconst WHY_US = [\n {\n icon: iconComponent('i-heroicons-rocket-launch'),\n title: 'We move fast for you',\n description: 'We cut through the noise and get straight to the work.',\n },\n {\n icon: iconComponent('i-heroicons-adjustments-horizontal'),\n title: 'We build around you',\n description: 'We tailor everything to what you\\'re trying to achieve.',\n },\n {\n icon: iconComponent('i-heroicons-hand-raised'),\n title: 'We show up for you',\n description: 'A dedicated team that knows your brand and wants to win.',\n },\n]\n\nconst fullName = ref('')\nconst email = ref('')\nconst company = ref('')\nconst phone = ref('')\nconst goals = ref<string[]>([])\nconst timeline = ref('')\nconst budget = ref('')\nconst message = ref('')\nconst hearAboutUs = ref('')\nconst isDecider = ref(false)\nconst submitted = ref(false)\n\nconst errors = computed(() => submitted.value\n ? {\n fullName: !fullName.value.trim() ? 'Required' : undefined,\n email: !email.value.trim() ? 'Required' : undefined,\n company: !company.value.trim() ? 'Required' : undefined,\n phone: !phone.value.trim() ? 'Required' : undefined,\n goals: goals.value.length === 0 ? 'Pick at least one' : undefined,\n timeline: !timeline.value ? 'Required' : undefined,\n budget: !budget.value ? 'Required' : undefined,\n }\n : {})\n\nfunction toggleGoal(goal: string) {\n goals.value = goals.value.includes(goal)\n ? goals.value.filter(g => g !== goal)\n : [...goals.value, goal]\n}\n<\/script>\n\n<template>\n <Center axis=\"horizontal\">\n <VStack justify=\"center\" width=\"100%\">\n <Section :max-width=\"800\" :padding=\"6\" :padding-block=\"10\" variant=\"section\">\n <VStack :gap=\"6\">\n <VStack :gap=\"2\" justify=\"center\">\n <Text type=\"display-1\" weight=\"bold\">\n Let's work together\n </Text>\n <Text type=\"body\" color=\"secondary\">\n Tell us a bit about what you're working on — we'd love to help.\n </Text>\n </VStack>\n\n <VStack :gap=\"5\">\n <Grid :columns=\"{ minWidth: 200 }\" :gap=\"4\">\n <Card v-for=\"item in WHY_US\" :key=\"item.title\">\n <VStack :gap=\"3\">\n <Icon :icon=\"item.icon\" size=\"lg\" color=\"accent\" />\n <VStack :gap=\"1\">\n <Text type=\"body\" weight=\"bold\">\n {{ item.title }}\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n {{ item.description }}\n </Text>\n </VStack>\n </VStack>\n </Card>\n </Grid>\n </VStack>\n\n <VStack :gap=\"5\">\n <Grid :columns=\"{ minWidth: 260 }\" :gap=\"4\">\n <TextInput\n v-model=\"fullName\"\n label=\"Full Name\"\n placeholder=\"Full Name\"\n :status=\"errors.fullName ? { type: 'error', message: errors.fullName } : undefined\"\n />\n <TextInput\n v-model=\"email\"\n label=\"Email\"\n placeholder=\"you@company.com\"\n :status=\"errors.email ? { type: 'error', message: errors.email } : undefined\"\n />\n </Grid>\n <Grid :columns=\"{ minWidth: 260 }\" :gap=\"4\">\n <TextInput\n v-model=\"company\"\n label=\"Company\"\n placeholder=\"Company\"\n :status=\"errors.company ? { type: 'error', message: errors.company } : undefined\"\n />\n <TextInput\n v-model=\"phone\"\n label=\"Phone\"\n placeholder=\"Phone number\"\n :status=\"errors.phone ? { type: 'error', message: errors.phone } : undefined\"\n />\n </Grid>\n </VStack>\n\n <Divider />\n\n <VStack :gap=\"5\">\n <VStack :gap=\"2\">\n <Text type=\"label\" color=\"secondary\">\n What are you going for?\n </Text>\n <HStack :gap=\"2\" wrap=\"wrap\">\n <Token\n v-for=\"goal in CAMPAIGN_GOALS\"\n :key=\"goal\"\n :label=\"goal\"\n :color=\"goals.includes(goal) ? 'blue' : 'default'\"\n @click=\"toggleGoal(goal)\"\n />\n </HStack>\n <Banner v-if=\"errors.goals\" status=\"error\" :title=\"errors.goals\" />\n </VStack>\n\n <Selector\n v-model=\"timeline\"\n label=\"When are you thinking?\"\n placeholder=\"When are you thinking of launching?\"\n :options=\"LAUNCH_OPTIONS\"\n :status=\"errors.timeline ? { type: 'error', message: errors.timeline } : undefined\"\n />\n\n <Selector\n v-model=\"budget\"\n label=\"Ballpark budget?\"\n placeholder=\"What's your rough monthly budget?\"\n :options=\"BUDGET_OPTIONS\"\n :status=\"errors.budget ? { type: 'error', message: errors.budget } : undefined\"\n />\n\n <RadioList v-model=\"hearAboutUs\" label=\"How did you hear about us?\">\n <RadioListItem label=\"Social media\" value=\"social\" />\n <RadioListItem label=\"Word of mouth\" value=\"word-of-mouth\" />\n <RadioListItem label=\"Search engine\" value=\"search\" />\n <RadioListItem label=\"Event or conference\" value=\"event\" />\n <RadioListItem label=\"Other\" value=\"other\" />\n </RadioList>\n\n <TextArea\n v-model=\"message\"\n label=\"Anything else?\"\n placeholder=\"Tell us whatever else is on your mind...\"\n />\n\n <CheckboxInput v-model=\"isDecider\" label=\"I'm a budget decision-maker\" />\n </VStack>\n\n <VStack :gap=\"3\">\n <Button label=\"Submit\" variant=\"primary\" size=\"lg\" @click=\"submitted = true\" />\n <HStack :gap=\"1\" justify=\"center\">\n <Text type=\"supporting\" color=\"secondary\">\n By submitting you agree to our\n <Link href=\"#\" type=\"supporting\">\n Privacy Policy\n </Link>.\n </Text>\n </HStack>\n </VStack>\n </VStack>\n </Section>\n </VStack>\n </Center>\n</template>\n",
77976
+ "source": "<script setup lang=\"ts\">\nimport {\n Banner,\n Button,\n Card,\n Center,\n CheckboxInput,\n Divider,\n Grid,\n HStack,\n Icon,\n Link,\n RadioList,\n RadioListItem,\n Section,\n Selector,\n Text,\n TextArea,\n TextInput,\n Token,\n VStack,\n} from '@astryx-vue/core'\nimport { computed, ref } from 'vue'\n\nconst CAMPAIGN_GOALS = [\n 'Brand Awareness',\n 'Product Sampling',\n 'Product Launch',\n 'Event Promotion',\n 'Retail / In-Store',\n 'Trade Show',\n 'Influencer Activation',\n 'Community Building',\n 'Seasonal Campaign',\n 'Other',\n]\n\nconst LAUNCH_OPTIONS = [\n 'Within 30 days',\n '1–3 months',\n '3–6 months',\n '6–12 months',\n '12+ months',\n]\n\nconst BUDGET_OPTIONS = [\n 'Under $5K/mo',\n '$5K–$15K/mo',\n '$15K–$50K/mo',\n '$50K–$100K/mo',\n '$100K+/mo',\n]\n\nconst WHY_US = [\n {\n icon: 'heroicons:rocket-launch',\n title: 'We move fast for you',\n description: 'We cut through the noise and get straight to the work.',\n },\n {\n icon: 'heroicons:adjustments-horizontal',\n title: 'We build around you',\n description: 'We tailor everything to what you\\'re trying to achieve.',\n },\n {\n icon: 'heroicons:hand-raised',\n title: 'We show up for you',\n description: 'A dedicated team that knows your brand and wants to win.',\n },\n]\n\nconst fullName = ref('')\nconst email = ref('')\nconst company = ref('')\nconst phone = ref('')\nconst goals = ref<string[]>([])\nconst timeline = ref('')\nconst budget = ref('')\nconst message = ref('')\nconst hearAboutUs = ref('')\nconst isDecider = ref(false)\nconst submitted = ref(false)\n\nconst errors = computed(() => submitted.value\n ? {\n fullName: !fullName.value.trim() ? 'Required' : undefined,\n email: !email.value.trim() ? 'Required' : undefined,\n company: !company.value.trim() ? 'Required' : undefined,\n phone: !phone.value.trim() ? 'Required' : undefined,\n goals: goals.value.length === 0 ? 'Pick at least one' : undefined,\n timeline: !timeline.value ? 'Required' : undefined,\n budget: !budget.value ? 'Required' : undefined,\n }\n : {})\n\nfunction toggleGoal(goal: string) {\n goals.value = goals.value.includes(goal)\n ? goals.value.filter(g => g !== goal)\n : [...goals.value, goal]\n}\n<\/script>\n\n<template>\n <Center axis=\"horizontal\">\n <VStack h-align=\"center\" width=\"100%\">\n <Section :max-width=\"800\" :padding=\"6\" :padding-block=\"10\" variant=\"section\">\n <VStack :gap=\"6\">\n <VStack :gap=\"2\" h-align=\"center\">\n <Text type=\"display-1\" weight=\"bold\">\n Let's work together\n </Text>\n <Text type=\"body\" color=\"secondary\">\n Tell us a bit about what you're working on — we'd love to help.\n </Text>\n </VStack>\n\n <VStack :gap=\"5\">\n <Grid :columns=\"{ minWidth: 200 }\" :gap=\"4\">\n <Card v-for=\"item in WHY_US\" :key=\"item.title\">\n <VStack :gap=\"3\">\n <Icon :name=\"item.icon\" size=\"lg\" color=\"accent\" />\n <VStack :gap=\"1\">\n <Text type=\"body\" weight=\"bold\">\n {{ item.title }}\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n {{ item.description }}\n </Text>\n </VStack>\n </VStack>\n </Card>\n </Grid>\n </VStack>\n\n <VStack :gap=\"5\">\n <Grid :columns=\"{ minWidth: 260 }\" :gap=\"4\">\n <TextInput\n v-model=\"fullName\"\n label=\"Full Name\"\n placeholder=\"Full Name\"\n :status=\"errors.fullName ? { type: 'error', message: errors.fullName } : undefined\"\n />\n <TextInput\n v-model=\"email\"\n label=\"Email\"\n placeholder=\"you@company.com\"\n :status=\"errors.email ? { type: 'error', message: errors.email } : undefined\"\n />\n </Grid>\n <Grid :columns=\"{ minWidth: 260 }\" :gap=\"4\">\n <TextInput\n v-model=\"company\"\n label=\"Company\"\n placeholder=\"Company\"\n :status=\"errors.company ? { type: 'error', message: errors.company } : undefined\"\n />\n <TextInput\n v-model=\"phone\"\n label=\"Phone\"\n placeholder=\"Phone number\"\n :status=\"errors.phone ? { type: 'error', message: errors.phone } : undefined\"\n />\n </Grid>\n </VStack>\n\n <Divider />\n\n <VStack :gap=\"5\">\n <VStack :gap=\"2\">\n <Text type=\"label\" color=\"secondary\">\n What are you going for?\n </Text>\n <HStack :gap=\"2\" wrap=\"wrap\">\n <Token\n v-for=\"goal in CAMPAIGN_GOALS\"\n :key=\"goal\"\n :label=\"goal\"\n :color=\"goals.includes(goal) ? 'blue' : 'default'\"\n @click=\"toggleGoal(goal)\"\n />\n </HStack>\n <Banner v-if=\"errors.goals\" status=\"error\" :title=\"errors.goals\" />\n </VStack>\n\n <Selector\n v-model=\"timeline\"\n label=\"When are you thinking?\"\n placeholder=\"When are you thinking of launching?\"\n :options=\"LAUNCH_OPTIONS\"\n :status=\"errors.timeline ? { type: 'error', message: errors.timeline } : undefined\"\n />\n\n <Selector\n v-model=\"budget\"\n label=\"Ballpark budget?\"\n placeholder=\"What's your rough monthly budget?\"\n :options=\"BUDGET_OPTIONS\"\n :status=\"errors.budget ? { type: 'error', message: errors.budget } : undefined\"\n />\n\n <RadioList v-model=\"hearAboutUs\" label=\"How did you hear about us?\">\n <RadioListItem label=\"Social media\" value=\"social\" />\n <RadioListItem label=\"Word of mouth\" value=\"word-of-mouth\" />\n <RadioListItem label=\"Search engine\" value=\"search\" />\n <RadioListItem label=\"Event or conference\" value=\"event\" />\n <RadioListItem label=\"Other\" value=\"other\" />\n </RadioList>\n\n <TextArea\n v-model=\"message\"\n label=\"Anything else?\"\n placeholder=\"Tell us whatever else is on your mind...\"\n :rows=\"3\"\n control-class=\"contact-form-textarea\"\n />\n\n <CheckboxInput v-model=\"isDecider\" label=\"I'm a budget decision-maker\" />\n </VStack>\n\n <VStack :gap=\"3\">\n <Button label=\"Submit\" variant=\"primary\" size=\"lg\" @click=\"submitted = true\" />\n <HStack :gap=\"1\" justify=\"center\">\n <Text type=\"supporting\" color=\"secondary\">\n By submitting you agree to our\n <Link href=\"#\" size=\"sm\">\n Privacy Policy\n </Link>.\n </Text>\n </HStack>\n </VStack>\n </VStack>\n </Section>\n </VStack>\n </Center>\n</template>\n\n<style>\n/* 待 core 修复后去掉:TextArea 外框自带 8px 内边距和 80px 最小高度,原版是文本区自己 4px 8px、高度由 rows 决定。 */\n.contact-form-textarea {\n min-height: auto;\n padding: 0;\n}\n.contact-form-textarea .astryx-text-area-control {\n padding: 4px 8px;\n}\n</style>\n",
77621
77977
  "docsUrl": {
77622
77978
  "en": "https://astryx-vue.pages.dev/en/playground/?template=contact-form",
77623
77979
  "zh": "https://astryx-vue.pages.dev/playground/?template=contact-form"
@@ -77905,7 +78261,7 @@ const catalog = {
77905
78261
  ],
77906
78262
  "dependencies": ["@astryx-vue/core", "vue"],
77907
78263
  "isReady": true,
77908
- "source": "<script setup lang=\"ts\">\nimport type { CSSProperties } from 'vue'\nimport { computed, ref } from 'vue'\nimport {\n AspectRatio,\n Button,\n Card,\n Center,\n Divider,\n Grid,\n HStack,\n Link,\n Section,\n Selector,\n Text,\n TextArea,\n TextInput,\n Token,\n VStack,\n} from '@astryx-vue/core'\n\nconst ILLUSTRATION_URL = '/template-assets/light-working-vertical-2.png'\n\nconst INQUIRY_REASONS = [\n 'New business',\n 'General inquiry',\n 'Press & media',\n 'Partnerships',\n 'Product feedback',\n 'Technical support',\n 'Other',\n]\n\nconst BUDGET_OPTIONS = [\n 'Under $10k',\n '$10k – $50k',\n '$50k – $100k',\n '$100k – $500k',\n '$500k+',\n 'Not sure yet',\n]\n\nconst CONTACT_COLUMNS = [\n { label: 'General inquiries', email: 'hello@company.com' },\n { label: 'New business', email: 'newbiz@company.com' },\n { label: 'Press & partnerships', email: 'press@company.com' },\n]\n\nconst pageStyle: CSSProperties = { minHeight: '100%' }\nconst illustrationImg: CSSProperties = {\n width: '100%',\n height: '100%',\n objectFit: 'cover',\n borderRadius: 'var(--astryx-radius-container)',\n overflow: 'hidden',\n}\n\nconst fullName = ref('')\nconst email = ref('')\nconst company = ref('')\nconst jobTitle = ref('')\nconst phone = ref('')\nconst inquiryReason = ref('')\nconst budget = ref('')\nconst details = ref('')\nconst submitted = ref(false)\n\nconst errors = computed(() => submitted.value\n ? {\n fullName: !fullName.value.trim() ? 'Required' : undefined,\n email: !email.value.trim() ? 'Required' : undefined,\n details: !details.value.trim() ? 'Required' : undefined,\n }\n : {})\n\nfunction handleSubmit() {\n submitted.value = true\n}\n\nfunction toggleReason(reason: string) {\n inquiryReason.value = inquiryReason.value === reason ? '' : reason\n}\n<\/script>\n\n<template>\n <Center :style=\"pageStyle\">\n <Section :max-width=\"1100\" width=\"100%\" :padding=\"10\" variant=\"transparent\">\n <VStack :gap=\"10\">\n <Grid :columns=\"{ minWidth: 320 }\" align=\"center\" :gap=\"10\">\n <VStack :gap=\"6\">\n <VStack :gap=\"3\">\n <Text type=\"display-1\" as=\"h1\">\n Let's work together\n </Text>\n <Text type=\"body\" color=\"secondary\">\n Tell us what you're working on and we'll help you\n figure out the best path forward.\n </Text>\n </VStack>\n <AspectRatio :ratio=\"4 / 3\">\n <img\n :src=\"ILLUSTRATION_URL\"\n alt=\"Two people working at a desk\"\n :style=\"illustrationImg\"\n >\n </AspectRatio>\n </VStack>\n\n <Card :padding=\"8\">\n <VStack :gap=\"4\">\n <Text type=\"label\">\n Your details\n </Text>\n <TextInput\n v-model=\"fullName\"\n label=\"Full name\"\n is-label-hidden\n placeholder=\"Full name*\"\n :status=\"errors.fullName ? { type: 'error', message: errors.fullName } : undefined\"\n />\n <Grid :columns=\"{ minWidth: 180 }\" :gap=\"3\">\n <TextInput\n v-model=\"email\"\n label=\"Email\"\n is-label-hidden\n placeholder=\"Email*\"\n :status=\"errors.email ? { type: 'error', message: errors.email } : undefined\"\n />\n <TextInput\n v-model=\"company\"\n label=\"Company name\"\n is-label-hidden\n placeholder=\"Company name\"\n />\n </Grid>\n <Grid :columns=\"{ minWidth: 180 }\" :gap=\"3\">\n <TextInput\n v-model=\"jobTitle\"\n label=\"Job title\"\n is-label-hidden\n placeholder=\"Job title\"\n />\n <TextInput\n v-model=\"phone\"\n label=\"Phone number\"\n is-label-hidden\n placeholder=\"Phone number\"\n />\n </Grid>\n\n <VStack :gap=\"2\">\n <Text type=\"label\">\n What are you reaching out about?\n </Text>\n <HStack :gap=\"2\" wrap=\"wrap\">\n <Token\n v-for=\"reason in INQUIRY_REASONS\"\n :key=\"reason\"\n :label=\"reason\"\n :color=\"inquiryReason === reason ? 'blue' : 'default'\"\n @click=\"toggleReason(reason)\"\n />\n </HStack>\n </VStack>\n <Selector\n v-model=\"budget\"\n label=\"Budget range\"\n :options=\"BUDGET_OPTIONS\"\n placeholder=\"Select a budget range...\"\n />\n <TextArea\n v-model=\"details\"\n label=\"Project details\"\n is-label-hidden\n placeholder=\"Project details*\"\n :status=\"errors.details ? { type: 'error', message: errors.details } : undefined\"\n />\n <VStack hAlign=\"stretch\">\n <Button label=\"Let's connect\" variant=\"primary\" @click=\"handleSubmit\" />\n </VStack>\n </VStack>\n </Card>\n </Grid>\n\n <VStack :gap=\"6\">\n <Divider />\n <Grid :columns=\"{ minWidth: 200 }\" :gap=\"6\">\n <VStack v-for=\"col in CONTACT_COLUMNS\" :key=\"col.label\" :gap=\"1\" justify=\"center\">\n <Text type=\"supporting\" color=\"secondary\">\n {{ col.label }}\n </Text>\n <Link :href=\"`mailto:${col.email}`\" type=\"body\" size=\"sm\">\n {{ col.email }}\n </Link>\n </VStack>\n </Grid>\n </VStack>\n </VStack>\n </Section>\n </Center>\n</template>\n",
78264
+ "source": "<script setup lang=\"ts\">\nimport type { CSSProperties } from 'vue'\nimport {\n AspectRatio,\n Button,\n Card,\n Center,\n Divider,\n Grid,\n HStack,\n Link,\n Section,\n Selector,\n Text,\n TextArea,\n TextInput,\n Token,\n VStack,\n} from '@astryx-vue/core'\nimport { computed, ref } from 'vue'\n\nconst ILLUSTRATION_URL = '/template-assets/light-working-vertical-2.png'\n\nconst INQUIRY_REASONS = [\n 'New business',\n 'General inquiry',\n 'Press & media',\n 'Partnerships',\n 'Product feedback',\n 'Technical support',\n 'Other',\n]\n\nconst BUDGET_OPTIONS = [\n 'Under $10k',\n '$10k – $50k',\n '$50k – $100k',\n '$100k – $500k',\n '$500k+',\n 'Not sure yet',\n]\n\nconst CONTACT_COLUMNS = [\n { label: 'General inquiries', email: 'hello@company.com' },\n { label: 'New business', email: 'newbiz@company.com' },\n { label: 'Press & partnerships', email: 'press@company.com' },\n]\n\nconst illustrationImg: CSSProperties = {\n width: '100%',\n height: '100%',\n objectFit: 'cover',\n borderRadius: 'var(--astryx-radius-container)',\n overflow: 'hidden',\n}\n\nconst fullName = ref('')\nconst email = ref('')\nconst company = ref('')\nconst jobTitle = ref('')\nconst phone = ref('')\nconst inquiryReason = ref('')\nconst budget = ref('')\nconst details = ref('')\nconst submitted = ref(false)\n\nconst errors = computed(() => submitted.value\n ? {\n fullName: !fullName.value.trim() ? 'Required' : undefined,\n email: !email.value.trim() ? 'Required' : undefined,\n details: !details.value.trim() ? 'Required' : undefined,\n }\n : {})\n\nfunction handleSubmit() {\n submitted.value = true\n}\n\nfunction toggleReason(reason: string) {\n inquiryReason.value = inquiryReason.value === reason ? '' : reason\n}\n<\/script>\n\n<template>\n <Center min-height=\"100%\">\n <Section :max-width=\"1100\" width=\"100%\" :padding=\"10\" variant=\"transparent\">\n <VStack :gap=\"10\">\n <Grid :columns=\"{ minWidth: 320 }\" align=\"center\" :gap=\"10\">\n <VStack :gap=\"6\">\n <VStack :gap=\"3\">\n <Text type=\"display-1\" as=\"h1\">\n Let's work together\n </Text>\n <Text type=\"body\" color=\"secondary\">\n Tell us what you're working on and we'll help you\n figure out the best path forward.\n </Text>\n </VStack>\n <AspectRatio :ratio=\"4 / 3\">\n <img\n :src=\"ILLUSTRATION_URL\"\n alt=\"Two people working at a desk\"\n :style=\"illustrationImg\"\n >\n </AspectRatio>\n </VStack>\n\n <Card :padding=\"8\">\n <VStack :gap=\"4\">\n <Text type=\"label\">\n Your details\n </Text>\n <TextInput\n v-model=\"fullName\"\n label=\"Full name\"\n is-label-hidden\n placeholder=\"Full name*\"\n :status=\"errors.fullName ? { type: 'error', message: errors.fullName } : undefined\"\n />\n <Grid :columns=\"{ minWidth: 180 }\" :gap=\"3\">\n <TextInput\n v-model=\"email\"\n label=\"Email\"\n is-label-hidden\n placeholder=\"Email*\"\n :status=\"errors.email ? { type: 'error', message: errors.email } : undefined\"\n />\n <TextInput\n v-model=\"company\"\n label=\"Company name\"\n is-label-hidden\n placeholder=\"Company name\"\n />\n </Grid>\n <Grid :columns=\"{ minWidth: 180 }\" :gap=\"3\">\n <TextInput\n v-model=\"jobTitle\"\n label=\"Job title\"\n is-label-hidden\n placeholder=\"Job title\"\n />\n <TextInput\n v-model=\"phone\"\n label=\"Phone number\"\n is-label-hidden\n placeholder=\"Phone number\"\n />\n </Grid>\n\n <VStack :gap=\"2\">\n <Text type=\"label\">\n What are you reaching out about?\n </Text>\n <HStack :gap=\"2\" wrap=\"wrap\">\n <Token\n v-for=\"reason in INQUIRY_REASONS\"\n :key=\"reason\"\n :label=\"reason\"\n :color=\"inquiryReason === reason ? 'blue' : 'default'\"\n @click=\"toggleReason(reason)\"\n />\n </HStack>\n </VStack>\n <Selector\n v-model=\"budget\"\n label=\"Budget range\"\n :options=\"BUDGET_OPTIONS\"\n placeholder=\"Select a budget range...\"\n />\n <TextArea\n v-model=\"details\"\n label=\"Project details\"\n is-label-hidden\n placeholder=\"Project details*\"\n :status=\"errors.details ? { type: 'error', message: errors.details } : undefined\"\n />\n <VStack h-align=\"stretch\">\n <Button label=\"Let's connect\" variant=\"primary\" @click=\"handleSubmit\" />\n </VStack>\n </VStack>\n </Card>\n </Grid>\n\n <VStack :gap=\"6\">\n <Divider />\n <Grid :columns=\"{ minWidth: 200 }\" :gap=\"6\">\n <VStack v-for=\"col in CONTACT_COLUMNS\" :key=\"col.label\" :gap=\"1\" h-align=\"center\">\n <Text type=\"supporting\" color=\"secondary\">\n {{ col.label }}\n </Text>\n <Link :href=\"`mailto:${col.email}`\" type=\"body\" size=\"sm\">\n {{ col.email }}\n </Link>\n </VStack>\n </Grid>\n </VStack>\n </VStack>\n </Section>\n </Center>\n</template>\n",
77909
78265
  "docsUrl": {
77910
78266
  "en": "https://astryx-vue.pages.dev/en/playground/?template=form-two-column",
77911
78267
  "zh": "https://astryx-vue.pages.dev/playground/?template=form-two-column"
@@ -78262,7 +78618,7 @@ const catalog = {
78262
78618
  ],
78263
78619
  "dependencies": ["@astryx-vue/core", "vue"],
78264
78620
  "isReady": true,
78265
- "source": "<script setup lang=\"ts\">\nimport type { CSSProperties } from 'vue'\nimport {\n AspectRatio,\n Button,\n Card,\n Grid,\n Heading,\n Icon,\n Layout,\n LayoutContent,\n Text,\n VStack,\n} from '@astryx-vue/core'\n\nconst image: CSSProperties = {\n width: '100%',\n height: '100%',\n objectFit: 'cover',\n}\n\ninterface Product {\n id: number\n name: string\n description: string\n price: number\n image: string\n}\n\nconst PRODUCTS: Product[] = [\n { id: 1, name: 'Going places', description: \"Sometimes all it takes is one small thing to turn your whole day around. That's what good design is for.\", price: 75.0, image: '/template-assets/illustrative-horizontal-1.png' },\n { id: 2, name: 'Meeting people', description: \"Sometimes all it takes is one small thing to turn your whole day around. That's what good design is for.\", price: 80.0, image: '/template-assets/illustrative-vertical-1.png' },\n { id: 3, name: 'Seeing things', description: \"Sometimes all it takes is one small thing to turn your whole day around. That's what good design is for.\", price: 75.0, image: '/template-assets/illustrative-horizontal-3.png' },\n { id: 4, name: 'Sharing ideas', description: \"Sometimes all it takes is one small thing to turn your whole day around. That's what good design is for.\", price: 75.0, image: '/template-assets/illustrative-horizontal-4.png' },\n { id: 5, name: 'Making memories', description: \"Sometimes all it takes is one small thing to turn your whole day around. That's what good design is for.\", price: 60.0, image: '/template-assets/illustrative-horizontal-5.png' },\n { id: 6, name: 'Being free', description: \"Sometimes all it takes is one small thing to turn your whole day around. That's what good design is for.\", price: 80.0, image: '/template-assets/illustrative-horizontal-2.png' },\n]\n\nfunction fmt(n: number) {\n return `$${n.toFixed(2)}`\n}\n<\/script>\n\n<template>\n <Layout height=\"fill\" :content-width=\"1200\">\n <LayoutContent :padding=\"6\">\n <VStack :gap=\"6\">\n <Grid :columns=\"{ minWidth: 280 }\" :gap=\"4\" align=\"start\">\n <Heading :level=\"1\">\n Make every day a little more delightful, one small detail at a time.\n </Heading>\n <VStack :gap=\"3\" justify=\"start\">\n <Text type=\"body\">\n We believe the smallest details are the ones that matter most. A little color, a thoughtful touch, a moment that catches your eye and makes you pause; that's what turns an ordinary day into something worth remembering.\n </Text>\n <Button label=\"Get started\" variant=\"primary\">\n <template #end>\n <Icon icon=\"i-heroicons-arrow-right\" color=\"inherit\" />\n </template>\n </Button>\n </VStack>\n </Grid>\n <Grid :columns=\"{ minWidth: 300 }\" :gap=\"6\">\n <VStack v-for=\"product in PRODUCTS\" :key=\"product.id\" :gap=\"3\">\n <Card :padding=\"0\">\n <AspectRatio :ratio=\"1\">\n <img :src=\"product.image\" :alt=\"product.name\" :style=\"image\">\n </AspectRatio>\n </Card>\n <VStack :gap=\"1\">\n <Heading :level=\"2\">\n {{ product.name }}\n </Heading>\n <Text type=\"body\" color=\"secondary\" :max-lines=\"2\">\n {{ product.description }}\n </Text>\n <Text type=\"large\" weight=\"bold\">\n {{ fmt(product.price) }}\n </Text>\n </VStack>\n </VStack>\n </Grid>\n </VStack>\n </LayoutContent>\n </Layout>\n</template>\n",
78621
+ "source": "<script setup lang=\"ts\">\nimport type { CSSProperties } from 'vue'\nimport {\n AspectRatio,\n Button,\n Card,\n Grid,\n Heading,\n Icon,\n Layout,\n LayoutContent,\n Text,\n VStack,\n} from '@astryx-vue/core'\n\nconst image: CSSProperties = {\n width: '100%',\n height: '100%',\n objectFit: 'cover',\n}\n\ninterface Product {\n id: number\n name: string\n description: string\n price: number\n image: string\n}\n\nconst PRODUCTS: Product[] = [\n { id: 1, name: 'Going places', description: \"Sometimes all it takes is one small thing to turn your whole day around. That's what good design is for.\", price: 75.0, image: '/template-assets/illustrative-horizontal-1.png' },\n { id: 2, name: 'Meeting people', description: \"Sometimes all it takes is one small thing to turn your whole day around. That's what good design is for.\", price: 80.0, image: '/template-assets/illustrative-vertical-1.png' },\n { id: 3, name: 'Seeing things', description: \"Sometimes all it takes is one small thing to turn your whole day around. That's what good design is for.\", price: 75.0, image: '/template-assets/illustrative-horizontal-3.png' },\n { id: 4, name: 'Sharing ideas', description: \"Sometimes all it takes is one small thing to turn your whole day around. That's what good design is for.\", price: 75.0, image: '/template-assets/illustrative-horizontal-4.png' },\n { id: 5, name: 'Making memories', description: \"Sometimes all it takes is one small thing to turn your whole day around. That's what good design is for.\", price: 60.0, image: '/template-assets/illustrative-horizontal-5.png' },\n { id: 6, name: 'Being free', description: \"Sometimes all it takes is one small thing to turn your whole day around. That's what good design is for.\", price: 80.0, image: '/template-assets/illustrative-horizontal-2.png' },\n]\n\nfunction fmt(n: number) {\n return `$${n.toFixed(2)}`\n}\n<\/script>\n\n<template>\n <Layout height=\"fill\" :content-width=\"1200\">\n <LayoutContent :padding=\"6\">\n <VStack :gap=\"6\">\n <Grid :columns=\"{ minWidth: 280 }\" :gap=\"4\" align=\"start\">\n <Heading :level=\"1\">\n Make every day a little more delightful, one small detail at a time.\n </Heading>\n <VStack :gap=\"3\" hAlign=\"start\">\n <Text type=\"body\">\n We believe the smallest details are the ones that matter most. A little color, a thoughtful touch, a moment that catches your eye and makes you pause; that's what turns an ordinary day into something worth remembering.\n </Text>\n <Button label=\"Get started\" variant=\"primary\">\n <template #end>\n <Icon icon=\"i-heroicons-arrow-right\" color=\"inherit\" />\n </template>\n </Button>\n </VStack>\n </Grid>\n <Grid :columns=\"{ minWidth: 300 }\" :gap=\"6\">\n <VStack v-for=\"product in PRODUCTS\" :key=\"product.id\" :gap=\"3\">\n <Card :padding=\"0\">\n <AspectRatio :ratio=\"1\">\n <img :src=\"product.image\" :alt=\"product.name\" :style=\"image\">\n </AspectRatio>\n </Card>\n <VStack :gap=\"1\">\n <Heading :level=\"2\">\n {{ product.name }}\n </Heading>\n <Text type=\"body\" color=\"secondary\" :max-lines=\"2\">\n {{ product.description }}\n </Text>\n <Text type=\"large\" weight=\"bold\">\n {{ fmt(product.price) }}\n </Text>\n </VStack>\n </VStack>\n </Grid>\n </VStack>\n </LayoutContent>\n </Layout>\n</template>\n",
78266
78622
  "docsUrl": {
78267
78623
  "en": "https://astryx-vue.pages.dev/en/playground/?template=product-gallery",
78268
78624
  "zh": "https://astryx-vue.pages.dev/playground/?template=product-gallery"
@@ -78317,7 +78673,7 @@ const catalog = {
78317
78673
  ],
78318
78674
  "dependencies": ["@astryx-vue/core", "vue"],
78319
78675
  "isReady": true,
78320
- "source": "<script setup lang=\"ts\">\nimport type { CSSProperties } from 'vue'\nimport {\n AspectRatio,\n Button,\n Divider,\n Grid,\n Heading,\n HStack,\n Layout,\n LayoutContent,\n Text,\n VStack,\n} from '@astryx-vue/core'\n\nconst imageStyle: CSSProperties = {\n width: '100%',\n height: '100%',\n objectFit: 'cover',\n}\n\nconst imageClip: CSSProperties = {\n borderRadius: 'var(--astryx-radius-element)',\n}\n\nconst IMAGES = [\n { src: '/template-assets/colorful-lifestyle-vertical-3.png', alt: 'Colorful lifestyle scene' },\n { src: '/template-assets/colorful-lifestyle-horizontal-1.png', alt: 'Colorful lifestyle horizontal' },\n { src: '/template-assets/colorful-lifestyle-vertical-1.png', alt: 'Colorful lifestyle vertical' },\n { src: '/template-assets/colorful-home-vertical-2.png', alt: 'Colorful home interior' },\n { src: '/template-assets/colorful-home-vertical-3.png', alt: 'Colorful home scene' },\n { src: '/template-assets/colorful-home-vertical-1.png', alt: 'Colorful home vertical' },\n { src: '/template-assets/colorful-lifestyle-horizontal-2.png', alt: 'Colorful lifestyle wide' },\n { src: '/template-assets/colorful-lifestyle-vertical-2.png', alt: 'Colorful lifestyle detail' },\n { src: '/template-assets/colorful-lifestyle-vertical-4.png', alt: 'Colorful lifestyle portrait' },\n]\n<\/script>\n\n<template>\n <Layout height=\"fill\" :content-width=\"1400\">\n <LayoutContent :padding=\"6\">\n <Grid :columns=\"{ minWidth: 360, repeat: 'fit' }\" :gap=\"8\" align=\"center\">\n <VStack :gap=\"6\" justify=\"center\">\n <VStack :gap=\"3\">\n <Text type=\"supporting\" color=\"secondary\" weight=\"semibold\">\n COLORFUL\n </Text>\n <Heading :level=\"1\">\n Make every day a little more delightful, one small detail at a time.\n </Heading>\n <Text type=\"body\" color=\"secondary\">\n The smallest details are the ones that matter most. A little color that catches your eye and makes you pause; that's what turns an ordinary day into something worth remembering.\n </Text>\n </VStack>\n <HStack :gap=\"3\" align=\"center\">\n <Button label=\"Explore\" variant=\"primary\" />\n </HStack>\n <VStack :gap=\"4\">\n <Divider />\n <HStack :gap=\"6\">\n <VStack :gap=\"0\">\n <Text type=\"large\" weight=\"bold\">\n 12k+\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Photos\n </Text>\n </VStack>\n <VStack :gap=\"0\">\n <Text type=\"large\" weight=\"bold\">\n 350+\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Projects\n </Text>\n </VStack>\n <VStack :gap=\"0\">\n <Text type=\"large\" weight=\"bold\">\n 8yrs\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Experience\n </Text>\n </VStack>\n </HStack>\n </VStack>\n </VStack>\n <Grid :columns=\"3\" :gap=\"3\">\n <AspectRatio\n v-for=\"img in IMAGES\"\n :key=\"img.src\"\n :ratio=\"1\"\n :style=\"imageClip\"\n >\n <img :src=\"img.src\" :alt=\"img.alt\" :style=\"imageStyle\">\n </AspectRatio>\n </Grid>\n </Grid>\n </LayoutContent>\n </Layout>\n</template>\n",
78676
+ "source": "<script setup lang=\"ts\">\nimport type { CSSProperties } from 'vue'\nimport {\n AspectRatio,\n Button,\n Divider,\n Grid,\n Heading,\n HStack,\n Layout,\n LayoutContent,\n Text,\n VStack,\n} from '@astryx-vue/core'\n\nconst imageStyle: CSSProperties = {\n width: '100%',\n height: '100%',\n objectFit: 'cover',\n}\n\nconst imageClip: CSSProperties = {\n borderRadius: 'var(--astryx-radius-element)',\n}\n\nconst IMAGES = [\n { src: '/template-assets/colorful-lifestyle-vertical-3.png', alt: 'Colorful lifestyle scene' },\n { src: '/template-assets/colorful-lifestyle-horizontal-1.png', alt: 'Colorful lifestyle horizontal' },\n { src: '/template-assets/colorful-lifestyle-vertical-1.png', alt: 'Colorful lifestyle vertical' },\n { src: '/template-assets/colorful-home-vertical-2.png', alt: 'Colorful home interior' },\n { src: '/template-assets/colorful-home-vertical-3.png', alt: 'Colorful home scene' },\n { src: '/template-assets/colorful-home-vertical-1.png', alt: 'Colorful home vertical' },\n { src: '/template-assets/colorful-lifestyle-horizontal-2.png', alt: 'Colorful lifestyle wide' },\n { src: '/template-assets/colorful-lifestyle-vertical-2.png', alt: 'Colorful lifestyle detail' },\n { src: '/template-assets/colorful-lifestyle-vertical-4.png', alt: 'Colorful lifestyle portrait' },\n]\n<\/script>\n\n<template>\n <Layout height=\"fill\" :content-width=\"1400\">\n <LayoutContent :padding=\"6\">\n <Grid :columns=\"{ minWidth: 360, repeat: 'fit' }\" :gap=\"8\" align=\"center\">\n <VStack :gap=\"6\" hAlign=\"center\">\n <VStack :gap=\"3\">\n <Text type=\"supporting\" color=\"secondary\" weight=\"semibold\">\n COLORFUL\n </Text>\n <Heading :level=\"1\">\n Make every day a little more delightful, one small detail at a time.\n </Heading>\n <Text type=\"body\" color=\"secondary\">\n The smallest details are the ones that matter most. A little color that catches your eye and makes you pause; that's what turns an ordinary day into something worth remembering.\n </Text>\n </VStack>\n <HStack :gap=\"3\" align=\"center\">\n <Button label=\"Explore\" variant=\"primary\" />\n </HStack>\n <VStack :gap=\"4\">\n <Divider />\n <HStack :gap=\"6\">\n <VStack :gap=\"0\">\n <Text type=\"large\" weight=\"bold\">\n 12k+\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Photos\n </Text>\n </VStack>\n <VStack :gap=\"0\">\n <Text type=\"large\" weight=\"bold\">\n 350+\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Projects\n </Text>\n </VStack>\n <VStack :gap=\"0\">\n <Text type=\"large\" weight=\"bold\">\n 8yrs\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Experience\n </Text>\n </VStack>\n </HStack>\n </VStack>\n </VStack>\n <Grid :columns=\"3\" :gap=\"3\">\n <AspectRatio\n v-for=\"img in IMAGES\"\n :key=\"img.src\"\n :ratio=\"1\"\n :style=\"imageClip\"\n >\n <img :src=\"img.src\" :alt=\"img.alt\" :style=\"imageStyle\">\n </AspectRatio>\n </Grid>\n </Grid>\n </LayoutContent>\n </Layout>\n</template>\n",
78321
78677
  "docsUrl": {
78322
78678
  "en": "https://astryx-vue.pages.dev/en/playground/?template=side-gallery",
78323
78679
  "zh": "https://astryx-vue.pages.dev/playground/?template=side-gallery"
@@ -78371,7 +78727,7 @@ const catalog = {
78371
78727
  ],
78372
78728
  "dependencies": ["@astryx-vue/core", "vue"],
78373
78729
  "isReady": true,
78374
- "source": "<script setup lang=\"ts\">\nimport {\n Banner,\n Button,\n Card,\n Center,\n Heading,\n Icon,\n Text,\n TextInput,\n VStack,\n} from '@astryx-vue/core'\nimport { ref } from 'vue'\n\nconst email = ref('')\nconst password = ref('')\nconst isLoading = ref(false)\nconst error = ref('')\n\nfunction signIn() {\n error.value = ''\n if (!email.value || !password.value) {\n error.value = 'Please enter both email and password.'\n return\n }\n isLoading.value = true\n setTimeout(() => { isLoading.value = false }, 2000)\n}\n<\/script>\n\n<template>\n <Center axis=\"both\" :padding=\"6\" class=\"login-page\">\n <VStack :gap=\"4\" h-align=\"center\" class=\"login-column\">\n <VStack :gap=\"2\" h-align=\"center\">\n <Icon icon=\"i-heroicons-cube\" size=\"lg\" />\n <Text type=\"body\" weight=\"bold\" size=\"lg\">\n Product Inc.\n </Text>\n </VStack>\n\n <Card :padding=\"8\" width=\"100%\">\n <VStack :gap=\"4\" h-align=\"stretch\">\n <VStack :gap=\"1\" h-align=\"center\">\n <Heading :level=\"2\">\n Sign in\n </Heading>\n <Text type=\"body\" color=\"secondary\" size=\"sm\">\n Enter your credentials to continue\n </Text>\n </VStack>\n\n <Banner v-if=\"error\" status=\"error\" :title=\"error\" container=\"card\" />\n\n <TextInput\n v-model=\"email\"\n label=\"Email\"\n placeholder=\"you@example.com\"\n type=\"email\"\n size=\"lg\"\n />\n\n <TextInput\n v-model=\"password\"\n label=\"Password\"\n placeholder=\"Enter your password\"\n type=\"password\"\n size=\"lg\"\n />\n\n <Button\n label=\"Sign in\"\n variant=\"primary\"\n size=\"lg\"\n :is-loading=\"isLoading\"\n @click=\"signIn\"\n />\n </VStack>\n </Card>\n </VStack>\n </Center>\n</template>\n\n<style>\n.login-page {\n min-height: 100%;\n background-color: var(--astryx-color-background-body);\n}\n.login-column {\n width: 100%;\n max-width: 400px;\n}\n</style>\n",
78730
+ "source": "<script setup lang=\"ts\">\nimport {\n Banner,\n Button,\n Card,\n Center,\n Heading,\n Icon,\n Text,\n TextInput,\n VStack,\n} from '@astryx-vue/core'\nimport { ref } from 'vue'\n\nconst email = ref('')\nconst password = ref('')\nconst isLoading = ref(false)\nconst error = ref('')\n\nfunction signIn() {\n error.value = ''\n if (!email.value || !password.value) {\n error.value = 'Please enter both email and password.'\n return\n }\n isLoading.value = true\n setTimeout(() => { isLoading.value = false }, 2000)\n}\n<\/script>\n\n<template>\n <Center axis=\"both\" :padding=\"6\" class=\"login-page\">\n <VStack :gap=\"4\" h-align=\"center\" class=\"login-column\">\n <VStack :gap=\"2\" h-align=\"center\">\n <Icon name=\"heroicons:cube\" size=\"lg\" />\n <Text type=\"body\" weight=\"bold\" size=\"lg\">\n Product Inc.\n </Text>\n </VStack>\n\n <Card :padding=\"8\" width=\"100%\">\n <VStack :gap=\"4\" h-align=\"stretch\">\n <VStack :gap=\"1\" h-align=\"center\">\n <Heading :level=\"2\">\n Sign in\n </Heading>\n <Text type=\"body\" color=\"secondary\" size=\"sm\">\n Enter your credentials to continue\n </Text>\n </VStack>\n\n <Banner v-if=\"error\" status=\"error\" :title=\"error\" container=\"card\" />\n\n <TextInput\n v-model=\"email\"\n label=\"Email\"\n placeholder=\"you@example.com\"\n type=\"email\"\n size=\"lg\"\n />\n\n <TextInput\n v-model=\"password\"\n label=\"Password\"\n placeholder=\"Enter your password\"\n type=\"password\"\n size=\"lg\"\n />\n\n <Button\n label=\"Sign in\"\n variant=\"primary\"\n size=\"lg\"\n :is-loading=\"isLoading\"\n @click=\"signIn\"\n />\n </VStack>\n </Card>\n </VStack>\n </Center>\n</template>\n\n<style>\n.login-page {\n min-height: 100%;\n background-color: var(--astryx-color-background-body);\n}\n.login-column {\n width: 100%;\n max-width: 400px;\n}\n</style>\n",
78375
78731
  "docsUrl": {
78376
78732
  "en": "https://astryx-vue.pages.dev/en/playground/?template=login",
78377
78733
  "zh": "https://astryx-vue.pages.dev/playground/?template=login"
@@ -78427,7 +78783,7 @@ const catalog = {
78427
78783
  ],
78428
78784
  "dependencies": ["@astryx-vue/core", "vue"],
78429
78785
  "isReady": true,
78430
- "source": "<script setup lang=\"ts\">\nimport type { CSSProperties } from 'vue'\nimport { ref } from 'vue'\nimport {\n Button,\n Card,\n Center,\n Divider,\n Heading,\n Icon,\n Link,\n Text,\n TextInput,\n VStack,\n} from '@astryx-vue/core'\n\nconst pageStyle: CSSProperties = {\n minHeight: '100%',\n backgroundColor: 'var(--astryx-color-background-body)',\n}\n\nconst contentStyle: CSSProperties = {\n width: '100%',\n maxWidth: '400px',\n}\n\nconst email = ref('')\nconst password = ref('')\nconst loginFailed = ref(false)\nconst isLoading = ref(false)\n\nfunction handleLogin() {\n if (!email.value || !password.value) {\n loginFailed.value = true\n return\n }\n isLoading.value = true\n loginFailed.value = false\n window.setTimeout(() => {\n isLoading.value = false\n loginFailed.value = true\n }, 2000)\n}\n\nfunction onPasswordChange(value: string) {\n password.value = value\n loginFailed.value = false\n}\n<\/script>\n\n<template>\n <Center axis=\"both\" :padding=\"6\" :style=\"pageStyle\">\n <VStack :gap=\"4\" justify=\"center\" :style=\"contentStyle\">\n <VStack :gap=\"2\" justify=\"center\">\n <Icon icon=\"i-heroicons-cube\" size=\"lg\" />\n <Text type=\"body\" weight=\"bold\" size=\"lg\">\n Product Inc.\n </Text>\n </VStack>\n <Card :padding=\"8\" width=\"100%\">\n <VStack :gap=\"4\" hAlign=\"stretch\">\n <VStack :gap=\"1\" justify=\"center\">\n <Heading :level=\"2\">\n Welcome back\n </Heading>\n <Text type=\"body\" color=\"secondary\" size=\"sm\">\n Sign in to your account\n </Text>\n </VStack>\n <VStack :gap=\"2\">\n <TextInput\n v-model=\"email\"\n label=\"Email\"\n is-label-hidden\n type=\"email\"\n placeholder=\"name@company.com\"\n size=\"lg\"\n />\n <VStack :gap=\"1\">\n <TextInput\n :model-value=\"password\"\n label=\"Password\"\n is-label-hidden\n placeholder=\"Enter your password\"\n type=\"password\"\n size=\"lg\"\n :status=\"loginFailed ? { type: 'error', message: 'Incorrect password. Try again.' } : undefined\"\n @update:model-value=\"onPasswordChange\"\n />\n <VStack v-if=\"loginFailed\" justify=\"end\">\n <Link href=\"#\" size=\"sm\" color=\"secondary\" type=\"supporting\">\n Forgot password?\n </Link>\n </VStack>\n </VStack>\n </VStack>\n <Button\n label=\"Login\"\n variant=\"primary\"\n size=\"lg\"\n :is-loading=\"isLoading\"\n @click=\"handleLogin\"\n />\n <Divider label=\"Or continue with\" />\n <VStack :gap=\"3\" hAlign=\"stretch\">\n <Button label=\"Login with Apple\" variant=\"secondary\" size=\"lg\">\n <template #icon>\n <svg viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"16\" height=\"16\" aria-hidden=\"true\">\n <path d=\"M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z\" />\n </svg>\n </template>\n </Button>\n <Button label=\"Login with Google\" variant=\"secondary\" size=\"lg\">\n <template #icon>\n <svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" aria-hidden=\"true\">\n <path d=\"M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z\" fill=\"#4285F4\" />\n <path d=\"M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z\" fill=\"#34A853\" />\n <path d=\"M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z\" fill=\"#FBBC05\" />\n <path d=\"M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z\" fill=\"#EA4335\" />\n </svg>\n </template>\n </Button>\n </VStack>\n <VStack justify=\"center\">\n <Text type=\"supporting\" color=\"secondary\">\n Don't have an account?\n <Link href=\"#\" type=\"supporting\">\n Sign up\n </Link>\n </Text>\n </VStack>\n </VStack>\n </Card>\n <VStack justify=\"center\" width=\"100%\">\n <Text type=\"supporting\" color=\"secondary\" justify=\"center\">\n By clicking continue, you agree to our\n <Link href=\"#\" type=\"supporting\">\n Terms of Service\n </Link>\n and\n <Link href=\"#\" type=\"supporting\">\n Privacy Policy\n </Link>.\n </Text>\n </VStack>\n </VStack>\n </Center>\n</template>\n",
78786
+ "source": "<script setup lang=\"ts\">\nimport type { CSSProperties } from 'vue'\nimport {\n Button,\n Card,\n Center,\n Divider,\n Heading,\n Icon,\n Link,\n Text,\n TextInput,\n VStack,\n} from '@astryx-vue/core'\nimport { ref } from 'vue'\n\nconst pageStyle: CSSProperties = {\n backgroundColor: 'var(--astryx-color-background-body)',\n}\n\nconst email = ref('')\nconst password = ref('')\nconst loginFailed = ref(false)\nconst isLoading = ref(false)\n\nfunction handleLogin() {\n if (!email.value || !password.value) {\n loginFailed.value = true\n return\n }\n isLoading.value = true\n loginFailed.value = false\n window.setTimeout(() => {\n isLoading.value = false\n loginFailed.value = true\n }, 2000)\n}\n\nfunction onPasswordChange(value: string) {\n password.value = value\n loginFailed.value = false\n}\n<\/script>\n\n<template>\n <Center axis=\"both\" :padding=\"6\" min-height=\"100%\" :style=\"pageStyle\">\n <VStack :gap=\"4\" h-align=\"center\" width=\"100%\" :max-width=\"400\">\n <VStack :gap=\"2\" h-align=\"center\">\n <Icon name=\"heroicons:cube\" size=\"lg\" />\n <Text type=\"body\" weight=\"bold\" size=\"lg\">\n Product Inc.\n </Text>\n </VStack>\n <Card :padding=\"8\" width=\"100%\">\n <VStack :gap=\"4\" h-align=\"stretch\">\n <VStack :gap=\"1\" h-align=\"center\">\n <Heading :level=\"2\">\n Welcome back\n </Heading>\n <Text type=\"body\" color=\"secondary\" size=\"sm\">\n Sign in to your account\n </Text>\n </VStack>\n <VStack :gap=\"2\">\n <TextInput\n v-model=\"email\"\n label=\"Email\"\n is-label-hidden\n type=\"email\"\n placeholder=\"name@company.com\"\n size=\"lg\"\n />\n <VStack :gap=\"1\">\n <TextInput\n :model-value=\"password\"\n label=\"Password\"\n is-label-hidden\n placeholder=\"Enter your password\"\n type=\"password\"\n size=\"lg\"\n :status=\"loginFailed ? { type: 'error', message: 'Incorrect password. Try again.' } : undefined\"\n @update:model-value=\"onPasswordChange\"\n />\n <VStack v-if=\"loginFailed\" h-align=\"end\">\n <Link href=\"#\" size=\"sm\" color=\"secondary\">\n Forgot password?\n </Link>\n </VStack>\n </VStack>\n </VStack>\n <Button\n label=\"Login\"\n variant=\"primary\"\n size=\"lg\"\n :is-loading=\"isLoading\"\n @click=\"handleLogin\"\n />\n <Divider label=\"Or continue with\" />\n <VStack :gap=\"3\" h-align=\"stretch\">\n <Button label=\"Login with Apple\" variant=\"secondary\" size=\"lg\">\n <template #icon>\n <svg viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"16\" height=\"16\" style=\"width: 16px; height: 16px\" aria-hidden=\"true\">\n <path d=\"M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z\" />\n </svg>\n </template>\n </Button>\n <Button label=\"Login with Google\" variant=\"secondary\" size=\"lg\">\n <template #icon>\n <svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" style=\"width: 16px; height: 16px\" aria-hidden=\"true\">\n <path d=\"M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z\" fill=\"#4285F4\" />\n <path d=\"M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z\" fill=\"#34A853\" />\n <path d=\"M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z\" fill=\"#FBBC05\" />\n <path d=\"M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z\" fill=\"#EA4335\" />\n </svg>\n </template>\n </Button>\n </VStack>\n <VStack h-align=\"center\">\n <Text type=\"supporting\" color=\"secondary\">\n Don't have an account?\n <Link href=\"#\" size=\"sm\">\n Sign up\n </Link>\n </Text>\n </VStack>\n </VStack>\n </Card>\n <VStack h-align=\"center\" width=\"100%\">\n <Text type=\"supporting\" color=\"secondary\" justify=\"center\">\n By clicking continue, you agree to our\n <Link href=\"#\" size=\"sm\">\n Terms of Service\n </Link>\n and\n <Link href=\"#\" size=\"sm\">\n Privacy Policy\n </Link>.\n </Text>\n </VStack>\n </VStack>\n </Center>\n</template>\n",
78431
78787
  "docsUrl": {
78432
78788
  "en": "https://astryx-vue.pages.dev/en/playground/?template=login-card",
78433
78789
  "zh": "https://astryx-vue.pages.dev/playground/?template=login-card"
@@ -78486,7 +78842,7 @@ const catalog = {
78486
78842
  ],
78487
78843
  "dependencies": ["@astryx-vue/core", "vue"],
78488
78844
  "isReady": true,
78489
- "source": "<script setup lang=\"ts\">\nimport type { CSSProperties } from 'vue'\nimport { ref } from 'vue'\nimport {\n Button,\n Card,\n Center,\n Divider,\n EmptyState,\n Grid,\n HStack,\n Icon,\n Link,\n Section,\n StackItem,\n Text,\n TextInput,\n VStack,\n} from '@astryx-vue/core'\n\nconst COVER_IMAGE_URL = '/template-assets/light-working-vertical-1.png'\nconst APPLE_LOGO_URL = '/template-assets/AppleLogo.png'\nconst GOOGLE_LOGO_URL = '/template-assets/GoogleLogo.png'\nconst COLUMN_MIN_WIDTH = 240\n\nconst pageStyle: CSSProperties = {\n minHeight: '100%',\n backgroundColor: 'var(--astryx-color-background-body)',\n}\n\nconst cardWrap: CSSProperties = {\n width: '100%',\n maxWidth: '1000px',\n marginInline: 'auto',\n}\n\nconst coverImage: CSSProperties = {\n width: '100%',\n height: '100%',\n objectFit: 'cover',\n}\n\nconst email = ref('')\nconst password = ref('')\nconst loginFailed = ref(false)\nconst isLoading = ref(false)\nconst isSuccess = ref(false)\n\nfunction handleLogin() {\n if (!email.value || !password.value) {\n loginFailed.value = true\n return\n }\n isLoading.value = true\n loginFailed.value = false\n window.setTimeout(() => {\n isLoading.value = false\n isSuccess.value = true\n }, 2000)\n}\n\nfunction onPasswordChange(value: string) {\n password.value = value\n loginFailed.value = false\n}\n<\/script>\n\n<template>\n <Center axis=\"both\" :padding=\"6\" :style=\"pageStyle\">\n <VStack :gap=\"4\" width=\"100%\">\n <div :style=\"cardWrap\">\n <Card :padding=\"0\" width=\"100%\">\n <Grid\n :columns=\"{ minWidth: COLUMN_MIN_WIDTH, repeat: 'fit' }\"\n :gap=\"8\"\n align=\"stretch\"\n class=\"login-split-grid\"\n >\n <Section variant=\"transparent\" :padding=\"0\" height=\"100%\">\n <VStack :gap=\"4\" height=\"100%\">\n <HStack :gap=\"2\" align=\"center\">\n <Icon icon=\"i-heroicons-squares-2x2\" />\n <Text type=\"body\" weight=\"bold\">\n Product Inc.\n </Text>\n </HStack>\n <StackItem size=\"fill\">\n <Center axis=\"vertical\" height=\"100%\">\n <EmptyState\n v-if=\"isSuccess\"\n title=\"You're signed in\"\n description=\"Redirecting to your dashboard…\"\n >\n <template #icon>\n <Icon icon=\"i-heroicons-check-circle\" size=\"lg\" />\n </template>\n </EmptyState>\n <VStack v-else :gap=\"4\" hAlign=\"stretch\" width=\"100%\">\n <VStack :gap=\"1\">\n <Text type=\"display-1\" as=\"h2\">\n Welcome back\n </Text>\n <Text type=\"body\" color=\"secondary\" size=\"sm\">\n Login to your Product Inc. account\n </Text>\n </VStack>\n <VStack :gap=\"2\">\n <TextInput\n v-model=\"email\"\n label=\"Email\"\n is-label-hidden\n type=\"email\"\n placeholder=\"name@company.com\"\n size=\"lg\"\n />\n <VStack :gap=\"1\">\n <TextInput\n :model-value=\"password\"\n label=\"Password\"\n is-label-hidden\n placeholder=\"Enter your password\"\n type=\"password\"\n size=\"lg\"\n :status=\"loginFailed ? { type: 'error', message: 'Incorrect password. Try again.' } : undefined\"\n @update:model-value=\"onPasswordChange\"\n />\n <VStack v-if=\"loginFailed\" justify=\"end\">\n <Link href=\"#\" size=\"sm\" color=\"secondary\" type=\"supporting\">\n Forgot your password?\n </Link>\n </VStack>\n </VStack>\n </VStack>\n <Button\n label=\"Login\"\n variant=\"primary\"\n size=\"lg\"\n :is-loading=\"isLoading\"\n @click=\"handleLogin\"\n />\n <Divider label=\"Or continue with\" />\n <Grid :columns=\"2\" :gap=\"3\" justify=\"stretch\">\n <Button label=\"Apple\" variant=\"secondary\" size=\"lg\">\n <template #icon>\n <img :src=\"APPLE_LOGO_URL\" alt=\"\" width=\"16\" height=\"16\">\n </template>\n </Button>\n <Button label=\"Google\" variant=\"secondary\" size=\"lg\">\n <template #icon>\n <img :src=\"GOOGLE_LOGO_URL\" alt=\"\" width=\"16\" height=\"16\">\n </template>\n </Button>\n </Grid>\n </VStack>\n </Center>\n </StackItem>\n <Text v-if=\"!isSuccess\" type=\"supporting\" color=\"secondary\">\n Don't have an account?\n <Link href=\"#\" type=\"supporting\">\n Sign up\n </Link>\n </Text>\n </VStack>\n </Section>\n <div class=\"login-split-image\">\n <Card variant=\"transparent\" :padding=\"0\" width=\"100%\" height=\"100%\">\n <img :style=\"coverImage\" :src=\"COVER_IMAGE_URL\" alt=\"Two people working at a desk\">\n </Card>\n </div>\n </Grid>\n </Card>\n </div>\n <VStack justify=\"center\">\n <Text type=\"supporting\" color=\"secondary\">\n By clicking continue, you agree to our\n <Link href=\"#\" type=\"supporting\">\n Terms of Service\n </Link>\n and\n <Link href=\"#\" type=\"supporting\">\n Privacy Policy\n </Link>.\n </Text>\n </VStack>\n </VStack>\n </Center>\n</template>\n\n<style>\n.login-split-grid {\n container-type: inline-size;\n container-name: login-split;\n padding: var(--astryx-spacing-8);\n}\n.login-split-image {\n width: 100%;\n order: 0;\n}\n@container login-split (max-width: 511px) {\n .login-split-grid {\n padding: var(--astryx-spacing-4);\n }\n .login-split-image {\n order: -1;\n }\n}\n</style>\n",
78845
+ "source": "<script setup lang=\"ts\">\nimport type { CSSProperties } from 'vue'\nimport {\n Button,\n Card,\n Center,\n Divider,\n EmptyState,\n Grid,\n HStack,\n Icon,\n Link,\n Section,\n StackItem,\n Text,\n TextInput,\n VStack,\n} from '@astryx-vue/core'\nimport { ref } from 'vue'\n\nconst COVER_IMAGE_URL = '/template-assets/light-working-vertical-1.png'\nconst APPLE_LOGO_URL = '/template-assets/AppleLogo.png'\nconst GOOGLE_LOGO_URL = '/template-assets/GoogleLogo.png'\nconst COLUMN_MIN_WIDTH = 240\n\nconst pageStyle: CSSProperties = {\n backgroundColor: 'var(--astryx-color-background-body)',\n}\n\nconst cardWrap: CSSProperties = {\n width: '100%',\n maxWidth: '1000px',\n marginInline: 'auto',\n}\n\nconst coverImage: CSSProperties = {\n width: '100%',\n height: '100%',\n objectFit: 'cover',\n}\n\nconst email = ref('')\nconst password = ref('')\nconst loginFailed = ref(false)\nconst isLoading = ref(false)\nconst isSuccess = ref(false)\n\nfunction handleLogin() {\n if (!email.value || !password.value) {\n loginFailed.value = true\n return\n }\n isLoading.value = true\n loginFailed.value = false\n window.setTimeout(() => {\n isLoading.value = false\n isSuccess.value = true\n }, 2000)\n}\n\nfunction onPasswordChange(value: string) {\n password.value = value\n loginFailed.value = false\n}\n<\/script>\n\n<template>\n <Center axis=\"both\" :padding=\"6\" min-height=\"100%\" :style=\"pageStyle\">\n <VStack :gap=\"4\" width=\"100%\">\n <div :style=\"cardWrap\">\n <Card :padding=\"0\" width=\"100%\">\n <Grid\n :columns=\"{ minWidth: COLUMN_MIN_WIDTH, repeat: 'fit' }\"\n :gap=\"8\"\n align=\"stretch\"\n class=\"login-split-grid\"\n >\n <Section variant=\"transparent\" :padding=\"0\" height=\"100%\">\n <VStack :gap=\"4\" height=\"100%\">\n <HStack :gap=\"2\" align=\"center\">\n <Icon size=\"md\" name=\"heroicons:squares-2x2\" />\n <Text type=\"body\" weight=\"bold\">\n Product Inc.\n </Text>\n </HStack>\n <StackItem size=\"fill\">\n <Center axis=\"vertical\" height=\"100%\" :padding=\"0\">\n <EmptyState\n v-if=\"isSuccess\"\n title=\"You're signed in\"\n description=\"Redirecting to your dashboard…\"\n >\n <template #icon>\n <Icon name=\"heroicons:check-circle\" size=\"lg\" />\n </template>\n </EmptyState>\n <VStack v-else :gap=\"4\" h-align=\"stretch\" width=\"100%\">\n <VStack :gap=\"1\">\n <Text type=\"display-1\" as=\"h2\">\n Welcome back\n </Text>\n <Text type=\"body\" color=\"secondary\" size=\"sm\">\n Login to your Product Inc. account\n </Text>\n </VStack>\n <VStack :gap=\"2\">\n <TextInput\n v-model=\"email\"\n label=\"Email\"\n is-label-hidden\n type=\"email\"\n placeholder=\"name@company.com\"\n size=\"lg\"\n />\n <VStack :gap=\"1\">\n <TextInput\n :model-value=\"password\"\n label=\"Password\"\n is-label-hidden\n placeholder=\"Enter your password\"\n type=\"password\"\n size=\"lg\"\n :status=\"loginFailed ? { type: 'error', message: 'Incorrect password. Try again.' } : undefined\"\n @update:model-value=\"onPasswordChange\"\n />\n <VStack v-if=\"loginFailed\" h-align=\"end\">\n <Link href=\"#\" size=\"sm\" color=\"secondary\">\n Forgot your password?\n </Link>\n </VStack>\n </VStack>\n </VStack>\n <Button\n label=\"Login\"\n variant=\"primary\"\n size=\"lg\"\n :is-loading=\"isLoading\"\n @click=\"handleLogin\"\n />\n <Divider label=\"Or continue with\" />\n <Grid :columns=\"2\" :gap=\"3\" justify=\"stretch\">\n <Button label=\"Apple\" variant=\"secondary\" size=\"lg\">\n <template #icon>\n <img :src=\"APPLE_LOGO_URL\" alt=\"\" width=\"16\" height=\"16\" style=\"width: 16px; height: 16px\">\n </template>\n </Button>\n <Button label=\"Google\" variant=\"secondary\" size=\"lg\">\n <template #icon>\n <img :src=\"GOOGLE_LOGO_URL\" alt=\"\" width=\"16\" height=\"16\" style=\"width: 16px; height: 16px\">\n </template>\n </Button>\n </Grid>\n </VStack>\n </Center>\n </StackItem>\n <Text v-if=\"!isSuccess\" type=\"supporting\" color=\"secondary\">\n Don't have an account?\n <Link href=\"#\" size=\"sm\">\n Sign up\n </Link>\n </Text>\n </VStack>\n </Section>\n <div class=\"login-split-image\">\n <Card variant=\"transparent\" :padding=\"0\" width=\"100%\" height=\"100%\">\n <img :style=\"coverImage\" :src=\"COVER_IMAGE_URL\" alt=\"Two people working at a desk\">\n </Card>\n </div>\n </Grid>\n </Card>\n </div>\n <VStack h-align=\"center\">\n <Text type=\"supporting\" color=\"secondary\">\n By clicking continue, you agree to our\n <Link href=\"#\" size=\"sm\">\n Terms of Service\n </Link>\n and\n <Link href=\"#\" size=\"sm\">\n Privacy Policy\n </Link>.\n </Text>\n </VStack>\n </VStack>\n </Center>\n</template>\n\n<style>\n.login-split-grid {\n container-type: inline-size;\n container-name: login-split;\n padding: var(--astryx-spacing-8);\n}\n.login-split-image {\n width: 100%;\n order: 0;\n}\n@container login-split (max-width: 511px) {\n .login-split-grid {\n padding: var(--astryx-spacing-4);\n }\n .login-split-image {\n order: -1;\n }\n}\n</style>\n",
78490
78846
  "docsUrl": {
78491
78847
  "en": "https://astryx-vue.pages.dev/en/playground/?template=login-split",
78492
78848
  "zh": "https://astryx-vue.pages.dev/playground/?template=login-split"
@@ -78543,7 +78899,7 @@ const catalog = {
78543
78899
  ],
78544
78900
  "dependencies": ["@astryx-vue/core", "vue"],
78545
78901
  "isReady": true,
78546
- "source": "<script setup lang=\"ts\">\nimport type { CSSProperties } from 'vue'\nimport { computed, ref } from 'vue'\nimport {\n Avatar,\n Button,\n Card,\n Center,\n Divider,\n HStack,\n Icon,\n Link,\n Section,\n Text,\n TextInput,\n VStack,\n} from '@astryx-vue/core'\n\nconst BG_URL = '/template-assets/building.png'\n\nconst pageStyle: CSSProperties = {\n minHeight: '100%',\n backgroundImage: `url(${BG_URL})`,\n backgroundSize: 'cover',\n backgroundPosition: 'center',\n}\n\ntype Step = 'email' | 'sso-confirm' | 'password-fallback'\n\ntype SSOProvider = {\n name: string\n abbr: string\n}\n\nconst SSO_PROVIDERS: Record<string, SSOProvider> = {\n 'google.com': { name: 'Google Workspace', abbr: 'G' },\n 'microsoft.com': { name: 'Microsoft Entra ID', abbr: 'M' },\n 'okta.com': { name: 'Okta', abbr: 'O' },\n 'meta.com': { name: 'Meta SSO', abbr: 'M' },\n 'apple.com': { name: 'Apple Business', abbr: 'A' },\n}\n\nfunction getProvider(email: string) {\n const domain = email.split('@')[1]?.toLowerCase()\n return domain ? (SSO_PROVIDERS[domain] ?? null) : null\n}\n\nfunction isValidEmail(email: string) {\n return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email)\n}\n\nconst step = ref<Step>('email')\nconst email = ref('')\nconst password = ref('')\nconst loginFailed = ref(false)\nconst isLoading = ref(false)\n\nconst provider = computed(() => getProvider(email.value))\nconst emailValid = computed(() => isValidEmail(email.value))\n\nfunction handleContinue() {\n if (!emailValid.value)\n return\n if (provider.value)\n step.value = 'sso-confirm'\n else\n step.value = 'password-fallback'\n}\n\nfunction handleBack() {\n step.value = 'email'\n loginFailed.value = false\n isLoading.value = false\n}\n\nfunction handleSignIn() {\n if (!password.value) {\n loginFailed.value = true\n return\n }\n isLoading.value = true\n loginFailed.value = false\n window.setTimeout(() => {\n isLoading.value = false\n loginFailed.value = true\n }, 2000)\n}\n\nfunction onEmailKeydown(event: KeyboardEvent) {\n if (event.key === 'Enter')\n handleContinue()\n}\n\nfunction onPasswordChange(value: string) {\n password.value = value\n loginFailed.value = false\n}\n<\/script>\n\n<template>\n <Center axis=\"both\" :padding=\"6\" :style=\"pageStyle\">\n <Card :padding=\"8\" width=\"100%\" :max-width=\"400\">\n <VStack :gap=\"4\" hAlign=\"stretch\">\n <template v-if=\"step === 'email'\">\n <VStack :gap=\"1\" justify=\"center\">\n <Text type=\"display-1\" as=\"h2\">\n Welcome back\n </Text>\n <Text type=\"body\" color=\"secondary\" size=\"sm\">\n Enter your details to sign in to your account\n </Text>\n </VStack>\n <VStack :gap=\"2\">\n <TextInput\n v-model=\"email\"\n label=\"Work email\"\n is-label-hidden\n type=\"email\"\n placeholder=\"you@company.com\"\n size=\"lg\"\n @keydown=\"onEmailKeydown\"\n />\n <TextInput\n v-model=\"password\"\n label=\"Password\"\n is-label-hidden\n type=\"password\"\n placeholder=\"Password\"\n size=\"lg\"\n />\n </VStack>\n <Link href=\"#\" size=\"sm\" color=\"secondary\" type=\"supporting\">\n Having trouble signing in?\n </Link>\n <Button\n label=\"Sign in\"\n variant=\"primary\"\n size=\"lg\"\n :is-disabled=\"!emailValid\"\n @click=\"handleContinue\"\n />\n <Divider label=\"Or sign in with\" />\n <Button\n label=\"Continue with SSO\"\n variant=\"secondary\"\n size=\"lg\"\n :is-disabled=\"!emailValid\"\n @click=\"handleContinue\"\n />\n <VStack justify=\"center\">\n <Text type=\"supporting\" color=\"secondary\">\n Don't have an account?\n <Link href=\"#\" type=\"supporting\">\n Request access\n </Link>\n </Text>\n </VStack>\n </template>\n\n <template v-else-if=\"step === 'sso-confirm' && provider\">\n <VStack :gap=\"2\" justify=\"center\">\n <Avatar :name=\"provider.name\" :size=\"48\" />\n <Text type=\"display-3\" as=\"h2\">\n Sign in with {{ provider.name }}\n </Text>\n <Text type=\"body\" color=\"secondary\" size=\"sm\">\n You will be redirected back after signing in.\n </Text>\n </VStack>\n <Card :padding=\"0\">\n <Section variant=\"muted\" :padding=\"4\">\n <HStack :gap=\"2\" align=\"center\">\n <Icon icon=\"i-heroicons-shield-check\" color=\"secondary\" />\n <VStack :gap=\"0\">\n <Text type=\"label\">\n {{ provider.name }}\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n {{ email }}\n </Text>\n </VStack>\n </HStack>\n </Section>\n </Card>\n <VStack :gap=\"3\">\n <Button\n :label=\"`Continue with ${provider.name}`\"\n variant=\"primary\"\n size=\"lg\"\n :is-loading=\"isLoading\"\n @click=\"isLoading = true\"\n />\n <Button\n label=\"Use a different email\"\n variant=\"ghost\"\n size=\"lg\"\n @click=\"handleBack\"\n />\n </VStack>\n </template>\n\n <template v-else-if=\"step === 'password-fallback'\">\n <VStack :gap=\"1\" justify=\"center\">\n <Text type=\"display-1\" as=\"h2\">\n Welcome back\n </Text>\n <Text type=\"body\" color=\"secondary\" size=\"sm\">\n {{ email }}\n </Text>\n </VStack>\n <VStack :gap=\"4\">\n <VStack :gap=\"1\">\n <TextInput\n :model-value=\"password\"\n label=\"Password\"\n type=\"password\"\n size=\"lg\"\n :status=\"loginFailed ? { type: 'error', message: 'Incorrect password. Try again.' } : undefined\"\n @update:model-value=\"onPasswordChange\"\n />\n <VStack v-if=\"loginFailed\" justify=\"end\">\n <Link href=\"#\" size=\"sm\" color=\"secondary\" type=\"supporting\">\n Forgot password?\n </Link>\n </VStack>\n </VStack>\n <Button\n label=\"Sign in\"\n variant=\"primary\"\n size=\"lg\"\n :is-loading=\"isLoading\"\n @click=\"handleSignIn\"\n />\n <Button\n label=\"Use a different email\"\n variant=\"ghost\"\n size=\"lg\"\n @click=\"handleBack\"\n />\n </VStack>\n </template>\n </VStack>\n </Card>\n </Center>\n</template>\n",
78902
+ "source": "<script setup lang=\"ts\">\nimport type { CSSProperties } from 'vue'\nimport {\n Avatar,\n Button,\n Card,\n Center,\n Divider,\n HStack,\n Icon,\n Link,\n Section,\n Text,\n TextInput,\n VStack,\n} from '@astryx-vue/core'\nimport { computed, ref } from 'vue'\n\nconst BG_URL = '/template-assets/building.png'\n\nconst pageStyle: CSSProperties = {\n backgroundImage: `url(${BG_URL})`,\n backgroundSize: 'cover',\n backgroundPosition: 'center',\n}\n\ntype Step = 'email' | 'sso-confirm' | 'password-fallback'\n\ninterface SSOProvider {\n name: string\n abbr: string\n}\n\nconst SSO_PROVIDERS: Record<string, SSOProvider> = {\n 'google.com': { name: 'Google Workspace', abbr: 'G' },\n 'microsoft.com': { name: 'Microsoft Entra ID', abbr: 'M' },\n 'okta.com': { name: 'Okta', abbr: 'O' },\n 'meta.com': { name: 'Meta SSO', abbr: 'M' },\n 'apple.com': { name: 'Apple Business', abbr: 'A' },\n}\n\nfunction getProvider(email: string) {\n const domain = email.split('@')[1]?.toLowerCase()\n return domain ? (SSO_PROVIDERS[domain] ?? null) : null\n}\n\nfunction isValidEmail(email: string) {\n return /^[^\\s@]+@[^\\s@][^\\s.@]*\\.[^\\s@]+$/.test(email)\n}\n\nconst step = ref<Step>('email')\nconst email = ref('')\nconst password = ref('')\nconst loginFailed = ref(false)\nconst isLoading = ref(false)\n\nconst provider = computed(() => getProvider(email.value))\nconst emailValid = computed(() => isValidEmail(email.value))\n\nfunction handleContinue() {\n if (!emailValid.value)\n return\n if (provider.value)\n step.value = 'sso-confirm'\n else\n step.value = 'password-fallback'\n}\n\nfunction handleBack() {\n step.value = 'email'\n loginFailed.value = false\n isLoading.value = false\n}\n\nfunction handleSignIn() {\n if (!password.value) {\n loginFailed.value = true\n return\n }\n isLoading.value = true\n loginFailed.value = false\n window.setTimeout(() => {\n isLoading.value = false\n loginFailed.value = true\n }, 2000)\n}\n\nfunction onEmailKeydown(event: KeyboardEvent) {\n if (event.key === 'Enter')\n handleContinue()\n}\n\nfunction onPasswordChange(value: string) {\n password.value = value\n loginFailed.value = false\n}\n<\/script>\n\n<template>\n <Center axis=\"both\" :padding=\"6\" min-height=\"100%\" :style=\"pageStyle\">\n <Card :padding=\"8\" width=\"100%\" :max-width=\"400\">\n <VStack :gap=\"4\" h-align=\"stretch\">\n <template v-if=\"step === 'email'\">\n <VStack :gap=\"1\" h-align=\"center\">\n <Text type=\"display-1\" as=\"h2\">\n Welcome back\n </Text>\n <Text type=\"body\" color=\"secondary\" size=\"sm\">\n Enter your details to sign in to your account\n </Text>\n </VStack>\n <VStack :gap=\"2\">\n <TextInput\n v-model=\"email\"\n label=\"Work email\"\n is-label-hidden\n type=\"email\"\n placeholder=\"you@company.com\"\n size=\"lg\"\n @keydown=\"onEmailKeydown\"\n />\n <TextInput\n v-model=\"password\"\n label=\"Password\"\n is-label-hidden\n type=\"password\"\n placeholder=\"Password\"\n size=\"lg\"\n />\n </VStack>\n <Link href=\"#\" size=\"sm\" color=\"secondary\">\n Having trouble signing in?\n </Link>\n <Button\n label=\"Sign in\"\n variant=\"primary\"\n size=\"lg\"\n :is-disabled=\"!emailValid\"\n @click=\"handleContinue\"\n />\n <Divider label=\"Or sign in with\" />\n <Button\n label=\"Continue with SSO\"\n variant=\"secondary\"\n size=\"lg\"\n :is-disabled=\"!emailValid\"\n @click=\"handleContinue\"\n />\n <VStack h-align=\"center\">\n <Text type=\"supporting\" color=\"secondary\">\n Don't have an account?\n <Link href=\"#\" size=\"sm\">\n Request access\n </Link>\n </Text>\n </VStack>\n </template>\n\n <template v-else-if=\"step === 'sso-confirm' && provider\">\n <VStack :gap=\"2\" h-align=\"center\">\n <Avatar :name=\"provider.name\" :size=\"48\" />\n <Text type=\"display-3\" as=\"h2\">\n Sign in with {{ provider.name }}\n </Text>\n <Text type=\"body\" color=\"secondary\" size=\"sm\">\n You will be redirected back after signing in.\n </Text>\n </VStack>\n <Card :padding=\"0\">\n <Section variant=\"muted\" :padding=\"4\">\n <HStack :gap=\"2\" align=\"center\">\n <Icon size=\"md\" name=\"heroicons:shield-check\" color=\"secondary\" />\n <VStack :gap=\"0\">\n <Text type=\"label\">\n {{ provider.name }}\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n {{ email }}\n </Text>\n </VStack>\n </HStack>\n </Section>\n </Card>\n <VStack :gap=\"3\">\n <Button\n :label=\"`Continue with ${provider.name}`\"\n variant=\"primary\"\n size=\"lg\"\n :is-loading=\"isLoading\"\n @click=\"isLoading = true\"\n />\n <Button\n label=\"Use a different email\"\n variant=\"ghost\"\n size=\"lg\"\n @click=\"handleBack\"\n />\n </VStack>\n </template>\n\n <template v-else-if=\"step === 'password-fallback'\">\n <VStack :gap=\"1\" h-align=\"center\">\n <Text type=\"display-1\" as=\"h2\">\n Welcome back\n </Text>\n <Text type=\"body\" color=\"secondary\" size=\"sm\">\n {{ email }}\n </Text>\n </VStack>\n <VStack :gap=\"4\">\n <VStack :gap=\"1\">\n <TextInput\n :model-value=\"password\"\n label=\"Password\"\n type=\"password\"\n size=\"lg\"\n :status=\"loginFailed ? { type: 'error', message: 'Incorrect password. Try again.' } : undefined\"\n @update:model-value=\"onPasswordChange\"\n />\n <VStack v-if=\"loginFailed\" h-align=\"end\">\n <Link href=\"#\" size=\"sm\" color=\"secondary\">\n Forgot password?\n </Link>\n </VStack>\n </VStack>\n <Button\n label=\"Sign in\"\n variant=\"primary\"\n size=\"lg\"\n :is-loading=\"isLoading\"\n @click=\"handleSignIn\"\n />\n <Button\n label=\"Use a different email\"\n variant=\"ghost\"\n size=\"lg\"\n @click=\"handleBack\"\n />\n </VStack>\n </template>\n </VStack>\n </Card>\n </Center>\n</template>\n",
78547
78903
  "docsUrl": {
78548
78904
  "en": "https://astryx-vue.pages.dev/en/playground/?template=login-sso",
78549
78905
  "zh": "https://astryx-vue.pages.dev/playground/?template=login-sso"
@@ -78616,7 +78972,7 @@ const catalog = {
78616
78972
  ],
78617
78973
  "dependencies": ["@astryx-vue/core", "vue"],
78618
78974
  "isReady": true,
78619
- "source": "<script setup lang=\"ts\">\nimport type { Component } from 'vue'\nimport { computed, defineComponent, h, ref, watch } from 'vue'\nimport {\n AspectRatio,\n Button,\n Card,\n Center,\n Dialog,\n Divider,\n Grid,\n Heading,\n HStack,\n Icon,\n IconButton,\n Kbd,\n Layout,\n LayoutContent,\n ListItem,\n SegmentedControl,\n SegmentedControlItem,\n SelectableCard,\n Selector,\n SideNav,\n SideNavItem,\n SideNavSection,\n StackItem,\n Switch,\n Text,\n TextInput,\n Token,\n useMediaQuery,\n VStack,\n} from '@astryx-vue/core'\n\n/** Below this the dialog swaps its rail for a strip of tabs. */\nconst NARROW_VIEWPORT = '(max-width: 640px)'\n\n/** Floor for every control that sits in a row's right column. */\nconst CONTROL_WIDTH = 192\n\nconst noop = () => {}\n\ntype PanelId =\n | 'profile'\n | 'security'\n | 'billing'\n | 'appearance'\n | 'notifications'\n | 'shortcuts'\n | 'language'\n | 'privacy'\n | 'integrations'\n | 'developer'\n\ninterface PanelConfig {\n id: PanelId\n label: string\n description: string\n icon: string\n}\n\n/** Heroicons outline, one set, so every row icon carries the same stroke weight. */\nconst ICONS = {\n user: 'i-heroicons-user',\n userCircle: 'i-heroicons-user-circle',\n briefcase: 'i-heroicons-briefcase',\n tag: 'i-heroicons-tag',\n clock: 'i-heroicons-clock',\n signal: 'i-heroicons-signal',\n key: 'i-heroicons-key',\n lockClosed: 'i-heroicons-lock-closed',\n shieldCheck: 'i-heroicons-shield-check',\n fingerPrint: 'i-heroicons-finger-print',\n computerDesktop: 'i-heroicons-computer-desktop',\n devicePhone: 'i-heroicons-device-phone-mobile',\n cube: 'i-heroicons-cube',\n users: 'i-heroicons-users',\n envelope: 'i-heroicons-envelope',\n receiptPercent: 'i-heroicons-receipt-percent',\n creditCard: 'i-heroicons-credit-card',\n paintBrush: 'i-heroicons-paint-brush',\n swatch: 'i-heroicons-swatch',\n documentText: 'i-heroicons-document-text',\n bars3: 'i-heroicons-bars-3',\n handRaised: 'i-heroicons-hand-raised',\n atSymbol: 'i-heroicons-at-symbol',\n chatBubble: 'i-heroicons-chat-bubble-left-right',\n speakerWave: 'i-heroicons-speaker-wave',\n commandLine: 'i-heroicons-command-line',\n language: 'i-heroicons-language',\n mapPin: 'i-heroicons-map-pin',\n globeAlt: 'i-heroicons-globe-alt',\n calendarDays: 'i-heroicons-calendar-days',\n magnifyingGlass: 'i-heroicons-magnifying-glass',\n eye: 'i-heroicons-eye',\n sparkles: 'i-heroicons-sparkles',\n arrowDownTray: 'i-heroicons-arrow-down-tray',\n trash: 'i-heroicons-trash',\n folder: 'i-heroicons-folder',\n codeBracket: 'i-heroicons-code-bracket',\n link: 'i-heroicons-link',\n beaker: 'i-heroicons-beaker',\n documentMagnifyingGlass: 'i-heroicons-document-magnifying-glass',\n pencilSquare: 'i-heroicons-pencil-square',\n arrowDownOnSquare: 'i-heroicons-arrow-down-on-square',\n cog: 'i-heroicons-cog-6-tooth',\n puzzlePiece: 'i-heroicons-puzzle-piece',\n bell: 'i-heroicons-bell',\n sun: 'i-heroicons-sun',\n moon: 'i-heroicons-moon',\n} as const\n\nconst PANEL_GROUPS: { label: string, panels: PanelConfig[] }[] = [\n {\n label: 'Account',\n panels: [\n { id: 'profile', label: 'Profile', description: 'How you appear to everyone else in the workspace.', icon: ICONS.userCircle },\n { id: 'security', label: 'Login & security', description: 'Sign-in methods and the devices signed in right now.', icon: ICONS.lockClosed },\n { id: 'billing', label: 'Billing', description: 'Your plan, seats, and where invoices are sent.', icon: ICONS.creditCard },\n ],\n },\n {\n label: 'Workspace',\n panels: [\n { id: 'appearance', label: 'Appearance', description: 'Theme and density for this browser.', icon: ICONS.paintBrush },\n { id: 'notifications', label: 'Notifications', description: 'What reaches you, and where it arrives.', icon: ICONS.bell },\n { id: 'shortcuts', label: 'Keyboard shortcuts', description: 'Rebind or switch off any shortcut on this surface.', icon: ICONS.commandLine },\n { id: 'language', label: 'Language & region', description: 'Language, formats, and time zone.', icon: ICONS.globeAlt },\n ],\n },\n {\n label: 'Advanced',\n panels: [\n { id: 'privacy', label: 'Privacy', description: 'What you share, and what we keep.', icon: ICONS.shieldCheck },\n { id: 'integrations', label: 'Connected apps', description: 'Services with access to this workspace.', icon: ICONS.puzzlePiece },\n { id: 'developer', label: 'Developer', description: 'API access and pre-release features.', icon: ICONS.codeBracket },\n ],\n },\n]\n\nconst PANELS = PANEL_GROUPS.flatMap(group => group.panels)\n\nfunction panelConfig(id: PanelId) {\n return PANELS.find(panel => panel.id === id) ?? PANELS[0]\n}\n\nconst settings = ref({\n displayName: 'Alex Johnson',\n jobTitle: 'Product designer',\n pronouns: 'they',\n showLocalTime: true,\n presence: 'available',\n twoFactor: true,\n billingEmail: 'billing@example.com',\n emailInvoices: true,\n theme: 'system',\n accent: 'blue',\n font: 'system',\n density: 'comfortable',\n reduceMotion: false,\n emailMentions: true,\n emailComments: false,\n weeklyDigest: true,\n desktopAlerts: true,\n notificationSounds: false,\n quietHours: 'none',\n language: 'en-US',\n region: 'US',\n timezone: 'PT',\n weekStart: 'sun',\n discoverable: true,\n readReceipts: true,\n activityStatus: false,\n personalization: true,\n apiAccess: false,\n webhookUrl: '',\n earlyAccess: false,\n verboseLogging: false,\n})\n\nfunction update<K extends keyof typeof settings.value>(key: K, value: (typeof settings.value)[K]) {\n settings.value = { ...settings.value, [key]: value }\n}\n\n/** `Selector` reports `string | number | null`; only a picked option reaches the state. */\nfunction updateFromSelector<K extends keyof typeof settings.value>(key: K, value: string | number | null) {\n if (value == null)\n return\n update(key, String(value) as (typeof settings.value)[K])\n}\n\n// SegmentedControl 的 update:modelValue 事件参数在我们的类型里是 unknown,用可写\n// computed 走 v-model,让 defineModel 那侧的 string 类型生效。\nconst density = computed({\n get: () => settings.value.density,\n set: (value: string) => update('density', value),\n})\n\nconst weekStart = computed({\n get: () => settings.value.weekStart,\n set: (value: string) => update('weekStart', value),\n})\n\nconst activePanel = ref<PanelId>('profile')\nconst titleId = 'settings-dialog-title'\nconst isNarrow = useMediaQuery(NARROW_VIEWPORT)\nconst searchQuery = ref('')\nconst searchActiveIndex = ref(0)\n\nconst SEARCHABLE_SETTINGS = [\n { title: 'Display name', description: 'The name shown on your posts and comments', panel: 'profile' as PanelId, icon: ICONS.userCircle },\n { title: 'Two-factor authentication', description: 'Require a second step when signing in', panel: 'security' as PanelId, icon: ICONS.key },\n { title: 'Active sessions', description: 'Devices currently signed in to this account', panel: 'security' as PanelId, icon: ICONS.computerDesktop },\n { title: 'Plan', description: 'Your current subscription and seat count', panel: 'billing' as PanelId, icon: ICONS.creditCard },\n { title: 'Color theme', description: 'Light, dark, or follow the system', panel: 'appearance' as PanelId, icon: ICONS.paintBrush },\n { title: 'Interface density', description: 'How much breathing room rows and lists get', panel: 'appearance' as PanelId, icon: ICONS.bars3 },\n { title: 'Email notifications', description: 'Mentions, comments, and the weekly digest', panel: 'notifications' as PanelId, icon: ICONS.envelope },\n { title: 'Quiet hours', description: 'Hold desktop notifications until the morning', panel: 'notifications' as PanelId, icon: ICONS.clock },\n { title: 'Keyboard shortcuts', description: 'Rebind a shortcut, or switch one off', panel: 'shortcuts' as PanelId, icon: ICONS.commandLine },\n { title: 'Time zone', description: 'Used for scheduling and timestamps', panel: 'language' as PanelId, icon: ICONS.globeAlt },\n { title: 'Read receipts', description: 'Let people see when you have read a message', panel: 'privacy' as PanelId, icon: ICONS.shieldCheck },\n { title: 'Export my data', description: 'Download everything this account holds', panel: 'privacy' as PanelId, icon: ICONS.arrowDownTray },\n { title: 'Connected apps', description: 'Calendar, storage, and chat integrations', panel: 'integrations' as PanelId, icon: ICONS.puzzlePiece },\n { title: 'API access', description: 'Personal access tokens and webhooks', panel: 'developer' as PanelId, icon: ICONS.codeBracket },\n]\n\nconst normalizedSearch = computed(() => searchQuery.value.trim().toLowerCase())\nconst isSearchActive = computed(() => normalizedSearch.value.length > 0)\nconst searchResults = computed(() => {\n if (!isSearchActive.value)\n return []\n const q = normalizedSearch.value\n return SEARCHABLE_SETTINGS.filter(setting =>\n setting.title.toLowerCase().includes(q)\n || setting.description.toLowerCase().includes(q)\n || panelConfig(setting.panel).label.toLowerCase().includes(q),\n )\n})\n\nwatch(normalizedSearch, () => { searchActiveIndex.value = 0 })\n\nfunction selectPanel(panel: PanelId) {\n activePanel.value = panel\n searchQuery.value = ''\n}\n\nfunction onSearchKeydown(event: KeyboardEvent) {\n if (event.key === 'Escape' && searchQuery.value) {\n event.preventDefault()\n searchQuery.value = ''\n return\n }\n const results = searchResults.value\n if (!isSearchActive.value || results.length === 0)\n return\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n searchActiveIndex.value = (searchActiveIndex.value + 1) % results.length\n }\n else if (event.key === 'ArrowUp') {\n event.preventDefault()\n searchActiveIndex.value = (searchActiveIndex.value - 1 + results.length) % results.length\n }\n else if (event.key === 'Enter') {\n event.preventDefault()\n const hit = results[searchActiveIndex.value]\n if (hit)\n selectPanel(hit.panel)\n }\n}\n\nconst THEME_CHOICES = [\n { value: 'light', label: 'Light', icon: ICONS.sun },\n { value: 'dark', label: 'Dark', icon: ICONS.moon },\n { value: 'system', label: 'System', icon: ICONS.computerDesktop },\n]\n\nconst INTERFACE_FONTS = [\n { value: 'system', label: 'System', stack: 'system-ui, sans-serif' },\n { value: 'serif', label: 'Serif', stack: 'Georgia, serif' },\n { value: 'mono', label: 'Monospace', stack: 'ui-monospace, monospace' },\n]\n\n/** The chosen face as a token override, so every `Text` in the preview follows it. */\nconst fontPreviewStyle = computed(() => ({\n '--astryx-font-family-body': INTERFACE_FONTS.find(font => font.value === settings.value.font)?.stack ?? '',\n}))\n\nconst PRONOUN_OPTIONS = [\n { value: 'none', label: 'Not specified' },\n { value: 'they', label: 'they/them' },\n { value: 'she', label: 'she/her' },\n { value: 'he', label: 'he/him' },\n]\n\nconst PRESENCE_OPTIONS = [\n { value: 'available', label: 'Available' },\n { value: 'busy', label: 'Busy' },\n { value: 'away', label: 'Away' },\n]\n\nconst ACCENT_OPTIONS = [\n { value: 'blue', label: 'Blue' },\n { value: 'teal', label: 'Teal' },\n { value: 'purple', label: 'Purple' },\n { value: 'orange', label: 'Orange' },\n]\n\nconst QUIET_HOURS_OPTIONS = [\n { value: 'none', label: 'Off' },\n { value: 'evening', label: '18:00 – 09:00' },\n { value: 'night', label: '22:00 – 07:00' },\n]\n\nconst LANGUAGE_OPTIONS = [\n { value: 'en-US', label: 'English (US)' },\n { value: 'en-GB', label: 'English (UK)' },\n { value: 'fr', label: 'Français' },\n { value: 'de', label: 'Deutsch' },\n { value: 'ja', label: '日本語' },\n]\n\nconst REGION_OPTIONS = [\n { value: 'US', label: 'United States' },\n { value: 'GB', label: 'United Kingdom' },\n { value: 'DE', label: 'Germany' },\n { value: 'JP', label: 'Japan' },\n]\n\nconst TIMEZONE_OPTIONS = [\n { value: 'PT', label: 'Pacific (GMT-08:00)' },\n { value: 'MT', label: 'Mountain (GMT-07:00)' },\n { value: 'CT', label: 'Central (GMT-06:00)' },\n { value: 'ET', label: 'Eastern (GMT-05:00)' },\n { value: 'UTC', label: 'UTC (GMT+00:00)' },\n]\n\nconst SHORTCUTS = [\n { id: 'search', label: 'Search everything', keys: 'mod+k', icon: ICONS.magnifyingGlass },\n { id: 'new', label: 'New item', keys: 'mod+n', icon: ICONS.pencilSquare },\n { id: 'save', label: 'Save', keys: 'mod+s', icon: ICONS.arrowDownOnSquare },\n { id: 'settings', label: 'Open settings', keys: 'mod+,', icon: ICONS.cog },\n]\n\nconst shortcutEnabled = ref<Record<string, boolean>>({\n search: true,\n new: true,\n save: true,\n settings: true,\n})\n\nfunction iconComponent(className: string): Component {\n return defineComponent({\n render: () => h('span', { class: className, 'aria-hidden': 'true', style: 'width:1em;height:1em' }),\n })\n}\n\nconst SearchIcon = iconComponent(ICONS.magnifyingGlass)\n<\/script>\n\n<template>\n <Center height=\"100dvh\">\n <Dialog\n is-open\n is-inline\n purpose=\"form\"\n :width=\"1120\"\n max-height=\"min(800px, calc(100dvh - 2rem))\"\n :padding=\"0\"\n :aria-labelledby=\"titleId\"\n @open-change=\"noop\"\n >\n <VStack class=\"settings-dialog-shell\" :gap=\"0\">\n <Layout>\n <template v-if=\"!isNarrow\" #start>\n <SideNav aria-label=\"Settings sections\" class=\"settings-side-nav\">\n <template #header>\n <HStack :gap=\"2\" align=\"center\" :padding-inline=\"2\" :min-height=\"32\">\n <Heading :level=\"4\" :id=\"titleId\">\n Settings\n </Heading>\n </HStack>\n </template>\n <template #top>\n <TextInput\n v-model=\"searchQuery\"\n label=\"Search settings\"\n is-label-hidden\n placeholder=\"Search settings\"\n size=\"sm\"\n width=\"100%\"\n has-clear\n :start-icon=\"SearchIcon\"\n @keydown=\"onSearchKeydown\"\n />\n </template>\n <template v-if=\"isSearchActive\">\n <VStack\n v-if=\"searchResults.length === 0\"\n id=\"settings-search-results\"\n :gap=\"0.5\"\n :padding=\"2\"\n >\n <Text type=\"label\">\n No settings found\n </Text>\n </VStack>\n <VStack v-else id=\"settings-search-results\" :gap=\"0\" is-scrollable>\n <ListItem\n v-for=\"(result, index) in searchResults\"\n :key=\"result.title\"\n :label=\"result.title\"\n :description=\"result.description\"\n :is-selected=\"index === searchActiveIndex\"\n @mouseenter=\"searchActiveIndex = index\"\n @click=\"selectPanel(result.panel)\"\n >\n <template #startContent>\n <Icon :icon=\"result.icon\" size=\"sm\" color=\"secondary\" />\n </template>\n </ListItem>\n </VStack>\n </template>\n <template v-else>\n <SideNavSection\n v-for=\"group in PANEL_GROUPS\"\n :key=\"group.label\"\n :title=\"group.label\"\n class=\"settings-side-nav-section\"\n >\n <SideNavItem\n v-for=\"panel in group.panels\"\n :key=\"panel.id\"\n :label=\"panel.label\"\n :is-selected=\"panel.id === activePanel\"\n @click=\"selectPanel(panel.id)\"\n >\n <template #icon>\n <Icon :icon=\"panel.icon\" size=\"sm\" color=\"primary\" />\n </template>\n </SideNavItem>\n </SideNavSection>\n </template>\n </SideNav>\n </template>\n\n <LayoutContent is-scrollable :padding=\"4\">\n <VStack v-if=\"isNarrow\" :gap=\"2\" class=\"settings-narrow-header\">\n <HStack :gap=\"2\" align=\"center\">\n <StackItem size=\"fill\">\n <Text type=\"label\" :id=\"titleId\">\n Settings\n </Text>\n </StackItem>\n <IconButton label=\"Close\" variant=\"ghost\" size=\"sm\" @click=\"noop\">\n <template #icon>\n <Icon icon=\"close\" size=\"sm\" />\n </template>\n </IconButton>\n </HStack>\n <TextInput\n v-model=\"searchQuery\"\n label=\"Search settings\"\n is-label-hidden\n placeholder=\"Search settings\"\n size=\"sm\"\n width=\"100%\"\n has-clear\n :start-icon=\"SearchIcon\"\n role=\"combobox\"\n aria-autocomplete=\"list\"\n aria-controls=\"settings-search-results\"\n :aria-expanded=\"isSearchActive && searchResults.length > 0\"\n @keydown=\"onSearchKeydown\"\n />\n <VStack\n v-if=\"isSearchActive && searchResults.length === 0\"\n id=\"settings-search-results\"\n :gap=\"0.5\"\n :padding=\"2\"\n >\n <Text type=\"label\">\n No settings found\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Try theme, notifications, or sessions.\n </Text>\n </VStack>\n <VStack\n v-else-if=\"isSearchActive\"\n id=\"settings-search-results\"\n as=\"ul\"\n role=\"listbox\"\n aria-label=\"Search results\"\n :gap=\"0\"\n >\n <ListItem\n v-for=\"(result, index) in searchResults\"\n :key=\"result.title\"\n role=\"option\"\n :aria-selected=\"index === searchActiveIndex\"\n :label=\"result.title\"\n :description=\"result.description\"\n :is-selected=\"index === searchActiveIndex\"\n @mouseenter=\"searchActiveIndex = index\"\n @click=\"selectPanel(result.panel)\"\n >\n <template #startContent>\n <Icon :icon=\"result.icon\" size=\"sm\" color=\"secondary\" />\n </template>\n </ListItem>\n </VStack>\n <HStack\n v-else\n as=\"nav\"\n aria-label=\"Settings sections\"\n :gap=\"1\"\n wrap=\"nowrap\"\n is-scrollable\n >\n <Button\n v-for=\"panel in PANELS\"\n :key=\"panel.id\"\n :label=\"panel.label\"\n :variant=\"panel.id === activePanel ? 'secondary' : 'ghost'\"\n size=\"sm\"\n @click=\"selectPanel(panel.id)\"\n >\n <template #icon>\n <Icon :icon=\"panel.icon\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n <Divider />\n </VStack>\n\n <HStack v-else width=\"100%\" justify=\"end\" class=\"settings-close-anchor\">\n <HStack class=\"settings-close-backing\">\n <IconButton label=\"Close\" variant=\"ghost\" size=\"sm\" @click=\"noop\">\n <template #icon>\n <Icon icon=\"close\" size=\"sm\" />\n </template>\n </IconButton>\n </HStack>\n </HStack>\n\n <VStack :gap=\"4\" class=\"settings-panel\">\n <HStack :gap=\"2\" align=\"start\">\n <StackItem size=\"fill\">\n <VStack :gap=\"0.5\">\n <Heading :level=\"2\">\n {{ panelConfig(activePanel).label }}\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n {{ panelConfig(activePanel).description }}\n </Text>\n </VStack>\n </StackItem>\n <VStack v-if=\"!isNarrow\" aria-hidden=\"true\" :width=\"32\" />\n </HStack>\n\n <VStack v-if=\"activePanel === 'profile'\" :gap=\"5\" class=\"settings-cards\">\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Identity\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.user\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Display name\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Shown on your posts, comments, and mentions.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <TextInput\n v-model=\"settings.displayName\"\n label=\"Display name\"\n is-label-hidden\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n @update:model-value=\"v => update('displayName', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.briefcase\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Job title\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Appears under your name on your profile card.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <TextInput\n v-model=\"settings.jobTitle\"\n label=\"Job title\"\n is-label-hidden\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n @update:model-value=\"v => update('jobTitle', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.tag\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Pronouns\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Selector\n :model-value=\"settings.pronouns\"\n label=\"Pronouns\"\n is-label-hidden\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n :options=\"PRONOUN_OPTIONS\"\n @update:model-value=\"v => updateFromSelector('pronouns', v)\"\n />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Availability\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.clock\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Show my local time\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Helps people across time zones pick a sane hour.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.showLocalTime\"\n label=\"Show my local time\"\n is-label-hidden\n @update:model-value=\"v => update('showLocalTime', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.signal\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Presence\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Selector\n :model-value=\"settings.presence\"\n label=\"Presence\"\n is-label-hidden\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n :options=\"PRESENCE_OPTIONS\"\n @update:model-value=\"v => updateFromSelector('presence', v)\"\n />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n </VStack>\n\n <VStack v-else-if=\"activePanel === 'security'\" :gap=\"5\" class=\"settings-cards\">\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Sign-in\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.key\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Password\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Last changed 4 months ago.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Button label=\"Change\" variant=\"secondary\" size=\"sm\" />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.shieldCheck\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <HStack :gap=\"1.5\" align=\"center\">\n <Text type=\"label\">\n Two-factor authentication\n </Text>\n <Token label=\"Recommended\" size=\"sm\" color=\"green\" />\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n Ask for a code from your authenticator app at sign-in.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.twoFactor\"\n label=\"Two-factor authentication\"\n is-label-hidden\n @update:model-value=\"v => update('twoFactor', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.fingerPrint\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Passkeys\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n No passkeys yet.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Button label=\"Add passkey\" variant=\"secondary\" size=\"sm\" />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Active sessions\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.computerDesktop\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <HStack :gap=\"1.5\" align=\"center\">\n <Text type=\"label\">\n MacBook Pro · Chrome\n </Text>\n <Token label=\"This device\" size=\"sm\" />\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n San Francisco, California · Active now\n </Text>\n </div>\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.devicePhone\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n iPhone 15 · Safari\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n San Francisco, California · 2 hours ago\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Button label=\"Sign out\" variant=\"ghost\" size=\"sm\" />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.computerDesktop\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Windows · Edge\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Austin, Texas · March 30 at 19:31\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Button label=\"Sign out\" variant=\"ghost\" size=\"sm\" />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n </VStack>\n\n <VStack v-else-if=\"activePanel === 'billing'\" :gap=\"5\" class=\"settings-cards\">\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Plan\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.cube\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <HStack :gap=\"1.5\" align=\"center\">\n <Text type=\"label\">\n Current plan\n </Text>\n <Token label=\"Team\" size=\"sm\" color=\"blue\" />\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n $12 per seat, per month. Renews on April 1.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Button label=\"Upgrade\" variant=\"primary\" size=\"sm\" />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.users\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Seats\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n 18 of 25 seats in use.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Button label=\"Manage seats\" variant=\"secondary\" size=\"sm\" />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Invoices\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.envelope\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Billing email\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Where receipts and payment failures are sent.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <TextInput\n v-model=\"settings.billingEmail\"\n label=\"Billing email\"\n is-label-hidden\n type=\"email\"\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n @update:model-value=\"v => update('billingEmail', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.receiptPercent\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Email invoices\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Send a PDF copy every time we charge the card.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.emailInvoices\"\n label=\"Email invoices\"\n is-label-hidden\n @update:model-value=\"v => update('emailInvoices', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.creditCard\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Payment method\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Visa ending 4242 · Expires 09/28\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Button label=\"Update\" variant=\"secondary\" size=\"sm\" />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n </VStack>\n\n <VStack v-else-if=\"activePanel === 'appearance'\" :gap=\"5\" class=\"settings-cards\">\n <VStack :gap=\"1.5\">\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.paintBrush\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Color theme\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Applies to this browser only.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-detail\">\n <Grid role=\"radiogroup\" aria-label=\"Color theme\" :columns=\"{ minWidth: 96, max: 3 }\" :gap=\"2\" :max-width=\"480\">\n <SelectableCard\n v-for=\"choice in THEME_CHOICES\"\n :key=\"choice.value\"\n :label=\"choice.label\"\n :is-selected=\"settings.theme === choice.value\"\n :padding=\"2\"\n @update:is-selected=\"(v: boolean) => { if (v) update('theme', choice.value) }\"\n >\n <VStack :gap=\"1.5\">\n <AspectRatio :ratio=\"3 / 2\">\n <div class=\"theme-swatch\" :data-theme=\"choice.value\" />\n </AspectRatio>\n <HStack :gap=\"1\" align=\"center\" justify=\"center\">\n <Icon :icon=\"choice.icon\" size=\"sm\" color=\"secondary\" />\n <Text type=\"supporting\" :weight=\"settings.theme === choice.value ? 'semibold' : undefined\">\n {{ choice.label }}\n </Text>\n </HStack>\n </VStack>\n </SelectableCard>\n </Grid>\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.swatch\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Accent color\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Used for links, selection, and primary buttons.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Selector\n :model-value=\"settings.accent\"\n label=\"Accent color\"\n is-label-hidden\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n :options=\"ACCENT_OPTIONS\"\n @update:model-value=\"v => updateFromSelector('accent', v)\"\n />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Typography\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.documentText\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Interface font\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Menus, labels, and message text.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Selector\n :model-value=\"settings.font\"\n label=\"Interface font\"\n is-label-hidden\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n :options=\"INTERFACE_FONTS.map(font => ({ value: font.value, label: font.label }))\"\n @update:model-value=\"v => updateFromSelector('font', v)\"\n />\n </div>\n <div class=\"settings-row-detail\">\n <div class=\"font-preview\" :style=\"fontPreviewStyle\">\n <Text type=\"body\">\n The quick brown fox jumps over the lazy dog.\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Settings inherit this face wherever the theme allows.\n </Text>\n </div>\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Layout\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.bars3\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Interface density\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n How much breathing room rows and lists get.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <SegmentedControl\n v-model=\"density\"\n label=\"Interface density\"\n size=\"sm\"\n >\n <SegmentedControlItem value=\"comfortable\" label=\"Comfortable\" />\n <SegmentedControlItem value=\"compact\" label=\"Compact\" />\n </SegmentedControl>\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.handRaised\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Reduce motion\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Skip transitions and animated transforms.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.reduceMotion\"\n label=\"Reduce motion\"\n is-label-hidden\n @update:model-value=\"v => update('reduceMotion', v)\"\n />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n </VStack>\n\n <VStack v-else-if=\"activePanel === 'notifications'\" :gap=\"5\" class=\"settings-cards\">\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Email\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.atSymbol\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Mentions\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Someone types your name.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.emailMentions\"\n label=\"Email me about mentions\"\n is-label-hidden\n @update:model-value=\"v => update('emailMentions', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.chatBubble\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Comments\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Replies on threads you are part of.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.emailComments\"\n label=\"Email me about comments\"\n is-label-hidden\n @update:model-value=\"v => update('emailComments', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.envelope\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Weekly digest\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n One summary every Monday morning.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.weeklyDigest\"\n label=\"Send the weekly digest\"\n is-label-hidden\n @update:model-value=\"v => update('weeklyDigest', v)\"\n />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Desktop\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.computerDesktop\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Desktop notifications\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Requires permission from your browser.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.desktopAlerts\"\n label=\"Desktop notifications\"\n is-label-hidden\n @update:model-value=\"v => update('desktopAlerts', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.speakerWave\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Play a sound\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.notificationSounds\"\n label=\"Play a sound\"\n is-label-hidden\n @update:model-value=\"v => update('notificationSounds', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.clock\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Quiet hours\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Hold notifications and deliver them in the morning.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Selector\n :model-value=\"settings.quietHours\"\n label=\"Quiet hours\"\n is-label-hidden\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n :options=\"QUIET_HOURS_OPTIONS\"\n @update:model-value=\"v => updateFromSelector('quietHours', v)\"\n />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n </VStack>\n\n <VStack v-else-if=\"activePanel === 'shortcuts'\" :gap=\"1.5\" class=\"settings-cards\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Shortcuts\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <template v-for=\"(shortcut, index) in SHORTCUTS\" :key=\"shortcut.id\">\n <Divider v-if=\"index > 0\" variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"shortcut.icon\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n {{ shortcut.label }}\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <HStack :gap=\"2\" align=\"center\">\n <Button variant=\"ghost\" size=\"sm\" :label=\"shortcut.keys\">\n <Kbd :keys=\"shortcut.keys\" />\n </Button>\n <Switch\n :model-value=\"shortcutEnabled[shortcut.id]\"\n :label=\"`${shortcut.label} shortcut`\"\n is-label-hidden\n size=\"sm\"\n @update:model-value=\"v => { shortcutEnabled[shortcut.id] = v }\"\n />\n </HStack>\n </div>\n </div>\n </template>\n </div>\n </Card>\n </VStack>\n\n <VStack v-else-if=\"activePanel === 'language'\" :gap=\"1.5\" class=\"settings-cards\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Language & region\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.language\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Language\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Used across menus, buttons, and email.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Selector\n :model-value=\"settings.language\"\n label=\"Language\"\n is-label-hidden\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n :options=\"LANGUAGE_OPTIONS\"\n @update:model-value=\"v => updateFromSelector('language', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.mapPin\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Region format\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Dates, numbers, and currency.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Selector\n :model-value=\"settings.region\"\n label=\"Region format\"\n is-label-hidden\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n :options=\"REGION_OPTIONS\"\n @update:model-value=\"v => updateFromSelector('region', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.globeAlt\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Time zone\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Used for scheduling and every timestamp you see.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Selector\n :model-value=\"settings.timezone\"\n label=\"Time zone\"\n is-label-hidden\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n :options=\"TIMEZONE_OPTIONS\"\n @update:model-value=\"v => updateFromSelector('timezone', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.calendarDays\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Start the week on\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <SegmentedControl\n v-model=\"weekStart\"\n label=\"Start the week on\"\n size=\"sm\"\n >\n <SegmentedControlItem value=\"sun\" label=\"Sunday\" />\n <SegmentedControlItem value=\"mon\" label=\"Monday\" />\n </SegmentedControl>\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n\n <VStack v-else-if=\"activePanel === 'privacy'\" :gap=\"5\" class=\"settings-cards\">\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Visibility\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.magnifyingGlass\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Discoverable by email\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Let people find your profile by searching your address.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.discoverable\"\n label=\"Discoverable by email\"\n is-label-hidden\n @update:model-value=\"v => update('discoverable', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.eye\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Read receipts\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Show people when you have read their message.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.readReceipts\"\n label=\"Read receipts\"\n is-label-hidden\n @update:model-value=\"v => update('readReceipts', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.signal\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Activity status\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Show a green dot while you are online.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.activityStatus\"\n label=\"Activity status\"\n is-label-hidden\n @update:model-value=\"v => update('activityStatus', v)\"\n />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Your data\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.sparkles\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Personalized suggestions\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Use your activity to rank what you see first.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.personalization\"\n label=\"Personalized suggestions\"\n is-label-hidden\n @update:model-value=\"v => update('personalization', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.arrowDownTray\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Export my data\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n We will email a download link within 24 hours.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Button label=\"Request export\" variant=\"secondary\" size=\"sm\" />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n </VStack>\n\n <VStack v-else-if=\"activePanel === 'integrations'\" :gap=\"1.5\" class=\"settings-cards\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Connected apps\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.calendarDays\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Calendar\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Connected as alex@example.com\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Button label=\"Disconnect\" variant=\"ghost\" size=\"sm\" />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.folder\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n File storage\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Attach files straight from your drive.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Button label=\"Connect\" variant=\"secondary\" size=\"sm\" />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.chatBubble\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Chat\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Post updates to a channel when work ships.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Button label=\"Connect\" variant=\"secondary\" size=\"sm\" />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n\n <VStack v-else :gap=\"5\" class=\"settings-cards\">\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n API\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.codeBracket\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n API access\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Issue tokens that act on your behalf.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.apiAccess\"\n label=\"API access\"\n is-label-hidden\n @update:model-value=\"v => update('apiAccess', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.link\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Webhook URL\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n We POST an event here whenever something changes.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <TextInput\n v-model=\"settings.webhookUrl\"\n label=\"Webhook URL\"\n is-label-hidden\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n placeholder=\"https://example.com/hooks\"\n :is-disabled=\"!settings.apiAccess\"\n @update:model-value=\"(v: string) => update('webhookUrl', v)\"\n />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Pre-release\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.beaker\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <HStack :gap=\"1.5\" align=\"center\">\n <Text type=\"label\">\n Early access features\n </Text>\n <Token label=\"Beta\" size=\"sm\" color=\"purple\" />\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n Unfinished work, turned on for your account only.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.earlyAccess\"\n label=\"Early access features\"\n is-label-hidden\n @update:model-value=\"v => update('earlyAccess', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :icon=\"ICONS.documentMagnifyingGlass\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Verbose logging\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Write a detailed trace to the browser console.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.verboseLogging\"\n label=\"Verbose logging\"\n is-label-hidden\n @update:model-value=\"v => update('verboseLogging', v)\"\n />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n </VStack>\n </VStack>\n </LayoutContent>\n </Layout>\n </VStack>\n </Dialog>\n </Center>\n</template>\n\n<style>\n.settings-dialog-shell {\n height: min(800px, calc(100dvh - 2rem));\n}\n.settings-side-nav {\n flex-shrink: 0;\n}\n.settings-side-nav-section {\n padding-block: var(--astryx-spacing-2);\n}\n.settings-close-anchor {\n position: sticky;\n inset-block-start: 0;\n z-index: 1;\n height: 0;\n}\n.settings-close-backing {\n display: flex;\n background-color: var(--astryx-color-background-surface);\n border-radius: var(--astryx-radius-element);\n}\n.settings-narrow-header {\n padding-block-end: var(--astryx-spacing-3);\n}\n/* The column every row measures itself against: rows stack on the width the\n panel actually got, not on the viewport's. */\n.settings-panel {\n container-type: inline-size;\n container-name: settings-panel;\n}\n/* One row: name and explanation beside the control, the control's own width\n held open, and any extra content spanning the row underneath. */\n.settings-row {\n display: grid;\n grid-template-columns: minmax(0, 1fr) auto;\n align-items: center;\n gap: var(--astryx-spacing-3);\n padding: var(--astryx-spacing-4);\n}\n.settings-row-main {\n display: flex;\n align-items: flex-start;\n gap: var(--astryx-spacing-2);\n min-width: 0;\n}\n/* The icon box aligns to the title's box; a hair of top padding makes it read\n level with the cap height beside it. */\n.settings-row-icon {\n display: inline-flex;\n padding-block-start: var(--astryx-spacing-0-5);\n}\n.settings-row-text {\n display: flex;\n flex-direction: column;\n gap: var(--astryx-spacing-0-5);\n max-width: 420px;\n}\n.settings-row-control {\n display: flex;\n flex-shrink: 0;\n}\n.settings-row-detail {\n grid-column: 1 / -1;\n}\n.font-preview {\n display: flex;\n flex-direction: column;\n gap: var(--astryx-spacing-1);\n padding: var(--astryx-spacing-3);\n background-color: var(--astryx-color-background-surface);\n border-radius: var(--astryx-radius-element);\n}\n@container settings-panel (max-width: 480px) {\n .settings-row {\n grid-template-columns: minmax(0, 1fr);\n align-items: stretch;\n gap: var(--astryx-spacing-2);\n }\n .settings-row-text {\n max-width: none;\n }\n}\n.theme-swatch {\n width: 100%;\n height: 100%;\n background: var(--astryx-color-background-muted);\n border-radius: var(--astryx-radius-element);\n}\n.theme-swatch[data-theme='dark'] {\n background: #1a1a1a;\n}\n.theme-swatch[data-theme='system'] {\n background: linear-gradient(90deg, var(--astryx-color-background-muted) 50%, #1a1a1a 50%);\n}\n</style>\n",
78975
+ "source": "<script setup lang=\"ts\">\nimport {\n AspectRatio,\n Button,\n Card,\n Center,\n Dialog,\n Divider,\n Grid,\n Heading,\n HStack,\n Icon,\n IconButton,\n Kbd,\n Layout,\n LayoutContent,\n ListItem,\n SegmentedControl,\n SegmentedControlItem,\n SelectableCard,\n Selector,\n SideNav,\n SideNavItem,\n SideNavSection,\n StackItem,\n Switch,\n Text,\n TextInput,\n Token,\n useMediaQuery,\n VStack,\n} from '@astryx-vue/core'\nimport { computed, defineComponent, h, ref, watch } from 'vue'\n\n/** Below this the dialog swaps its rail for a strip of tabs. */\nconst NARROW_VIEWPORT = '(max-width: 640px)'\n\n/** Floor for every control that sits in a row's right column. */\nconst CONTROL_WIDTH = 192\n\nfunction noop() {}\n\ntype PanelId\n = | 'profile'\n | 'security'\n | 'billing'\n | 'appearance'\n | 'notifications'\n | 'shortcuts'\n | 'language'\n | 'privacy'\n | 'integrations'\n | 'developer'\n\ninterface PanelConfig {\n id: PanelId\n label: string\n description: string\n icon: string\n}\n\n/** Heroicons outline, one set, so every row icon carries the same stroke weight. */\nconst ICONS = {\n user: 'heroicons:user',\n userCircle: 'heroicons:user-circle',\n briefcase: 'heroicons:briefcase',\n tag: 'heroicons:tag',\n clock: 'heroicons:clock',\n signal: 'heroicons:signal',\n key: 'heroicons:key',\n lockClosed: 'heroicons:lock-closed',\n shieldCheck: 'heroicons:shield-check',\n fingerPrint: 'heroicons:finger-print',\n computerDesktop: 'heroicons:computer-desktop',\n devicePhone: 'heroicons:device-phone-mobile',\n cube: 'heroicons:cube',\n users: 'heroicons:users',\n envelope: 'heroicons:envelope',\n receiptPercent: 'heroicons:receipt-percent',\n creditCard: 'heroicons:credit-card',\n paintBrush: 'heroicons:paint-brush',\n swatch: 'heroicons:swatch',\n documentText: 'heroicons:document-text',\n bars3: 'heroicons:bars-3',\n handRaised: 'heroicons:hand-raised',\n atSymbol: 'heroicons:at-symbol',\n chatBubble: 'heroicons:chat-bubble-left-right',\n speakerWave: 'heroicons:speaker-wave',\n commandLine: 'heroicons:command-line',\n language: 'heroicons:language',\n mapPin: 'heroicons:map-pin',\n globeAlt: 'heroicons:globe-alt',\n calendarDays: 'heroicons:calendar-days',\n magnifyingGlass: 'heroicons:magnifying-glass',\n eye: 'heroicons:eye',\n sparkles: 'heroicons:sparkles',\n arrowDownTray: 'heroicons:arrow-down-tray',\n trash: 'heroicons:trash',\n folder: 'heroicons:folder',\n codeBracket: 'heroicons:code-bracket',\n link: 'heroicons:link',\n beaker: 'heroicons:beaker',\n documentMagnifyingGlass: 'heroicons:document-magnifying-glass',\n pencilSquare: 'heroicons:pencil-square',\n arrowDownOnSquare: 'heroicons:arrow-down-on-square',\n cog: 'heroicons:cog-6-tooth',\n puzzlePiece: 'heroicons:puzzle-piece',\n bell: 'heroicons:bell',\n sun: 'heroicons:sun',\n moon: 'heroicons:moon',\n} as const\n\nconst PANEL_GROUPS: { label: string, panels: PanelConfig[] }[] = [\n {\n label: 'Account',\n panels: [\n { id: 'profile', label: 'Profile', description: 'How you appear to everyone else in the workspace.', icon: ICONS.userCircle },\n { id: 'security', label: 'Login & security', description: 'Sign-in methods and the devices signed in right now.', icon: ICONS.lockClosed },\n { id: 'billing', label: 'Billing', description: 'Your plan, seats, and where invoices are sent.', icon: ICONS.creditCard },\n ],\n },\n {\n label: 'Workspace',\n panels: [\n { id: 'appearance', label: 'Appearance', description: 'Theme and density for this browser.', icon: ICONS.paintBrush },\n { id: 'notifications', label: 'Notifications', description: 'What reaches you, and where it arrives.', icon: ICONS.bell },\n { id: 'shortcuts', label: 'Keyboard shortcuts', description: 'Rebind or switch off any shortcut on this surface.', icon: ICONS.commandLine },\n { id: 'language', label: 'Language & region', description: 'Language, formats, and time zone.', icon: ICONS.globeAlt },\n ],\n },\n {\n label: 'Advanced',\n panels: [\n { id: 'privacy', label: 'Privacy', description: 'What you share, and what we keep.', icon: ICONS.shieldCheck },\n { id: 'integrations', label: 'Connected apps', description: 'Services with access to this workspace.', icon: ICONS.puzzlePiece },\n { id: 'developer', label: 'Developer', description: 'API access and pre-release features.', icon: ICONS.codeBracket },\n ],\n },\n]\n\nconst PANELS = PANEL_GROUPS.flatMap(group => group.panels)\n\nfunction panelConfig(id: PanelId) {\n return PANELS.find(panel => panel.id === id) ?? PANELS[0]\n}\n\nconst settings = ref({\n displayName: 'Alex Johnson',\n jobTitle: 'Product designer',\n pronouns: 'they',\n showLocalTime: true,\n presence: 'available',\n twoFactor: true,\n billingEmail: 'billing@example.com',\n emailInvoices: true,\n theme: 'system',\n accent: 'blue',\n font: 'system',\n density: 'comfortable',\n reduceMotion: false,\n emailMentions: true,\n emailComments: false,\n weeklyDigest: true,\n desktopAlerts: true,\n notificationSounds: false,\n quietHours: 'none',\n language: 'en-US',\n region: 'US',\n timezone: 'PT',\n weekStart: 'sun',\n discoverable: true,\n readReceipts: true,\n activityStatus: false,\n personalization: true,\n apiAccess: false,\n webhookUrl: '',\n earlyAccess: false,\n verboseLogging: false,\n})\n\nfunction update<K extends keyof typeof settings.value>(key: K, value: (typeof settings.value)[K]) {\n settings.value = { ...settings.value, [key]: value }\n}\n\n/** `Selector` reports `string | number | null`; only a picked option reaches the state. */\nfunction updateFromSelector<K extends keyof typeof settings.value>(key: K, value: string | number | null) {\n if (value == null)\n return\n update(key, String(value) as (typeof settings.value)[K])\n}\n\n// SegmentedControl 的 update:modelValue 事件参数在我们的类型里是 unknown,用可写\n// computed 走 v-model,让 defineModel 那侧的 string 类型生效。\nconst density = computed({\n get: () => settings.value.density,\n set: (value: string) => update('density', value),\n})\n\nconst weekStart = computed({\n get: () => settings.value.weekStart,\n set: (value: string) => update('weekStart', value),\n})\n\nconst activePanel = ref<PanelId>('profile')\nconst titleId = 'settings-dialog-title'\nconst isNarrow = useMediaQuery(NARROW_VIEWPORT)\nconst searchQuery = ref('')\nconst searchActiveIndex = ref(0)\n\nconst SEARCHABLE_SETTINGS = [\n { title: 'Display name', description: 'The name shown on your posts and comments', panel: 'profile' as PanelId, icon: ICONS.userCircle },\n { title: 'Two-factor authentication', description: 'Require a second step when signing in', panel: 'security' as PanelId, icon: ICONS.key },\n { title: 'Active sessions', description: 'Devices currently signed in to this account', panel: 'security' as PanelId, icon: ICONS.computerDesktop },\n { title: 'Plan', description: 'Your current subscription and seat count', panel: 'billing' as PanelId, icon: ICONS.creditCard },\n { title: 'Color theme', description: 'Light, dark, or follow the system', panel: 'appearance' as PanelId, icon: ICONS.paintBrush },\n { title: 'Interface density', description: 'How much breathing room rows and lists get', panel: 'appearance' as PanelId, icon: ICONS.bars3 },\n { title: 'Email notifications', description: 'Mentions, comments, and the weekly digest', panel: 'notifications' as PanelId, icon: ICONS.envelope },\n { title: 'Quiet hours', description: 'Hold desktop notifications until the morning', panel: 'notifications' as PanelId, icon: ICONS.clock },\n { title: 'Keyboard shortcuts', description: 'Rebind a shortcut, or switch one off', panel: 'shortcuts' as PanelId, icon: ICONS.commandLine },\n { title: 'Time zone', description: 'Used for scheduling and timestamps', panel: 'language' as PanelId, icon: ICONS.globeAlt },\n { title: 'Read receipts', description: 'Let people see when you have read a message', panel: 'privacy' as PanelId, icon: ICONS.shieldCheck },\n { title: 'Export my data', description: 'Download everything this account holds', panel: 'privacy' as PanelId, icon: ICONS.arrowDownTray },\n { title: 'Connected apps', description: 'Calendar, storage, and chat integrations', panel: 'integrations' as PanelId, icon: ICONS.puzzlePiece },\n { title: 'API access', description: 'Personal access tokens and webhooks', panel: 'developer' as PanelId, icon: ICONS.codeBracket },\n]\n\nconst normalizedSearch = computed(() => searchQuery.value.trim().toLowerCase())\nconst isSearchActive = computed(() => normalizedSearch.value.length > 0)\nconst searchResults = computed(() => {\n if (!isSearchActive.value)\n return []\n const q = normalizedSearch.value\n return SEARCHABLE_SETTINGS.filter(setting =>\n setting.title.toLowerCase().includes(q)\n || setting.description.toLowerCase().includes(q)\n || panelConfig(setting.panel).label.toLowerCase().includes(q),\n )\n})\n\nwatch(normalizedSearch, () => { searchActiveIndex.value = 0 })\n\nfunction selectPanel(panel: PanelId) {\n activePanel.value = panel\n searchQuery.value = ''\n}\n\nfunction onSearchKeydown(event: KeyboardEvent) {\n if (event.key === 'Escape' && searchQuery.value) {\n event.preventDefault()\n searchQuery.value = ''\n return\n }\n const results = searchResults.value\n if (!isSearchActive.value || results.length === 0)\n return\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n searchActiveIndex.value = (searchActiveIndex.value + 1) % results.length\n }\n else if (event.key === 'ArrowUp') {\n event.preventDefault()\n searchActiveIndex.value = (searchActiveIndex.value - 1 + results.length) % results.length\n }\n else if (event.key === 'Enter') {\n event.preventDefault()\n const hit = results[searchActiveIndex.value]\n if (hit)\n selectPanel(hit.panel)\n }\n}\n\nconst THEME_CHOICES = [\n { value: 'light', label: 'Light', icon: ICONS.sun },\n { value: 'dark', label: 'Dark', icon: ICONS.moon },\n { value: 'system', label: 'System', icon: ICONS.computerDesktop },\n]\n\nconst INTERFACE_FONTS = [\n { value: 'system', label: 'System', stack: 'system-ui, sans-serif' },\n { value: 'serif', label: 'Serif', stack: 'Georgia, serif' },\n { value: 'mono', label: 'Monospace', stack: 'ui-monospace, monospace' },\n]\n\n/** The chosen face as a token override, so every `Text` in the preview follows it. */\nconst fontPreviewStyle = computed(() => ({\n '--astryx-font-family-body': INTERFACE_FONTS.find(font => font.value === settings.value.font)?.stack ?? '',\n}))\n\nconst PRONOUN_OPTIONS = [\n { value: 'none', label: 'Not specified' },\n { value: 'they', label: 'they/them' },\n { value: 'she', label: 'she/her' },\n { value: 'he', label: 'he/him' },\n]\n\nconst PRESENCE_OPTIONS = [\n { value: 'available', label: 'Available' },\n { value: 'busy', label: 'Busy' },\n { value: 'away', label: 'Away' },\n]\n\nconst ACCENT_OPTIONS = [\n { value: 'blue', label: 'Blue' },\n { value: 'teal', label: 'Teal' },\n { value: 'purple', label: 'Purple' },\n { value: 'orange', label: 'Orange' },\n]\n\nconst QUIET_HOURS_OPTIONS = [\n { value: 'none', label: 'Off' },\n { value: 'evening', label: '18:00 – 09:00' },\n { value: 'night', label: '22:00 – 07:00' },\n]\n\nconst LANGUAGE_OPTIONS = [\n { value: 'en-US', label: 'English (US)' },\n { value: 'en-GB', label: 'English (UK)' },\n { value: 'fr', label: 'Français' },\n { value: 'de', label: 'Deutsch' },\n { value: 'ja', label: '日本語' },\n]\n\nconst REGION_OPTIONS = [\n { value: 'US', label: 'United States' },\n { value: 'GB', label: 'United Kingdom' },\n { value: 'DE', label: 'Germany' },\n { value: 'JP', label: 'Japan' },\n]\n\nconst TIMEZONE_OPTIONS = [\n { value: 'PT', label: 'Pacific (GMT-08:00)' },\n { value: 'MT', label: 'Mountain (GMT-07:00)' },\n { value: 'CT', label: 'Central (GMT-06:00)' },\n { value: 'ET', label: 'Eastern (GMT-05:00)' },\n { value: 'UTC', label: 'UTC (GMT+00:00)' },\n]\n\nconst SHORTCUTS = [\n { id: 'search', label: 'Search everything', keys: 'mod+k', icon: ICONS.magnifyingGlass },\n { id: 'new', label: 'New item', keys: 'mod+n', icon: ICONS.pencilSquare },\n { id: 'save', label: 'Save', keys: 'mod+s', icon: ICONS.arrowDownOnSquare },\n { id: 'settings', label: 'Open settings', keys: 'mod+,', icon: ICONS.cog },\n]\n\nconst shortcutEnabled = ref<Record<string, boolean>>({\n search: true,\n new: true,\n save: true,\n settings: true,\n})\n\nconst SearchIcon = defineComponent({\n render: () => h(Icon, { name: ICONS.magnifyingGlass }),\n})\n<\/script>\n\n<template>\n <Center height=\"100dvh\">\n <Dialog\n is-open\n is-inline\n purpose=\"form\"\n :width=\"1120\"\n max-height=\"min(800px, calc(100dvh - 2rem))\"\n :padding=\"0\"\n class=\"settings-dialog-frame\"\n :aria-labelledby=\"titleId\"\n @open-change=\"noop\"\n >\n <VStack class=\"settings-dialog-shell\" :gap=\"0\">\n <Layout>\n <template v-if=\"!isNarrow\" #start>\n <SideNav aria-label=\"Settings sections\" class=\"settings-side-nav\">\n <template #header>\n <HStack :gap=\"2\" align=\"center\" :padding-inline=\"2\" :min-height=\"32\" width=\"100%\">\n <Heading :id=\"titleId\" :level=\"4\">\n Settings\n </Heading>\n </HStack>\n </template>\n <template #top>\n <TextInput\n v-model=\"searchQuery\"\n label=\"Search settings\"\n is-label-hidden\n placeholder=\"Search settings\"\n size=\"sm\"\n width=\"100%\"\n has-clear\n :start-icon=\"SearchIcon\"\n @keydown=\"onSearchKeydown\"\n />\n </template>\n <template v-if=\"isSearchActive\">\n <VStack\n v-if=\"searchResults.length === 0\"\n id=\"settings-search-results\"\n :gap=\"0.5\"\n :padding=\"2\"\n >\n <Text type=\"label\">\n No settings found\n </Text>\n </VStack>\n <VStack v-else id=\"settings-search-results\" :gap=\"0\" is-scrollable>\n <ListItem\n v-for=\"(result, index) in searchResults\"\n :key=\"result.title\"\n :label=\"result.title\"\n :description=\"result.description\"\n :is-selected=\"index === searchActiveIndex\"\n @mouseenter=\"searchActiveIndex = index\"\n @click=\"selectPanel(result.panel)\"\n >\n <template #startContent>\n <Icon :name=\"result.icon\" size=\"sm\" color=\"secondary\" />\n </template>\n </ListItem>\n </VStack>\n </template>\n <template v-else>\n <SideNavSection\n v-for=\"group in PANEL_GROUPS\"\n :key=\"group.label\"\n :title=\"group.label\"\n class=\"settings-side-nav-section\"\n >\n <SideNavItem\n v-for=\"panel in group.panels\"\n :key=\"panel.id\"\n :label=\"panel.label\"\n :is-selected=\"panel.id === activePanel\"\n @click=\"selectPanel(panel.id)\"\n >\n <template #icon>\n <Icon :name=\"panel.icon\" size=\"sm\" color=\"primary\" />\n </template>\n </SideNavItem>\n </SideNavSection>\n </template>\n </SideNav>\n </template>\n\n <LayoutContent is-scrollable :padding=\"4\">\n <VStack v-if=\"isNarrow\" :gap=\"2\" class=\"settings-narrow-header\">\n <HStack :gap=\"2\" align=\"center\">\n <StackItem size=\"fill\">\n <Text :id=\"titleId\" type=\"label\">\n Settings\n </Text>\n </StackItem>\n <IconButton label=\"Close\" variant=\"ghost\" size=\"sm\" @click=\"noop\">\n <Icon name=\"heroicons:x-mark\" size=\"sm\" />\n </IconButton>\n </HStack>\n <TextInput\n v-model=\"searchQuery\"\n label=\"Search settings\"\n is-label-hidden\n placeholder=\"Search settings\"\n size=\"sm\"\n width=\"100%\"\n has-clear\n :start-icon=\"SearchIcon\"\n role=\"combobox\"\n aria-autocomplete=\"list\"\n aria-controls=\"settings-search-results\"\n :aria-expanded=\"isSearchActive && searchResults.length > 0\"\n @keydown=\"onSearchKeydown\"\n />\n <VStack\n v-if=\"isSearchActive && searchResults.length === 0\"\n id=\"settings-search-results\"\n :gap=\"0.5\"\n :padding=\"2\"\n >\n <Text type=\"label\">\n No settings found\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Try theme, notifications, or sessions.\n </Text>\n </VStack>\n <VStack\n v-else-if=\"isSearchActive\"\n id=\"settings-search-results\"\n as=\"ul\"\n role=\"listbox\"\n aria-label=\"Search results\"\n :gap=\"0\"\n >\n <ListItem\n v-for=\"(result, index) in searchResults\"\n :key=\"result.title\"\n role=\"option\"\n :aria-selected=\"index === searchActiveIndex\"\n :label=\"result.title\"\n :description=\"result.description\"\n :is-selected=\"index === searchActiveIndex\"\n @mouseenter=\"searchActiveIndex = index\"\n @click=\"selectPanel(result.panel)\"\n >\n <template #startContent>\n <Icon :name=\"result.icon\" size=\"sm\" color=\"secondary\" />\n </template>\n </ListItem>\n </VStack>\n <HStack\n v-else\n as=\"nav\"\n aria-label=\"Settings sections\"\n :gap=\"1\"\n wrap=\"nowrap\"\n is-scrollable\n >\n <Button\n v-for=\"panel in PANELS\"\n :key=\"panel.id\"\n :label=\"panel.label\"\n :variant=\"panel.id === activePanel ? 'secondary' : 'ghost'\"\n size=\"sm\"\n @click=\"selectPanel(panel.id)\"\n >\n <template #icon>\n <Icon :name=\"panel.icon\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n <Divider />\n </VStack>\n\n <HStack v-else width=\"100%\" justify=\"end\" class=\"settings-close-anchor\">\n <HStack class=\"settings-close-backing\">\n <IconButton label=\"Close\" variant=\"ghost\" size=\"sm\" @click=\"noop\">\n <Icon name=\"heroicons:x-mark\" size=\"sm\" />\n </IconButton>\n </HStack>\n </HStack>\n\n <VStack :gap=\"4\" class=\"settings-panel\">\n <HStack :gap=\"2\" align=\"start\">\n <StackItem size=\"fill\">\n <VStack :gap=\"0.5\">\n <Heading :level=\"2\">\n {{ panelConfig(activePanel).label }}\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n {{ panelConfig(activePanel).description }}\n </Text>\n </VStack>\n </StackItem>\n <VStack v-if=\"!isNarrow\" aria-hidden=\"true\" :width=\"32\" />\n </HStack>\n\n <VStack v-if=\"activePanel === 'profile'\" :gap=\"5\" class=\"settings-cards\">\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Identity\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.user\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Display name\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Shown on your posts, comments, and mentions.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <TextInput\n v-model=\"settings.displayName\"\n label=\"Display name\"\n is-label-hidden\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n @update:model-value=\"v => update('displayName', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.briefcase\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Job title\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Appears under your name on your profile card.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <TextInput\n v-model=\"settings.jobTitle\"\n label=\"Job title\"\n is-label-hidden\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n @update:model-value=\"v => update('jobTitle', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.tag\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Pronouns\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Selector\n :model-value=\"settings.pronouns\"\n label=\"Pronouns\"\n is-label-hidden\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n :options=\"PRONOUN_OPTIONS\"\n @update:model-value=\"v => updateFromSelector('pronouns', v)\"\n />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Availability\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.clock\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Show my local time\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Helps people across time zones pick a sane hour.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.showLocalTime\"\n label=\"Show my local time\"\n is-label-hidden\n @update:model-value=\"v => update('showLocalTime', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.signal\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Presence\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Selector\n :model-value=\"settings.presence\"\n label=\"Presence\"\n is-label-hidden\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n :options=\"PRESENCE_OPTIONS\"\n @update:model-value=\"v => updateFromSelector('presence', v)\"\n />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n </VStack>\n\n <VStack v-else-if=\"activePanel === 'security'\" :gap=\"5\" class=\"settings-cards\">\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Sign-in\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.key\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Password\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Last changed 4 months ago.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Button label=\"Change\" variant=\"secondary\" size=\"sm\" />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.shieldCheck\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <HStack :gap=\"1.5\" align=\"center\">\n <Text type=\"label\">\n Two-factor authentication\n </Text>\n <Token label=\"Recommended\" size=\"sm\" color=\"green\" />\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n Ask for a code from your authenticator app at sign-in.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.twoFactor\"\n label=\"Two-factor authentication\"\n is-label-hidden\n @update:model-value=\"v => update('twoFactor', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.fingerPrint\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Passkeys\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n No passkeys yet.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Button label=\"Add passkey\" variant=\"secondary\" size=\"sm\" />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Active sessions\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.computerDesktop\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <HStack :gap=\"1.5\" align=\"center\">\n <Text type=\"label\">\n MacBook Pro · Chrome\n </Text>\n <Token label=\"This device\" size=\"sm\" />\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n San Francisco, California · Active now\n </Text>\n </div>\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.devicePhone\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n iPhone 15 · Safari\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n San Francisco, California · 2 hours ago\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Button label=\"Sign out\" variant=\"ghost\" size=\"sm\" />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.computerDesktop\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Windows · Edge\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Austin, Texas · March 30 at 19:31\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Button label=\"Sign out\" variant=\"ghost\" size=\"sm\" />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n </VStack>\n\n <VStack v-else-if=\"activePanel === 'billing'\" :gap=\"5\" class=\"settings-cards\">\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Plan\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.cube\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <HStack :gap=\"1.5\" align=\"center\">\n <Text type=\"label\">\n Current plan\n </Text>\n <Token label=\"Team\" size=\"sm\" color=\"blue\" />\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n $12 per seat, per month. Renews on April 1.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Button label=\"Upgrade\" variant=\"primary\" size=\"sm\" />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.users\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Seats\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n 18 of 25 seats in use.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Button label=\"Manage seats\" variant=\"secondary\" size=\"sm\" />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Invoices\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.envelope\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Billing email\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Where receipts and payment failures are sent.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <TextInput\n v-model=\"settings.billingEmail\"\n label=\"Billing email\"\n is-label-hidden\n type=\"email\"\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n @update:model-value=\"v => update('billingEmail', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.receiptPercent\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Email invoices\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Send a PDF copy every time we charge the card.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.emailInvoices\"\n label=\"Email invoices\"\n is-label-hidden\n @update:model-value=\"v => update('emailInvoices', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.creditCard\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Payment method\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Visa ending 4242 · Expires 09/28\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Button label=\"Update\" variant=\"secondary\" size=\"sm\" />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n </VStack>\n\n <VStack v-else-if=\"activePanel === 'appearance'\" :gap=\"5\" class=\"settings-cards\">\n <VStack :gap=\"1.5\">\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.paintBrush\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Color theme\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Applies to this browser only.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-detail\">\n <Grid role=\"radiogroup\" aria-label=\"Color theme\" :columns=\"{ minWidth: 96, max: 3 }\" :gap=\"2\" :max-width=\"480\">\n <SelectableCard\n v-for=\"choice in THEME_CHOICES\"\n :key=\"choice.value\"\n :label=\"choice.label\"\n :is-selected=\"settings.theme === choice.value\"\n :padding=\"2\"\n @update:is-selected=\"(v: boolean) => { if (v) update('theme', choice.value) }\"\n >\n <VStack :gap=\"1.5\">\n <AspectRatio :ratio=\"3 / 2\">\n <div class=\"theme-swatch\" :data-theme=\"choice.value\" />\n </AspectRatio>\n <HStack :gap=\"1\" align=\"center\" justify=\"center\">\n <Icon :name=\"choice.icon\" size=\"sm\" color=\"secondary\" />\n <Text type=\"supporting\" :weight=\"settings.theme === choice.value ? 'semibold' : undefined\">\n {{ choice.label }}\n </Text>\n </HStack>\n </VStack>\n </SelectableCard>\n </Grid>\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.swatch\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Accent color\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Used for links, selection, and primary buttons.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Selector\n :model-value=\"settings.accent\"\n label=\"Accent color\"\n is-label-hidden\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n :options=\"ACCENT_OPTIONS\"\n @update:model-value=\"v => updateFromSelector('accent', v)\"\n />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Typography\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.documentText\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Interface font\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Menus, labels, and message text.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Selector\n :model-value=\"settings.font\"\n label=\"Interface font\"\n is-label-hidden\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n :options=\"INTERFACE_FONTS.map(font => ({ value: font.value, label: font.label }))\"\n @update:model-value=\"v => updateFromSelector('font', v)\"\n />\n </div>\n <div class=\"settings-row-detail\">\n <div class=\"font-preview\" :style=\"fontPreviewStyle\">\n <Text type=\"body\">\n The quick brown fox jumps over the lazy dog.\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Settings inherit this face wherever the theme allows.\n </Text>\n </div>\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Layout\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.bars3\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Interface density\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n How much breathing room rows and lists get.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <SegmentedControl\n v-model=\"density\"\n label=\"Interface density\"\n size=\"sm\"\n >\n <SegmentedControlItem value=\"comfortable\" label=\"Comfortable\" />\n <SegmentedControlItem value=\"compact\" label=\"Compact\" />\n </SegmentedControl>\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.handRaised\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Reduce motion\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Skip transitions and animated transforms.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.reduceMotion\"\n label=\"Reduce motion\"\n is-label-hidden\n @update:model-value=\"v => update('reduceMotion', v)\"\n />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n </VStack>\n\n <VStack v-else-if=\"activePanel === 'notifications'\" :gap=\"5\" class=\"settings-cards\">\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Email\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.atSymbol\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Mentions\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Someone types your name.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.emailMentions\"\n label=\"Email me about mentions\"\n is-label-hidden\n @update:model-value=\"v => update('emailMentions', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.chatBubble\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Comments\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Replies on threads you are part of.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.emailComments\"\n label=\"Email me about comments\"\n is-label-hidden\n @update:model-value=\"v => update('emailComments', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.envelope\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Weekly digest\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n One summary every Monday morning.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.weeklyDigest\"\n label=\"Send the weekly digest\"\n is-label-hidden\n @update:model-value=\"v => update('weeklyDigest', v)\"\n />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Desktop\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.computerDesktop\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Desktop notifications\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Requires permission from your browser.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.desktopAlerts\"\n label=\"Desktop notifications\"\n is-label-hidden\n @update:model-value=\"v => update('desktopAlerts', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.speakerWave\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Play a sound\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.notificationSounds\"\n label=\"Play a sound\"\n is-label-hidden\n @update:model-value=\"v => update('notificationSounds', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.clock\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Quiet hours\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Hold notifications and deliver them in the morning.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Selector\n :model-value=\"settings.quietHours\"\n label=\"Quiet hours\"\n is-label-hidden\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n :options=\"QUIET_HOURS_OPTIONS\"\n @update:model-value=\"v => updateFromSelector('quietHours', v)\"\n />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n </VStack>\n\n <VStack v-else-if=\"activePanel === 'shortcuts'\" :gap=\"1.5\" class=\"settings-cards\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Shortcuts\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <template v-for=\"(shortcut, index) in SHORTCUTS\" :key=\"shortcut.id\">\n <Divider v-if=\"index > 0\" variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"shortcut.icon\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n {{ shortcut.label }}\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <HStack :gap=\"2\" align=\"center\">\n <Button variant=\"ghost\" size=\"sm\" :label=\"shortcut.keys\">\n <Kbd :keys=\"shortcut.keys\" />\n </Button>\n <Switch\n :model-value=\"shortcutEnabled[shortcut.id]\"\n :label=\"`${shortcut.label} shortcut`\"\n is-label-hidden\n size=\"sm\"\n @update:model-value=\"v => { shortcutEnabled[shortcut.id] = v }\"\n />\n </HStack>\n </div>\n </div>\n </template>\n </div>\n </Card>\n </VStack>\n\n <VStack v-else-if=\"activePanel === 'language'\" :gap=\"1.5\" class=\"settings-cards\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Language & region\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.language\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Language\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Used across menus, buttons, and email.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Selector\n :model-value=\"settings.language\"\n label=\"Language\"\n is-label-hidden\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n :options=\"LANGUAGE_OPTIONS\"\n @update:model-value=\"v => updateFromSelector('language', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.mapPin\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Region format\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Dates, numbers, and currency.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Selector\n :model-value=\"settings.region\"\n label=\"Region format\"\n is-label-hidden\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n :options=\"REGION_OPTIONS\"\n @update:model-value=\"v => updateFromSelector('region', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.globeAlt\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Time zone\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Used for scheduling and every timestamp you see.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Selector\n :model-value=\"settings.timezone\"\n label=\"Time zone\"\n is-label-hidden\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n :options=\"TIMEZONE_OPTIONS\"\n @update:model-value=\"v => updateFromSelector('timezone', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.calendarDays\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Start the week on\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <SegmentedControl\n v-model=\"weekStart\"\n label=\"Start the week on\"\n size=\"sm\"\n >\n <SegmentedControlItem value=\"sun\" label=\"Sunday\" />\n <SegmentedControlItem value=\"mon\" label=\"Monday\" />\n </SegmentedControl>\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n\n <VStack v-else-if=\"activePanel === 'privacy'\" :gap=\"5\" class=\"settings-cards\">\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Visibility\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.magnifyingGlass\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Discoverable by email\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Let people find your profile by searching your address.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.discoverable\"\n label=\"Discoverable by email\"\n is-label-hidden\n @update:model-value=\"v => update('discoverable', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.eye\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Read receipts\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Show people when you have read their message.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.readReceipts\"\n label=\"Read receipts\"\n is-label-hidden\n @update:model-value=\"v => update('readReceipts', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.signal\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Activity status\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Show a green dot while you are online.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.activityStatus\"\n label=\"Activity status\"\n is-label-hidden\n @update:model-value=\"v => update('activityStatus', v)\"\n />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Your data\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.sparkles\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Personalized suggestions\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Use your activity to rank what you see first.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.personalization\"\n label=\"Personalized suggestions\"\n is-label-hidden\n @update:model-value=\"v => update('personalization', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.arrowDownTray\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Export my data\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n We will email a download link within 24 hours.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Button label=\"Request export\" variant=\"secondary\" size=\"sm\" />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n </VStack>\n\n <VStack v-else-if=\"activePanel === 'integrations'\" :gap=\"1.5\" class=\"settings-cards\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Connected apps\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.calendarDays\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Calendar\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Connected as alex@example.com\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Button label=\"Disconnect\" variant=\"ghost\" size=\"sm\" />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.folder\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n File storage\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Attach files straight from your drive.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Button label=\"Connect\" variant=\"secondary\" size=\"sm\" />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.chatBubble\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Chat\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Post updates to a channel when work ships.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Button label=\"Connect\" variant=\"secondary\" size=\"sm\" />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n\n <VStack v-else :gap=\"5\" class=\"settings-cards\">\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n API\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.codeBracket\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n API access\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Issue tokens that act on your behalf.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.apiAccess\"\n label=\"API access\"\n is-label-hidden\n @update:model-value=\"v => update('apiAccess', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.link\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Webhook URL\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n We POST an event here whenever something changes.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <TextInput\n v-model=\"settings.webhookUrl\"\n label=\"Webhook URL\"\n is-label-hidden\n size=\"sm\"\n :width=\"CONTROL_WIDTH\"\n placeholder=\"https://example.com/hooks\"\n :is-disabled=\"!settings.apiAccess\"\n @update:model-value=\"(v: string) => update('webhookUrl', v)\"\n />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n\n <VStack :gap=\"1.5\">\n <Text type=\"supporting\" weight=\"semibold\" color=\"secondary\">\n Pre-release\n </Text>\n <Card variant=\"muted\" :padding=\"0\" width=\"100%\">\n <div role=\"list\">\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.beaker\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <HStack :gap=\"1.5\" align=\"center\">\n <Text type=\"label\">\n Early access features\n </Text>\n <Token label=\"Beta\" size=\"sm\" color=\"purple\" />\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n Unfinished work, turned on for your account only.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.earlyAccess\"\n label=\"Early access features\"\n is-label-hidden\n @update:model-value=\"v => update('earlyAccess', v)\"\n />\n </div>\n </div>\n <Divider variant=\"subtle\" role=\"presentation\" />\n <div class=\"settings-row\" role=\"listitem\">\n <div class=\"settings-row-main\">\n <span class=\"settings-row-icon\"><Icon :name=\"ICONS.documentMagnifyingGlass\" size=\"sm\" color=\"secondary\" /></span>\n <div class=\"settings-row-text\">\n <Text type=\"label\">\n Verbose logging\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n Write a detailed trace to the browser console.\n </Text>\n </div>\n </div>\n <div class=\"settings-row-control\">\n <Switch\n :model-value=\"settings.verboseLogging\"\n label=\"Verbose logging\"\n is-label-hidden\n @update:model-value=\"v => update('verboseLogging', v)\"\n />\n </div>\n </div>\n </div>\n </Card>\n </VStack>\n </VStack>\n </VStack>\n </LayoutContent>\n </Layout>\n </VStack>\n </Dialog>\n </Center>\n</template>\n\n<style>\n/* 待 core 修复后去掉:原版 Dialog 的最大宽度是 min(100%, 100dvw - 2 × 16px),两侧各留 16px。 */\n.settings-dialog-frame {\n max-width: min(100%, calc(100dvw - 2rem));\n}\n.settings-dialog-shell {\n height: min(800px, calc(100dvh - 2rem));\n}\n.settings-side-nav {\n flex-shrink: 0;\n}\n/* 待 core 修复后去掉:SideNav 的内容区在分组之间多了 4px 间距、底部内边距 4px(原版无间距、底部 8px)。 */\n.settings-side-nav .astryx-side-nav__body {\n gap: 0;\n padding-block-end: var(--astryx-spacing-2);\n}\n/* 待 core 修复后去掉:Switch 的行是 inline-flex,外层多出行高基线空隙(28 对 24),开关被顶高 2px。 */\n.settings-row-control .astryx-switch-row {\n display: flex;\n}\n.settings-side-nav-section {\n /* 待 core 修复后去掉 gap:SideNavSection 在标题和条目之间多了 2px 间距,原版没有。 */\n gap: 0;\n padding-block: var(--astryx-spacing-2);\n}\n.settings-close-anchor {\n position: sticky;\n inset-block-start: 0;\n z-index: 1;\n height: 0;\n}\n.settings-close-backing {\n display: flex;\n background-color: var(--astryx-color-background-surface);\n border-radius: var(--astryx-radius-element);\n}\n.settings-narrow-header {\n padding-block-end: var(--astryx-spacing-3);\n}\n/* The column every row measures itself against: rows stack on the width the\n panel actually got, not on the viewport's. */\n.settings-panel {\n container-type: inline-size;\n container-name: settings-panel;\n}\n/* One row: name and explanation beside the control, the control's own width\n held open, and any extra content spanning the row underneath. */\n.settings-row {\n display: grid;\n grid-template-columns: minmax(0, 1fr) auto;\n align-items: center;\n gap: var(--astryx-spacing-3);\n padding: var(--astryx-spacing-4);\n}\n.settings-row-main {\n display: flex;\n align-items: flex-start;\n gap: var(--astryx-spacing-2);\n min-width: 0;\n}\n/* The icon box aligns to the title's box; a hair of top padding makes it read\n level with the cap height beside it. */\n.settings-row-icon {\n display: inline-flex;\n padding-block-start: var(--astryx-spacing-0-5);\n}\n.settings-row-text {\n display: flex;\n flex-direction: column;\n gap: var(--astryx-spacing-0-5);\n max-width: 420px;\n}\n.settings-row-control {\n display: flex;\n flex-shrink: 0;\n}\n.settings-row-detail {\n grid-column: 1 / -1;\n}\n.font-preview {\n display: flex;\n flex-direction: column;\n gap: var(--astryx-spacing-1);\n padding: var(--astryx-spacing-3);\n background-color: var(--astryx-color-background-surface);\n border-radius: var(--astryx-radius-element);\n}\n@container settings-panel (max-width: 480px) {\n .settings-row {\n grid-template-columns: minmax(0, 1fr);\n align-items: stretch;\n gap: var(--astryx-spacing-2);\n }\n .settings-row-text {\n max-width: none;\n }\n}\n.theme-swatch {\n width: 100%;\n height: 100%;\n background: var(--astryx-color-background-muted);\n border-radius: var(--astryx-radius-element);\n}\n.theme-swatch[data-theme='dark'] {\n background: #1a1a1a;\n}\n.theme-swatch[data-theme='system'] {\n background: linear-gradient(90deg, var(--astryx-color-background-muted) 50%, #1a1a1a 50%);\n}\n</style>\n",
78620
78976
  "docsUrl": {
78621
78977
  "en": "https://astryx-vue.pages.dev/en/playground/?template=settings-dialog",
78622
78978
  "zh": "https://astryx-vue.pages.dev/playground/?template=settings-dialog"
@@ -78664,6 +79020,7 @@ const catalog = {
78664
79020
  "Grid",
78665
79021
  "Heading",
78666
79022
  "HStack",
79023
+ "Icon",
78667
79024
  "Layout",
78668
79025
  "LayoutContent",
78669
79026
  "LayoutHeader",
@@ -78680,7 +79037,7 @@ const catalog = {
78680
79037
  ],
78681
79038
  "dependencies": ["@astryx-vue/core", "vue"],
78682
79039
  "isReady": true,
78683
- "source": "<script setup lang=\"ts\">\nimport type { Component } from 'vue'\nimport type { SearchableItem } from '@astryx-vue/core'\nimport { defineComponent, h, ref } from 'vue'\nimport {\n Button,\n CheckboxInput,\n createStaticSource,\n Divider,\n Grid,\n Heading,\n HStack,\n Layout,\n LayoutContent,\n LayoutHeader,\n LayoutPanel,\n List,\n ListItem,\n StackItem,\n Tab,\n TabList,\n Text,\n TextInput,\n Typeahead,\n useMediaQuery,\n VStack,\n} from '@astryx-vue/core'\n\nconst NAV_ITEMS = ['Profile', 'Account', 'Members', 'Billing', 'Invoices', 'API']\n\nconst SETTINGS_ITEMS: SearchableItem[] = [\n { id: '1', label: 'Username' },\n { id: '2', label: 'First name' },\n { id: '3', label: 'Last name' },\n { id: '4', label: 'Email address' },\n { id: '5', label: 'Change password' },\n { id: '6', label: 'Data Export Access' },\n { id: '7', label: 'Allow Admin to Add Members' },\n { id: '8', label: 'Two-Factor Authentication' },\n]\n\nconst settingsSearchSource = createStaticSource(SETTINGS_ITEMS)\n\nfunction iconComponent(className: string): Component {\n return defineComponent({\n render: () => h('span', { class: className, 'aria-hidden': 'true', style: 'width:1em;height:1em' }),\n })\n}\n\nconst MagnifyingGlassIcon = iconComponent('i-heroicons-magnifying-glass')\n\nconst isNarrow = useMediaQuery('(max-width: 768px)')\nconst activeNav = ref('Profile')\nconst username = ref('nicol43')\nconst firstName = ref('Stephanie')\nconst lastName = ref('Nicol')\nconst email = ref('stephanie_nicol@mail.com')\nconst currentPw = ref('password123')\nconst newPw = ref('password123')\nconst confirmPw = ref('password123')\nconst dataExport = ref(false)\nconst adminMembers = ref(false)\nconst twoFactor = ref(false)\nconst searchValue = ref<SearchableItem | null>(null)\n<\/script>\n\n<template>\n <Layout height=\"fill\" :content-width=\"1440\">\n <template #header>\n <LayoutHeader has-divider>\n <HStack align=\"center\">\n <StackItem size=\"fill\">\n <Heading :level=\"1\">\n Settings\n </Heading>\n </StackItem>\n <Typeahead\n v-model=\"searchValue\"\n label=\"Search\"\n is-label-hidden\n placeholder=\"Search settings...\"\n :search-source=\"settingsSearchSource\"\n has-entries-on-focus\n :start-icon=\"MagnifyingGlassIcon\"\n />\n </HStack>\n </LayoutHeader>\n </template>\n <template v-if=\"!isNarrow\" #start>\n <LayoutPanel :has-divider=\"false\" :width=\"260\" :padding=\"2\">\n <List density=\"balanced\">\n <ListItem\n v-for=\"item in NAV_ITEMS\"\n :key=\"item\"\n :label=\"item\"\n :is-selected=\"activeNav === item\"\n @click=\"activeNav = item\"\n />\n </List>\n </LayoutPanel>\n </template>\n <LayoutContent :padding=\"4\">\n <VStack :gap=\"4\">\n <VStack v-if=\"isNarrow\" justify=\"center\">\n <TabList v-model:value=\"activeNav\">\n <Tab v-for=\"item in NAV_ITEMS\" :key=\"item\" :value=\"item\" :label=\"item\" />\n </TabList>\n </VStack>\n <Grid :columns=\"{ minWidth: 320 }\" :gap=\"10\">\n <VStack :gap=\"1\">\n <Heading :level=\"3\">\n Basic information\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n View and update your personal details and account information.\n </Text>\n </VStack>\n <VStack :gap=\"4\">\n <TextInput v-model=\"username\" label=\"Username\" />\n <TextInput v-model=\"firstName\" label=\"First name\" />\n <TextInput v-model=\"lastName\" label=\"Last name\" />\n <TextInput v-model=\"email\" label=\"Email address\" />\n <HStack>\n <Button label=\"Save\" variant=\"primary\" />\n </HStack>\n </VStack>\n </Grid>\n <Divider />\n <Grid :columns=\"{ minWidth: 320 }\" :gap=\"10\">\n <VStack :gap=\"1\">\n <Heading :level=\"3\">\n Change password\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Update your password to keep your account secure.\n </Text>\n </VStack>\n <VStack :gap=\"4\">\n <TextInput v-model=\"currentPw\" label=\"Verify current password\" type=\"password\" />\n <TextInput v-model=\"newPw\" label=\"New password\" type=\"password\" />\n <TextInput v-model=\"confirmPw\" label=\"Confirm password\" type=\"password\" />\n <HStack>\n <Button label=\"Save\" variant=\"primary\" />\n </HStack>\n </VStack>\n </Grid>\n <Divider />\n <Grid :columns=\"{ minWidth: 320 }\" :gap=\"10\">\n <VStack :gap=\"1\">\n <Heading :level=\"3\">\n Advanced settings\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Configure detailed account preferences and security options.\n </Text>\n </VStack>\n <VStack :gap=\"5\">\n <CheckboxInput\n v-model=\"dataExport\"\n label=\"Data Export Access\"\n description=\"Allow export of personal data and backups.\"\n />\n <CheckboxInput\n v-model=\"adminMembers\"\n label=\"Allow Admin to Add Members\"\n description=\"Admins can invite and manage members.\"\n />\n <CheckboxInput\n v-model=\"twoFactor\"\n label=\"Enable Two-Factor Authentication\"\n description=\"Require 2FA for added account security.\"\n />\n <HStack>\n <Button label=\"Save\" variant=\"primary\" />\n </HStack>\n </VStack>\n </Grid>\n </VStack>\n </LayoutContent>\n </Layout>\n</template>\n",
79040
+ "source": "<script setup lang=\"ts\">\nimport type { SearchableItem } from '@astryx-vue/core'\nimport {\n Button,\n CheckboxInput,\n createStaticSource,\n Divider,\n Grid,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n LayoutHeader,\n LayoutPanel,\n List,\n ListItem,\n StackItem,\n Tab,\n TabList,\n Text,\n TextInput,\n Typeahead,\n useMediaQuery,\n VStack,\n} from '@astryx-vue/core'\nimport { defineComponent, h, ref } from 'vue'\n\nconst NAV_ITEMS = ['Profile', 'Account', 'Members', 'Billing', 'Invoices', 'API']\n\nconst SETTINGS_ITEMS: SearchableItem[] = [\n { id: '1', label: 'Username' },\n { id: '2', label: 'First name' },\n { id: '3', label: 'Last name' },\n { id: '4', label: 'Email address' },\n { id: '5', label: 'Change password' },\n { id: '6', label: 'Data Export Access' },\n { id: '7', label: 'Allow Admin to Add Members' },\n { id: '8', label: 'Two-Factor Authentication' },\n]\n\nconst settingsSearchSource = createStaticSource(SETTINGS_ITEMS)\n\nconst MagnifyingGlassIcon = defineComponent({\n render: () => h(Icon, { name: 'heroicons:magnifying-glass' }),\n})\n\nconst isNarrow = useMediaQuery('(max-width: 768px)')\nconst activeNav = ref('Profile')\nconst username = ref('nicol43')\nconst firstName = ref('Stephanie')\nconst lastName = ref('Nicol')\nconst email = ref('stephanie_nicol@mail.com')\nconst currentPw = ref('password123')\nconst newPw = ref('password123')\nconst confirmPw = ref('password123')\nconst dataExport = ref(false)\nconst adminMembers = ref(false)\nconst twoFactor = ref(false)\nconst searchValue = ref<SearchableItem | null>(null)\n<\/script>\n\n<template>\n <Layout height=\"fill\" :content-width=\"1440\">\n <template #header>\n <LayoutHeader has-divider>\n <HStack align=\"center\">\n <StackItem size=\"fill\">\n <Heading :level=\"1\">\n Settings\n </Heading>\n </StackItem>\n <Typeahead\n v-model=\"searchValue\"\n label=\"Search\"\n is-label-hidden\n placeholder=\"Search settings...\"\n :search-source=\"settingsSearchSource\"\n has-entries-on-focus\n :width=\"184\"\n :start-icon=\"MagnifyingGlassIcon\"\n />\n </HStack>\n </LayoutHeader>\n </template>\n <template v-if=\"!isNarrow\" #start>\n <LayoutPanel :has-divider=\"false\" :width=\"260\" :padding=\"2\">\n <List density=\"balanced\">\n <ListItem\n v-for=\"item in NAV_ITEMS\"\n :key=\"item\"\n :label=\"item\"\n :is-selected=\"activeNav === item\"\n @click=\"activeNav = item\"\n />\n </List>\n </LayoutPanel>\n </template>\n <LayoutContent :padding=\"4\">\n <VStack :gap=\"4\">\n <VStack v-if=\"isNarrow\" h-align=\"center\">\n <TabList v-model:value=\"activeNav\">\n <Tab v-for=\"item in NAV_ITEMS\" :key=\"item\" :value=\"item\" :label=\"item\" />\n </TabList>\n </VStack>\n <Grid :columns=\"{ minWidth: 320 }\" :gap=\"10\">\n <VStack :gap=\"1\">\n <Heading :level=\"3\">\n Basic information\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n View and update your personal details and account information.\n </Text>\n </VStack>\n <VStack :gap=\"4\">\n <TextInput v-model=\"username\" label=\"Username\" />\n <TextInput v-model=\"firstName\" label=\"First name\" />\n <TextInput v-model=\"lastName\" label=\"Last name\" />\n <TextInput v-model=\"email\" label=\"Email address\" />\n <HStack>\n <Button label=\"Save\" variant=\"primary\" />\n </HStack>\n </VStack>\n </Grid>\n <Divider />\n <Grid :columns=\"{ minWidth: 320 }\" :gap=\"10\">\n <VStack :gap=\"1\">\n <Heading :level=\"3\">\n Change password\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Update your password to keep your account secure.\n </Text>\n </VStack>\n <VStack :gap=\"4\">\n <TextInput v-model=\"currentPw\" label=\"Verify current password\" type=\"password\" />\n <TextInput v-model=\"newPw\" label=\"New password\" type=\"password\" />\n <TextInput v-model=\"confirmPw\" label=\"Confirm password\" type=\"password\" />\n <HStack>\n <Button label=\"Save\" variant=\"primary\" />\n </HStack>\n </VStack>\n </Grid>\n <Divider />\n <Grid :columns=\"{ minWidth: 320 }\" :gap=\"10\">\n <VStack :gap=\"1\">\n <Heading :level=\"3\">\n Advanced settings\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Configure detailed account preferences and security options.\n </Text>\n </VStack>\n <VStack :gap=\"5\">\n <CheckboxInput\n v-model=\"dataExport\"\n label=\"Data Export Access\"\n description=\"Allow export of personal data and backups.\"\n />\n <CheckboxInput\n v-model=\"adminMembers\"\n label=\"Allow Admin to Add Members\"\n description=\"Admins can invite and manage members.\"\n />\n <CheckboxInput\n v-model=\"twoFactor\"\n label=\"Enable Two-Factor Authentication\"\n description=\"Require 2FA for added account security.\"\n />\n <HStack>\n <Button label=\"Save\" variant=\"primary\" />\n </HStack>\n </VStack>\n </Grid>\n </VStack>\n </LayoutContent>\n </Layout>\n</template>\n",
78684
79041
  "docsUrl": {
78685
79042
  "en": "https://astryx-vue.pages.dev/en/playground/?template=settings",
78686
79043
  "zh": "https://astryx-vue.pages.dev/playground/?template=settings"
@@ -78746,7 +79103,7 @@ const catalog = {
78746
79103
  ],
78747
79104
  "dependencies": ["@astryx-vue/core", "vue"],
78748
79105
  "isReady": true,
78749
- "source": "<script setup lang=\"ts\">\nimport type { CSSProperties } from 'vue'\nimport { reactive, ref } from 'vue'\nimport {\n Badge,\n Button,\n Card,\n Center,\n Divider,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n LayoutPanel,\n Link,\n List,\n ListItem,\n Selector,\n StackItem,\n Switch,\n Tab,\n TabList,\n Text,\n TextInput,\n Toolbar,\n useMediaQuery,\n VStack,\n} from '@astryx-vue/core'\n\nconst fillViewport: CSSProperties = { minHeight: '100dvh' }\nconst iconBox: CSSProperties = {\n borderRadius: 'var(--astryx-radius-container)',\n backgroundColor: 'var(--astryx-color-background-surface)',\n flexShrink: 0,\n}\nconst rowPadding: CSSProperties = { paddingBlock: 'var(--astryx-spacing-4)' }\nconst sideNavPadding: CSSProperties = {\n paddingBlock: 'var(--astryx-spacing-4)',\n paddingInline: 'var(--astryx-spacing-3)',\n}\nconst sideNavHeading: CSSProperties = { marginInline: 'var(--astryx-spacing-4)' }\n\n// ── 数据 ──────────────────────────────────────────────────────────────────────\n\nconst NAV_ITEMS = [\n { label: 'Personal information', icon: 'i-heroicons-user' },\n { label: 'Login & security', icon: 'i-heroicons-lock-closed' },\n { label: 'Privacy', icon: 'i-heroicons-shield-check' },\n { label: 'Notifications', icon: 'i-heroicons-bell' },\n { label: 'Taxes', icon: 'i-heroicons-document-text' },\n { label: 'Payments', icon: 'i-heroicons-credit-card' },\n { label: 'Languages & currency', icon: 'i-heroicons-globe-alt' },\n { label: 'Travel for work', icon: 'i-heroicons-briefcase' },\n]\n\nconst SECTION_TITLES: Record<string, string> = {\n 'Personal information': 'Personal info',\n 'Login & security': 'Login & security',\n Privacy: 'Privacy',\n 'Languages & currency': 'Languages & currency',\n}\n\nconst LOGIN_ROWS = [\n { label: 'Password', value: 'Not created', action: 'Create' },\n]\n\nconst SOCIAL_ROWS = [\n { label: 'Google', value: 'Connected', action: 'Disconnect' },\n]\n\nconst DEVICE_ROWS = [\n { label: 'OS X 10.15.7 · Chrome', badge: 'CURRENT SESSION', location: 'McKinney, Texas · March 30, 2026 at 19:31', action: undefined as string | undefined },\n { label: 'Session', badge: undefined as string | undefined, location: 'August 9, 2023 at 04:19', action: 'Log out' },\n { label: 'OS X 10.15.7 · unknown', badge: undefined as string | undefined, location: 'Sunnyvale, California · April 14, 2023 at 17:47', action: 'Log out' },\n]\n\nconst LANGUAGES = [\n { label: 'English (Canada)', value: 'en-CA' },\n { label: 'English (US)', value: 'en-US' },\n { label: 'French', value: 'fr' },\n { label: 'Spanish', value: 'es' },\n { label: 'German', value: 'de' },\n { label: 'Japanese', value: 'ja' },\n]\n\nconst CURRENCIES = [\n { label: 'Canadian dollar (CAD)', value: 'CAD' },\n { label: 'US dollar (USD)', value: 'USD' },\n { label: 'Euro (EUR)', value: 'EUR' },\n { label: 'British pound (GBP)', value: 'GBP' },\n { label: 'Japanese yen (JPY)', value: 'JPY' },\n]\n\nconst TIMEZONES = [\n { label: '(GMT-05:00) Eastern Time (US & Canada)', value: 'ET' },\n { label: '(GMT-06:00) Central Time (US & Canada)', value: 'CT' },\n { label: '(GMT-07:00) Mountain Time (US & Canada)', value: 'MT' },\n { label: '(GMT-08:00) Pacific Time (US & Canada)', value: 'PT' },\n { label: '(GMT+00:00) UTC', value: 'UTC' },\n { label: '(GMT+01:00) London', value: 'GMT+1' },\n]\n\n/** 每行一个字段;`isReadOnly` 的那一行只显示,不给编辑入口。 */\ninterface PersonalField {\n key: string\n label: string\n isReadOnly?: boolean\n}\n\nconst PERSONAL_FIELDS: PersonalField[] = [\n { key: 'legalName', label: 'Legal name' },\n { key: 'preferredName', label: 'Preferred first name' },\n { key: 'email', label: 'Email address' },\n { key: 'phone', label: 'Phone number' },\n { key: 'identity', label: 'Identity verification', isReadOnly: true },\n { key: 'address', label: 'Residential address' },\n { key: 'mailingAddress', label: 'Mailing address' },\n { key: 'emergencyContact', label: 'Emergency contact' },\n] as const\n\n/** 值留空的字段显示占位文案,和原版一致。 */\nconst PERSONAL_PLACEHOLDERS: Record<string, string> = {\n preferredName: 'Not provided',\n address: 'Not provided',\n mailingAddress: 'Not provided',\n}\n\nconst HELP_CARDS = [\n {\n icon: 'i-heroicons-lock-closed',\n title: \"Why isn't my info shown here?\",\n body: \"We're hiding some account details to protect your identity.\",\n },\n {\n icon: 'i-heroicons-pencil-square',\n title: 'Which details can be edited?',\n body: \"Contact info and personal details can be edited. If this info was used to verify your identity, you'll need to get verified again the next time you book—or to continue hosting.\",\n },\n {\n icon: 'i-heroicons-share',\n title: 'What info is shared with others?',\n body: 'We only release contact information after a reservation is confirmed.',\n },\n]\n\n// ── 状态 ──────────────────────────────────────────────────────────────────────\n\nconst isNarrow = useMediaQuery('(max-width: 768px)')\n\nconst mobileView = ref<'nav' | 'detail'>('nav')\nconst activeNav = ref('Personal information')\nconst activeTab = ref('login')\n\nconst expandedRow = ref<string | null>(null)\nconst language = ref('en-CA')\nconst currency = ref('CAD')\nconst timezone = ref('ET')\n\nconst personal = reactive<Record<string, string>>({\n legalName: 'Alex Johnson',\n preferredName: '',\n email: 'a***n@example.com',\n phone: '+1 ***-***-0123',\n address: '',\n mailingAddress: '',\n emergencyContact: 'Provided',\n})\n\nconst readReceipts = ref(true)\nconst searchEngines = ref(true)\nconst showCity = ref(true)\nconst showTripType = ref(true)\nconst showStayLength = ref(true)\nconst showServices = ref(true)\nconst aiFeatures = ref(true)\n\nfunction selectNav(label: string) {\n activeNav.value = label\n mobileView.value = 'detail'\n}\n\nfunction labelForOption(options: { label: string, value: string }[], value: string) {\n return options.find(o => o.value === value)?.label ?? value\n}\n\nfunction personalValue(key: string) {\n const value = personal[key]\n return value || PERSONAL_PLACEHOLDERS[key] || value\n}\n<\/script>\n\n<template>\n <Layout\n v-if=\"isNarrow && mobileView === 'nav'\"\n height=\"fill\"\n :style=\"fillViewport\"\n >\n <LayoutContent :padding=\"2\">\n <VStack :gap=\"4\" :style=\"sideNavPadding\">\n <Heading :level=\"2\" :style=\"sideNavHeading\">\n Account settings\n </Heading>\n <List density=\"spacious\">\n <ListItem\n v-for=\"item in NAV_ITEMS\"\n :key=\"item.label\"\n :label=\"item.label\"\n @click=\"selectNav(item.label)\"\n >\n <template #startContent>\n <Icon :icon=\"item.icon\" />\n </template>\n <template #endContent>\n <Icon icon=\"i-heroicons-chevron-right\" size=\"sm\" color=\"secondary\" />\n </template>\n </ListItem>\n </List>\n <Divider />\n <List density=\"spacious\">\n <ListItem label=\"Professional hosting tools\">\n <template #startContent>\n <Icon icon=\"i-heroicons-wrench-screwdriver\" />\n </template>\n </ListItem>\n </List>\n </VStack>\n </LayoutContent>\n </Layout>\n\n <Layout\n v-else\n height=\"fill\"\n :content-width=\"1200\"\n :style=\"fillViewport\"\n >\n <template v-if=\"!isNarrow\" #start>\n <LayoutPanel :has-divider=\"true\" :padding=\"0\">\n <VStack :gap=\"4\" :style=\"sideNavPadding\">\n <Heading :level=\"2\" :style=\"sideNavHeading\">\n Account settings\n </Heading>\n <List density=\"spacious\">\n <ListItem\n v-for=\"item in NAV_ITEMS\"\n :key=\"item.label\"\n :label=\"item.label\"\n :is-selected=\"activeNav === item.label\"\n @click=\"selectNav(item.label)\"\n >\n <template #startContent>\n <Icon :icon=\"item.icon\" />\n </template>\n </ListItem>\n </List>\n <Divider />\n <List density=\"spacious\">\n <ListItem label=\"Professional hosting tools\">\n <template #startContent>\n <Icon icon=\"i-heroicons-wrench-screwdriver\" />\n </template>\n </ListItem>\n </List>\n </VStack>\n </LayoutPanel>\n </template>\n\n <LayoutContent :padding=\"4\">\n <VStack :gap=\"0\">\n <Toolbar\n v-if=\"isNarrow\"\n :label=\"`Back to Account settings — ${SECTION_TITLES[activeNav] ?? activeNav}`\"\n :gap=\"2\"\n >\n <template #start>\n <Button\n label=\"Back to Account settings\"\n variant=\"ghost\"\n size=\"sm\"\n is-icon-only\n @click=\"mobileView = 'nav'\"\n >\n <template #icon>\n <Icon icon=\"i-heroicons-arrow-left\" size=\"sm\" />\n </template>\n </Button>\n <Heading :level=\"2\">\n {{ SECTION_TITLES[activeNav] ?? activeNav }}\n </Heading>\n </template>\n </Toolbar>\n\n <VStack v-if=\"activeNav === 'Login & security'\" :gap=\"6\">\n <Heading v-if=\"!isNarrow\" :level=\"2\">\n Login & security\n </Heading>\n <TabList :value=\"activeTab\" has-divider @update:value=\"activeTab = $event\">\n <Tab value=\"login\" label=\"Login\" />\n <Tab value=\"shared\" label=\"Shared access\" />\n </TabList>\n\n <VStack v-if=\"activeTab === 'login'\" :gap=\"8\">\n <VStack :gap=\"0\">\n <Heading :level=\"3\">\n Login\n </Heading>\n <Divider />\n <template v-for=\"row in LOGIN_ROWS\" :key=\"row.label\">\n <HStack justify=\"between\" align=\"start\" :style=\"rowPadding\">\n <VStack :gap=\"0\">\n <Text type=\"body\" weight=\"semibold\" display=\"block\">\n {{ row.label }}\n </Text>\n <Text type=\"supporting\" color=\"secondary\" display=\"block\">\n {{ row.value }}\n </Text>\n </VStack>\n <Link v-if=\"row.action\" href=\"#\">\n {{ row.action }}\n </Link>\n </HStack>\n <Divider />\n </template>\n </VStack>\n\n <VStack :gap=\"0\">\n <Heading :level=\"3\">\n Social accounts\n </Heading>\n <Divider />\n <template v-for=\"row in SOCIAL_ROWS\" :key=\"row.label\">\n <HStack justify=\"between\" align=\"start\" :style=\"rowPadding\">\n <VStack :gap=\"0\">\n <Text type=\"body\" weight=\"semibold\" display=\"block\">\n {{ row.label }}\n </Text>\n <Text type=\"supporting\" color=\"secondary\" display=\"block\">\n {{ row.value }}\n </Text>\n </VStack>\n <Link href=\"#\">\n {{ row.action }}\n </Link>\n </HStack>\n <Divider />\n </template>\n </VStack>\n\n <VStack :gap=\"0\">\n <Heading :level=\"3\">\n Device history\n </Heading>\n <Divider />\n <HStack\n v-for=\"(device, i) in DEVICE_ROWS\"\n :key=\"i\"\n :gap=\"3\"\n align=\"start\"\n :style=\"rowPadding\"\n >\n <Icon icon=\"i-heroicons-computer-desktop\" />\n <StackItem size=\"fill\">\n <VStack :gap=\"0\">\n <HStack :gap=\"2\" align=\"center\">\n <Text type=\"body\" weight=\"semibold\">\n {{ device.label }}\n </Text>\n <Badge v-if=\"device.badge\" :label=\"device.badge\" />\n </HStack>\n <Text type=\"supporting\" color=\"secondary\" display=\"block\">\n {{ device.location }}\n </Text>\n </VStack>\n </StackItem>\n <Link v-if=\"device.action\" href=\"#\">\n {{ device.action }}\n </Link>\n </HStack>\n <Divider />\n </VStack>\n\n <VStack :gap=\"0\">\n <Heading :level=\"3\">\n Account\n </Heading>\n <Divider />\n <HStack justify=\"between\" align=\"start\" :style=\"rowPadding\">\n <VStack :gap=\"0\">\n <Text type=\"body\" weight=\"semibold\" display=\"block\">\n Deactivate your account\n </Text>\n <Text type=\"supporting\" color=\"secondary\" display=\"block\">\n This action cannot be undone\n </Text>\n </VStack>\n <Link href=\"#\">\n Deactivate\n </Link>\n </HStack>\n <Divider />\n </VStack>\n </VStack>\n\n <VStack v-else :gap=\"8\">\n <VStack :gap=\"2\">\n <Heading :level=\"3\">\n Shared access\n </Heading>\n <Divider />\n <Text type=\"body\" color=\"secondary\">\n Review each request carefully before approving access. We'll email your employee or co-worker a 4-digit code that lets them log into your account with their trusted device.\n </Text>\n </VStack>\n <Card variant=\"muted\">\n <HStack :gap=\"4\" align=\"start\">\n <Center :width=\"48\" :height=\"48\" :style=\"iconBox\">\n <Icon icon=\"i-heroicons-lock-closed\" />\n </Center>\n <VStack :gap=\"1\">\n <Text type=\"body\" weight=\"bold\">\n Adding devices from people you trust\n </Text>\n <Text type=\"body\" color=\"secondary\">\n When you approve a request, you grant someone full access to your account. They'll be able to change reservations and send messages on your behalf.\n </Text>\n </VStack>\n </HStack>\n </Card>\n </VStack>\n </VStack>\n\n <VStack v-else-if=\"activeNav === 'Languages & currency'\" :gap=\"6\">\n <Heading v-if=\"!isNarrow\" :level=\"2\">\n Languages & currency\n </Heading>\n <VStack :gap=\"0\">\n <template v-for=\"row in [\n { key: 'language', label: 'Preferred language', options: LANGUAGES, current: language },\n { key: 'currency', label: 'Preferred currency', options: CURRENCIES, current: currency },\n { key: 'timezone', label: 'Time zone', options: TIMEZONES, current: timezone },\n ]\" :key=\"row.key\">\n <VStack v-if=\"expandedRow === row.key\" :gap=\"4\" :style=\"rowPadding\">\n <Text type=\"body\" weight=\"semibold\" display=\"block\">\n {{ row.label }}\n </Text>\n <Selector\n v-if=\"row.key === 'language'\"\n v-model=\"language\"\n :label=\"row.label\"\n is-label-hidden\n size=\"lg\"\n :options=\"row.options\"\n />\n <Selector\n v-else-if=\"row.key === 'currency'\"\n v-model=\"currency\"\n :label=\"row.label\"\n is-label-hidden\n size=\"lg\"\n :options=\"row.options\"\n />\n <Selector\n v-else\n v-model=\"timezone\"\n :label=\"row.label\"\n is-label-hidden\n size=\"lg\"\n :options=\"row.options\"\n />\n <HStack :gap=\"2\">\n <Button label=\"Save\" variant=\"primary\" @click=\"expandedRow = null\" />\n <Button label=\"Cancel\" variant=\"ghost\" @click=\"expandedRow = null\" />\n </HStack>\n </VStack>\n <HStack v-else justify=\"between\" align=\"start\" :style=\"rowPadding\">\n <VStack :gap=\"0\">\n <Text type=\"body\" weight=\"semibold\" display=\"block\">\n {{ row.label }}\n </Text>\n <Text type=\"supporting\" color=\"secondary\" display=\"block\">\n {{ labelForOption(row.options, row.current) }}\n </Text>\n </VStack>\n <Link href=\"#\" @click.prevent=\"expandedRow = row.key\">\n Edit\n </Link>\n </HStack>\n <Divider />\n </template>\n </VStack>\n </VStack>\n\n <VStack v-else-if=\"activeNav === 'Personal information'\" :gap=\"6\">\n <Heading v-if=\"!isNarrow\" :level=\"2\">\n Personal info\n </Heading>\n <VStack :gap=\"0\">\n <template v-for=\"field in PERSONAL_FIELDS\" :key=\"field.key\">\n <VStack\n v-if=\"!field.isReadOnly && expandedRow === field.key\"\n :gap=\"4\"\n :style=\"rowPadding\"\n >\n <Text type=\"body\" weight=\"semibold\" display=\"block\">\n {{ field.label }}\n </Text>\n <TextInput\n v-model=\"personal[field.key]\"\n :label=\"field.label\"\n is-label-hidden\n />\n <HStack :gap=\"2\">\n <Button label=\"Save\" variant=\"primary\" @click=\"expandedRow = null\" />\n <Button label=\"Cancel\" variant=\"ghost\" @click=\"expandedRow = null\" />\n </HStack>\n </VStack>\n <HStack v-else justify=\"between\" align=\"start\" :style=\"rowPadding\">\n <VStack :gap=\"0\">\n <Text type=\"body\" weight=\"semibold\" display=\"block\">\n {{ field.label }}\n </Text>\n <Text type=\"supporting\" color=\"secondary\" display=\"block\">\n {{ field.isReadOnly ? 'Verified' : personalValue(field.key) }}\n </Text>\n </VStack>\n <Link\n v-if=\"!field.isReadOnly\"\n href=\"#\"\n @click.prevent=\"expandedRow = field.key\"\n >\n Edit\n </Link>\n </HStack>\n <Divider />\n </template>\n </VStack>\n\n <Card :padding=\"4\">\n <VStack :gap=\"4\">\n <template v-for=\"(card, i) in HELP_CARDS\" :key=\"card.title\">\n <Divider v-if=\"i > 0\" />\n <HStack :gap=\"3\" align=\"start\">\n <Center :width=\"48\" :height=\"48\" :style=\"iconBox\">\n <Icon :icon=\"card.icon\" />\n </Center>\n <VStack :gap=\"0\">\n <Text type=\"body\" weight=\"semibold\" display=\"block\">\n {{ card.title }}\n </Text>\n <Text type=\"supporting\" color=\"secondary\" display=\"block\">\n {{ card.body }}\n </Text>\n </VStack>\n </HStack>\n </template>\n </VStack>\n </Card>\n </VStack>\n\n <VStack v-else-if=\"activeNav === 'Privacy'\" :gap=\"6\">\n <Heading v-if=\"!isNarrow\" :level=\"2\">\n Privacy\n </Heading>\n <VStack :gap=\"8\">\n <VStack :gap=\"0\">\n <Heading :level=\"3\">\n Messages\n </Heading>\n <VStack :style=\"rowPadding\">\n <Switch\n v-model=\"readReceipts\"\n label=\"Show people when I've read their messages.\"\n label-position=\"start\"\n label-spacing=\"spread\"\n />\n </VStack>\n <HStack justify=\"between\" align=\"center\" :style=\"rowPadding\">\n <Text type=\"body\" weight=\"semibold\">\n Blocked people\n </Text>\n <Link href=\"#\">\n View\n </Link>\n </HStack>\n <Divider />\n </VStack>\n\n <VStack :gap=\"0\">\n <Heading :level=\"3\">\n Listings\n </Heading>\n <VStack :style=\"rowPadding\">\n <Switch\n v-model=\"searchEngines\"\n label=\"Include my listing(s) in search engines\"\n description=\"Turning this on means search engines, like Google, will display your listing page(s) in search results.\"\n label-position=\"start\"\n label-spacing=\"spread\"\n />\n </VStack>\n <Divider />\n </VStack>\n\n <VStack :gap=\"4\">\n <Heading :level=\"3\">\n Reviews\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Choose what's shared when you write a review.\n <Link href=\"#\" type=\"supporting\">\n Learn more\n </Link>\n </Text>\n <VStack :gap=\"4\">\n <Switch\n v-model=\"showCity\"\n label=\"Show my home city and country\"\n description=\"Ex: City and country\"\n label-position=\"start\"\n label-spacing=\"spread\"\n />\n <Switch\n v-model=\"showTripType\"\n label=\"Show my trip type\"\n description=\"Ex: Stayed with kids or pets\"\n label-position=\"start\"\n label-spacing=\"spread\"\n />\n <Switch\n v-model=\"showStayLength\"\n label=\"Show my length of stay\"\n description=\"Ex: A few nights, about a week, etc.\"\n label-position=\"start\"\n label-spacing=\"spread\"\n />\n <Switch\n v-model=\"showServices\"\n label=\"Show my booked services\"\n description=\"Ex: Gourmet brunch or tasting menu\"\n label-position=\"start\"\n label-spacing=\"spread\"\n />\n </VStack>\n <Divider />\n </VStack>\n\n <VStack :gap=\"4\">\n <Heading :level=\"3\">\n Data privacy\n </Heading>\n <Card>\n <HStack justify=\"between\" align=\"center\">\n <Text type=\"body\">\n Request my personal data\n </Text>\n <Link href=\"#\">\n Request\n </Link>\n </HStack>\n </Card>\n <Switch\n v-model=\"aiFeatures\"\n label=\"Help improve AI-powered features\"\n description=\"When this is on, we use your data to develop and improve AI models.\"\n label-position=\"start\"\n label-spacing=\"spread\"\n />\n <Card>\n <HStack justify=\"between\" align=\"center\">\n <Text type=\"body\">\n Delete my account\n </Text>\n <Link href=\"#\">\n Delete\n </Link>\n </HStack>\n </Card>\n <Card variant=\"muted\">\n <HStack :gap=\"4\" align=\"start\">\n <Center :width=\"48\" :height=\"48\" :style=\"iconBox\">\n <Icon icon=\"i-heroicons-shield-check\" />\n </Center>\n <VStack :gap=\"1\">\n <Text type=\"body\" weight=\"bold\">\n Committed to privacy\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n We're committed to keeping your data protected. See details in our\n <Link href=\"#\" type=\"supporting\">\n Privacy Policy\n </Link>.\n </Text>\n </VStack>\n </HStack>\n </Card>\n </VStack>\n </VStack>\n </VStack>\n </VStack>\n </LayoutContent>\n </Layout>\n</template>\n",
79106
+ "source": "<script setup lang=\"ts\">\nimport type { CSSProperties } from 'vue'\nimport {\n Badge,\n Button,\n Card,\n Center,\n Divider,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n LayoutPanel,\n Link,\n List,\n ListItem,\n Selector,\n StackItem,\n Switch,\n Tab,\n TabList,\n Text,\n TextInput,\n Toolbar,\n useMediaQuery,\n VStack,\n} from '@astryx-vue/core'\nimport { reactive, ref } from 'vue'\n\nconst fillViewport: CSSProperties = { minHeight: '100dvh' }\nconst iconBox: CSSProperties = {\n borderRadius: 'var(--astryx-radius-container)',\n backgroundColor: 'var(--astryx-color-background-surface)',\n flexShrink: 0,\n}\nconst rowPadding: CSSProperties = { paddingBlock: 'var(--astryx-spacing-4)' }\nconst sideNavPadding: CSSProperties = {\n paddingBlock: 'var(--astryx-spacing-4)',\n paddingInline: 'var(--astryx-spacing-3)',\n}\nconst sideNavHeading: CSSProperties = { marginInline: 'var(--astryx-spacing-4)' }\n\n// ── 数据 ──────────────────────────────────────────────────────────────────────\n\nconst NAV_ITEMS = [\n { label: 'Personal information', icon: 'heroicons:user' },\n { label: 'Login & security', icon: 'heroicons:lock-closed' },\n { label: 'Privacy', icon: 'heroicons:shield-check' },\n { label: 'Notifications', icon: 'heroicons:bell' },\n { label: 'Taxes', icon: 'heroicons:document-text' },\n { label: 'Payments', icon: 'heroicons:credit-card' },\n { label: 'Languages & currency', icon: 'heroicons:globe-alt' },\n { label: 'Travel for work', icon: 'heroicons:briefcase' },\n]\n\nconst SECTION_TITLES: Record<string, string> = {\n 'Personal information': 'Personal info',\n 'Login & security': 'Login & security',\n 'Privacy': 'Privacy',\n 'Languages & currency': 'Languages & currency',\n}\n\nconst LOGIN_ROWS = [\n { label: 'Password', value: 'Not created', action: 'Create' },\n]\n\nconst SOCIAL_ROWS = [\n { label: 'Google', value: 'Connected', action: 'Disconnect' },\n]\n\nconst DEVICE_ROWS = [\n { label: 'OS X 10.15.7 · Chrome', badge: 'CURRENT SESSION', location: 'McKinney, Texas · March 30, 2026 at 19:31', action: undefined as string | undefined },\n { label: 'Session', badge: undefined as string | undefined, location: 'August 9, 2023 at 04:19', action: 'Log out' },\n { label: 'OS X 10.15.7 · unknown', badge: undefined as string | undefined, location: 'Sunnyvale, California · April 14, 2023 at 17:47', action: 'Log out' },\n]\n\nconst LANGUAGES = [\n { label: 'English (Canada)', value: 'en-CA' },\n { label: 'English (US)', value: 'en-US' },\n { label: 'French', value: 'fr' },\n { label: 'Spanish', value: 'es' },\n { label: 'German', value: 'de' },\n { label: 'Japanese', value: 'ja' },\n]\n\nconst CURRENCIES = [\n { label: 'Canadian dollar (CAD)', value: 'CAD' },\n { label: 'US dollar (USD)', value: 'USD' },\n { label: 'Euro (EUR)', value: 'EUR' },\n { label: 'British pound (GBP)', value: 'GBP' },\n { label: 'Japanese yen (JPY)', value: 'JPY' },\n]\n\nconst TIMEZONES = [\n { label: '(GMT-05:00) Eastern Time (US & Canada)', value: 'ET' },\n { label: '(GMT-06:00) Central Time (US & Canada)', value: 'CT' },\n { label: '(GMT-07:00) Mountain Time (US & Canada)', value: 'MT' },\n { label: '(GMT-08:00) Pacific Time (US & Canada)', value: 'PT' },\n { label: '(GMT+00:00) UTC', value: 'UTC' },\n { label: '(GMT+01:00) London', value: 'GMT+1' },\n]\n\n/** 每行一个字段;`isReadOnly` 的那一行只显示,不给编辑入口。 */\ninterface PersonalField {\n key: string\n label: string\n isReadOnly?: boolean\n}\n\nconst PERSONAL_FIELDS: PersonalField[] = [\n { key: 'legalName', label: 'Legal name' },\n { key: 'preferredName', label: 'Preferred first name' },\n { key: 'email', label: 'Email address' },\n { key: 'phone', label: 'Phone number' },\n { key: 'identity', label: 'Identity verification', isReadOnly: true },\n { key: 'address', label: 'Residential address' },\n { key: 'mailingAddress', label: 'Mailing address' },\n { key: 'emergencyContact', label: 'Emergency contact' },\n] as const\n\n/** 值留空的字段显示占位文案,和原版一致。 */\nconst PERSONAL_PLACEHOLDERS: Record<string, string> = {\n preferredName: 'Not provided',\n address: 'Not provided',\n mailingAddress: 'Not provided',\n}\n\nconst HELP_CARDS = [\n {\n icon: 'heroicons:lock-closed',\n title: 'Why isn\\'t my info shown here?',\n body: 'We\\'re hiding some account details to protect your identity.',\n },\n {\n icon: 'heroicons:pencil-square',\n title: 'Which details can be edited?',\n body: 'Contact info and personal details can be edited. If this info was used to verify your identity, you\\'ll need to get verified again the next time you book—or to continue hosting.',\n },\n {\n icon: 'heroicons:share',\n title: 'What info is shared with others?',\n body: 'We only release contact information after a reservation is confirmed.',\n },\n]\n\n// ── 状态 ──────────────────────────────────────────────────────────────────────\n\nconst isNarrow = useMediaQuery('(max-width: 768px)')\n\nconst mobileView = ref<'nav' | 'detail'>('nav')\nconst activeNav = ref('Personal information')\nconst activeTab = ref('login')\n\nconst expandedRow = ref<string | null>(null)\nconst language = ref('en-CA')\nconst currency = ref('CAD')\nconst timezone = ref('ET')\n\nconst personal = reactive<Record<string, string>>({\n legalName: 'Alex Johnson',\n preferredName: '',\n email: 'a***n@example.com',\n phone: '+1 ***-***-0123',\n address: '',\n mailingAddress: '',\n emergencyContact: 'Provided',\n})\n\nconst readReceipts = ref(true)\nconst searchEngines = ref(true)\nconst showCity = ref(true)\nconst showTripType = ref(true)\nconst showStayLength = ref(true)\nconst showServices = ref(true)\nconst aiFeatures = ref(true)\n\nfunction selectNav(label: string) {\n activeNav.value = label\n mobileView.value = 'detail'\n}\n\nfunction labelForOption(options: { label: string, value: string }[], value: string) {\n return options.find(o => o.value === value)?.label ?? value\n}\n\nfunction personalValue(key: string) {\n const value = personal[key]\n return value || PERSONAL_PLACEHOLDERS[key] || value\n}\n<\/script>\n\n<template>\n <Layout\n v-if=\"isNarrow && mobileView === 'nav'\"\n height=\"fill\"\n :style=\"fillViewport\"\n >\n <LayoutContent :padding=\"2\">\n <VStack :gap=\"4\" :style=\"sideNavPadding\">\n <Heading :level=\"2\" :style=\"sideNavHeading\">\n Account settings\n </Heading>\n <List density=\"spacious\">\n <ListItem\n v-for=\"item in NAV_ITEMS\"\n :key=\"item.label\"\n :label=\"item.label\"\n @click=\"selectNav(item.label)\"\n >\n <template #startContent>\n <Icon size=\"md\" :name=\"item.icon\" />\n </template>\n <template #endContent>\n <Icon name=\"heroicons:chevron-right\" size=\"sm\" color=\"secondary\" />\n </template>\n </ListItem>\n </List>\n <Divider />\n <List density=\"spacious\">\n <ListItem label=\"Professional hosting tools\">\n <template #startContent>\n <Icon size=\"md\" name=\"heroicons:wrench-screwdriver\" />\n </template>\n </ListItem>\n </List>\n </VStack>\n </LayoutContent>\n </Layout>\n\n <Layout\n v-else\n height=\"fill\"\n :content-width=\"1200\"\n :style=\"fillViewport\"\n >\n <template v-if=\"!isNarrow\" #start>\n <LayoutPanel :has-divider=\"true\" :padding=\"0\">\n <VStack :gap=\"4\" :style=\"sideNavPadding\">\n <Heading :level=\"2\" :style=\"sideNavHeading\">\n Account settings\n </Heading>\n <List density=\"spacious\">\n <ListItem\n v-for=\"item in NAV_ITEMS\"\n :key=\"item.label\"\n :label=\"item.label\"\n :is-selected=\"activeNav === item.label\"\n @click=\"selectNav(item.label)\"\n >\n <template #startContent>\n <Icon size=\"md\" :name=\"item.icon\" />\n </template>\n </ListItem>\n </List>\n <Divider />\n <List density=\"spacious\">\n <ListItem label=\"Professional hosting tools\">\n <template #startContent>\n <Icon size=\"md\" name=\"heroicons:wrench-screwdriver\" />\n </template>\n </ListItem>\n </List>\n </VStack>\n </LayoutPanel>\n </template>\n\n <LayoutContent :padding=\"4\">\n <VStack :gap=\"0\">\n <Toolbar\n v-if=\"isNarrow\"\n :label=\"`Back to Account settings — ${SECTION_TITLES[activeNav] ?? activeNav}`\"\n :gap=\"2\"\n >\n <template #start>\n <Button\n label=\"Back to Account settings\"\n variant=\"ghost\"\n size=\"sm\"\n is-icon-only\n @click=\"mobileView = 'nav'\"\n >\n <template #icon>\n <Icon name=\"heroicons:arrow-left\" size=\"sm\" />\n </template>\n </Button>\n <Heading :level=\"2\">\n {{ SECTION_TITLES[activeNav] ?? activeNav }}\n </Heading>\n </template>\n </Toolbar>\n\n <VStack v-if=\"activeNav === 'Login & security'\" :gap=\"6\">\n <Heading v-if=\"!isNarrow\" :level=\"2\">\n Login & security\n </Heading>\n <TabList :value=\"activeTab\" has-divider @update:value=\"activeTab = $event\">\n <Tab value=\"login\" label=\"Login\" />\n <Tab value=\"shared\" label=\"Shared access\" />\n </TabList>\n\n <VStack v-if=\"activeTab === 'login'\" :gap=\"8\">\n <VStack :gap=\"0\">\n <Heading :level=\"3\">\n Login\n </Heading>\n <Divider />\n <template v-for=\"row in LOGIN_ROWS\" :key=\"row.label\">\n <HStack justify=\"between\" align=\"start\" :style=\"rowPadding\">\n <VStack :gap=\"0\">\n <Text type=\"body\" weight=\"semibold\" display=\"block\">\n {{ row.label }}\n </Text>\n <Text type=\"supporting\" color=\"secondary\" display=\"block\">\n {{ row.value }}\n </Text>\n </VStack>\n <Link v-if=\"row.action\" size=\"md\" href=\"#\">\n {{ row.action }}\n </Link>\n </HStack>\n <Divider />\n </template>\n </VStack>\n\n <VStack :gap=\"0\">\n <Heading :level=\"3\">\n Social accounts\n </Heading>\n <Divider />\n <template v-for=\"row in SOCIAL_ROWS\" :key=\"row.label\">\n <HStack justify=\"between\" align=\"start\" :style=\"rowPadding\">\n <VStack :gap=\"0\">\n <Text type=\"body\" weight=\"semibold\" display=\"block\">\n {{ row.label }}\n </Text>\n <Text type=\"supporting\" color=\"secondary\" display=\"block\">\n {{ row.value }}\n </Text>\n </VStack>\n <Link size=\"md\" href=\"#\">\n {{ row.action }}\n </Link>\n </HStack>\n <Divider />\n </template>\n </VStack>\n\n <VStack :gap=\"0\">\n <Heading :level=\"3\">\n Device history\n </Heading>\n <Divider />\n <HStack\n v-for=\"(device, i) in DEVICE_ROWS\"\n :key=\"i\"\n :gap=\"3\"\n align=\"start\"\n :style=\"rowPadding\"\n >\n <Icon size=\"md\" name=\"heroicons:computer-desktop\" />\n <StackItem size=\"fill\">\n <VStack :gap=\"0\">\n <HStack :gap=\"2\" align=\"center\">\n <Text type=\"body\" weight=\"semibold\">\n {{ device.label }}\n </Text>\n <Badge v-if=\"device.badge\" :label=\"device.badge\" />\n </HStack>\n <Text type=\"supporting\" color=\"secondary\" display=\"block\">\n {{ device.location }}\n </Text>\n </VStack>\n </StackItem>\n <Link v-if=\"device.action\" size=\"md\" href=\"#\">\n {{ device.action }}\n </Link>\n </HStack>\n <Divider />\n </VStack>\n\n <VStack :gap=\"0\">\n <Heading :level=\"3\">\n Account\n </Heading>\n <Divider />\n <HStack justify=\"between\" align=\"start\" :style=\"rowPadding\">\n <VStack :gap=\"0\">\n <Text type=\"body\" weight=\"semibold\" display=\"block\">\n Deactivate your account\n </Text>\n <Text type=\"supporting\" color=\"secondary\" display=\"block\">\n This action cannot be undone\n </Text>\n </VStack>\n <Link size=\"md\" href=\"#\">\n Deactivate\n </Link>\n </HStack>\n <Divider />\n </VStack>\n </VStack>\n\n <VStack v-else :gap=\"8\">\n <VStack :gap=\"2\">\n <Heading :level=\"3\">\n Shared access\n </Heading>\n <Divider />\n <Text type=\"body\" color=\"secondary\">\n Review each request carefully before approving access. We'll email your employee or co-worker a 4-digit code that lets them log into your account with their trusted device.\n </Text>\n </VStack>\n <Card variant=\"muted\">\n <HStack :gap=\"4\" align=\"start\">\n <Center :width=\"48\" :height=\"48\" :style=\"iconBox\">\n <Icon size=\"md\" name=\"heroicons:lock-closed\" />\n </Center>\n <VStack :gap=\"1\">\n <Text type=\"body\" weight=\"bold\">\n Adding devices from people you trust\n </Text>\n <Text type=\"body\" color=\"secondary\">\n When you approve a request, you grant someone full access to your account. They'll be able to change reservations and send messages on your behalf.\n </Text>\n </VStack>\n </HStack>\n </Card>\n </VStack>\n </VStack>\n\n <VStack v-else-if=\"activeNav === 'Languages & currency'\" :gap=\"6\">\n <Heading v-if=\"!isNarrow\" :level=\"2\">\n Languages & currency\n </Heading>\n <VStack :gap=\"0\">\n <template\n v-for=\"row in [\n { key: 'language', label: 'Preferred language', options: LANGUAGES, current: language },\n { key: 'currency', label: 'Preferred currency', options: CURRENCIES, current: currency },\n { key: 'timezone', label: 'Time zone', options: TIMEZONES, current: timezone },\n ]\" :key=\"row.key\"\n >\n <VStack v-if=\"expandedRow === row.key\" :gap=\"4\" :style=\"rowPadding\">\n <Text type=\"body\" weight=\"semibold\" display=\"block\">\n {{ row.label }}\n </Text>\n <Selector\n v-if=\"row.key === 'language'\"\n v-model=\"language\"\n :label=\"row.label\"\n is-label-hidden\n size=\"lg\"\n :options=\"row.options\"\n />\n <Selector\n v-else-if=\"row.key === 'currency'\"\n v-model=\"currency\"\n :label=\"row.label\"\n is-label-hidden\n size=\"lg\"\n :options=\"row.options\"\n />\n <Selector\n v-else\n v-model=\"timezone\"\n :label=\"row.label\"\n is-label-hidden\n size=\"lg\"\n :options=\"row.options\"\n />\n <HStack :gap=\"2\">\n <Button label=\"Save\" variant=\"primary\" @click=\"expandedRow = null\" />\n <Button label=\"Cancel\" variant=\"ghost\" @click=\"expandedRow = null\" />\n </HStack>\n </VStack>\n <HStack v-else justify=\"between\" align=\"start\" :style=\"rowPadding\">\n <VStack :gap=\"0\">\n <Text type=\"body\" weight=\"semibold\" display=\"block\">\n {{ row.label }}\n </Text>\n <Text type=\"supporting\" color=\"secondary\" display=\"block\">\n {{ labelForOption(row.options, row.current) }}\n </Text>\n </VStack>\n <Link size=\"md\" href=\"#\" @click.prevent=\"expandedRow = row.key\">\n Edit\n </Link>\n </HStack>\n <Divider />\n </template>\n </VStack>\n </VStack>\n\n <VStack v-else-if=\"activeNav === 'Personal information'\" :gap=\"6\">\n <Heading v-if=\"!isNarrow\" :level=\"2\">\n Personal info\n </Heading>\n <VStack :gap=\"0\">\n <template v-for=\"field in PERSONAL_FIELDS\" :key=\"field.key\">\n <VStack\n v-if=\"!field.isReadOnly && expandedRow === field.key\"\n :gap=\"4\"\n :style=\"rowPadding\"\n >\n <Text type=\"body\" weight=\"semibold\" display=\"block\">\n {{ field.label }}\n </Text>\n <TextInput\n v-model=\"personal[field.key]\"\n :label=\"field.label\"\n is-label-hidden\n />\n <HStack :gap=\"2\">\n <Button label=\"Save\" variant=\"primary\" @click=\"expandedRow = null\" />\n <Button label=\"Cancel\" variant=\"ghost\" @click=\"expandedRow = null\" />\n </HStack>\n </VStack>\n <HStack v-else justify=\"between\" align=\"start\" :style=\"rowPadding\">\n <VStack :gap=\"0\">\n <Text type=\"body\" weight=\"semibold\" display=\"block\">\n {{ field.label }}\n </Text>\n <Text type=\"supporting\" color=\"secondary\" display=\"block\">\n {{ field.isReadOnly ? 'Verified' : personalValue(field.key) }}\n </Text>\n </VStack>\n <Link\n v-if=\"!field.isReadOnly\"\n size=\"md\"\n href=\"#\"\n @click.prevent=\"expandedRow = field.key\"\n >\n Edit\n </Link>\n </HStack>\n <Divider />\n </template>\n </VStack>\n\n <Card :padding=\"4\">\n <VStack :gap=\"4\">\n <template v-for=\"(card, i) in HELP_CARDS\" :key=\"card.title\">\n <Divider v-if=\"i > 0\" />\n <HStack :gap=\"3\" align=\"start\">\n <Center :width=\"48\" :height=\"48\" :style=\"iconBox\">\n <Icon size=\"md\" :name=\"card.icon\" />\n </Center>\n <VStack :gap=\"0\">\n <Text type=\"body\" weight=\"semibold\" display=\"block\">\n {{ card.title }}\n </Text>\n <Text type=\"supporting\" color=\"secondary\" display=\"block\">\n {{ card.body }}\n </Text>\n </VStack>\n </HStack>\n </template>\n </VStack>\n </Card>\n </VStack>\n\n <VStack v-else-if=\"activeNav === 'Privacy'\" :gap=\"6\">\n <Heading v-if=\"!isNarrow\" :level=\"2\">\n Privacy\n </Heading>\n <VStack :gap=\"8\">\n <VStack :gap=\"0\">\n <Heading :level=\"3\">\n Messages\n </Heading>\n <VStack :style=\"rowPadding\">\n <Switch\n v-model=\"readReceipts\"\n label=\"Show people when I've read their messages.\"\n label-position=\"start\"\n label-spacing=\"spread\"\n />\n </VStack>\n <HStack justify=\"between\" align=\"center\" :style=\"rowPadding\">\n <Text type=\"body\" weight=\"semibold\">\n Blocked people\n </Text>\n <Link size=\"md\" href=\"#\">\n View\n </Link>\n </HStack>\n <Divider />\n </VStack>\n\n <VStack :gap=\"0\">\n <Heading :level=\"3\">\n Listings\n </Heading>\n <VStack :style=\"rowPadding\">\n <Switch\n v-model=\"searchEngines\"\n label=\"Include my listing(s) in search engines\"\n description=\"Turning this on means search engines, like Google, will display your listing page(s) in search results.\"\n label-position=\"start\"\n label-spacing=\"spread\"\n />\n </VStack>\n <Divider />\n </VStack>\n\n <VStack :gap=\"4\">\n <Heading :level=\"3\">\n Reviews\n </Heading>\n <Text type=\"supporting\" color=\"secondary\">\n Choose what's shared when you write a review.\n <Link href=\"#\" size=\"sm\">\n Learn more\n </Link>\n </Text>\n <VStack :gap=\"4\">\n <Switch\n v-model=\"showCity\"\n label=\"Show my home city and country\"\n description=\"Ex: City and country\"\n label-position=\"start\"\n label-spacing=\"spread\"\n />\n <Switch\n v-model=\"showTripType\"\n label=\"Show my trip type\"\n description=\"Ex: Stayed with kids or pets\"\n label-position=\"start\"\n label-spacing=\"spread\"\n />\n <Switch\n v-model=\"showStayLength\"\n label=\"Show my length of stay\"\n description=\"Ex: A few nights, about a week, etc.\"\n label-position=\"start\"\n label-spacing=\"spread\"\n />\n <Switch\n v-model=\"showServices\"\n label=\"Show my booked services\"\n description=\"Ex: Gourmet brunch or tasting menu\"\n label-position=\"start\"\n label-spacing=\"spread\"\n />\n </VStack>\n <Divider />\n </VStack>\n\n <VStack :gap=\"4\">\n <Heading :level=\"3\">\n Data privacy\n </Heading>\n <Card>\n <HStack justify=\"between\" align=\"center\">\n <Text type=\"body\">\n Request my personal data\n </Text>\n <Link size=\"md\" href=\"#\">\n Request\n </Link>\n </HStack>\n </Card>\n <Switch\n v-model=\"aiFeatures\"\n label=\"Help improve AI-powered features\"\n description=\"When this is on, we use your data to develop and improve AI models.\"\n label-position=\"start\"\n label-spacing=\"spread\"\n />\n <Card>\n <HStack justify=\"between\" align=\"center\">\n <Text type=\"body\">\n Delete my account\n </Text>\n <Link size=\"md\" href=\"#\">\n Delete\n </Link>\n </HStack>\n </Card>\n <Card variant=\"muted\">\n <HStack :gap=\"4\" align=\"start\">\n <Center :width=\"48\" :height=\"48\" :style=\"iconBox\">\n <Icon size=\"md\" name=\"heroicons:shield-check\" />\n </Center>\n <VStack :gap=\"1\">\n <Text type=\"body\" weight=\"bold\">\n Committed to privacy\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n We're committed to keeping your data protected. See details in our\n <Link href=\"#\" size=\"sm\">\n Privacy Policy\n </Link>.\n </Text>\n </VStack>\n </HStack>\n </Card>\n </VStack>\n </VStack>\n </VStack>\n </VStack>\n </LayoutContent>\n </Layout>\n</template>\n",
78750
79107
  "docsUrl": {
78751
79108
  "en": "https://astryx-vue.pages.dev/en/playground/?template=settings-sidebar",
78752
79109
  "zh": "https://astryx-vue.pages.dev/playground/?template=settings-sidebar"
@@ -79181,7 +79538,7 @@ const catalog = {
79181
79538
  "en": "It imports @astryx-vue/playground-charts, which exists only inside the docs playground and is not a published package.",
79182
79539
  "zh": "它引用了 @astryx-vue/playground-charts,这个包只存在于本站演练场,没有发布到 npm。"
79183
79540
  },
79184
- "source": "<script setup lang=\"ts\">\nimport {\n Badge,\n Button,\n Card,\n Collapsible,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n LayoutHeader,\n pixel,\n ProgressBar,\n proportional,\n SegmentedControl,\n SegmentedControlItem,\n StackItem,\n Table,\n Text,\n Token,\n useTableSortable,\n useTableSortableState,\n VStack,\n} from '@astryx-vue/core'\nimport type { TableColumn } from '@astryx-vue/core'\nimport { PlaygroundSparkline } from '@astryx-vue/playground-charts'\nimport { computed, h, ref } from 'vue'\n\ntype RangeId = '1W' | '1M' | '3M' | 'YTD' | '1Y'\n\ninterface AccountRecord extends Record<string, unknown> {\n id: string\n trend: number\n}\n\ninterface BankAccount extends AccountRecord {\n name: string\n institution: string\n mask: string\n kind: string\n availableCents: number\n}\n\ninterface CreditCardAccount extends AccountRecord {\n name: string\n issuer: string\n mask: string\n balanceCents: number\n limitCents: number\n}\n\ninterface ProcessorAccount extends AccountRecord {\n name: string\n merchantId: string\n pendingPayoutCents: number\n feesMtdCents: number\n}\n\ninterface InvestmentAccount extends AccountRecord {\n name: string\n custodian: string\n marketValueCents: number\n costBasisCents: number\n}\n\nconst RANGE_IDS: RangeId[] = ['1W', '1M', '3M', 'YTD', '1Y']\nconst RANGE_DAYS: Record<RangeId, number> = { '1W': 7, '1M': 30, '3M': 90, YTD: 273, '1Y': 365 }\nconst SERIES_DAYS = RANGE_DAYS['1Y']\n\nconst currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })\nconst percentFormat = new Intl.NumberFormat('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 })\n\nfunction money(cents: number): string {\n return currency.format(cents / 100)\n}\n\nfunction seedFrom(id: string): number {\n let hash = 2166136261\n for (let i = 0; i < id.length; i++) {\n hash ^= id.charCodeAt(i)\n hash = Math.imul(hash, 16777619)\n }\n return hash >>> 0\n}\n\nfunction buildHistory<T extends AccountRecord>(rows: T[], pick: (row: T) => number, volatility: number): Record<string, number[]> {\n const out: Record<string, number[]> = {}\n for (const row of rows) {\n let state = seedFrom(row.id) || 1\n const random = () => {\n state ^= state << 13\n state ^= state >>> 17\n state ^= state << 5\n return ((state >>> 0) % 10000) / 10000 - 0.5\n }\n const series = new Array<number>(SERIES_DAYS)\n let value = pick(row)\n series[SERIES_DAYS - 1] = value\n for (let day = SERIES_DAYS - 2; day >= 0; day--) {\n value /= 1 + row.trend + random() * volatility\n series[day] = Math.max(1, Math.round(value))\n }\n out[row.id] = series\n }\n return out\n}\n\nconst BANK_ACCOUNTS: BankAccount[] = [\n { id: 'bank-1', name: 'Operating', institution: 'First Meridian Bank', mask: '••4417', kind: 'Checking', availableCents: 84_215_600, trend: 0.00022 },\n { id: 'bank-2', name: 'Payroll', institution: 'First Meridian Bank', mask: '••8830', kind: 'Checking', availableCents: 21_940_800, trend: 0.00004 },\n { id: 'bank-3', name: 'Tax reserve', institution: 'Harborline Credit Union', mask: '••1265', kind: 'Savings', availableCents: 47_500_000, trend: 0.00009 },\n { id: 'bank-4', name: 'Treasury sweep', institution: 'Northgate Financial', mask: '••7702', kind: 'Money market', availableCents: 132_800_000, trend: 0.00033 },\n]\n\nconst CREDIT_CARDS: CreditCardAccount[] = [\n { id: 'card-1', name: 'Corporate — Engineering', issuer: 'Meridian Business Card', mask: '••2041', balanceCents: 4_182_300, limitCents: 15_000_000, trend: 0.00042 },\n { id: 'card-2', name: 'Corporate — Travel', issuer: 'Meridian Business Card', mask: '••6688', balanceCents: 9_640_500, limitCents: 12_000_000, trend: 0.00071 },\n { id: 'card-3', name: 'Vendor payments', issuer: 'Northgate Commercial', mask: '••3319', balanceCents: 1_205_000, limitCents: 25_000_000, trend: -0.00018 },\n]\n\nconst PROCESSORS: ProcessorAccount[] = [\n { id: 'proc-1', name: 'Stripe', merchantId: 'acct_1Qf82LmR', pendingPayoutCents: 18_442_900, feesMtdCents: 612_400, trend: 0.00058 },\n { id: 'proc-2', name: 'PayPal Commerce', merchantId: 'MRC-88214-XT', pendingPayoutCents: 3_218_650, feesMtdCents: 148_900, trend: -0.00024 },\n { id: 'proc-3', name: 'Adyen', merchantId: 'AD-NORTHWIND-01', pendingPayoutCents: 7_905_100, feesMtdCents: 233_050, trend: 0.00031 },\n]\n\nconst INVESTMENTS: InvestmentAccount[] = [\n { id: 'inv-1', name: 'Short-duration treasuries', custodian: 'Northgate Asset Management', marketValueCents: 245_180_000, costBasisCents: 240_000_000, trend: 0.00012 },\n { id: 'inv-2', name: 'Corporate bond ladder', custodian: 'Northgate Asset Management', marketValueCents: 98_420_000, costBasisCents: 100_000_000, trend: -0.00006 },\n { id: 'inv-3', name: 'Money market fund', custodian: 'Harborline Capital', marketValueCents: 61_050_000, costBasisCents: 61_000_000, trend: 0.00003 },\n]\n\nconst HISTORY: Record<string, number[]> = {\n ...buildHistory(BANK_ACCOUNTS, a => a.availableCents, 0.014),\n ...buildHistory(CREDIT_CARDS, c => c.balanceCents, 0.016),\n ...buildHistory(PROCESSORS, p => p.pendingPayoutCents, 0.04),\n ...buildHistory(INVESTMENTS, i => i.marketValueCents, 0.008),\n}\n\nconst range = ref<RangeId>('YTD')\nconst chartAccountId = ref<string | null>('bank-1')\n\nconst bankOpen = ref(true)\nconst cardsOpen = ref(true)\nconst processorsOpen = ref(true)\nconst investmentsOpen = ref(true)\n\nfunction changePercent(id: string): number {\n const points = HISTORY[id].slice(-RANGE_DAYS[range.value])\n const start = points[0]\n if (start === 0)\n return 0\n return ((points[points.length - 1] - start) / Math.abs(start)) * 100\n}\n\nfunction changeCell(id: string, isLiability = false) {\n const pct = changePercent(id)\n const isUp = pct >= 0\n const isGood = isLiability ? !isUp : isUp\n return h(HStack, { hAlign: 'end' }, () => h(Token, {\n size: 'sm',\n color: isGood ? 'green' : 'red',\n label: `${isUp ? '+' : '−'}${percentFormat.format(Math.abs(pct))}%`,\n }))\n}\n\nconst totals = computed(() => {\n const banks = BANK_ACCOUNTS.reduce((s, a) => s + a.availableCents, 0)\n const cards = CREDIT_CARDS.reduce((s, c) => s + c.balanceCents, 0)\n const pending = PROCESSORS.reduce((s, p) => s + p.pendingPayoutCents, 0)\n const investments = INVESTMENTS.reduce((s, i) => s + i.marketValueCents, 0)\n return { banks, cards, pending, investments, net: banks + pending + investments - cards }\n})\n\nconst bankColumns: TableColumn<BankAccount>[] = [\n {\n key: 'name',\n header: 'Account',\n width: proportional(2),\n sortable: { compare: (a, b) => a.name.localeCompare(b.name) },\n renderCell: account => h(VStack, { gap: 0 }, () => [\n h(Text, {}, () => account.name),\n h(Text, { type: 'supporting' }, () => `${account.institution} · ${account.mask}`),\n ]),\n },\n { key: 'kind', header: 'Type', width: pixel(150), sortable: { compare: (a, b) => a.kind.localeCompare(b.kind) }, renderCell: a => h(Text, {}, () => a.kind) },\n { key: 'available', header: 'Available', width: pixel(150), align: 'end', sortable: { compare: (a, b) => a.availableCents - b.availableCents }, renderCell: a => h(Text, { hasTabularNumbers: true }, () => money(a.availableCents)) },\n { key: 'change', header: 'Change', width: pixel(110), align: 'end', renderCell: a => changeCell(a.id) },\n]\n\nconst creditColumns: TableColumn<CreditCardAccount>[] = [\n {\n key: 'name',\n header: 'Card',\n width: proportional(2),\n sortable: { compare: (a, b) => a.name.localeCompare(b.name) },\n renderCell: card => h(VStack, { gap: 0 }, () => [\n h(Text, {}, () => card.name),\n h(Text, { type: 'supporting' }, () => `${card.issuer} · ${card.mask}`),\n ]),\n },\n {\n key: 'utilization',\n header: 'Utilization',\n width: pixel(150),\n renderCell: (card) => {\n const percent = Math.round((card.balanceCents / card.limitCents) * 100)\n return h(HStack, { gap: 2, vAlign: 'center' }, () => [\n h(StackItem, { size: 'fill' }, () => h(ProgressBar, { label: 'Utilization', isLabelHidden: true, value: percent, variant: 'neutral' })),\n h(Text, { hasTabularNumbers: true }, () => `${percent}%`),\n ])\n },\n },\n { key: 'balance', header: 'Balance', width: pixel(150), align: 'end', sortable: { compare: (a, b) => a.balanceCents - b.balanceCents }, renderCell: c => h(Text, { hasTabularNumbers: true }, () => money(c.balanceCents)) },\n { key: 'change', header: 'Change', width: pixel(110), align: 'end', renderCell: c => changeCell(c.id, true) },\n]\n\nconst processorColumns: TableColumn<ProcessorAccount>[] = [\n {\n key: 'name',\n header: 'Processor',\n width: proportional(2),\n renderCell: p => h(VStack, { gap: 0 }, () => [h(Text, {}, () => p.name), h(Text, { type: 'supporting' }, () => p.merchantId)]),\n },\n { key: 'pending', header: 'Pending payout', width: pixel(150), align: 'end', renderCell: p => h(Text, { hasTabularNumbers: true }, () => money(p.pendingPayoutCents)) },\n { key: 'fees', header: 'Fees (MTD)', width: pixel(150), align: 'end', renderCell: p => h(Text, { hasTabularNumbers: true }, () => money(p.feesMtdCents)) },\n { key: 'change', header: 'Change', width: pixel(110), align: 'end', renderCell: p => changeCell(p.id) },\n]\n\nconst investmentColumns: TableColumn<InvestmentAccount>[] = [\n {\n key: 'name',\n header: 'Account',\n width: proportional(2),\n renderCell: a => h(VStack, { gap: 0 }, () => [h(Text, {}, () => a.name), h(Text, { type: 'supporting' }, () => a.custodian)]),\n },\n { key: 'market', header: 'Market value', width: pixel(150), align: 'end', renderCell: a => h(Text, { hasTabularNumbers: true }, () => money(a.marketValueCents)) },\n { key: 'cost', header: 'Cost basis', width: pixel(150), align: 'end', renderCell: a => h(Text, { hasTabularNumbers: true }, () => money(a.costBasisCents)) },\n { key: 'change', header: 'Change', width: pixel(110), align: 'end', renderCell: a => changeCell(a.id) },\n]\n\nconst { sortedData: bankData, sortConfig: bankSortConfig } = useTableSortableState({ data: BANK_ACCOUNTS, columns: bankColumns })\nconst bankPlugin = useTableSortable<BankAccount>(bankSortConfig)\nconst { sortedData: cardData, sortConfig: cardSortConfig } = useTableSortableState({ data: CREDIT_CARDS, columns: creditColumns })\nconst cardPlugin = useTableSortable<CreditCardAccount>(cardSortConfig)\nconst { sortedData: procData, sortConfig: procSortConfig } = useTableSortableState({ data: PROCESSORS, columns: processorColumns })\nconst procPlugin = useTableSortable<ProcessorAccount>(procSortConfig)\nconst { sortedData: invData, sortConfig: invSortConfig } = useTableSortableState({ data: INVESTMENTS, columns: investmentColumns })\nconst invPlugin = useTableSortable<InvestmentAccount>(invSortConfig)\n\nconst chartSeries = computed(() => {\n if (!chartAccountId.value)\n return []\n return HISTORY[chartAccountId.value]?.slice(-RANGE_DAYS[range.value]) ?? []\n})\n\nconst chartColor = computed(() => {\n if (!chartAccountId.value)\n return 'var(--astryx-color-data-categorical-green, #0B991F)'\n const points = chartSeries.value\n if (points.length < 2)\n return 'var(--astryx-color-data-categorical-green, #0B991F)'\n const delta = points[points.length - 1] - points[0]\n const liability = chartAccountId.value.startsWith('card-')\n const good = liability ? delta <= 0 : delta >= 0\n return good ? 'var(--astryx-color-data-categorical-green, #0B991F)' : 'var(--astryx-color-data-categorical-red, #F5394F)'\n})\n\nfunction toggleAll(): void {\n const allOpen = bankOpen.value && cardsOpen.value && processorsOpen.value && investmentsOpen.value\n const next = !allOpen\n bankOpen.value = next\n cardsOpen.value = next\n processorsOpen.value = next\n investmentsOpen.value = next\n}\n<\/script>\n\n<template>\n <Layout height=\"auto\" :content-width=\"1200\" class=\"wash\">\n <template #header>\n <LayoutHeader :padding=\"4\">\n <HStack :gap=\"3\" align=\"center\" wrap=\"wrap\">\n <StackItem size=\"fill\">\n <VStack :gap=\"0.5\">\n <Heading :level=\"1\">\n Connected accounts\n </Heading>\n <Text type=\"supporting\">\n Net position {{ money(totals.net) }} across 13 accounts\n </Text>\n </VStack>\n </StackItem>\n <Button label=\"Expand / collapse all\" variant=\"ghost\" @click=\"toggleAll\" />\n <SegmentedControl v-model=\"range\" label=\"Time range\">\n <SegmentedControlItem v-for=\"id in RANGE_IDS\" :key=\"id\" :value=\"id\" :label=\"id\" />\n </SegmentedControl>\n </HStack>\n </LayoutHeader>\n </template>\n\n <LayoutContent :padding=\"4\">\n <VStack :gap=\"4\">\n <Card :padding=\"2\">\n <Collapsible v-model:is-open=\"bankOpen\">\n <template #trigger>\n <HStack :gap=\"2\" align=\"center\" justify=\"between\" class=\"group-trigger\">\n <HStack :gap=\"2\" align=\"center\">\n <Icon icon=\"i-heroicons-building-library\" size=\"sm\" />\n <Text weight=\"semibold\">\n Bank accounts\n </Text>\n <Badge variant=\"neutral\" :label=\"String(BANK_ACCOUNTS.length)\" />\n </HStack>\n <HStack :gap=\"2\" align=\"center\">\n <Text color=\"secondary\">\n Available\n </Text>\n <Text weight=\"semibold\" has-tabular-numbers>\n {{ money(totals.banks) }}\n </Text>\n </HStack>\n </HStack>\n </template>\n <VStack :padding-block-start=\"3\" :gap=\"2\">\n <Table :data=\"bankData\" :columns=\"bankColumns\" id-key=\"id\" density=\"compact\" dividers=\"rows\" :plugins=\"{ sort: bankPlugin }\" has-hover />\n </VStack>\n </Collapsible>\n </Card>\n\n <Card :padding=\"2\">\n <Collapsible v-model:is-open=\"cardsOpen\">\n <template #trigger>\n <HStack :gap=\"2\" align=\"center\" justify=\"between\" class=\"group-trigger\">\n <HStack :gap=\"2\" align=\"center\">\n <Icon icon=\"i-heroicons-credit-card\" size=\"sm\" />\n <Text weight=\"semibold\">\n Credit cards\n </Text>\n <Badge variant=\"neutral\" :label=\"String(CREDIT_CARDS.length)\" />\n </HStack>\n <HStack :gap=\"2\" align=\"center\">\n <Text color=\"secondary\">\n Outstanding\n </Text>\n <Text weight=\"semibold\" has-tabular-numbers>\n {{ money(totals.cards) }}\n </Text>\n </HStack>\n </HStack>\n </template>\n <VStack :padding-block-start=\"3\">\n <Table :data=\"cardData\" :columns=\"creditColumns\" id-key=\"id\" density=\"compact\" dividers=\"rows\" :plugins=\"{ sort: cardPlugin }\" has-hover />\n </VStack>\n </Collapsible>\n </Card>\n\n <Card :padding=\"2\">\n <Collapsible v-model:is-open=\"processorsOpen\">\n <template #trigger>\n <HStack :gap=\"2\" align=\"center\" justify=\"between\" class=\"group-trigger\">\n <HStack :gap=\"2\" align=\"center\">\n <Icon icon=\"i-heroicons-arrows-right-left\" size=\"sm\" />\n <Text weight=\"semibold\">\n Payment processors\n </Text>\n <Badge variant=\"neutral\" :label=\"String(PROCESSORS.length)\" />\n </HStack>\n <HStack :gap=\"2\" align=\"center\">\n <Text color=\"secondary\">\n Pending payout\n </Text>\n <Text weight=\"semibold\" has-tabular-numbers>\n {{ money(totals.pending) }}\n </Text>\n </HStack>\n </HStack>\n </template>\n <VStack :padding-block-start=\"3\">\n <Table :data=\"procData\" :columns=\"processorColumns\" id-key=\"id\" density=\"compact\" dividers=\"rows\" :plugins=\"{ sort: procPlugin }\" />\n </VStack>\n </Collapsible>\n </Card>\n\n <Card :padding=\"2\">\n <Collapsible v-model:is-open=\"investmentsOpen\">\n <template #trigger>\n <HStack :gap=\"2\" align=\"center\" justify=\"between\" class=\"group-trigger\">\n <HStack :gap=\"2\" align=\"center\">\n <Icon icon=\"i-heroicons-presentation-chart-line\" size=\"sm\" />\n <Text weight=\"semibold\">\n Investment accounts\n </Text>\n <Badge variant=\"neutral\" :label=\"String(INVESTMENTS.length)\" />\n </HStack>\n <HStack :gap=\"2\" align=\"center\">\n <Text color=\"secondary\">\n Market value\n </Text>\n <Text weight=\"semibold\" has-tabular-numbers>\n {{ money(totals.investments) }}\n </Text>\n </HStack>\n </HStack>\n </template>\n <VStack :padding-block-start=\"3\">\n <Table :data=\"invData\" :columns=\"investmentColumns\" id-key=\"id\" density=\"compact\" dividers=\"rows\" :plugins=\"{ sort: invPlugin }\" />\n </VStack>\n </Collapsible>\n </Card>\n\n <Card v-if=\"chartAccountId && chartSeries.length\" :padding=\"4\">\n <VStack :gap=\"2\">\n <Text type=\"label\">\n Balance trend ({{ range }})\n </Text>\n <PlaygroundSparkline :data=\"chartSeries\" :color=\"chartColor\" :height=\"100\" />\n </VStack>\n </Card>\n </VStack>\n </LayoutContent>\n </Layout>\n</template>\n\n<style scoped>\n.wash {\n background: var(--astryx-color-background-body);\n}\n.group-trigger {\n width: 100%;\n padding: var(--astryx-spacing-2) var(--astryx-spacing-2);\n}\n</style>\n",
79541
+ "source": "<script setup lang=\"ts\">\nimport {\n Badge,\n Button,\n Card,\n Collapsible,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n LayoutHeader,\n pixel,\n ProgressBar,\n proportional,\n SegmentedControl,\n SegmentedControlItem,\n StackItem,\n Table,\n Text,\n Token,\n useTableRowExpansion,\n useTableSortable,\n useTableSortableState,\n VStack,\n} from '@astryx-vue/core'\nimport type { TableColumn } from '@astryx-vue/core'\nimport { PlaygroundAreaChart } from '@astryx-vue/playground-charts'\nimport { computed, defineComponent, h, ref } from 'vue'\n\ntype RangeId = '1W' | '1M' | '3M' | 'YTD' | '1Y'\n\ninterface AccountRecord extends Record<string, unknown> {\n id: string\n trend: number\n}\n\ninterface BankAccount extends AccountRecord {\n name: string\n institution: string\n mask: string\n kind: string\n availableCents: number\n}\n\ninterface CreditCardAccount extends AccountRecord {\n name: string\n issuer: string\n mask: string\n balanceCents: number\n limitCents: number\n}\n\ninterface ProcessorAccount extends AccountRecord {\n name: string\n merchantId: string\n pendingPayoutCents: number\n feesMtdCents: number\n}\n\ninterface InvestmentAccount extends AccountRecord {\n name: string\n custodian: string\n marketValueCents: number\n costBasisCents: number\n}\n\nconst RANGE_IDS: RangeId[] = ['1W', '1M', '3M', 'YTD', '1Y']\nconst RANGE_DAYS: Record<RangeId, number> = { '1W': 7, '1M': 30, '3M': 90, YTD: 273, '1Y': 365 }\nconst SERIES_DAYS = RANGE_DAYS['1Y']\n\nconst currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })\nconst percentFormat = new Intl.NumberFormat('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 })\n\nfunction money(cents: number): string {\n return currency.format(cents / 100)\n}\n\nfunction seedFrom(id: string): number {\n let hash = 2166136261\n for (let i = 0; i < id.length; i++) {\n hash ^= id.charCodeAt(i)\n hash = Math.imul(hash, 16777619)\n }\n return hash >>> 0\n}\n\nfunction buildHistory<T extends AccountRecord>(rows: T[], pick: (row: T) => number, volatility: number): Record<string, number[]> {\n const out: Record<string, number[]> = {}\n for (const row of rows) {\n let state = seedFrom(row.id) || 1\n const random = () => {\n state ^= state << 13\n state ^= state >>> 17\n state ^= state << 5\n return ((state >>> 0) % 10000) / 10000 - 0.5\n }\n const series = new Array<number>(SERIES_DAYS)\n let value = pick(row)\n series[SERIES_DAYS - 1] = value\n for (let day = SERIES_DAYS - 2; day >= 0; day--) {\n value /= 1 + row.trend + random() * volatility\n series[day] = Math.max(1, Math.round(value))\n }\n out[row.id] = series\n }\n return out\n}\n\nconst BANK_ACCOUNTS: BankAccount[] = [\n { id: 'bank-1', name: 'Operating', institution: 'First Meridian Bank', mask: '••4417', kind: 'Checking', availableCents: 84_215_600, trend: 0.00022 },\n { id: 'bank-2', name: 'Payroll', institution: 'First Meridian Bank', mask: '••8830', kind: 'Checking', availableCents: 21_940_800, trend: 0.00004 },\n { id: 'bank-3', name: 'Tax reserve', institution: 'Harborline Credit Union', mask: '••1265', kind: 'Savings', availableCents: 47_500_000, trend: 0.00009 },\n { id: 'bank-4', name: 'Treasury sweep', institution: 'Northgate Financial', mask: '••7702', kind: 'Money market', availableCents: 132_800_000, trend: 0.00033 },\n]\n\nconst CREDIT_CARDS: CreditCardAccount[] = [\n { id: 'card-1', name: 'Corporate — Engineering', issuer: 'Meridian Business Card', mask: '••2041', balanceCents: 4_182_300, limitCents: 15_000_000, trend: 0.00042 },\n { id: 'card-2', name: 'Corporate — Travel', issuer: 'Meridian Business Card', mask: '••6688', balanceCents: 9_640_500, limitCents: 12_000_000, trend: 0.00071 },\n { id: 'card-3', name: 'Vendor payments', issuer: 'Northgate Commercial', mask: '••3319', balanceCents: 1_205_000, limitCents: 25_000_000, trend: -0.00018 },\n]\n\nconst PROCESSORS: ProcessorAccount[] = [\n { id: 'proc-1', name: 'Stripe', merchantId: 'acct_1Qf82LmR', pendingPayoutCents: 18_442_900, feesMtdCents: 612_400, trend: 0.00058 },\n { id: 'proc-2', name: 'PayPal Commerce', merchantId: 'MRC-88214-XT', pendingPayoutCents: 3_218_650, feesMtdCents: 148_900, trend: -0.00024 },\n { id: 'proc-3', name: 'Adyen', merchantId: 'AD-NORTHWIND-01', pendingPayoutCents: 7_905_100, feesMtdCents: 233_050, trend: 0.00031 },\n]\n\nconst INVESTMENTS: InvestmentAccount[] = [\n { id: 'inv-1', name: 'Short-duration treasuries', custodian: 'Northgate Asset Management', marketValueCents: 245_180_000, costBasisCents: 240_000_000, trend: 0.00012 },\n { id: 'inv-2', name: 'Corporate bond ladder', custodian: 'Northgate Asset Management', marketValueCents: 98_420_000, costBasisCents: 100_000_000, trend: -0.00006 },\n { id: 'inv-3', name: 'Money market fund', custodian: 'Harborline Capital', marketValueCents: 61_050_000, costBasisCents: 61_000_000, trend: 0.00003 },\n]\n\nconst HISTORY: Record<string, number[]> = {\n ...buildHistory(BANK_ACCOUNTS, a => a.availableCents, 0.014),\n ...buildHistory(CREDIT_CARDS, c => c.balanceCents, 0.016),\n ...buildHistory(PROCESSORS, p => p.pendingPayoutCents, 0.04),\n ...buildHistory(INVESTMENTS, i => i.marketValueCents, 0.008),\n}\n\nconst range = ref<RangeId>('YTD')\n\nconst bankOpen = ref(true)\nconst cardsOpen = ref(true)\nconst processorsOpen = ref(true)\nconst investmentsOpen = ref(true)\nconst expandedRows = ref<ReadonlySet<string>>(new Set())\n\nfunction toggleRow(key: string): void {\n const next = new Set(expandedRows.value)\n if (next.has(key))\n next.delete(key)\n else\n next.add(key)\n expandedRows.value = next\n}\n\nconst ALL_ROW_IDS = [...BANK_ACCOUNTS, ...CREDIT_CARDS, ...PROCESSORS, ...INVESTMENTS].map(record => record.id)\n\nfunction changePercent(id: string): number {\n const points = HISTORY[id].slice(-RANGE_DAYS[range.value])\n const start = points[0]\n if (start === 0)\n return 0\n return ((points[points.length - 1] - start) / Math.abs(start)) * 100\n}\n\nfunction windowOf(id: string): number[] {\n return HISTORY[id].slice(-RANGE_DAYS[range.value])\n}\n\nconst TODAY = Date.UTC(2025, 8, 30)\nconst dayFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })\n\nfunction dayLabel(daysAgo: number): string {\n const date = new Date(TODAY)\n date.setUTCDate(date.getUTCDate() - daysAgo)\n return dayFormat.format(date)\n}\n\nfunction axisTicks(count: number): number[] {\n const stops = Math.min(5, count)\n if (stops < 2)\n return [0]\n return Array.from({ length: stops }, (_, stop) => Math.round((stop * (count - 1)) / (stops - 1)))\n}\n\n/** 展开行里的历史曲线:涨用绿、跌用红(负债反过来),虚线是窗口起点。 */\nconst DetailChart = defineComponent({\n props: {\n id: { type: String, required: true },\n isLiability: { type: Boolean, default: false },\n },\n setup(props) {\n return () => {\n const points = windowOf(props.id)\n const delta = points[points.length - 1] - points[0]\n const isGood = props.isLiability ? delta <= 0 : delta >= 0\n const color = isGood ? 'var(--astryx-color-data-categorical-green, #0B991F)' : 'var(--astryx-color-data-categorical-red, #F5394F)'\n const data = points.map((value, day) => ({ day, value: value / 100 }))\n const values = data.map(item => item.value)\n return h(PlaygroundAreaChart, {\n data,\n xKey: 'day',\n yKey: 'value',\n color,\n height: 132,\n yDomain: [Math.min(...values), Math.max(...values)],\n xDomain: [0, data.length - 1],\n xTicks: axisTicks(data.length),\n xTickFormat: (day: number) => dayLabel(points.length - 1 - day),\n hasYAxis: false,\n hasGrid: false,\n referenceValue: values[0],\n fillOpacity: [0.2, 0],\n padInline: 16,\n })\n }\n },\n})\n\nfunction changeCell(id: string, isLiability = false) {\n const pct = changePercent(id)\n const isUp = pct >= 0\n const isGood = isLiability ? !isUp : isUp\n return h(HStack, { hAlign: 'end' }, () => h(Token, {\n size: 'sm',\n color: isGood ? 'green' : 'red',\n label: `${isUp ? '+' : '−'}${percentFormat.format(Math.abs(pct))}%`,\n }))\n}\n\nconst totals = computed(() => {\n const banks = BANK_ACCOUNTS.reduce((s, a) => s + a.availableCents, 0)\n const cards = CREDIT_CARDS.reduce((s, c) => s + c.balanceCents, 0)\n const pending = PROCESSORS.reduce((s, p) => s + p.pendingPayoutCents, 0)\n const investments = INVESTMENTS.reduce((s, i) => s + i.marketValueCents, 0)\n return { banks, cards, pending, investments, net: banks + pending + investments - cards }\n})\n\nfunction money2(cents: number) {\n return h(Text, { hasTabularNumbers: true }, () => money(cents))\n}\n\nfunction nameCell(title: string, subtitle: string) {\n return h(VStack, { gap: 0 }, () => [\n h(Text, {}, () => title),\n h(Text, { type: 'supporting' }, () => subtitle),\n ])\n}\n\n/** 涨跌幅排序走窗口内的百分比,随时间范围变。 */\nconst byChange = (a: { id: string }, b: { id: string }): number => changePercent(a.id) - changePercent(b.id)\n\nconst bankColumns: TableColumn<BankAccount>[] = [\n { key: 'name', header: 'Account', width: proportional(2), sortable: { compare: (a, b) => a.name.localeCompare(b.name) }, renderCell: a => nameCell(a.name, `${a.institution} · ${a.mask}`) },\n { key: 'kind', header: 'Type', width: pixel(150), sortable: { compare: (a, b) => a.kind.localeCompare(b.kind) }, renderCell: a => h(Text, {}, () => a.kind) },\n { key: 'available', header: 'Available', width: pixel(150), align: 'end', sortable: { compare: (a, b) => a.availableCents - b.availableCents }, renderCell: a => money2(a.availableCents) },\n { key: 'change', header: 'Change', width: pixel(110), align: 'end', sortable: { compare: byChange }, renderCell: a => changeCell(a.id) },\n]\n\nconst creditColumns: TableColumn<CreditCardAccount>[] = [\n { key: 'name', header: 'Card', width: proportional(2), sortable: { compare: (a, b) => a.name.localeCompare(b.name) }, renderCell: c => nameCell(c.name, `${c.issuer} · ${c.mask}`) },\n {\n key: 'utilization',\n header: 'Utilization',\n width: pixel(150),\n sortable: { compare: (a, b) => a.balanceCents / a.limitCents - b.balanceCents / b.limitCents },\n renderCell: (card) => {\n const percent = Math.round((card.balanceCents / card.limitCents) * 100)\n return h(HStack, { gap: 2, vAlign: 'center' }, () => [\n h(StackItem, { size: 'fill' }, () => h(ProgressBar, { label: `${card.name} utilization`, isLabelHidden: true, value: percent, variant: 'neutral' })),\n h(Text, { hasTabularNumbers: true }, () => `${percent}%`),\n ])\n },\n },\n { key: 'balance', header: 'Balance', width: pixel(150), align: 'end', sortable: { compare: (a, b) => a.balanceCents - b.balanceCents }, renderCell: c => money2(c.balanceCents) },\n { key: 'limit', header: 'Limit', width: pixel(150), align: 'end', sortable: { compare: (a, b) => a.limitCents - b.limitCents }, renderCell: c => money2(c.limitCents) },\n { key: 'change', header: 'Change', width: pixel(110), align: 'end', sortable: { compare: byChange }, renderCell: c => changeCell(c.id, true) },\n]\n\nconst processorColumns: TableColumn<ProcessorAccount>[] = [\n { key: 'name', header: 'Processor', width: proportional(2), sortable: { compare: (a, b) => a.name.localeCompare(b.name) }, renderCell: p => nameCell(p.name, p.merchantId) },\n { key: 'pendingPayout', header: 'Pending payout', width: pixel(150), align: 'end', sortable: { compare: (a, b) => a.pendingPayoutCents - b.pendingPayoutCents }, renderCell: p => money2(p.pendingPayoutCents) },\n { key: 'feesMtd', header: 'Fees (MTD)', width: pixel(150), align: 'end', sortable: { compare: (a, b) => a.feesMtdCents - b.feesMtdCents }, renderCell: p => money2(p.feesMtdCents) },\n { key: 'change', header: 'Change', width: pixel(110), align: 'end', sortable: { compare: byChange }, renderCell: p => changeCell(p.id) },\n]\n\nconst investmentColumns: TableColumn<InvestmentAccount>[] = [\n { key: 'name', header: 'Account', width: proportional(2), sortable: { compare: (a, b) => a.name.localeCompare(b.name) }, renderCell: a => nameCell(a.name, a.custodian) },\n { key: 'marketValue', header: 'Market value', width: pixel(150), align: 'end', sortable: { compare: (a, b) => a.marketValueCents - b.marketValueCents }, renderCell: a => money2(a.marketValueCents) },\n { key: 'costBasis', header: 'Cost basis', width: pixel(150), align: 'end', sortable: { compare: (a, b) => a.costBasisCents - b.costBasisCents }, renderCell: a => money2(a.costBasisCents) },\n { key: 'change', header: 'Change', width: pixel(110), align: 'end', sortable: { compare: byChange }, renderCell: a => changeCell(a.id) },\n]\n\n/** 每组一张表:排序加展开,展开后画这一行的历史曲线。 */\nfunction useGroupTable<T extends AccountRecord>(data: T[], columns: TableColumn<T>[], isLiability = false) {\n const { sortedData, sortConfig } = useTableSortableState<T>({ data, columns })\n const sort = useTableSortable<T>(sortConfig)\n const expansion = useTableRowExpansion<T>(() => ({\n expandedKeys: expandedRows.value,\n onToggle: toggleRow,\n getRowKey: item => item.id,\n renderExpanded: item => h(DetailChart, { id: item.id, isLiability }),\n }))\n return { sortedData, plugins: { sort, expansion } }\n}\n\nconst bankTable = useGroupTable(BANK_ACCOUNTS, bankColumns)\nconst cardTable = useGroupTable(CREDIT_CARDS, creditColumns, true)\nconst procTable = useGroupTable(PROCESSORS, processorColumns)\nconst invTable = useGroupTable(INVESTMENTS, investmentColumns)\n\nconst isEverythingOpen = computed(() =>\n bankOpen.value && cardsOpen.value && processorsOpen.value && investmentsOpen.value\n && expandedRows.value.size === ALL_ROW_IDS.length,\n)\n\nfunction toggleAll(): void {\n const next = !isEverythingOpen.value\n bankOpen.value = next\n cardsOpen.value = next\n processorsOpen.value = next\n investmentsOpen.value = next\n expandedRows.value = next ? new Set(ALL_ROW_IDS) : new Set()\n}\n<\/script>\n\n<template>\n <Layout height=\"auto\" :content-width=\"1200\" class=\"wash\">\n <template #header>\n <LayoutHeader :padding=\"4\">\n <HStack :gap=\"3\" align=\"center\" wrap=\"wrap\">\n <StackItem size=\"fill\">\n <VStack :gap=\"0.5\">\n <Heading :level=\"1\">\n Connected accounts\n </Heading>\n <Text type=\"supporting\">\n Net position {{ money(totals.net) }} across 13 accounts\n </Text>\n </VStack>\n </StackItem>\n <Button :label=\"isEverythingOpen ? 'Collapse all' : 'Expand all'\" variant=\"ghost\" @click=\"toggleAll\" />\n <SegmentedControl v-model=\"range\" label=\"Time range\">\n <SegmentedControlItem v-for=\"id in RANGE_IDS\" :key=\"id\" :value=\"id\" :label=\"id\" />\n </SegmentedControl>\n </HStack>\n </LayoutHeader>\n </template>\n\n <LayoutContent :padding=\"4\">\n <VStack :gap=\"4\">\n <Card :padding=\"2\">\n <Collapsible v-model:is-open=\"bankOpen\">\n <template #trigger>\n <HStack :gap=\"2\" align=\"center\" justify=\"between\" class=\"group-trigger\">\n <HStack :gap=\"2\" align=\"center\">\n <span class=\"category-tile\"><Icon icon=\"i-heroicons-building-library\" size=\"sm\" /></span>\n <Text weight=\"semibold\">\n Bank accounts\n </Text>\n <Badge variant=\"neutral\" :label=\"String(BANK_ACCOUNTS.length)\" />\n </HStack>\n <HStack :gap=\"2\" align=\"center\">\n <Text color=\"secondary\">\n Available\n </Text>\n <Text weight=\"semibold\" has-tabular-numbers>\n {{ money(totals.banks) }}\n </Text>\n </HStack>\n </HStack>\n </template>\n <VStack :padding-block-start=\"3\" :gap=\"2\">\n <div class=\"fixed-table\"><Table :data=\"bankTable.sortedData.value\" :columns=\"bankColumns\" id-key=\"id\" density=\"compact\" dividers=\"rows\" :plugins=\"bankTable.plugins\" /></div>\n </VStack>\n </Collapsible>\n </Card>\n\n <Card :padding=\"2\">\n <Collapsible v-model:is-open=\"cardsOpen\">\n <template #trigger>\n <HStack :gap=\"2\" align=\"center\" justify=\"between\" class=\"group-trigger\">\n <HStack :gap=\"2\" align=\"center\">\n <span class=\"category-tile\"><Icon icon=\"i-heroicons-credit-card\" size=\"sm\" /></span>\n <Text weight=\"semibold\">\n Credit cards\n </Text>\n <Badge variant=\"neutral\" :label=\"String(CREDIT_CARDS.length)\" />\n </HStack>\n <HStack :gap=\"2\" align=\"center\">\n <Text color=\"secondary\">\n Outstanding\n </Text>\n <Text weight=\"semibold\" has-tabular-numbers>\n {{ money(totals.cards) }}\n </Text>\n </HStack>\n </HStack>\n </template>\n <VStack :padding-block-start=\"3\">\n <div class=\"fixed-table\"><Table :data=\"cardTable.sortedData.value\" :columns=\"creditColumns\" id-key=\"id\" density=\"compact\" dividers=\"rows\" :plugins=\"cardTable.plugins\" /></div>\n </VStack>\n </Collapsible>\n </Card>\n\n <Card :padding=\"2\">\n <Collapsible v-model:is-open=\"processorsOpen\">\n <template #trigger>\n <HStack :gap=\"2\" align=\"center\" justify=\"between\" class=\"group-trigger\">\n <HStack :gap=\"2\" align=\"center\">\n <span class=\"category-tile\"><Icon icon=\"i-heroicons-arrows-right-left\" size=\"sm\" /></span>\n <Text weight=\"semibold\">\n Payment processors\n </Text>\n <Badge variant=\"neutral\" :label=\"String(PROCESSORS.length)\" />\n </HStack>\n <HStack :gap=\"2\" align=\"center\">\n <Text color=\"secondary\">\n Pending payout\n </Text>\n <Text weight=\"semibold\" has-tabular-numbers>\n {{ money(totals.pending) }}\n </Text>\n </HStack>\n </HStack>\n </template>\n <VStack :padding-block-start=\"3\">\n <div class=\"fixed-table\"><Table :data=\"procTable.sortedData.value\" :columns=\"processorColumns\" id-key=\"id\" density=\"compact\" dividers=\"rows\" :plugins=\"procTable.plugins\" /></div>\n </VStack>\n </Collapsible>\n </Card>\n\n <Card :padding=\"2\">\n <Collapsible v-model:is-open=\"investmentsOpen\">\n <template #trigger>\n <HStack :gap=\"2\" align=\"center\" justify=\"between\" class=\"group-trigger\">\n <HStack :gap=\"2\" align=\"center\">\n <span class=\"category-tile\"><Icon icon=\"i-heroicons-presentation-chart-line\" size=\"sm\" /></span>\n <Text weight=\"semibold\">\n Investment accounts\n </Text>\n <Badge variant=\"neutral\" :label=\"String(INVESTMENTS.length)\" />\n </HStack>\n <HStack :gap=\"2\" align=\"center\">\n <Text color=\"secondary\">\n Market value\n </Text>\n <Text weight=\"semibold\" has-tabular-numbers>\n {{ money(totals.investments) }}\n </Text>\n </HStack>\n </HStack>\n </template>\n <VStack :padding-block-start=\"3\">\n <div class=\"fixed-table\"><Table :data=\"invTable.sortedData.value\" :columns=\"investmentColumns\" id-key=\"id\" density=\"compact\" dividers=\"rows\" :plugins=\"invTable.plugins\" /></div>\n </VStack>\n </Collapsible>\n </Card>\n\n </VStack>\n </LayoutContent>\n </Layout>\n</template>\n\n<style scoped>\n/* 原版 Table 的数据模式默认 `table-layout: fixed`,列宽严格按声明的比例分;core 目前是 auto,列宽按内容算、行高有 0.5px 的小数。 */\n.fixed-table :deep(.astryx-table) {\n table-layout: fixed;\n}\n\n.wash {\n background: var(--astryx-color-background-body);\n}\n.category-tile {\n display: grid;\n flex-shrink: 0;\n place-items: center;\n width: 24px;\n height: 24px;\n border-radius: var(--astryx-radius-inner);\n background: var(--astryx-color-background-muted);\n}\n.group-trigger {\n width: 100%;\n padding: var(--astryx-spacing-2) var(--astryx-spacing-2);\n}\n</style>\n",
79185
79542
  "docsUrl": {
79186
79543
  "en": "https://astryx-vue.pages.dev/en/playground/?template=table-collapsible",
79187
79544
  "zh": "https://astryx-vue.pages.dev/playground/?template=table-collapsible"
@@ -79237,11 +79594,13 @@ const catalog = {
79237
79594
  "Switch",
79238
79595
  "Table",
79239
79596
  "Text",
79597
+ "Tooltip",
79598
+ "VisuallyHidden",
79240
79599
  "VStack"
79241
79600
  ],
79242
79601
  "dependencies": ["@astryx-vue/core", "vue"],
79243
79602
  "isReady": true,
79244
- "source": "<script setup lang=\"ts\">\nimport {\n Button,\n Card,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n LayoutHeader,\n pixel,\n ProgressBar,\n proportional,\n Selector,\n StackItem,\n Switch,\n Table,\n Text,\n useTableGroupedRows,\n useTableStickyColumns,\n VStack,\n} from '@astryx-vue/core'\nimport type { TableColumn } from '@astryx-vue/core'\nimport { computed, h, ref } from 'vue'\n\ntype Modality = 'text' | 'image' | 'audio' | 'video' | 'document'\n\nconst MODALITY_ORDER: Modality[] = ['text', 'image', 'audio', 'video', 'document']\n\n/** 输入输出模态各占同一个格子位置,缺哪一项就用暗色图标占位。 */\nconst MODALITY_ICON: Record<Modality, string> = {\n text: 'i-carbon-text-font',\n image: 'i-carbon-image',\n audio: 'i-carbon-volume-up',\n video: 'i-carbon-video',\n document: 'i-carbon-document',\n}\n\nconst MODALITY_LABEL: Record<Modality, string> = {\n text: 'Text',\n image: 'Image',\n audio: 'Audio',\n video: 'Video',\n document: 'Document',\n}\n\ninterface Model {\n id: string\n name: string\n provider: string\n context: number\n maxOutput: number\n cutoff: string\n inputModalities: Modality[]\n outputModalities: Modality[]\n hasOpenWeights: boolean\n inputPrice: number\n outputPrice: number\n cachedPrice: number\n speed: number\n latency: number\n mmlu: number\n hasTools: boolean\n}\n\nconst MODELS: Model[] = [\n { id: 'atlas-4-flash', name: 'Atlas 4 Flash', provider: 'Northwind', context: 1_000_000, maxOutput: 65_536, cutoff: 'Mar 2026', inputModalities: ['text', 'image'], outputModalities: ['text'], hasOpenWeights: false, inputPrice: 0.1, outputPrice: 0.4, cachedPrice: 0.025, speed: 186, latency: 0.28, mmlu: 78.4, hasTools: true },\n { id: 'meridian-2-5-flash', name: 'Meridian 2.5 Flash', provider: 'Calder Labs', context: 1_048_576, maxOutput: 8_192, cutoff: 'Jan 2026', inputModalities: ['text', 'image', 'video'], outputModalities: ['text'], hasOpenWeights: false, inputPrice: 0.075, outputPrice: 0.3, cachedPrice: 0.019, speed: 214, latency: 0.24, mmlu: 76.9, hasTools: true },\n { id: 'kestrel-v4-turbo', name: 'Kestrel V4 Turbo', provider: 'Sable AI', context: 400_000, maxOutput: 16_384, cutoff: 'Feb 2026', inputModalities: ['text', 'image', 'document'], outputModalities: ['text'], hasOpenWeights: false, inputPrice: 0.3, outputPrice: 1.2, cachedPrice: 0.075, speed: 142, latency: 0.33, mmlu: 82.6, hasTools: true },\n { id: 'orion-mini-3', name: 'Orion Mini 3', provider: 'Halcyon', context: 262_144, maxOutput: 32_768, cutoff: 'Nov 2025', inputModalities: ['text'], outputModalities: ['text'], hasOpenWeights: true, inputPrice: 0.05, outputPrice: 0.2, cachedPrice: 0.012, speed: 248, latency: 0.19, mmlu: 71.2, hasTools: true },\n { id: 'orion-3-ultra', name: 'Orion 3 Ultra', provider: 'Halcyon', context: 524_288, maxOutput: 65_536, cutoff: 'Nov 2025', inputModalities: ['text', 'image', 'document'], outputModalities: ['text', 'image'], hasOpenWeights: true, inputPrice: 0.9, outputPrice: 2.7, cachedPrice: 0.22, speed: 96, latency: 0.48, mmlu: 85.1, hasTools: true },\n]\n\nconst DEFAULT_SLOTS = ['atlas-4-flash', 'meridian-2-5-flash', 'kestrel-v4-turbo']\nconst MIN_SLOTS = 2\n\nconst compact = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 2 })\nconst price = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 3 })\nconst decimal = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 })\n\ntype GroupKey = 'Overview' | 'Pricing' | 'Performance' | 'Benchmarks'\n\ninterface SpecRow extends Record<string, unknown> {\n id: string\n label: string\n group: GroupKey\n better?: 'higher' | 'lower'\n get: (model: Model) => number | string | boolean\n format?: (value: number) => string\n unit?: string\n isScore?: boolean\n /** 模态行:格子位置固定,缺的项画成暗色。 */\n modalities?: (model: Model) => Modality[]\n}\n\nconst SPEC_ROWS: SpecRow[] = [\n { id: 'provider', group: 'Overview', label: 'Provider', get: m => m.provider },\n { id: 'context', group: 'Overview', label: 'Context window', unit: 'tokens', better: 'higher', get: m => m.context, format: v => compact.format(v) },\n { id: 'max-output', group: 'Overview', label: 'Max output', unit: 'tokens', better: 'higher', get: m => m.maxOutput, format: v => compact.format(v) },\n { id: 'cutoff', group: 'Overview', label: 'Knowledge cutoff', get: m => m.cutoff },\n { id: 'input-modalities', group: 'Overview', label: 'Input modalities', get: () => '', modalities: m => m.inputModalities },\n { id: 'output-modalities', group: 'Overview', label: 'Output modalities', get: () => '', modalities: m => m.outputModalities },\n { id: 'open-weights', group: 'Overview', label: 'Open weights', get: m => m.hasOpenWeights },\n { id: 'input-price', group: 'Pricing', label: 'Input', unit: '/1M', better: 'lower', get: m => m.inputPrice, format: v => price.format(v) },\n { id: 'output-price', group: 'Pricing', label: 'Output', unit: '/1M', better: 'lower', get: m => m.outputPrice, format: v => price.format(v) },\n { id: 'cached-price', group: 'Pricing', label: 'Cached input', unit: '/1M', better: 'lower', get: m => m.cachedPrice, format: v => price.format(v) },\n { id: 'blended', group: 'Pricing', label: 'Blended (3:1)', unit: '/1M', better: 'lower', get: m => (m.inputPrice * 3 + m.outputPrice) / 4, format: v => price.format(v) },\n { id: 'speed', group: 'Performance', label: 'Output speed', unit: 'tok/s', better: 'higher', get: m => m.speed, format: v => String(Math.round(v)) },\n { id: 'latency', group: 'Performance', label: 'Time to first token', unit: 'sec', better: 'lower', get: m => m.latency, format: v => v.toFixed(2) },\n { id: 'mmlu', group: 'Benchmarks', label: 'MMLU-Pro', better: 'higher', get: m => m.mmlu, format: v => decimal.format(v), isScore: true },\n { id: 'tools', group: 'Benchmarks', label: 'Function calling', get: m => m.hasTools },\n]\n\nconst GROUP_ORDER: GroupKey[] = ['Overview', 'Pricing', 'Performance', 'Benchmarks']\n\nconst slots = ref<string[]>([...DEFAULT_SLOTS])\nconst highlightBest = ref(true)\n\nconst modelById = new Map(MODELS.map(m => [m.id, m]))\n\nconst selectedModels = computed(() =>\n slots.value.map(id => modelById.get(id)).filter((m): m is Model => m != null),\n)\n\nconst bestByRow = computed(() => {\n const best = new Map<string, number>()\n for (const row of SPEC_ROWS) {\n if (!row.better)\n continue\n const values = selectedModels.value\n .map(row.get)\n .filter((v): v is number => typeof v === 'number')\n if (!values.length)\n continue\n best.set(row.id, row.better === 'higher' ? Math.max(...values) : Math.min(...values))\n }\n return best\n})\n\nfunction selectSlot(index: number, modelId: string): void {\n slots.value = slots.value.map((current, i) => (i === index ? modelId : current))\n}\n\nfunction removeSlot(index: number): void {\n if (slots.value.length <= MIN_SLOTS)\n return\n slots.value = slots.value.filter((_, i) => i !== index)\n}\n\nfunction addModel(): void {\n const next = MODELS.find(m => !slots.value.includes(m.id))\n if (next)\n slots.value = [...slots.value, next.id]\n}\n\nconst collapsedGroups = ref(new Set<string>())\n\nfunction toggleGroup(key: string): void {\n const next = new Set(collapsedGroups.value)\n if (next.has(key))\n next.delete(key)\n else\n next.add(key)\n collapsedGroups.value = next\n}\n\nconst grouped = useTableGroupedRows<SpecRow>(() => ({\n data: SPEC_ROWS,\n groupBy: row => row.group,\n groupOrder: GROUP_ORDER,\n collapsedGroups: collapsedGroups.value,\n onToggleGroup: toggleGroup,\n getRowKey: row => row.id,\n renderGroupHeader: groupKey => h(Heading, { level: 3 }, () => groupKey),\n}))\n\nconst sticky = useTableStickyColumns<SpecRow>({ startKeys: ['spec'] })\n\nconst columns = computed<TableColumn<SpecRow>[]>(() => [\n {\n key: 'spec',\n header: 'Models',\n width: pixel(220),\n renderCell: row => h(Text, { type: 'label', color: 'secondary' }, () => row.label),\n },\n ...slots.value.map((modelId, index) => {\n const model = modelById.get(modelId)\n return {\n key: `slot-${index}`,\n width: proportional(1, { minWidth: 236 }),\n align: 'end' as const,\n header: model?.name ?? `Model ${index + 1}`,\n renderCell: (row: SpecRow) => {\n if (!model)\n return h(Text, { type: 'supporting' }, () => '—')\n if (row.modalities) {\n const included = row.modalities(model)\n return h(HStack, { gap: 1, vAlign: 'center', hAlign: 'end' }, () => MODALITY_ORDER.map(modality =>\n h(Icon, {\n icon: MODALITY_ICON[modality],\n size: 'md',\n color: included.includes(modality) ? 'primary' : 'disabled',\n label: MODALITY_LABEL[modality],\n }),\n ))\n }\n const value = row.get(model)\n if (typeof value === 'boolean') {\n return h(HStack, { hAlign: 'end', vAlign: 'center' }, () => h(Icon, {\n icon: value ? 'i-carbon-checkmark-filled' : 'i-carbon-close-filled',\n size: 'md',\n color: value ? 'success' : 'error',\n }))\n }\n if (typeof value === 'string') {\n return h(Text, { type: 'large' }, () => value)\n }\n const best = bestByRow.value.get(row.id)\n const isBest = highlightBest.value && best != null && value === best\n if (row.isScore) {\n return h(HStack, { gap: 1, vAlign: 'center', hAlign: 'end' }, () => [\n h(HStack, { width: 56, vAlign: 'center' }, () => h(ProgressBar, { label: 'Score', isLabelHidden: true, value, variant: 'accent' })),\n h(Text, { hasTabularNumbers: true, weight: isBest ? 'semibold' : 'normal' }, () => row.format?.(value) ?? String(value)),\n ])\n }\n const formatted = row.format?.(value) ?? String(value)\n return h(HStack, { gap: 1, vAlign: 'center', hAlign: 'end', class: isBest ? 'best-cell' : undefined }, () => [\n h(Text, { type: 'large', hasTabularNumbers: true, weight: isBest ? 'semibold' : 'normal' }, () => formatted),\n row.unit ? h(Text, { type: 'large', color: 'secondary' }, () => row.unit) : null,\n ])\n },\n }\n }),\n])\n\n<\/script>\n\n<template>\n <Layout height=\"auto\" :content-width=\"1440\">\n <template #header>\n <LayoutHeader :padding=\"4\">\n <HStack :gap=\"3\" align=\"center\" wrap=\"wrap\">\n <StackItem size=\"fill\">\n <Heading :level=\"1\">\n Compare models\n </Heading>\n </StackItem>\n <Switch v-model=\"highlightBest\" label=\"Highlight best\" size=\"sm\" />\n <Button label=\"Add model\" variant=\"secondary\" :is-disabled=\"slots.length >= MODELS.length\" @click=\"addModel\">\n <template #icon>\n <Icon icon=\"i-carbon-add\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n </LayoutHeader>\n </template>\n\n <LayoutContent :padding=\"4\">\n <VStack :gap=\"4\">\n <HStack :gap=\"3\" align=\"end\" wrap=\"wrap\">\n <VStack v-for=\"(slotId, index) in slots\" :key=\"slotId\" :width=\"280\">\n <Selector\n :model-value=\"slotId\"\n :label=\"`Model ${index + 1}`\"\n size=\"lg\"\n :has-clear=\"slots.length > MIN_SLOTS\"\n :options=\"MODELS.map(option => ({\n value: option.id,\n label: option.name,\n description: option.provider,\n disabled: option.id !== slotId && slots.includes(option.id),\n }))\"\n @update:model-value=\"(next: string | number | null) => next == null ? removeSlot(index) : selectSlot(index, String(next))\"\n />\n </VStack>\n </HStack>\n <Card :padding=\"5\" class=\"table-frame\">\n <Table\n :data=\"grouped.data.value\"\n :columns=\"columns\"\n :id-key=\"grouped.idKey\"\n density=\"spacious\"\n dividers=\"grid\"\n :plugins=\"{ grouped: grouped.plugin, sticky }\"\n />\n </Card>\n </VStack>\n </LayoutContent>\n </Layout>\n</template>\n\n<style scoped>\n.table-frame {\n width: 100%;\n max-width: 100%;\n min-width: 0;\n}\n\n/* 胜出的格子整格铺满底色:抵掉 spacious 密度下单元格自己的内边距。 */\n:deep(.best-cell) {\n box-sizing: content-box;\n background: var(--astryx-color-background-blue);\n margin-block: calc(-1 * var(--astryx-spacing-3));\n margin-inline: calc(-1 * var(--astryx-spacing-4));\n padding-block: var(--astryx-spacing-3);\n padding-inline: var(--astryx-spacing-4);\n}\n</style>\n",
79603
+ "source": "<script setup lang=\"ts\">\nimport {\n Button,\n Card,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n LayoutHeader,\n pixel,\n ProgressBar,\n proportional,\n Selector,\n StackItem,\n Switch,\n Table,\n Text,\n Tooltip,\n useTableGroupedRows,\n useTableStickyColumns,\n VisuallyHidden,\n VStack,\n} from '@astryx-vue/core'\nimport type { TableColumn } from '@astryx-vue/core'\nimport { computed, defineComponent, h, ref } from 'vue'\nimport type { VNodeChild } from 'vue'\n\ntype Modality = 'text' | 'image' | 'audio' | 'video' | 'document'\n\nconst MODALITY_ORDER: Modality[] = ['text', 'image', 'audio', 'video', 'document']\n\n/** 原版用 lucide 描边图标;这里按 lucide 的路径自绘成组件,颜色走 currentColor。 */\nfunction lucideIcon(nodes: Array<[string, Record<string, string | number>]>, strokeWidth = 2) {\n return () => h('svg', {\n 'xmlns': 'http://www.w3.org/2000/svg',\n 'viewBox': '0 0 24 24',\n 'fill': 'none',\n 'stroke': 'currentColor',\n 'stroke-width': strokeWidth,\n 'stroke-linecap': 'round',\n 'stroke-linejoin': 'round',\n 'width': '1em',\n 'height': '1em',\n 'aria-hidden': 'true',\n 'style': { fill: 'none' },\n }, nodes.map(([tag, attrs]) => h(tag, attrs)))\n}\n\nconst TextTypeIcon = lucideIcon([\n ['path', { d: 'M12 4v16' }],\n ['path', { d: 'M4 7V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2' }],\n ['path', { d: 'M9 20h6' }],\n], 1.5)\nconst ImageIcon = lucideIcon([\n ['rect', { width: 18, height: 18, x: 3, y: 3, rx: 2, ry: 2 }],\n ['circle', { cx: 9, cy: 9, r: 2 }],\n ['path', { d: 'm21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21' }],\n])\nconst AudioLinesIcon = lucideIcon([['path', { d: 'M2 10v3m4-7v11m4-14v18m4-13v7m4-10v13m4-8v3' }]])\nconst VideoIcon = lucideIcon([\n ['path', { d: 'm16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5' }],\n ['rect', { width: 14, height: 12, x: 2, y: 6, rx: 2 }],\n])\nconst FileTextIcon = lucideIcon([\n ['path', { d: 'M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z' }],\n ['path', { d: 'M14 2v5a1 1 0 0 0 1 1h5M10 9H8m8 4H8m8 4H8' }],\n])\n\n/** 布尔格的「支持 / 不支持」,和原版一样是描边圆形图标(lucide circle-check / circle-x)。 */\nfunction outlineCircle(paths: string[]) {\n return lucideIcon([['circle', { cx: 12, cy: 12, r: 10 }], ...paths.map((d): [string, Record<string, string>] => ['path', { d }])])\n}\nconst CircleCheckIcon = outlineCircle(['m9 12 2 2 4-4'])\nconst CircleXIcon = outlineCircle(['m15 9-6 6', 'm9 9 6 6'])\n\nconst MODALITY_ICON = {\n text: TextTypeIcon,\n image: ImageIcon,\n audio: AudioLinesIcon,\n video: VideoIcon,\n document: FileTextIcon,\n}\n\n/** 选择器里每个提供商的图标。 */\nconst PROVIDER_ICON: Record<string, string> = {\n 'Northwind': 'i-heroicons-cpu-chip',\n 'Calder Labs': 'i-heroicons-sparkles',\n 'Halcyon': 'i-heroicons-bolt',\n 'Sable AI': 'i-heroicons-cube',\n}\n\nconst MODALITY_LABEL: Record<Modality, string> = {\n text: 'Text',\n image: 'Image',\n audio: 'Audio',\n video: 'Video',\n document: 'Document',\n}\n\ninterface Model {\n id: string\n name: string\n provider: string\n context: number\n maxOutput: number\n cutoff: string\n inputModalities: Modality[]\n outputModalities: Modality[]\n hasOpenWeights: boolean\n inputPrice: number\n outputPrice: number\n cachedPrice: number\n speed: number\n latency: number\n mmlu: number\n humanEval: number\n math: number\n rateLimit: number\n hasStreaming: boolean\n hasTools: boolean\n hasFineTuning: boolean\n}\n\nconst MODELS: Model[] = [\n { id: 'atlas-4-flash', name: 'Atlas 4 Flash', provider: 'Northwind', context: 1_000_000, maxOutput: 65_536, cutoff: 'Mar 2026', inputModalities: ['text', 'image'], outputModalities: ['text'], hasOpenWeights: false, inputPrice: 0.1, outputPrice: 0.4, cachedPrice: 0.025, speed: 186, latency: 0.28, mmlu: 78.4, humanEval: 84.1, math: 71.2, rateLimit: 4_000, hasStreaming: true, hasTools: true, hasFineTuning: true, },\n { id: 'atlas-4-pro', name: 'Atlas 4 Pro', provider: 'Northwind', context: 2_000_000, maxOutput: 131_072, cutoff: 'Mar 2026', inputModalities: ['text', 'image', 'audio', 'document'], outputModalities: ['text', 'audio'], hasOpenWeights: false, inputPrice: 1.25, outputPrice: 5.0, cachedPrice: 0.31, speed: 74, latency: 0.62, mmlu: 88.7, humanEval: 93.4, math: 89.1, rateLimit: 1_000, hasStreaming: true, hasTools: true, hasFineTuning: false, },\n { id: 'meridian-2-5-flash', name: 'Meridian 2.5 Flash', provider: 'Calder Labs', context: 1_048_576, maxOutput: 8_192, cutoff: 'Jan 2026', inputModalities: ['text', 'image', 'video'], outputModalities: ['text'], hasOpenWeights: false, inputPrice: 0.075, outputPrice: 0.3, cachedPrice: 0.019, speed: 214, latency: 0.24, mmlu: 76.9, humanEval: 81.7, math: 68.4, rateLimit: 2_000, hasStreaming: true, hasTools: true, hasFineTuning: true, },\n { id: 'meridian-2-5-pro', name: 'Meridian 2.5 Pro', provider: 'Calder Labs', context: 2_097_152, maxOutput: 65_536, cutoff: 'Jan 2026', inputModalities: ['text', 'image', 'audio', 'video', 'document'], outputModalities: ['text', 'image', 'audio'], hasOpenWeights: false, inputPrice: 1.25, outputPrice: 10.0, cachedPrice: 0.31, speed: 62, latency: 0.71, mmlu: 89.3, humanEval: 92.8, math: 91.6, rateLimit: 360, hasStreaming: true, hasTools: true, hasFineTuning: false, },\n { id: 'orion-mini-3', name: 'Orion Mini 3', provider: 'Halcyon', context: 262_144, maxOutput: 32_768, cutoff: 'Nov 2025', inputModalities: ['text'], outputModalities: ['text'], hasOpenWeights: true, inputPrice: 0.05, outputPrice: 0.2, cachedPrice: 0.012, speed: 248, latency: 0.19, mmlu: 71.2, humanEval: 74.6, math: 58.9, rateLimit: 8_000, hasStreaming: true, hasTools: true, hasFineTuning: true, },\n { id: 'orion-3-ultra', name: 'Orion 3 Ultra', provider: 'Halcyon', context: 524_288, maxOutput: 65_536, cutoff: 'Nov 2025', inputModalities: ['text', 'image', 'document'], outputModalities: ['text', 'image'], hasOpenWeights: true, inputPrice: 0.9, outputPrice: 2.7, cachedPrice: 0.22, speed: 96, latency: 0.48, mmlu: 85.1, humanEval: 89.2, math: 82.3, rateLimit: 1_200, hasStreaming: true, hasTools: true, hasFineTuning: true, },\n { id: 'kestrel-v4-turbo', name: 'Kestrel V4 Turbo', provider: 'Sable AI', context: 400_000, maxOutput: 16_384, cutoff: 'Feb 2026', inputModalities: ['text', 'image', 'document'], outputModalities: ['text'], hasOpenWeights: false, inputPrice: 0.3, outputPrice: 1.2, cachedPrice: 0.075, speed: 142, latency: 0.33, mmlu: 82.6, humanEval: 87.3, math: 77.8, rateLimit: 3_000, hasStreaming: true, hasTools: true, hasFineTuning: false, },\n { id: 'kestrel-v4-lite', name: 'Kestrel V4 Lite', provider: 'Sable AI', context: 128_000, maxOutput: 8_192, cutoff: 'Feb 2026', inputModalities: ['text'], outputModalities: ['text'], hasOpenWeights: false, inputPrice: 0.04, outputPrice: 0.16, cachedPrice: 0.01, speed: 265, latency: 0.17, mmlu: 68.4, humanEval: 70.1, math: 54.2, rateLimit: 10_000, hasStreaming: true, hasTools: false, hasFineTuning: false, },\n]\n\nconst DEFAULT_SLOTS = ['atlas-4-flash', 'meridian-2-5-flash', 'kestrel-v4-turbo']\nconst MIN_SLOTS = 2\n\nconst compact = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 2 })\nconst price = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 3 })\nconst whole = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 })\nconst decimal = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 })\n\ntype GroupKey = 'Overview' | 'Pricing' | 'Performance' | 'Availability' | 'Benchmarks' | 'Limits'\n\ninterface SpecRow extends Record<string, unknown> {\n id: string\n label: string\n group: GroupKey\n better?: 'higher' | 'lower'\n get: (model: Model) => number | string | boolean\n format?: (value: number) => string\n unit?: string\n isScore?: boolean\n /** 整格自绘(跳过排名)。 */\n renderValue?: (model: Model) => VNodeChild\n /** 模态行:格子位置固定,缺的项画成暗色。 */\n modalities?: (model: Model) => Modality[]\n}\n\n// ===== 30 天可用性条 =====\n\nconst UPTIME_DAYS = 30\nconst UPTIME_END = new Date('2026-09-07T00:00:00Z')\nconst dayFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })\nconst uptimeFormat = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })\n\n/** 用模型 id 做种子的 xorshift:每个模型有自己固定的故障历史。 */\nfunction dailyUptime(seed: number, days: number): number[] {\n let state = seed || 1\n const next = () => {\n state ^= state << 13\n state ^= state >>> 17\n state ^= state << 5\n return Math.abs(state % 1000) / 1000\n }\n const rough = next()\n const outageOdds = 0.01 + rough * 0.05\n const degradedOdds = outageOdds + 0.03 + rough * 0.09\n const out: number[] = []\n for (let i = 0; i < days; i++) {\n const roll = next()\n if (roll < outageOdds)\n out.push(96.5 + next() * 2.4)\n else if (roll < degradedOdds)\n out.push(99.0 + next() * 0.9)\n else\n out.push(99.95 + next() * 0.05)\n }\n return out\n}\n\nfunction hashId(id: string): number {\n let hash = 0\n for (let i = 0; i < id.length; i++)\n hash = (hash << 5) - hash + id.charCodeAt(i)\n return hash\n}\n\nfunction uptimeStatus(value: number): { color: string, status: string } {\n if (value >= 99.95)\n return { color: 'var(--astryx-color-success)', status: 'Operational' }\n if (value >= 99)\n return { color: 'var(--astryx-color-warning)', status: 'Degraded' }\n return { color: 'var(--astryx-color-error)', status: 'Outage' }\n}\n\n/** 一行文字(平均值,悬停时换成当天读数)加一条 30 根的日柱。 */\nconst UptimeStrip = defineComponent({\n props: { modelId: { type: String, required: true } },\n setup(props) {\n const hovered = ref<number | null>(null)\n const days = computed(() => dailyUptime(hashId(props.modelId), UPTIME_DAYS).map((uptime, i) => {\n const date = new Date(UPTIME_END)\n date.setUTCDate(date.getUTCDate() - (UPTIME_DAYS - 1 - i))\n return { uptime, date: dayFormat.format(date), ...uptimeStatus(uptime) }\n }))\n const average = computed(() => days.value.reduce((total, day) => total + day.uptime, 0) / days.value.length)\n /** 颜色是日柱唯一的编码,读屏用户拿到的是同一份统计。 */\n const spoken = computed(() => {\n const tally: Record<string, number> = {}\n for (const day of days.value)\n tally[day.status] = (tally[day.status] ?? 0) + 1\n return `Last ${UPTIME_DAYS} days: ${['Operational', 'Degraded', 'Outage'].filter(status => tally[status] > 0).map(status => `${tally[status]} ${status.toLowerCase()}`).join(', ')}.`\n })\n return () => {\n const day = hovered.value == null ? undefined : days.value[hovered.value]\n return h(VStack, { gap: 1, hAlign: 'end', width: '100%' }, () => [\n h(VisuallyHidden, null, () => spoken.value),\n h(HStack, { height: 24, vAlign: 'center' }, () => h(Text, { type: 'body', color: 'primary' }, () =>\n day == null ? `${uptimeFormat.format(average.value)}% average` : `${day.date} · ${uptimeFormat.format(day.uptime)}% · ${day.status}`)),\n h('div', { style: { display: 'flex', width: '100%', height: '28px' }, role: 'img', ariaLabel: `Last ${UPTIME_DAYS} days uptime`, onMouseleave: () => { hovered.value = null } },\n days.value.map((d, i) => h('div', { key: i, style: { flex: '1 1 0', minWidth: 0, padding: '0 1px', display: 'flex' }, onMouseenter: () => { hovered.value = i } },\n h('div', { style: { flex: 1, borderRadius: '2px', background: d.color } })))),\n ])\n }\n },\n})\n\nconst SPEC_ROWS: SpecRow[] = [\n { id: 'provider', group: 'Overview', label: 'Provider', get: m => m.provider },\n { id: 'context', group: 'Overview', label: 'Context window', unit: 'tokens', better: 'higher', get: m => m.context, format: v => compact.format(v) },\n { id: 'max-output', group: 'Overview', label: 'Max output', unit: 'tokens', better: 'higher', get: m => m.maxOutput, format: v => compact.format(v) },\n { id: 'cutoff', group: 'Overview', label: 'Knowledge cutoff', get: m => m.cutoff },\n { id: 'input-modalities', group: 'Overview', label: 'Input modalities', get: () => '', modalities: m => m.inputModalities },\n { id: 'output-modalities', group: 'Overview', label: 'Output modalities', get: () => '', modalities: m => m.outputModalities },\n { id: 'open-weights', group: 'Overview', label: 'Open weights', get: m => m.hasOpenWeights },\n { id: 'input-price', group: 'Pricing', label: 'Input', unit: '/1M', better: 'lower', get: m => m.inputPrice, format: v => price.format(v) },\n { id: 'output-price', group: 'Pricing', label: 'Output', unit: '/1M', better: 'lower', get: m => m.outputPrice, format: v => price.format(v) },\n { id: 'cached-price', group: 'Pricing', label: 'Cached input', unit: '/1M', better: 'lower', get: m => m.cachedPrice, format: v => price.format(v) },\n { id: 'blended', group: 'Pricing', label: 'Blended (3:1)', unit: '/1M', better: 'lower', get: m => (m.inputPrice * 3 + m.outputPrice) / 4, format: v => price.format(v) },\n { id: 'speed', group: 'Performance', label: 'Output speed', unit: 'tok/s', better: 'higher', get: m => m.speed, format: v => String(Math.round(v)) },\n { id: 'latency', group: 'Performance', label: 'Time to first token', unit: 'sec', better: 'lower', get: m => m.latency, format: v => decimal.format(v) },\n { id: 'mmlu', group: 'Benchmarks', label: 'MMLU-Pro', better: 'higher', get: m => m.mmlu, format: v => decimal.format(v), isScore: true },\n { id: 'human-eval', group: 'Benchmarks', label: 'HumanEval', better: 'higher', get: m => m.humanEval, format: v => decimal.format(v), isScore: true },\n { id: 'math', group: 'Benchmarks', label: 'MATH', better: 'higher', get: m => m.math, format: v => decimal.format(v), isScore: true },\n { id: 'rate-limit', group: 'Limits', label: 'Rate limit', unit: '/min', better: 'higher', get: m => m.rateLimit, format: v => whole.format(v) },\n { id: 'streaming', group: 'Limits', label: 'Streaming', get: m => m.hasStreaming },\n { id: 'tools', group: 'Limits', label: 'Function calling', get: m => m.hasTools },\n { id: 'fine-tuning', group: 'Limits', label: 'Fine-tuning', get: m => m.hasFineTuning },\n { id: 'usage', group: 'Availability', label: 'Uptime (30d)', get: m => m.name, renderValue: m => h(UptimeStrip, { modelId: m.id }) },\n]\n\nconst GROUP_ORDER: GroupKey[] = ['Overview', 'Pricing', 'Performance', 'Availability', 'Benchmarks', 'Limits']\n\nconst slots = ref<string[]>([...DEFAULT_SLOTS])\nconst highlightBest = ref(true)\n\nconst modelById = new Map(MODELS.map(m => [m.id, m]))\n\nconst selectedModels = computed(() =>\n slots.value.map(id => modelById.get(id)).filter((m): m is Model => m != null),\n)\n\nconst bestByRow = computed(() => {\n const best = new Map<string, number>()\n for (const row of SPEC_ROWS) {\n if (!row.better)\n continue\n const values = selectedModels.value\n .map(row.get)\n .filter((v): v is number => typeof v === 'number')\n if (!values.length)\n continue\n best.set(row.id, row.better === 'higher' ? Math.max(...values) : Math.min(...values))\n }\n return best\n})\n\nfunction selectSlot(index: number, modelId: string): void {\n slots.value = slots.value.map((current, i) => (i === index ? modelId : current))\n}\n\nfunction removeSlot(index: number): void {\n if (slots.value.length <= MIN_SLOTS)\n return\n slots.value = slots.value.filter((_, i) => i !== index)\n}\n\nfunction addModel(): void {\n const next = MODELS.find(m => !slots.value.includes(m.id))\n if (next)\n slots.value = [...slots.value, next.id]\n}\n\nconst collapsedGroups = ref(new Set<string>())\n\nfunction toggleGroup(key: string): void {\n const next = new Set(collapsedGroups.value)\n if (next.has(key))\n next.delete(key)\n else\n next.add(key)\n collapsedGroups.value = next\n}\n\nconst grouped = useTableGroupedRows<SpecRow>(() => ({\n data: SPEC_ROWS,\n groupBy: row => row.group,\n groupOrder: GROUP_ORDER,\n collapsedGroups: collapsedGroups.value,\n onToggleGroup: toggleGroup,\n getRowKey: row => row.id,\n renderGroupHeader: groupKey => h(HStack, { gap: 2, vAlign: 'center', style: { position: 'sticky', insetInlineStart: 'var(--astryx-spacing-6)', width: 'fit-content' } }, () =>\n h(Heading, { level: 3, accessibilityLevel: 2 }, () => groupKey),\n ),\n}))\n\nconst sticky = useTableStickyColumns<SpecRow>({ startKeys: ['spec'] })\n\n/** 正被悬停或聚焦的模型列;清除(即移除该列)只在这时出现。 */\nconst activeHeader = ref<number | null>(null)\n\n/** 模型列的表头就是一个选择器;已被其它列选中的模型不可再选,保留至少 MIN_SLOTS 列时才显示清除。 */\nfunction renderModelHeader(index: number, modelId: string) {\n const setActive = (value: boolean) => {\n activeHeader.value = value ? index : (activeHeader.value === index ? null : activeHeader.value)\n }\n return h('div', {\n class: 'model-header',\n style: { flex: '1 1 100%', minWidth: 0 },\n onMouseenter: () => setActive(true),\n onMouseleave: () => setActive(false),\n onFocusin: () => setActive(true),\n onFocusout: () => setActive(false),\n }, h(Selector, {\n 'modelValue': modelId,\n 'label': `Model ${index + 1}`,\n 'isLabelHidden': true,\n 'size': 'lg',\n 'hasClear': activeHeader.value === index && slots.value.length > MIN_SLOTS,\n 'options': MODELS.map(option => ({\n value: option.id,\n label: option.name,\n description: option.provider,\n icon: PROVIDER_ICON[option.provider],\n disabled: option.id !== modelId && slots.value.includes(option.id),\n })),\n 'onUpdate:modelValue': (next: string | number | null) => next == null ? removeSlot(index) : selectSlot(index, String(next)),\n }))\n}\n\nconst columns = computed<TableColumn<SpecRow>[]>(() => [\n {\n key: 'spec',\n header: 'Models',\n width: pixel(220),\n renderCell: row => h(HStack, { vAlign: 'center', height: 24 }, () => h(Text, { type: 'label', color: 'secondary' }, () => row.label)),\n },\n ...slots.value.map((modelId, index) => {\n const model = modelById.get(modelId)\n return {\n key: `slot-${index}`,\n width: proportional(1, { minWidth: 236 }),\n align: 'end' as const,\n header: () => renderModelHeader(index, modelId),\n renderCell: (row: SpecRow) => {\n if (!model)\n return h(Text, { type: 'supporting' }, () => '—')\n if (row.renderValue)\n return row.renderValue(model)\n if (row.modalities) {\n const included = row.modalities(model)\n const explanation = MODALITY_ORDER.map(modality => `${MODALITY_LABEL[modality]}: ${included.includes(modality) ? 'supported' : 'not supported'}`).join('; ')\n return h(Tooltip, { content: explanation, hasHoverIndication: false, touchTrigger: 'tap' }, () => h(HStack, { gap: 3, vAlign: 'center', hAlign: 'end', height: 24, tabindex: 0, role: 'group', ariaLabel: 'Model modalities' }, () => MODALITY_ORDER.map(modality =>\n h(HStack, { width: 20, height: 20, hAlign: 'center', vAlign: 'center' }, () => h(Icon, {\n icon: MODALITY_ICON[modality],\n size: 'md',\n color: included.includes(modality) ? 'primary' : 'disabled',\n })),\n )))\n }\n const value = row.get(model)\n if (typeof value === 'boolean') {\n return h(HStack, { gap: 1, hAlign: 'end', vAlign: 'center', height: 24 }, () => h(Icon, {\n icon: value ? CircleCheckIcon : CircleXIcon,\n label: value ? 'Supported' : 'Not supported',\n size: 'md',\n color: value ? 'success' : 'error',\n }))\n }\n if (typeof value === 'string') {\n return h(HStack, { gap: 1, vAlign: 'center', hAlign: 'end', height: 24 }, () => h(Text, { type: 'large', weight: 'normal' }, () => value))\n }\n const best = bestByRow.value.get(row.id)\n const isBest = highlightBest.value && best != null && value === best\n if (row.isScore) {\n return h(HStack, { gap: 1, vAlign: 'center', hAlign: 'end', height: 24 }, () => [\n h(HStack, { width: 56, vAlign: 'center' }, () => h(ProgressBar, { label: `Score ${decimal.format(value)} out of 100`, isLabelHidden: true, value, variant: 'accent', width: '100%' })),\n h(Text, { type: 'large', hasTabularNumbers: true, weight: isBest ? 'semibold' : 'normal' }, () => row.format?.(value) ?? String(value)),\n ])\n }\n const formatted = row.format?.(value) ?? String(value)\n return h(HStack, { gap: 1, vAlign: 'center', hAlign: 'end', height: 24, class: isBest ? 'best-cell' : undefined }, () => [\n h(Text, { type: 'large', hasTabularNumbers: true, weight: isBest ? 'semibold' : 'normal' }, () => formatted),\n row.unit ? h(Text, { type: 'large', weight: 'normal', color: isBest ? 'primary' : 'secondary' }, () => row.unit) : null,\n ])\n },\n }\n }),\n])\n\n<\/script>\n\n<template>\n <Layout height=\"auto\" :content-width=\"1440\">\n <template #header>\n <LayoutHeader :padding=\"4\">\n <HStack :gap=\"3\" align=\"center\" wrap=\"wrap\">\n <StackItem size=\"fill\">\n <Heading :level=\"1\">\n Compare models\n </Heading>\n </StackItem>\n <Switch v-model=\"highlightBest\" label=\"Highlight best\" size=\"sm\" />\n <Button label=\"Add model\" variant=\"secondary\" :is-disabled=\"slots.length >= MODELS.length\" @click=\"addModel\">\n <template #icon>\n <Icon icon=\"i-heroicons-plus\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n </LayoutHeader>\n </template>\n\n <LayoutContent :padding=\"4\">\n <VStack :gap=\"4\">\n <Card :padding=\"5\" class=\"table-frame\">\n <Table\n :data=\"grouped.data.value\"\n :columns=\"columns\"\n :id-key=\"grouped.idKey\"\n density=\"spacious\"\n dividers=\"grid\"\n :plugins=\"{ grouped: grouped.plugin, sticky }\"\n />\n </Card>\n </VStack>\n </LayoutContent>\n </Layout>\n</template>\n\n<style scoped>\n/* 原版分组头单元格的内边距固定为 spacing-2 / spacing-3(起始侧 0),不随密度变;\n core 的 `.astryx-table[data-density='spacious'] :is(th, td)` 会把它撑成 12 / 16,行高 49 而不是 41。 */\n.table-frame :deep(.astryx-table td.astryx-table__group:first-child) {\n padding-block: var(--astryx-spacing-2);\n padding-inline: 0 var(--astryx-spacing-3);\n}\n\n.table-frame {\n width: 100%;\n max-width: 100%;\n min-width: 0;\n}\n\n/* 胜出的格子整格铺满底色:抵掉 spacious 密度下单元格自己的内边距。 */\n:deep(.best-cell) {\n box-sizing: content-box;\n background: var(--astryx-color-background-blue);\n margin-block: calc(-1 * var(--astryx-spacing-3));\n margin-inline: calc(-1 * var(--astryx-spacing-4));\n padding-block: var(--astryx-spacing-3);\n padding-inline: var(--astryx-spacing-4);\n}\n</style>\n",
79245
79604
  "docsUrl": {
79246
79605
  "en": "https://astryx-vue.pages.dev/en/playground/?template=table-comparison",
79247
79606
  "zh": "https://astryx-vue.pages.dev/playground/?template=table-comparison"
@@ -79286,26 +79645,46 @@ const catalog = {
79286
79645
  "Avatar",
79287
79646
  "Badge",
79288
79647
  "Button",
79289
- "DropdownMenu",
79648
+ "ComplexSelector",
79649
+ "Dialog",
79650
+ "DialogHeader",
79651
+ "Divider",
79652
+ "EmptyState",
79290
79653
  "Heading",
79291
79654
  "HStack",
79292
79655
  "Icon",
79293
79656
  "IconButton",
79294
79657
  "Layout",
79295
79658
  "LayoutContent",
79659
+ "LayoutHeader",
79660
+ "LayoutPanel",
79661
+ "Link",
79662
+ "MetadataList",
79663
+ "MetadataListItem",
79664
+ "MultiSelector",
79665
+ "OverflowList",
79666
+ "Popover",
79667
+ "PowerSearch",
79668
+ "ProgressBar",
79669
+ "RadioList",
79670
+ "RadioListItem",
79671
+ "ResizeHandle",
79672
+ "Section",
79296
79673
  "Selector",
79674
+ "Skeleton",
79675
+ "Slider",
79297
79676
  "StackItem",
79298
79677
  "StatusDot",
79299
79678
  "Table",
79300
79679
  "Text",
79301
79680
  "TextInput",
79302
- "ToggleButton",
79303
79681
  "Token",
79682
+ "ToggleButton",
79304
79683
  "VStack"
79305
79684
  ],
79306
79685
  "dependencies": ["@astryx-vue/core", "vue"],
79307
79686
  "isReady": true,
79308
- "source": "<script setup lang=\"ts\">\nimport {\n Avatar,\n Badge,\n Button,\n DropdownMenu,\n Heading,\n HStack,\n Icon,\n IconButton,\n Layout,\n LayoutContent,\n pixel,\n proportional,\n Selector,\n StackItem,\n StatusDot,\n Table,\n Text,\n TextInput,\n ToggleButton,\n Token,\n useTableGroupedRows,\n useTableSelection,\n useTableSelectionState,\n useTableSortable,\n useTableSortableState,\n VStack,\n} from '@astryx-vue/core'\nimport type { TableColumn } from '@astryx-vue/core'\nimport { computed, h, ref } from 'vue'\n\ntype JobStatus = 'scheduled' | 'in_progress' | 'on_hold' | 'overdue' | 'completed'\ntype Priority = 'urgent' | 'high' | 'normal'\n\n/** 优先级和状态按紧急程度排,不按字母排:升序时 Urgent、Overdue 在最前。 */\nconst PRIORITY_RANK: Record<Priority, number> = { urgent: 0, high: 1, normal: 2 }\nconst STATUS_RANK: Record<JobStatus, number> = { overdue: 0, on_hold: 1, in_progress: 2, scheduled: 3, completed: 4 }\n\ninterface SeedJob {\n id: string\n summary: string\n customer: string\n address: string\n technician: string\n status: JobStatus\n priority: Priority\n /** ISO 时间戳,日期列按它排序。 */\n scheduledAt: string\n /** 报价,整数美元,便于筛选和排序。 */\n quoted: number\n equipment: string\n}\n\ninterface ServiceJob extends SeedJob, Record<string, unknown> {\n /** 和 `scheduledAt` 同一时刻,日期筛选读这个字段。 */\n scheduledOn: Date\n}\n\nconst STATUS_META: Record<JobStatus, { label: string, badge: 'neutral' | 'blue' | 'teal' | 'green' | 'red' }> = {\n scheduled: { label: 'Scheduled', badge: 'blue' },\n in_progress: { label: 'In progress', badge: 'teal' },\n on_hold: { label: 'On hold', badge: 'neutral' },\n overdue: { label: 'Overdue', badge: 'red' },\n completed: { label: 'Completed', badge: 'green' },\n}\n\n/** 优先级是往下读的三档,用圆点把红色挑出来就够,不必读字。 */\nconst PRIORITY_META: Record<Priority, { label: string, dot: 'neutral' | 'warning' | 'error' }> = {\n urgent: { label: 'Urgent', dot: 'error' },\n high: { label: 'High', dot: 'warning' },\n normal: { label: 'Normal', dot: 'neutral' },\n}\n\nconst seedJobs: SeedJob[] = [\n {\n id: 'SJ-2148',\n summary: 'No cooling — rooftop unit 3',\n customer: 'Harborview Grand Hotel',\n address: '1200 Harbor Blvd, Bayside',\n technician: 'Luis Camarena',\n status: 'in_progress',\n priority: 'urgent',\n scheduledAt: '2026-07-02T08:00:00Z',\n quoted: 1480,\n equipment: 'Trane RTU-3, 25-ton (2019)',\n },\n {\n id: 'SJ-2147',\n summary: 'Quarterly preventive maintenance',\n customer: 'Northgate Dental Group',\n address: '88 Northgate Mall, Suite 210',\n technician: 'Dana Whitfield',\n status: 'scheduled',\n priority: 'normal',\n scheduledAt: '2026-07-02T10:30:00Z',\n quoted: 420,\n equipment: 'Carrier split system, 5-ton',\n },\n {\n id: 'SJ-2146',\n summary: 'Walk-in freezer overshooting setpoint',\n customer: 'Beacon Street Bistro',\n address: '412 Beacon St',\n technician: 'Marcus Osei',\n status: 'on_hold',\n priority: 'high',\n scheduledAt: '2026-07-01T13:00:00Z',\n quoted: 2150,\n equipment: 'Kolpak walk-in, Copeland compressor',\n },\n {\n id: 'SJ-2145',\n summary: 'Thermostat replacement, suite 400',\n customer: 'Pinnacle Property Mgmt',\n address: '500 Commerce Tower, Floor 4',\n technician: 'Dana Whitfield',\n status: 'scheduled',\n priority: 'normal',\n scheduledAt: '2026-07-02T14:00:00Z',\n quoted: 310,\n equipment: 'Honeywell T6 Pro (2 units)',\n },\n {\n id: 'SJ-2144',\n summary: 'Boiler pilot fails to stay lit',\n customer: 'Elm & 5th Lofts',\n address: '501 Elm St',\n technician: 'Luis Camarena',\n status: 'overdue',\n priority: 'high',\n scheduledAt: '2026-07-01T09:00:00Z',\n quoted: 760,\n equipment: 'Weil-McLain CGa boiler (2011)',\n },\n {\n id: 'SJ-2143',\n summary: 'Condenser coil cleaning',\n customer: 'Riverside Athletic Club',\n address: '9 Riverside Way',\n technician: 'Priya Raman',\n status: 'scheduled',\n priority: 'normal',\n scheduledAt: '2026-07-03T07:30:00Z',\n quoted: 540,\n equipment: 'Lennox RTU, 20-ton (2018)',\n },\n {\n id: 'SJ-2142',\n summary: 'Intermittent short cycling',\n customer: 'Harborview Grand Hotel',\n address: '1200 Harbor Blvd, Bayside',\n technician: 'Marcus Osei',\n status: 'on_hold',\n priority: 'high',\n scheduledAt: '2026-07-01T15:30:00Z',\n quoted: 890,\n equipment: 'Trane RTU-1, 25-ton (2019)',\n },\n {\n id: 'SJ-2141',\n summary: 'Emergency steam leak, basement',\n customer: 'Elm & 5th Lofts',\n address: '501 Elm St',\n technician: 'Priya Raman',\n status: 'in_progress',\n priority: 'urgent',\n scheduledAt: '2026-07-01T06:00:00Z',\n quoted: 3200,\n equipment: 'Weil-McLain CGa boiler (2011)',\n },\n {\n id: 'SJ-2140',\n summary: 'Annual fire damper inspection',\n customer: 'Pinnacle Property Mgmt',\n address: '500 Commerce Tower, Floor 4',\n technician: 'Dana Whitfield',\n status: 'scheduled',\n priority: 'normal',\n scheduledAt: '2026-07-06T09:00:00Z',\n quoted: 1250,\n equipment: 'Building-wide duct network',\n },\n {\n id: 'SJ-2139',\n summary: 'Ice machine not producing',\n customer: 'Beacon Street Bistro',\n address: '412 Beacon St',\n technician: 'Luis Camarena',\n status: 'overdue',\n priority: 'urgent',\n scheduledAt: '2026-06-30T11:00:00Z',\n quoted: 680,\n equipment: 'Hoshizaki KM-660, air cooled',\n },\n {\n id: 'SJ-2138',\n summary: 'Makeup air unit rebalancing',\n customer: 'Northgate Dental Group',\n address: '88 Northgate Mall, Suite 210',\n technician: 'Priya Raman',\n status: 'scheduled',\n priority: 'normal',\n scheduledAt: '2026-07-07T13:00:00Z',\n quoted: 495,\n equipment: 'Greenheck MAU, 4000 CFM',\n },\n {\n id: 'SJ-2137',\n summary: 'Chiller low refrigerant alarm',\n customer: 'Summit Medical Plaza',\n address: '77 Summit Ridge Rd',\n technician: 'Marcus Osei',\n status: 'in_progress',\n priority: 'urgent',\n scheduledAt: '2026-07-01T08:30:00Z',\n quoted: 4100,\n equipment: 'Daikin air-cooled chiller, 80-ton',\n },\n {\n id: 'SJ-2136',\n summary: 'VAV box actuator replacement',\n customer: 'Summit Medical Plaza',\n address: '77 Summit Ridge Rd',\n technician: 'Dana Whitfield',\n status: 'scheduled',\n priority: 'high',\n scheduledAt: '2026-07-03T10:00:00Z',\n quoted: 720,\n equipment: 'Titus VAV, zones 12–15',\n },\n {\n id: 'SJ-2135',\n summary: 'Exhaust fan bearing noise',\n customer: 'Riverside Athletic Club',\n address: '9 Riverside Way',\n technician: 'Luis Camarena',\n status: 'on_hold',\n priority: 'normal',\n scheduledAt: '2026-06-30T14:30:00Z',\n quoted: 380,\n equipment: 'Greenheck exhaust fan, pool deck',\n },\n {\n id: 'SJ-2134',\n summary: 'Humidifier control board fault',\n customer: 'Summit Medical Plaza',\n address: '77 Summit Ridge Rd',\n technician: 'Priya Raman',\n status: 'overdue',\n priority: 'high',\n scheduledAt: '2026-06-29T09:30:00Z',\n quoted: 1340,\n equipment: 'DriSteem steam humidifier',\n },\n {\n id: 'SJ-2133',\n summary: 'Rooftop economizer stuck closed',\n customer: 'Northgate Dental Group',\n address: '88 Northgate Mall, Suite 210',\n technician: 'Marcus Osei',\n status: 'scheduled',\n priority: 'normal',\n scheduledAt: '2026-07-08T08:00:00Z',\n quoted: 610,\n equipment: 'Carrier split system, 5-ton',\n },\n {\n id: 'SJ-2132',\n summary: 'Hot water recirculation pump seized',\n customer: 'Elm & 5th Lofts',\n address: '501 Elm St',\n technician: 'Dana Whitfield',\n status: 'in_progress',\n priority: 'high',\n scheduledAt: '2026-07-01T12:00:00Z',\n quoted: 950,\n equipment: 'Taco 007 circulator',\n },\n {\n id: 'SJ-2131',\n summary: 'Split system quote, new tenant build',\n customer: 'Pinnacle Property Mgmt',\n address: '500 Commerce Tower, Floor 4',\n technician: 'Priya Raman',\n status: 'scheduled',\n priority: 'normal',\n scheduledAt: '2026-07-09T11:00:00Z',\n quoted: 8600,\n equipment: 'Proposed: Mitsubishi VRF, 6 zones',\n },\n {\n id: 'SJ-2101',\n summary: 'Compressor contactor replacement',\n customer: 'Harborview Grand Hotel',\n address: '1200 Harbor Blvd, Bayside',\n technician: 'Marcus Osei',\n status: 'completed',\n priority: 'high',\n scheduledAt: '2026-06-24T09:00:00Z',\n quoted: 640,\n equipment: 'Trane RTU-3, 25-ton (2019)',\n },\n {\n id: 'SJ-2095',\n summary: 'Pool dehumidifier service',\n customer: 'Riverside Athletic Club',\n address: '9 Riverside Way',\n technician: 'Dana Whitfield',\n status: 'completed',\n priority: 'normal',\n scheduledAt: '2026-06-22T08:00:00Z',\n quoted: 720,\n equipment: 'Seresco NP series dehumidifier',\n },\n {\n id: 'SJ-2088',\n summary: 'Refrigerant leak diagnostic',\n customer: 'Beacon Street Bistro',\n address: '412 Beacon St',\n technician: 'Luis Camarena',\n status: 'completed',\n priority: 'normal',\n scheduledAt: '2026-06-18T11:00:00Z',\n quoted: 390,\n equipment: 'Kolpak walk-in, Copeland compressor',\n },\n {\n id: 'SJ-2081',\n summary: 'Chiller annual certification',\n customer: 'Summit Medical Plaza',\n address: '77 Summit Ridge Rd',\n technician: 'Priya Raman',\n status: 'completed',\n priority: 'high',\n scheduledAt: '2026-06-15T07:00:00Z',\n quoted: 2450,\n equipment: 'Daikin air-cooled chiller, 80-ton',\n },\n {\n id: 'SJ-2067',\n summary: 'RTU belt and filter service',\n customer: 'Pinnacle Property Mgmt',\n address: '500 Commerce Tower, Floor 4',\n technician: 'Dana Whitfield',\n status: 'completed',\n priority: 'normal',\n scheduledAt: '2026-06-12T08:30:00Z',\n quoted: 280,\n equipment: 'York RTU, 15-ton (2016)',\n },\n {\n id: 'SJ-2044',\n summary: 'Cooling tower fan bearing replacement',\n customer: 'Harborview Grand Hotel',\n address: '1200 Harbor Blvd, Bayside',\n technician: 'Marcus Osei',\n status: 'completed',\n priority: 'high',\n scheduledAt: '2026-05-28T07:30:00Z',\n quoted: 1120,\n equipment: 'BAC cooling tower, cell 2',\n },\n {\n id: 'SJ-2019',\n summary: 'Spring maintenance visit',\n customer: 'Northgate Dental Group',\n address: '88 Northgate Mall, Suite 210',\n technician: 'Dana Whitfield',\n status: 'completed',\n priority: 'normal',\n scheduledAt: '2026-04-15T10:00:00Z',\n quoted: 420,\n equipment: 'Carrier split system, 5-ton',\n },\n];\n\nconst DAY_MS = 86_400_000\n\n/** 只读一次,预置筛选取到的值才稳定。 */\nconst NOW_MS = Date.now()\n\n/** 把整批日期平移,让积压跨过今天,最新的活儿大约在三周后。 */\nconst DATE_SHIFT_MS = NOW_MS + 21 * DAY_MS - Math.max(...seedJobs.map(j => Date.parse(j.scheduledAt)))\n\n/** 过去的活儿不可能还在等, authored 状态只在日期撑得住时保留。 */\nfunction statusForDate(authored: JobStatus, when: number): JobStatus {\n if (when > NOW_MS)\n return 'scheduled'\n const isRecent = when > NOW_MS - 45 * DAY_MS\n if (authored === 'scheduled')\n return isRecent ? 'overdue' : 'completed'\n if (authored === 'in_progress' && !isRecent)\n return 'completed'\n return authored\n}\n\n/** 原始几条按日历往回循环五轮,凑出一条更长的积压。 */\nconst allJobs: ServiceJob[] = Array.from({ length: 5 }, (_, cycle) =>\n seedJobs.map((job, index) => {\n const cycleDays = cycle === 0 ? 0 : cycle * 37 + ((index * 13) % 29)\n const when = Date.parse(job.scheduledAt) + DATE_SHIFT_MS - cycleDays * DAY_MS\n const scheduledOn = new Date(when)\n return {\n ...job,\n id: cycle === 0 ? job.id : `SJ-${1999 - (cycle - 1) * seedJobs.length - index}`,\n status: statusForDate(job.status, when),\n scheduledAt: scheduledOn.toISOString(),\n scheduledOn,\n quoted: cycle === 0 ? job.quoted : job.quoted + ((index * 17) % 9) * 10,\n }\n }),\n).flat()\n\nconst TECHNICIANS = Array.from(new Set(allJobs.map(j => j.technician))).sort()\nconst CUSTOMERS = Array.from(new Set(allJobs.map(j => j.customer))).sort()\n\nconst MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']\n\nfunction formatTime(iso: string): string {\n const d = new Date(iso)\n const hours = d.getUTCHours()\n const minutes = String(d.getUTCMinutes()).padStart(2, '0')\n const suffix = hours < 12 ? 'am' : 'pm'\n const hour12 = hours % 12 === 0 ? 12 : hours % 12\n return `${hour12}:${minutes}${suffix}`\n}\n\nfunction formatDate(iso: string, withYear = false): string {\n const d = new Date(iso)\n const base = `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`\n return withYear ? `${base}, ${d.getUTCFullYear()}` : base\n}\n\nconst formatScheduled = (iso: string): string => `${formatDate(iso)}, ${formatTime(iso)}`\nconst formatMoney = (amount: number): string => `$${amount.toLocaleString('en-US')}`\n\n// ============= 筛选状态 =============\n\nconst query = ref('')\nconst isUpcoming = ref(false)\nconst statusFilter = ref<string[]>(['scheduled', 'in_progress', 'on_hold', 'overdue'])\nconst customerFilter = ref<string | null>(null)\nconst technicianFilter = ref<string | null>(null)\nconst priorityFilter = ref<string | null>(null)\nconst quoteFilter = ref<string | null>(null)\nconst collapsedGroups = ref<ReadonlySet<string>>(new Set())\n\nconst QUOTE_BANDS = [\n { value: 'under-500', label: 'Under $500' },\n { value: '500-1500', label: '$500 – $1,500' },\n { value: '1500-3000', label: '$1,500 – $3,000' },\n { value: 'over-3000', label: 'Over $3,000' },\n]\n\nfunction inQuoteBand(job: ServiceJob, band: string | null): boolean {\n if (band == null)\n return true\n if (band === 'under-500')\n return job.quoted < 500\n if (band === '500-1500')\n return job.quoted >= 500 && job.quoted < 1500\n if (band === '1500-3000')\n return job.quoted >= 1500 && job.quoted < 3000\n return job.quoted >= 3000\n}\n\nconst visibleJobs = computed(() => {\n const term = query.value.trim().toLowerCase()\n return allJobs.filter((job) => {\n if (isUpcoming.value && job.scheduledOn.getTime() < NOW_MS)\n return false\n if (statusFilter.value.length > 0 && !statusFilter.value.includes(job.status))\n return false\n if (customerFilter.value != null && job.customer !== customerFilter.value)\n return false\n if (technicianFilter.value != null && job.technician !== technicianFilter.value)\n return false\n if (priorityFilter.value != null && job.priority !== priorityFilter.value)\n return false\n if (!inQuoteBand(job, quoteFilter.value))\n return false\n if (term !== '' && !job.summary.toLowerCase().includes(term) && !job.id.toLowerCase().includes(term))\n return false\n return true\n })\n})\n\nconst activeChipCount = computed(() =>\n (isUpcoming.value ? 1 : 0)\n + (statusFilter.value.length > 0 ? 1 : 0)\n + (customerFilter.value != null ? 1 : 0)\n + (technicianFilter.value != null ? 1 : 0)\n + (priorityFilter.value != null ? 1 : 0)\n + (quoteFilter.value != null ? 1 : 0),\n)\n\n/** 只有「Scheduled」那枚 chip 带数量与浮层,其余筛选各有自己的控件。 */\nconst scheduledChipLabel = computed(() => {\n const statuses = statusFilter.value\n if (statuses.length === 0)\n return 'Any status'\n const labels = statuses.map(status => STATUS_META[status as JobStatus].label)\n if (labels.length <= 1)\n return labels[0] ?? 'Any status'\n return `${labels[0]}, +${labels.length - 1}`\n})\n\nfunction clearAll(): void {\n query.value = ''\n isUpcoming.value = false\n statusFilter.value = []\n customerFilter.value = null\n technicianFilter.value = null\n priorityFilter.value = null\n quoteFilter.value = null\n}\n\nfunction toggleGroup(key: string): void {\n const next = new Set(collapsedGroups.value)\n if (next.has(key))\n next.delete(key)\n else\n next.add(key)\n collapsedGroups.value = next\n}\n\n// ============= 表格 =============\n\nconst selectedKeys = ref<ReadonlySet<string>>(new Set())\n\nconst { sortedData, sortConfig } = useTableSortableState<ServiceJob>({\n data: () => visibleJobs.value,\n columns: () => columns,\n defaultSort: [{ sortKey: 'priority', direction: 'ascending' }],\n})\n\nconst { selectionConfig } = useTableSelectionState<ServiceJob>({\n data: sortedData,\n idKey: 'id',\n selectedKeys,\n setSelectedKeys(keys) {\n selectedKeys.value = keys\n },\n})\nconst selectionPlugin = useTableSelection<ServiceJob>(selectionConfig)\n\nconst { data: groupedData, plugin: groupedPlugin } = useTableGroupedRows<ServiceJob>(() => ({\n data: sortedData.value,\n groupBy: job => job.customer,\n groupOrder: CUSTOMERS,\n collapsedGroups: collapsedGroups.value,\n onToggleGroup: toggleGroup,\n getRowKey: job => job.id,\n renderGroupHeader: (groupKey, count) => h(Text, { weight: 'semibold' }, () => `${groupKey} (${count})`),\n}))\n\nconst sortPlugin = useTableSortable<ServiceJob>(sortConfig)\n\nconst columns: TableColumn<ServiceJob>[] = [\n {\n key: 'job',\n header: 'Job',\n width: proportional(3, { minWidth: 240 }),\n sortable: { sortKey: 'summary', compare: (a, b) => a.summary.localeCompare(b.summary) },\n renderCell: job => h(VStack, { gap: 0.5 }, () => [\n h(Text, { maxLines: 1 }, () => job.summary),\n h(Text, { type: 'supporting', color: 'secondary' }, () => job.id),\n ]),\n },\n {\n key: 'customer',\n header: 'Customer',\n width: proportional(2, { minWidth: 180 }),\n sortable: { compare: (a, b) => a.customer.localeCompare(b.customer) },\n renderCell: job => h(Text, { maxLines: 1 }, () => job.customer),\n },\n {\n key: 'technician',\n header: 'Technician',\n width: proportional(2, { minWidth: 170 }),\n sortable: { compare: (a, b) => a.technician.localeCompare(b.technician) },\n renderCell: job => h(HStack, { gap: 2, vAlign: 'center' }, () => [\n h(Avatar, { name: job.technician, size: 'sm' }),\n h(Text, { maxLines: 1 }, () => job.technician),\n ]),\n },\n {\n key: 'status',\n header: 'Status',\n width: pixel(130),\n sortable: { compare: (a, b) => STATUS_RANK[a.status] - STATUS_RANK[b.status] },\n renderCell: job => h(Badge, {\n label: STATUS_META[job.status].label,\n variant: STATUS_META[job.status].badge,\n }),\n },\n {\n key: 'priority',\n header: 'Priority',\n width: pixel(110),\n sortable: { compare: (a, b) => PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority] },\n renderCell: job => h(HStack, { gap: 2, vAlign: 'center' }, () => [\n h(StatusDot, { variant: PRIORITY_META[job.priority].dot, label: PRIORITY_META[job.priority].label }),\n h(Text, () => PRIORITY_META[job.priority].label),\n ]),\n },\n {\n key: 'scheduled',\n header: 'Scheduled',\n width: pixel(170),\n sortable: { sortKey: 'scheduledAt', compare: (a, b) => a.scheduledAt.localeCompare(b.scheduledAt) },\n renderCell: job => h(Text, { color: 'secondary' }, () => formatScheduled(job.scheduledAt)),\n },\n {\n key: 'quoted',\n header: 'Quote',\n width: pixel(90),\n align: 'end',\n sortable: { compare: (a, b) => a.quoted - b.quoted },\n renderCell: job => h(Text, { hasTabularNumbers: true }, () => formatMoney(job.quoted)),\n },\n]\n\nconst CUSTOMER_OPTIONS = CUSTOMERS.map(name => ({ value: name, label: name }))\nconst TECHNICIAN_OPTIONS = TECHNICIANS.map(name => ({ value: name, label: name }))\nconst PRIORITY_OPTIONS = (['urgent', 'high', 'normal'] as Priority[]).map(value => ({\n value,\n label: PRIORITY_META[value].label,\n}))\nconst STATUS_OPTIONS = (['scheduled', 'in_progress', 'on_hold', 'overdue', 'completed'] as JobStatus[]).map(value => ({\n value,\n label: STATUS_META[value].label,\n}))\n\nconst statusItems = STATUS_OPTIONS.map(option => ({\n label: option.label,\n onClick: () => {\n const next = statusFilter.value.includes(option.value)\n ? statusFilter.value.filter(value => value !== option.value)\n : [...statusFilter.value, option.value]\n statusFilter.value = next\n },\n}))\n\nconst viewOptionItems = [\n { label: 'Columns', onClick: () => {} },\n { label: 'Density', onClick: () => {} },\n { label: 'Grouping', onClick: () => {} },\n]\n\n/** 搜索框起始位置的放大镜,供 `TextInput` 的 `startIcon` 使用。 */\nfunction SearchIcon() {\n return h(Icon, { icon: 'search', size: 'sm' })\n}\n\n/** Stack 的内边距会通过 CSS 变量传给嵌套 Stack,所以容器留白用内联样式。 */\nfunction spacing(step: number): string {\n return `var(--astryx-spacing-${String(step).replace('.', '-')}, ${step * 4}px)`\n}\n<\/script>\n\n<template>\n <Layout height=\"fill\">\n <LayoutContent :padding=\"0\" is-scrollable>\n <VStack :gap=\"0\">\n <HStack :gap=\"3\" align=\"center\" :style=\"{ padding: spacing(4) }\">\n <StackItem size=\"fill\">\n <Heading :level=\"1\">\n Service jobs\n </Heading>\n </StackItem>\n <Button label=\"New job\" variant=\"primary\" size=\"sm\">\n <template #icon>\n <Icon name=\"heroicons:plus\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n\n <HStack\n :gap=\"2\"\n align=\"center\"\n wrap=\"wrap\"\n :style=\"{ paddingInline: spacing(4), paddingBlockEnd: spacing(3) }\"\n >\n <VStack :width=\"200\">\n <TextInput\n v-model=\"query\"\n label=\"Job name\"\n is-label-hidden\n placeholder=\"Job name\"\n has-clear\n :start-icon=\"SearchIcon\"\n />\n </VStack>\n <ToggleButton\n v-model:is-pressed=\"isUpcoming\"\n label=\"Upcoming\"\n size=\"sm\"\n variant=\"secondary\"\n />\n <HStack :gap=\"1\" align=\"center\">\n <Token\n size=\"md\"\n :label=\"scheduledChipLabel\"\n removable\n @remove=\"statusFilter = []\"\n />\n <DropdownMenu has-chevron size=\"sm\" :items=\"statusItems\" :button=\"{ label: 'Status', size: 'sm', variant: 'ghost' }\" />\n </HStack>\n <Selector\n v-model=\"customerFilter\"\n label=\"Customer\"\n is-label-hidden\n size=\"sm\"\n placeholder=\"Customer\"\n :options=\"CUSTOMER_OPTIONS\"\n />\n <Selector\n v-model=\"technicianFilter\"\n label=\"Technician\"\n is-label-hidden\n size=\"sm\"\n placeholder=\"Technician\"\n :options=\"TECHNICIAN_OPTIONS\"\n />\n <Selector\n v-model=\"priorityFilter\"\n label=\"Priority\"\n is-label-hidden\n size=\"sm\"\n placeholder=\"Priority\"\n :options=\"PRIORITY_OPTIONS\"\n />\n <Selector\n v-model=\"quoteFilter\"\n label=\"Quote\"\n is-label-hidden\n size=\"sm\"\n placeholder=\"Quote\"\n :options=\"QUOTE_BANDS\"\n />\n <IconButton label=\"More filters\" variant=\"ghost\" size=\"sm\">\n <Icon icon=\"funnel\" size=\"sm\" />\n </IconButton>\n <Text type=\"supporting\" color=\"secondary\">\n {{ visibleJobs.length }} results\n </Text>\n <Button\n v-if=\"activeChipCount > 0\"\n label=\"Clear all\"\n variant=\"ghost\"\n size=\"sm\"\n @click=\"clearAll\"\n />\n <StackItem size=\"fill\" />\n <DropdownMenu has-chevron size=\"sm\" :items=\"viewOptionItems\" :button=\"{ label: 'View options', size: 'sm', variant: 'ghost' }\" />\n <IconButton label=\"Save this view\" variant=\"ghost\" size=\"sm\">\n <Icon name=\"heroicons:bookmark\" size=\"sm\" />\n </IconButton>\n <IconButton label=\"View settings\" variant=\"ghost\" size=\"sm\">\n <Icon name=\"heroicons:adjustments-horizontal\" size=\"sm\" />\n </IconButton>\n </HStack>\n\n <Table\n :data=\"groupedData\"\n :columns=\"columns\"\n id-key=\"id\"\n density=\"balanced\"\n dividers=\"rows\"\n has-hover\n text-overflow=\"truncate\"\n :plugins=\"{ selection: selectionPlugin, sort: sortPlugin, grouped: groupedPlugin }\"\n />\n </VStack>\n </LayoutContent>\n </Layout>\n</template>\n",
79687
+ "source": "<script setup lang=\"ts\">\nimport {\n Avatar,\n Badge,\n Button,\n ComplexSelector,\n Dialog,\n DialogHeader,\n Divider,\n EmptyState,\n Heading,\n HStack,\n Icon,\n IconButton,\n isTableGroupHeader,\n Layout,\n LayoutContent,\n LayoutHeader,\n LayoutPanel,\n Link,\n MetadataList,\n MetadataListItem,\n MultiSelector,\n OverflowList,\n pixel,\n Popover,\n PowerSearch,\n proportional,\n ProgressBar,\n RadioList,\n RadioListItem,\n ResizeHandle,\n Section,\n Selector,\n Skeleton,\n Slider,\n StackItem,\n StatusDot,\n Table,\n Text,\n TextInput,\n Token,\n ToggleButton,\n usePowerSearchConfig,\n useResizable,\n useTableGroupedRows,\n useTableSelection,\n useTableSelectionState,\n useTableSortable,\n useTableSortableState,\n useTableStickyColumns,\n VStack,\n} from '@astryx-vue/core'\nimport type { PowerSearchFilter, TableColumn, TablePlugin } from '@astryx-vue/core'\nimport { computed, h, onBeforeUnmount, onMounted, ref, watch } from 'vue'\n\ntype JobStatus = 'scheduled' | 'in_progress' | 'on_hold' | 'overdue' | 'completed'\ntype Priority = 'urgent' | 'high' | 'normal'\n\n/** 优先级和状态按紧急程度排,不按字母排:升序时 Urgent、Overdue 在最前。 */\nconst PRIORITY_RANK: Record<Priority, number> = { urgent: 0, high: 1, normal: 2 }\nconst STATUS_RANK: Record<JobStatus, number> = { overdue: 0, on_hold: 1, in_progress: 2, scheduled: 3, completed: 4 }\n\ninterface SeedJob {\n id: string\n summary: string\n customer: string\n address: string\n technician: string\n status: JobStatus\n priority: Priority\n /** ISO 时间戳,日期列按它排序。 */\n scheduledAt: string\n /** 报价,整数美元,便于筛选和排序。 */\n quoted: number\n equipment: string\n}\n\ninterface ServiceJob extends SeedJob, Record<string, unknown> {\n /** 和 `scheduledAt` 同一时刻,日期筛选读这个字段。 */\n scheduledOn: Date\n}\n\nconst STATUS_META: Record<JobStatus, { label: string, badge: 'neutral' | 'blue' | 'teal' | 'green' | 'red' }> = {\n scheduled: { label: 'Scheduled', badge: 'blue' },\n in_progress: { label: 'In progress', badge: 'teal' },\n on_hold: { label: 'On hold', badge: 'neutral' },\n overdue: { label: 'Overdue', badge: 'red' },\n completed: { label: 'Completed', badge: 'green' },\n}\n\n/** 优先级是往下读的三档,用圆点把红色挑出来就够,不必读字。 */\nconst PRIORITY_META: Record<Priority, { label: string, dot: 'neutral' | 'warning' | 'error' }> = {\n urgent: { label: 'Urgent', dot: 'error' },\n high: { label: 'High', dot: 'warning' },\n normal: { label: 'Normal', dot: 'neutral' },\n}\n\n/** 详情面板主按钮按状态换文案,和原版一致。 */\nconst NEXT_ACTION: Record<JobStatus, string> = {\n scheduled: 'Dispatch technician',\n in_progress: 'Mark complete',\n on_hold: 'Check parts order',\n overdue: 'Reschedule',\n completed: 'Create follow-up',\n}\n\nconst seedJobs: SeedJob[] = [\n {\n id: 'SJ-2148',\n summary: 'No cooling — rooftop unit 3',\n customer: 'Harborview Grand Hotel',\n address: '1200 Harbor Blvd, Bayside',\n technician: 'Luis Camarena',\n status: 'in_progress',\n priority: 'urgent',\n scheduledAt: '2026-07-02T08:00:00Z',\n quoted: 1480,\n equipment: 'Trane RTU-3, 25-ton (2019)',\n },\n {\n id: 'SJ-2147',\n summary: 'Quarterly preventive maintenance',\n customer: 'Northgate Dental Group',\n address: '88 Northgate Mall, Suite 210',\n technician: 'Dana Whitfield',\n status: 'scheduled',\n priority: 'normal',\n scheduledAt: '2026-07-02T10:30:00Z',\n quoted: 420,\n equipment: 'Carrier split system, 5-ton',\n },\n {\n id: 'SJ-2146',\n summary: 'Walk-in freezer overshooting setpoint',\n customer: 'Beacon Street Bistro',\n address: '412 Beacon St',\n technician: 'Marcus Osei',\n status: 'on_hold',\n priority: 'high',\n scheduledAt: '2026-07-01T13:00:00Z',\n quoted: 2150,\n equipment: 'Kolpak walk-in, Copeland compressor',\n },\n {\n id: 'SJ-2145',\n summary: 'Thermostat replacement, suite 400',\n customer: 'Pinnacle Property Mgmt',\n address: '500 Commerce Tower, Floor 4',\n technician: 'Dana Whitfield',\n status: 'scheduled',\n priority: 'normal',\n scheduledAt: '2026-07-02T14:00:00Z',\n quoted: 310,\n equipment: 'Honeywell T6 Pro (2 units)',\n },\n {\n id: 'SJ-2144',\n summary: 'Boiler pilot fails to stay lit',\n customer: 'Elm & 5th Lofts',\n address: '501 Elm St',\n technician: 'Luis Camarena',\n status: 'overdue',\n priority: 'high',\n scheduledAt: '2026-07-01T09:00:00Z',\n quoted: 760,\n equipment: 'Weil-McLain CGa boiler (2011)',\n },\n {\n id: 'SJ-2143',\n summary: 'Condenser coil cleaning',\n customer: 'Riverside Athletic Club',\n address: '9 Riverside Way',\n technician: 'Priya Raman',\n status: 'scheduled',\n priority: 'normal',\n scheduledAt: '2026-07-03T07:30:00Z',\n quoted: 540,\n equipment: 'Lennox RTU, 20-ton (2018)',\n },\n {\n id: 'SJ-2142',\n summary: 'Intermittent short cycling',\n customer: 'Harborview Grand Hotel',\n address: '1200 Harbor Blvd, Bayside',\n technician: 'Marcus Osei',\n status: 'on_hold',\n priority: 'high',\n scheduledAt: '2026-07-01T15:30:00Z',\n quoted: 890,\n equipment: 'Trane RTU-1, 25-ton (2019)',\n },\n {\n id: 'SJ-2141',\n summary: 'Emergency steam leak, basement',\n customer: 'Elm & 5th Lofts',\n address: '501 Elm St',\n technician: 'Priya Raman',\n status: 'in_progress',\n priority: 'urgent',\n scheduledAt: '2026-07-01T06:00:00Z',\n quoted: 3200,\n equipment: 'Weil-McLain CGa boiler (2011)',\n },\n {\n id: 'SJ-2140',\n summary: 'Annual fire damper inspection',\n customer: 'Pinnacle Property Mgmt',\n address: '500 Commerce Tower, Floor 4',\n technician: 'Dana Whitfield',\n status: 'scheduled',\n priority: 'normal',\n scheduledAt: '2026-07-06T09:00:00Z',\n quoted: 1250,\n equipment: 'Building-wide duct network',\n },\n {\n id: 'SJ-2139',\n summary: 'Ice machine not producing',\n customer: 'Beacon Street Bistro',\n address: '412 Beacon St',\n technician: 'Luis Camarena',\n status: 'overdue',\n priority: 'urgent',\n scheduledAt: '2026-06-30T11:00:00Z',\n quoted: 680,\n equipment: 'Hoshizaki KM-660, air cooled',\n },\n {\n id: 'SJ-2138',\n summary: 'Makeup air unit rebalancing',\n customer: 'Northgate Dental Group',\n address: '88 Northgate Mall, Suite 210',\n technician: 'Priya Raman',\n status: 'scheduled',\n priority: 'normal',\n scheduledAt: '2026-07-07T13:00:00Z',\n quoted: 495,\n equipment: 'Greenheck MAU, 4000 CFM',\n },\n {\n id: 'SJ-2137',\n summary: 'Chiller low refrigerant alarm',\n customer: 'Summit Medical Plaza',\n address: '77 Summit Ridge Rd',\n technician: 'Marcus Osei',\n status: 'in_progress',\n priority: 'urgent',\n scheduledAt: '2026-07-01T08:30:00Z',\n quoted: 4100,\n equipment: 'Daikin air-cooled chiller, 80-ton',\n },\n {\n id: 'SJ-2136',\n summary: 'VAV box actuator replacement',\n customer: 'Summit Medical Plaza',\n address: '77 Summit Ridge Rd',\n technician: 'Dana Whitfield',\n status: 'scheduled',\n priority: 'high',\n scheduledAt: '2026-07-03T10:00:00Z',\n quoted: 720,\n equipment: 'Titus VAV, zones 12–15',\n },\n {\n id: 'SJ-2135',\n summary: 'Exhaust fan bearing noise',\n customer: 'Riverside Athletic Club',\n address: '9 Riverside Way',\n technician: 'Luis Camarena',\n status: 'on_hold',\n priority: 'normal',\n scheduledAt: '2026-06-30T14:30:00Z',\n quoted: 380,\n equipment: 'Greenheck exhaust fan, pool deck',\n },\n {\n id: 'SJ-2134',\n summary: 'Humidifier control board fault',\n customer: 'Summit Medical Plaza',\n address: '77 Summit Ridge Rd',\n technician: 'Priya Raman',\n status: 'overdue',\n priority: 'high',\n scheduledAt: '2026-06-29T09:30:00Z',\n quoted: 1340,\n equipment: 'DriSteem steam humidifier',\n },\n {\n id: 'SJ-2133',\n summary: 'Rooftop economizer stuck closed',\n customer: 'Northgate Dental Group',\n address: '88 Northgate Mall, Suite 210',\n technician: 'Marcus Osei',\n status: 'scheduled',\n priority: 'normal',\n scheduledAt: '2026-07-08T08:00:00Z',\n quoted: 610,\n equipment: 'Carrier split system, 5-ton',\n },\n {\n id: 'SJ-2132',\n summary: 'Hot water recirculation pump seized',\n customer: 'Elm & 5th Lofts',\n address: '501 Elm St',\n technician: 'Dana Whitfield',\n status: 'in_progress',\n priority: 'high',\n scheduledAt: '2026-07-01T12:00:00Z',\n quoted: 950,\n equipment: 'Taco 007 circulator',\n },\n {\n id: 'SJ-2131',\n summary: 'Split system quote, new tenant build',\n customer: 'Pinnacle Property Mgmt',\n address: '500 Commerce Tower, Floor 4',\n technician: 'Priya Raman',\n status: 'scheduled',\n priority: 'normal',\n scheduledAt: '2026-07-09T11:00:00Z',\n quoted: 8600,\n equipment: 'Proposed: Mitsubishi VRF, 6 zones',\n },\n {\n id: 'SJ-2101',\n summary: 'Compressor contactor replacement',\n customer: 'Harborview Grand Hotel',\n address: '1200 Harbor Blvd, Bayside',\n technician: 'Marcus Osei',\n status: 'completed',\n priority: 'high',\n scheduledAt: '2026-06-24T09:00:00Z',\n quoted: 640,\n equipment: 'Trane RTU-3, 25-ton (2019)',\n },\n {\n id: 'SJ-2095',\n summary: 'Pool dehumidifier service',\n customer: 'Riverside Athletic Club',\n address: '9 Riverside Way',\n technician: 'Dana Whitfield',\n status: 'completed',\n priority: 'normal',\n scheduledAt: '2026-06-22T08:00:00Z',\n quoted: 720,\n equipment: 'Seresco NP series dehumidifier',\n },\n {\n id: 'SJ-2088',\n summary: 'Refrigerant leak diagnostic',\n customer: 'Beacon Street Bistro',\n address: '412 Beacon St',\n technician: 'Luis Camarena',\n status: 'completed',\n priority: 'normal',\n scheduledAt: '2026-06-18T11:00:00Z',\n quoted: 390,\n equipment: 'Kolpak walk-in, Copeland compressor',\n },\n {\n id: 'SJ-2081',\n summary: 'Chiller annual certification',\n customer: 'Summit Medical Plaza',\n address: '77 Summit Ridge Rd',\n technician: 'Priya Raman',\n status: 'completed',\n priority: 'high',\n scheduledAt: '2026-06-15T07:00:00Z',\n quoted: 2450,\n equipment: 'Daikin air-cooled chiller, 80-ton',\n },\n {\n id: 'SJ-2067',\n summary: 'RTU belt and filter service',\n customer: 'Pinnacle Property Mgmt',\n address: '500 Commerce Tower, Floor 4',\n technician: 'Dana Whitfield',\n status: 'completed',\n priority: 'normal',\n scheduledAt: '2026-06-12T08:30:00Z',\n quoted: 280,\n equipment: 'York RTU, 15-ton (2016)',\n },\n {\n id: 'SJ-2044',\n summary: 'Cooling tower fan bearing replacement',\n customer: 'Harborview Grand Hotel',\n address: '1200 Harbor Blvd, Bayside',\n technician: 'Marcus Osei',\n status: 'completed',\n priority: 'high',\n scheduledAt: '2026-05-28T07:30:00Z',\n quoted: 1120,\n equipment: 'BAC cooling tower, cell 2',\n },\n {\n id: 'SJ-2019',\n summary: 'Spring maintenance visit',\n customer: 'Northgate Dental Group',\n address: '88 Northgate Mall, Suite 210',\n technician: 'Dana Whitfield',\n status: 'completed',\n priority: 'normal',\n scheduledAt: '2026-04-15T10:00:00Z',\n quoted: 420,\n equipment: 'Carrier split system, 5-ton',\n },\n]\n\nconst DAY_MS = 86_400_000\n\n/** 只读一次,预置筛选取到的值才稳定。 */\nconst NOW_MS = Date.now()\n\n/** 把整批日期平移,让积压跨过今天,最新的活儿大约在三周后。 */\nconst DATE_SHIFT_MS = NOW_MS + 21 * DAY_MS - Math.max(...seedJobs.map(j => Date.parse(j.scheduledAt)))\n\n/** 过去的活儿不可能还在等, authored 状态只在日期撑得住时保留。 */\nfunction statusForDate(authored: JobStatus, when: number): JobStatus {\n if (when > NOW_MS)\n return 'scheduled'\n const isRecent = when > NOW_MS - 45 * DAY_MS\n if (authored === 'scheduled')\n return isRecent ? 'overdue' : 'completed'\n if (authored === 'in_progress' && !isRecent)\n return 'completed'\n return authored\n}\n\n/** 原始几条按日历往回循环五轮,凑出一条更长的积压。 */\nconst allJobs: ServiceJob[] = Array.from({ length: 5 }, (_, cycle) =>\n seedJobs.map((job, index) => {\n const cycleDays = cycle === 0 ? 0 : cycle * 37 + ((index * 13) % 29)\n const when = Date.parse(job.scheduledAt) + DATE_SHIFT_MS - cycleDays * DAY_MS\n const scheduledOn = new Date(when)\n return {\n ...job,\n id: cycle === 0 ? job.id : `SJ-${1999 - (cycle - 1) * seedJobs.length - index}`,\n status: statusForDate(job.status, when),\n scheduledAt: scheduledOn.toISOString(),\n scheduledOn,\n quoted: cycle === 0 ? job.quoted : job.quoted + ((index * 17) % 9) * 10,\n }\n }),\n).flat()\n\nconst TECHNICIANS = Array.from(new Set(allJobs.map(j => j.technician))).sort()\nconst CUSTOMERS = Array.from(new Set(allJobs.map(j => j.customer))).sort()\n\nconst MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']\n\nfunction formatTime(iso: string): string {\n const d = new Date(iso)\n const hours = d.getUTCHours()\n const minutes = String(d.getUTCMinutes()).padStart(2, '0')\n const suffix = hours < 12 ? 'am' : 'pm'\n const hour12 = hours % 12 === 0 ? 12 : hours % 12\n return `${hour12}:${minutes}${suffix}`\n}\n\nfunction formatDate(iso: string, withYear = false): string {\n const d = new Date(iso)\n const base = `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`\n return withYear ? `${base}, ${d.getUTCFullYear()}` : base\n}\n\nconst formatScheduled = (iso: string): string => `${formatDate(iso)}, ${formatTime(iso)}`\nconst formatMoney = (amount: number): string => `$${amount.toLocaleString('en-US')}`\n\n// ============= Power Search 配置 =============\n\n/** 字段定义同时喂给筛选行的子句和高级搜索模式,两种模式读写同一组 filter。 */\nconst fieldDefs = [\n { key: 'summary', type: 'string', label: 'Job' },\n { key: 'id', type: 'string', label: 'ID' },\n { key: 'customer', type: 'enum', label: 'Customer', enumValues: CUSTOMERS.map(v => ({ value: v, label: v })) },\n { key: 'technician', type: 'enum', label: 'Technician', enumValues: TECHNICIANS.map(v => ({ value: v, label: v })) },\n { key: 'status', type: 'enum', label: 'Status', enumValues: (Object.keys(STATUS_META) as JobStatus[]).map(v => ({ value: v, label: STATUS_META[v].label })) },\n { key: 'priority', type: 'enum', label: 'Priority', enumValues: (Object.keys(PRIORITY_META) as Priority[]).map(v => ({ value: v, label: PRIORITY_META[v].label })) },\n { key: 'scheduledOn', type: 'date', label: 'Scheduled' },\n { key: 'quoted', type: 'number', label: 'Quote' },\n] as const\n\nconst { config: searchConfig, applyFilters } = usePowerSearchConfig(fieldDefs, 'Jobs')\n\n/** 屏幕打开时只显示进行中的活儿——除了 Completed 以外的所有状态。 */\nconst INITIAL_FILTERS: PowerSearchFilter[] = [\n { field: 'status', operator: 'is_any_of', value: { type: 'enum_list', value: ['scheduled', 'in_progress', 'on_hold', 'overdue'] } },\n]\n\n/** Upcoming 预置子句:排期在今天之后。 */\nconst UPCOMING_FILTER: PowerSearchFilter = {\n field: 'scheduledOn',\n operator: 'after',\n value: { type: 'date_absolute', unixSeconds: Math.floor(NOW_MS / 1000) },\n}\n\nconst filters = ref<PowerSearchFilter[]>([...INITIAL_FILTERS])\nconst isPowerSearch = ref(false)\nconst query = ref('')\n\nconst hasFilters = computed(() => filters.value.length > 0 || query.value !== '')\n\nfunction sameFilter(a: PowerSearchFilter, b: PowerSearchFilter): boolean {\n return JSON.stringify(a) === JSON.stringify(b)\n}\n\n/** 写某个字段的单条子句;null 表示撤掉这个字段的筛选。 */\nfunction setFieldFilter(fieldKey: string, next: PowerSearchFilter | null): void {\n activeSavedViewId.value = null\n const rest = filters.value.filter(f => f.field !== fieldKey)\n filters.value = next ? [...rest, next] : rest\n}\n\nfunction togglePreset(preset: PowerSearchFilter): void {\n activeSavedViewId.value = null\n filters.value = filters.value.some(f => sameFilter(f, preset))\n ? filters.value.filter(f => !sameFilter(f, preset))\n : [...filters.value.filter(f => f.field !== preset.field), preset]\n}\n\nfunction clearAll(): void {\n filters.value = []\n query.value = ''\n activeSavedViewId.value = null\n}\n\nconst isUpcoming = computed(() => filters.value.some(f => sameFilter(f, UPCOMING_FILTER)))\n\n// ============= 多选字段 =============\n\n/** 多选字段共用一套读写:一条 is_any_of 子句装整个列表。 */\nfunction readMultiValues(fieldKey: string): string[] {\n const active = filters.value.find(f => f.field === fieldKey)\n const raw = active ? (active.value as { value?: unknown }).value : null\n if (Array.isArray(raw))\n return raw.map(String)\n return raw == null ? [] : [String(raw)]\n}\n\nfunction writeMultiValues(fieldKey: string, next: string[]): void {\n setFieldFilter(\n fieldKey,\n next.length === 0\n ? null\n : { field: fieldKey, operator: 'is_any_of', value: { type: 'enum_list', value: next } },\n )\n}\n\nconst statusFilter = computed({\n get: () => readMultiValues('status'),\n set: value => writeMultiValues('status', value),\n})\nconst customerFilter = computed({\n get: () => readMultiValues('customer'),\n set: value => writeMultiValues('customer', value),\n})\nconst technicianFilter = computed({\n get: () => readMultiValues('technician'),\n set: value => writeMultiValues('technician', value),\n})\n\n// ============= Priority 单选 =============\n\nconst priorityFilter = computed<string | null>({\n get: () => {\n const active = filters.value.find(f => f.field === 'priority')\n const raw = active ? (active.value as { value?: unknown }).value : null\n return raw == null ? null : String(raw)\n },\n set: (value) => {\n setFieldFilter(\n 'priority',\n value == null\n ? null\n : { field: 'priority', operator: 'is', value: { type: 'enum', value } },\n )\n },\n})\n\n// ============= Quote 区间 =============\n\nconst QUOTE_MIN = 0\nconst QUOTE_MAX = Math.ceil(Math.max(...allJobs.map(j => j.quoted)) / 500) * 500\nconst QUOTE_STEP = 100\n\nfunction quoteBound(operator: string, fallback: number): number {\n const found = filters.value.find(f => f.field === 'quoted' && f.operator === operator)\n return found ? Number((found.value as { value: number }).value) : fallback\n}\n\nconst quoteLow = computed(() => quoteBound('greater_than_or_equal', QUOTE_MIN))\nconst quoteHigh = computed(() => quoteBound('less_than_or_equal', QUOTE_MAX))\nconst hasQuoteFilter = computed(() => quoteLow.value > QUOTE_MIN || quoteHigh.value < QUOTE_MAX)\n\n/** 拖动中的区间;松手后才写进 filters,和原版一致。 */\nconst quoteDraft = ref<[number, number]>([QUOTE_MIN, QUOTE_MAX])\nwatch([quoteLow, quoteHigh], () => {\n quoteDraft.value = [quoteLow.value, quoteHigh.value]\n})\n\nfunction commitQuote(next: [number, number]): void {\n quoteDraft.value = next\n activeSavedViewId.value = null\n const rest = filters.value.filter(f => f.field !== 'quoted')\n const bounds: PowerSearchFilter[] = []\n if (next[0] > QUOTE_MIN) {\n bounds.push({ field: 'quoted', operator: 'greater_than_or_equal', value: { type: 'integer', value: next[0] } })\n }\n if (next[1] < QUOTE_MAX) {\n bounds.push({ field: 'quoted', operator: 'less_than_or_equal', value: { type: 'integer', value: next[1] } })\n }\n filters.value = [...rest, ...bounds]\n}\n\n/** 区间收起后的整句,没动过就是占位的「Quote」。 */\nconst quoteLabel = computed(() => {\n if (!hasQuoteFilter.value)\n return undefined\n if (quoteLow.value > QUOTE_MIN && quoteHigh.value < QUOTE_MAX)\n return `Quote ${formatMoney(quoteLow.value)} – ${formatMoney(quoteHigh.value)}`\n return quoteLow.value > QUOTE_MIN ? `Quote over ${formatMoney(quoteLow.value)}` : `Quote under ${formatMoney(quoteHigh.value)}`\n})\n\n// ============= 结果集 =============\n\nconst results = computed(() => {\n const byFilters = applyFilters(filters.value, allJobs)\n const term = query.value.trim().toLowerCase()\n if (!term)\n return byFilters\n return byFilters.filter(j =>\n j.summary.toLowerCase().includes(term)\n || j.customer.toLowerCase().includes(term)\n || j.id.toLowerCase().includes(term),\n )\n})\n\n/** 筛选或搜索一变就回到第一页,顶部进度条站一班岗。 */\nconst isLoading = ref(false)\nlet loadingTimer: ReturnType<typeof setTimeout> | undefined\nwatch([filters, query], () => {\n isLoading.value = true\n clearTimeout(loadingTimer)\n loadingTimer = setTimeout(() => {\n isLoading.value = false\n }, 450)\n})\nonBeforeUnmount(() => clearTimeout(loadingTimer))\n\n// ============= 视图配置 =============\n\ntype Density = 'compact' | 'balanced' | 'spacious'\ntype StickyEdge = 'none' | 'one' | 'two'\ntype GroupField = 'none' | 'status' | 'priority' | 'technician' | 'customer'\ntype ViewSectionKey = 'columns' | 'density' | 'sticky' | 'grouping'\n\ninterface ViewState {\n columnKeys: string[]\n density: Density\n stickyStart: StickyEdge\n stickyEnd: StickyEdge\n grouping: GroupField\n}\n\nconst COLUMN_LABELS: Record<string, string> = {\n summary: 'Job',\n customer: 'Customer',\n technician: 'Technician',\n status: 'Status',\n priority: 'Priority',\n scheduledAt: 'Scheduled',\n quoted: 'Quote',\n equipment: 'Equipment',\n address: 'Address',\n}\n\nconst ALL_COLUMN_KEYS = ['summary', 'customer', 'technician', 'status', 'priority', 'scheduledAt', 'quoted', 'equipment', 'address']\nconst DEFAULT_COLUMN_KEYS = ['summary', 'customer', 'technician', 'status', 'priority', 'scheduledAt', 'quoted']\n\n/** Job 列给每行命名,不能移除;位置仍可以拖动。 */\nconst LOCKED_COLUMN_KEY = 'summary'\nconst LOCKED_COLUMN_MESSAGE = 'Job names every row, so it cannot be removed.'\n\nconst DENSITY_OPTIONS: { value: Density, label: string }[] = [\n { value: 'compact', label: 'Compact' },\n { value: 'balanced', label: 'Balanced' },\n { value: 'spacious', label: 'Spacious' },\n]\nconst STICKY_START_OPTIONS: { value: StickyEdge, label: string }[] = [\n { value: 'none', label: 'None' },\n { value: 'one', label: 'First column' },\n { value: 'two', label: 'First two columns' },\n]\nconst STICKY_END_OPTIONS: { value: StickyEdge, label: string }[] = [\n { value: 'none', label: 'None' },\n { value: 'one', label: 'Last column' },\n { value: 'two', label: 'Last two columns' },\n]\nconst GROUPING_OPTIONS: { value: GroupField, label: string }[] = [\n { value: 'none', label: 'None' },\n { value: 'status', label: 'Status' },\n { value: 'priority', label: 'Priority' },\n { value: 'technician', label: 'Technician' },\n { value: 'customer', label: 'Customer' },\n]\n\nconst VIEW_SECTIONS: { key: ViewSectionKey, label: string, title: string, icon: string }[] = [\n { key: 'columns', label: 'Columns', title: 'Columns', icon: 'heroicons:view-columns' },\n { key: 'density', label: 'Density', title: 'Density', icon: 'heroicons:table-cells' },\n { key: 'sticky', label: 'Sticky Columns', title: 'Sticky Columns', icon: 'heroicons:lock-closed' },\n { key: 'grouping', label: 'Grouping', title: 'Grouping', icon: 'heroicons:rectangle-group' },\n]\n\nconst INITIAL_VIEW: ViewState = {\n columnKeys: [...DEFAULT_COLUMN_KEYS],\n density: 'balanced',\n stickyStart: 'one',\n stickyEnd: 'none',\n grouping: 'customer',\n}\n\nconst view = ref<ViewState>({ ...INITIAL_VIEW, columnKeys: [...INITIAL_VIEW.columnKeys] })\nconst viewSection = ref<ViewSectionKey>('columns')\n\nfunction updateView(patch: (current: ViewState) => ViewState): void {\n view.value = patch(view.value)\n}\n\n/** 分组键用标签(状态/优先级)或原名(技师/客户),组序按紧急程度或字母。 */\nconst VALUE_LABELS: Record<string, string> = {\n ...Object.fromEntries((Object.keys(STATUS_META) as JobStatus[]).map(k => [k, STATUS_META[k].label])),\n ...Object.fromEntries((Object.keys(PRIORITY_META) as Priority[]).map(k => [k, PRIORITY_META[k].label])),\n}\n\nfunction groupKeyOf(job: ServiceJob, field: GroupField): string {\n if (field === 'none')\n return ''\n const stored = String(job[field])\n return VALUE_LABELS[stored] ?? stored\n}\n\nconst GROUP_ORDERS: Record<GroupField, string[]> = {\n none: [],\n status: (Object.keys(STATUS_META) as JobStatus[]).map(k => STATUS_META[k].label),\n priority: (Object.keys(PRIORITY_META) as Priority[]).map(k => PRIORITY_META[k].label),\n technician: TECHNICIANS,\n customer: CUSTOMERS,\n}\n\nconst groupField = computed(() => view.value.grouping)\nconst isGrouped = computed(() => groupField.value !== 'none')\n\n// ============= 列定义 =============\n\nfunction makeColumn(key: string): TableColumn<ServiceJob> {\n switch (key) {\n case 'summary':\n return {\n key: 'summary',\n header: 'Job',\n width: proportional(2, { minWidth: 240 }),\n sortable: { compare: (a, b) => a.summary.localeCompare(b.summary) },\n renderCell: job => h(VStack, { gap: 0 }, () => [\n h(Text, { type: 'body' }, () => job.summary),\n h(Text, { type: 'supporting', color: 'secondary' }, () => job.id),\n ]),\n }\n case 'customer':\n return {\n key: 'customer',\n header: 'Customer',\n width: proportional(1, { minWidth: 180 }),\n sortable: { compare: (a, b) => a.customer.localeCompare(b.customer) },\n renderCell: job => h(Text, { type: 'body' }, () => job.customer),\n }\n case 'technician':\n return {\n key: 'technician',\n header: 'Technician',\n width: pixel(200),\n sortable: { compare: (a, b) => a.technician.localeCompare(b.technician) },\n renderCell: job => h(HStack, { gap: 2, vAlign: 'center' }, () => [\n h(Avatar, { name: job.technician, size: 'sm' }),\n h(Text, { type: 'body' }, () => job.technician),\n ]),\n }\n case 'status':\n return {\n key: 'status',\n header: 'Status',\n width: pixel(150),\n sortable: { compare: (a, b) => STATUS_RANK[a.status] - STATUS_RANK[b.status] },\n renderCell: job => h(Badge, {\n label: STATUS_META[job.status].label,\n variant: STATUS_META[job.status].badge,\n }),\n }\n case 'priority':\n return {\n key: 'priority',\n header: 'Priority',\n width: pixel(120),\n sortable: { compare: (a, b) => PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority] },\n renderCell: job => h(HStack, { gap: 2, vAlign: 'center' }, () => [\n h(StatusDot, {\n variant: PRIORITY_META[job.priority].dot,\n label: PRIORITY_META[job.priority].label,\n isPulsing: job.priority === 'urgent',\n }),\n h(Text, { type: 'body' }, () => PRIORITY_META[job.priority].label),\n ]),\n }\n case 'scheduledAt':\n return {\n key: 'scheduledAt',\n header: 'Scheduled',\n width: pixel(160),\n sortable: { compare: (a, b) => a.scheduledAt.localeCompare(b.scheduledAt) },\n renderCell: job => h(Text, { type: 'body' }, () => formatScheduled(job.scheduledAt)),\n }\n case 'quoted':\n return {\n key: 'quoted',\n header: 'Quote',\n width: pixel(110),\n align: 'end',\n sortable: { compare: (a, b) => a.quoted - b.quoted },\n renderCell: job => h(Text, { type: 'body' }, () => formatMoney(job.quoted)),\n }\n case 'equipment':\n return {\n key: 'equipment',\n header: 'Equipment',\n width: proportional(1, { minWidth: 200 }),\n sortable: { compare: (a, b) => a.equipment.localeCompare(b.equipment) },\n renderCell: job => h(Text, { type: 'body' }, () => job.equipment),\n }\n default:\n return {\n key: 'address',\n header: 'Address',\n width: proportional(1, { minWidth: 180 }),\n sortable: { compare: (a, b) => a.address.localeCompare(b.address) },\n renderCell: job => h(Text, { type: 'body' }, () => job.address),\n }\n }\n}\n\nconst COLUMN_DEFS = new Map(ALL_COLUMN_KEYS.map(key => [key, makeColumn(key)]))\n\nconst columns = computed<TableColumn<ServiceJob>[]>(() =>\n view.value.columnKeys.map(key => COLUMN_DEFS.get(key)!).filter(Boolean),\n)\n\nconst displayedColumns = computed(() => view.value.columnKeys)\nconst availableColumns = computed(() => ALL_COLUMN_KEYS.filter(key => !view.value.columnKeys.includes(key)))\n\n/** 视图选项里拖动排序:按住把手,行跟着指针走,松手落位。 */\nconst columnRowRefs = new Map<string, HTMLElement>()\nconst reorderDropKey = ref<string | null>(null)\n\nfunction columnRowRef(key: string) {\n return (el: unknown) => {\n if (el == null)\n columnRowRefs.delete(key)\n else\n columnRowRefs.set(key, el as HTMLElement)\n }\n}\n\nlet reorderSession: { key: string, pointerId: number, fromIndex: number } | null = null\n\nfunction onGripPointerDown(event: PointerEvent, key: string): void {\n const fromIndex = view.value.columnKeys.indexOf(key)\n if (fromIndex < 0)\n return\n reorderSession = { key, pointerId: event.pointerId, fromIndex }\n ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)\n}\n\nfunction onGripPointerMove(event: PointerEvent): void {\n if (reorderSession == null)\n return\n const over = [...columnRowRefs.entries()].find(([, el]) => {\n const rect = el.getBoundingClientRect()\n return event.clientY >= rect.top && event.clientY <= rect.bottom\n })\n reorderDropKey.value = over?.[0] ?? null\n}\n\nfunction onGripPointerUp(): void {\n if (reorderSession == null)\n return\n const { key, fromIndex } = reorderSession\n const toKey = reorderDropKey.value\n reorderSession = null\n reorderDropKey.value = null\n if (toKey == null || toKey === key)\n return\n const toIndex = view.value.columnKeys.indexOf(toKey)\n if (toIndex < 0)\n return\n updateView((current) => {\n const next = [...current.columnKeys]\n next.splice(fromIndex, 1)\n next.splice(toIndex, 0, key)\n return { ...current, columnKeys: next }\n })\n}\n\n// ============= 排序 / 选择 / 分组 / 冻结 =============\n\nconst { sortedData, sortConfig } = useTableSortableState<ServiceJob>({\n data: () => results.value,\n columns,\n defaultSort: [{ sortKey: 'priority', direction: 'ascending' }],\n})\n\nconst SORT_RANKS: Record<string, Record<string, number>> = {\n priority: { urgent: 0, high: 1, normal: 2 },\n status: { overdue: 0, on_hold: 1, in_progress: 2, scheduled: 3, completed: 4 },\n}\n\n/**\n * 分组时组序优先于排序键(原版 comparator 先比组),保证一个组的行连续、\n * 分页切点落在组边界;不分组时直接用排序组合的结果。\n */\nconst groupMajorData = computed(() => {\n if (!isGrouped.value)\n return sortedData.value\n const order = GROUP_ORDERS[groupField.value]\n const rank = new Map(order.map((key, index) => [key, index]))\n return [...results.value].sort((a, b) => {\n const byGroup = (rank.get(groupKeyOf(a, groupField.value)) ?? Number.MAX_SAFE_INTEGER)\n - (rank.get(groupKeyOf(b, groupField.value)) ?? Number.MAX_SAFE_INTEGER)\n if (byGroup !== 0)\n return byGroup\n for (const { sortKey, direction } of sortConfig.sort) {\n const av = a[sortKey]\n const bv = b[sortKey]\n const ranks = SORT_RANKS[sortKey]\n let cmp: number\n if (ranks != null) {\n cmp = (ranks[String(av)] ?? Number.MAX_SAFE_INTEGER) - (ranks[String(bv)] ?? Number.MAX_SAFE_INTEGER)\n }\n else if (typeof av === 'number' && typeof bv === 'number') {\n cmp = av - bv\n }\n else {\n cmp = String(av ?? '').localeCompare(String(bv ?? ''))\n }\n if (cmp !== 0)\n return direction === 'ascending' ? cmp : -cmp\n }\n return 0\n })\n})\n\nconst selectedKeys = ref<ReadonlySet<string>>(new Set())\n\nconst { selectionConfig } = useTableSelectionState<ServiceJob>({\n data: sortedData,\n idKey: 'id',\n selectedKeys,\n setSelectedKeys(keys) {\n selectedKeys.value = keys\n },\n})\nconst selectionPlugin = useTableSelection<ServiceJob>({\n ...selectionConfig,\n getRowLabel: job => `${job.id} ${job.summary}`,\n hasRowHighlight: false,\n})\n\nconst selectedJobs = computed(() => allJobs.filter(job => selectedKeys.value.has(job.id)))\nconst selectedCount = computed(() => selectedKeys.value.size)\n\nfunction unselectAll(): void {\n selectedKeys.value = new Set()\n}\n\nconst collapsedGroups = ref<ReadonlySet<string>>(new Set())\n\nfunction toggleGroup(key: string): void {\n const next = new Set(collapsedGroups.value)\n if (next.has(key))\n next.delete(key)\n else\n next.add(key)\n collapsedGroups.value = next\n}\n\nconst { data: groupedRows, plugin: groupedPlugin, idKey: groupedIdKey } = useTableGroupedRows<ServiceJob>(() => ({\n data: pagedData.value,\n groupBy: job => groupKeyOf(job, groupField.value),\n groupOrder: GROUP_ORDERS[groupField.value],\n collapsedGroups: collapsedGroups.value,\n onToggleGroup: toggleGroup,\n getRowKey: job => job.id,\n}))\n\nconst sortPlugin = useTableSortable<ServiceJob>(() => ({\n sort: sortConfig.sort,\n onSortChange: sortConfig.onSortChange,\n allowUnsortedState: true,\n isMultiSortEnabled: true,\n}))\n\n/** 冻结边映射到当前显示列的头/尾。 */\nconst stickyStartKeys = computed<string[]>(() => {\n const keys = view.value.columnKeys\n if (view.value.stickyStart === 'one')\n return keys.slice(0, 1)\n if (view.value.stickyStart === 'two')\n return keys.slice(0, 2)\n return []\n})\nconst stickyEndKeys = computed<string[]>(() => {\n const keys = view.value.columnKeys\n if (view.value.stickyEnd === 'one')\n return keys.slice(-1)\n if (view.value.stickyEnd === 'two')\n return keys.slice(-2)\n return []\n})\nconst stickyPlugin = useTableStickyColumns<ServiceJob>(() => ({\n startKeys: stickyStartKeys.value,\n endKeys: stickyEndKeys.value,\n}))\n\n// ============= 无限加载 =============\n\nconst PAGE_SIZE = 15\nconst SKELETON_ROWS = 5\n\nconst loadedCount = ref(PAGE_SIZE)\nconst isLoadingNext = ref(false)\nlet nextTimer: ReturnType<typeof setTimeout> | undefined\nlet inFlight = false\n\n/** 分组时把分页切点补齐到当前组末尾,不把一个组截成两半。 */\nconst visibleCount = computed(() => {\n const items = groupMajorData.value\n if (loadedCount.value >= items.length)\n return items.length\n if (!isGrouped.value)\n return loadedCount.value\n const openKey = groupKeyOf(items[loadedCount.value - 1], groupField.value)\n let end = loadedCount.value\n while (end < items.length && groupKeyOf(items[end], groupField.value) === openKey)\n end++\n return end\n})\n\nconst pagedData = computed(() => groupMajorData.value.slice(0, visibleCount.value))\n\nwatch(groupMajorData, () => {\n loadedCount.value = PAGE_SIZE\n isLoadingNext.value = false\n})\n\nfunction loadNext(count: number): void {\n if (inFlight)\n return\n inFlight = true\n isLoadingNext.value = true\n clearTimeout(nextTimer)\n nextTimer = setTimeout(() => {\n loadedCount.value = visibleCount.value + count\n isLoadingNext.value = false\n inFlight = false\n }, 500)\n}\nonBeforeUnmount(() => clearTimeout(nextTimer))\n\nconst hasNext = computed(() => visibleCount.value < sortedData.value.length)\n\nconst sentinelRef = ref<HTMLElement | null>(null)\nlet sentinelScrollRoot: HTMLElement | null = null\n\n/** 哨兵的滚动容器:页面滚就是 document,内容区内部滚就是那个容器。 */\nfunction scrollRootOf(el: HTMLElement | null): HTMLElement | null {\n let node = el?.parentElement ?? null\n while (node != null) {\n const overflowY = getComputedStyle(node).overflowY\n if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight)\n return node\n node = node.parentElement\n }\n return null\n}\n\n/** 哨兵距滚动容器底部不足 240px 就取下一批,和原版的 IntersectionObserver 同语义。 */\nfunction checkSentinel(): void {\n const el = sentinelRef.value\n if (el == null || !hasNext.value)\n return\n const elRect = el.getBoundingClientRect()\n const rootRect = sentinelScrollRoot == null\n ? { top: 0, bottom: window.innerHeight }\n : sentinelScrollRoot.getBoundingClientRect()\n if (elRect.top - rootRect.bottom < 240)\n loadNext(PAGE_SIZE)\n}\n\nfunction bindSentinel(el: HTMLElement | null): void {\n if (sentinelScrollRoot != null && sentinelScrollRoot !== document.scrollingElement) {\n sentinelScrollRoot.removeEventListener('scroll', checkSentinel)\n sentinelScrollRoot = null\n }\n window.removeEventListener('scroll', checkSentinel)\n window.removeEventListener('resize', checkSentinel)\n if (el == null)\n return\n sentinelScrollRoot = scrollRootOf(el)\n const root = sentinelScrollRoot ?? document.scrollingElement\n if (root != null && root !== document.scrollingElement)\n root.addEventListener('scroll', checkSentinel, { passive: true })\n window.addEventListener('scroll', checkSentinel, { passive: true })\n window.addEventListener('resize', checkSentinel)\n checkSentinel()\n}\n\nonMounted(() => bindSentinel(sentinelRef.value))\n\nwatch(sentinelRef, el => bindSentinel(el))\n\nconst tableRows = computed(() => (isGrouped.value ? groupedRows.value : pagedData.value))\nconst tableIdKey = computed(() => (isGrouped.value ? groupedIdKey : 'id'))\n\nconst tablePlugins = computed<Record<string, TablePlugin<ServiceJob>>>(() => {\n const plugins: Record<string, TablePlugin<ServiceJob>> = {\n selection: selectionPlugin,\n sortable: sortPlugin,\n sticky: stickyPlugin,\n activate: rowActivationPlugin,\n }\n if (isGrouped.value)\n plugins.grouped = groupedPlugin\n return plugins\n})\n\n// ============= 行激活(详情面板) =============\n\nconst activeJobId = ref<string | null>(null)\nconst activeJob = computed(() => allJobs.find(j => j.id === activeJobId.value) ?? null)\nconst siteHistory = computed(() => {\n if (activeJob.value == null)\n return []\n return allJobs\n .filter(j => j.customer === activeJob.value!.customer && j.id !== activeJob.value!.id)\n .sort((a, b) => b.scheduledAt.localeCompare(a.scheduledAt))\n})\n\n/** 点到行内控件不算点中行本身。 */\nconst INTERACTIVE_WITHIN_ROW = 'button, input, a, label, [role=\"checkbox\"], [role=\"button\"], [role=\"menuitem\"]'\n\n/** core 内部的选择列 key。 */\nconst SELECTION_COLUMN_WIDTH = 48\n\nconst rowActivationPlugin: TablePlugin<ServiceJob> = {\n rowPatch(item) {\n if (isTableGroupHeader(item))\n return null\n return {\n class: 'tf-row',\n attrs: { 'aria-current': item.id === activeJobId.value ? 'true' : undefined },\n onClick: (event) => {\n if ((event.target as HTMLElement | null)?.closest(INTERACTIVE_WITHIN_ROW))\n return\n activeJobId.value = item.id\n },\n }\n },\n cellPatch({ column, row, kind }) {\n // 选择列默认 36px,勾选框离表格边 6px;原版模板把它加宽到 48px,让勾选框落在 16px 的内容边距上。\n // core 里选择列的 36px 写在 CSS 类上,行内样式才压得过。\n if (column.key === '__select') {\n const width = `${SELECTION_COLUMN_WIDTH}px`\n return { style: { width, minWidth: width, maxWidth: width } }\n }\n if (kind !== 'body' || row == null || isTableGroupHeader(row) || row.id !== activeJobId.value)\n return null\n return { style: { backgroundColor: 'var(--astryx-color-accent-muted)' } }\n },\n}\n\n/** 详情面板宽度可拖,和原版同档:默认 380,320–560。 */\nconst detailWidth = useResizable({ defaultSize: 380, minSize: 320, maxSize: 560 })\n\n// ============= 保存视图 =============\n\ninterface SavedView {\n id: string\n name: string\n filters: PowerSearchFilter[]\n view: ViewState\n}\n\nconst cloneView = (snapshot: ViewState): ViewState => ({ ...snapshot, columnKeys: [...snapshot.columnKeys] })\n\nconst INITIAL_SAVED_VIEWS: SavedView[] = [\n {\n id: 'needs-dispatch',\n name: 'Needs dispatch',\n filters: [{ field: 'status', operator: 'is', value: { type: 'enum', value: 'scheduled' } }],\n view: cloneView(INITIAL_VIEW),\n },\n {\n id: 'luis-open',\n name: 'Luis’s jobs',\n filters: [{ field: 'technician', operator: 'is', value: { type: 'enum', value: 'Luis Camarena' } }],\n view: { ...cloneView(INITIAL_VIEW), columnKeys: DEFAULT_COLUMN_KEYS.filter(k => k !== 'technician'), grouping: 'status' },\n },\n {\n id: 'high-value',\n name: 'High value',\n filters: [{ field: 'quoted', operator: 'greater_than_or_equal', value: { type: 'integer', value: 1000 } }],\n view: { ...cloneView(INITIAL_VIEW), density: 'compact', stickyEnd: 'one' },\n },\n]\n\n/** 深拷贝一条筛选子句,保持联合类型不被打散。 */\nfunction cloneFilter(filter: PowerSearchFilter): PowerSearchFilter {\n return JSON.parse(JSON.stringify(filter)) as PowerSearchFilter\n}\n\nfunction cloneFilters(list: readonly PowerSearchFilter[]): PowerSearchFilter[] {\n return list.map(cloneFilter)\n}\n\nconst savedViews = ref<SavedView[]>(INITIAL_SAVED_VIEWS.map(saved => ({\n ...saved,\n filters: cloneFilters(saved.filters),\n view: cloneView(saved.view),\n})))\nconst isSavedViewsBarOpen = ref(false)\nconst activeSavedViewId = ref<string | null>(null)\nconst creatingName = ref<string | null>(null)\nconst editing = ref<SavedView | null>(null)\n\n/**\n * 应用一个保存视图:null 是 All,只丢筛选,列配置保持现状。\n * 保存视图带结构化筛选,高级搜索表达不了,所以应用视图时退出高级搜索。\n */\nfunction applySavedView(saved: SavedView | null): void {\n activeSavedViewId.value = saved?.id ?? null\n filters.value = saved ? cloneFilters(saved.filters) : []\n isPowerSearch.value = false\n if (saved == null)\n return\n view.value = cloneView(saved.view)\n}\n\nlet savedViewSeq = 1\nfunction createSavedView(name: string): void {\n const trimmed = name.trim()\n if (trimmed === '')\n return\n const id = `custom-${savedViewSeq++}`\n savedViews.value = [\n ...savedViews.value,\n { id, name: trimmed, filters: cloneFilters(filters.value), view: cloneView(view.value) },\n ]\n activeSavedViewId.value = id\n creatingName.value = null\n isSavedViewsBarOpen.value = true\n}\n\nfunction saveEditedView(saved: SavedView): void {\n savedViews.value = savedViews.value.map(item => (item.id === saved.id ? { ...saved, filters: cloneFilters(saved.filters), view: cloneView(saved.view) } : item))\n editing.value = null\n}\n\nfunction deleteSavedView(id: string): void {\n savedViews.value = savedViews.value.filter(item => item.id !== id)\n if (activeSavedViewId.value === id)\n activeSavedViewId.value = null\n editing.value = null\n}\n\nconst activeSavedView = computed(() => savedViews.value.find(saved => saved.id === activeSavedViewId.value) ?? null)\n\nfunction startEditSavedView(): void {\n const found = activeSavedView.value\n if (found == null)\n return\n editing.value = {\n ...found,\n filters: cloneFilters(found.filters),\n view: cloneView(found.view),\n }\n}\n\nfunction onSavedViewsToggle(next: boolean): void {\n isSavedViewsBarOpen.value = next\n if (next)\n isPowerSearch.value = false\n}\n\n// ============= 保存视图摘要(创建/编辑对话框里用) =============\n\nconst OPERATOR_LABELS: Record<string, string> = {\n is: 'is',\n is_any_of: 'is any of',\n greater_than_or_equal: '≥',\n less_than_or_equal: '≤',\n after: 'after',\n}\n\nfunction filterValueText(filter: PowerSearchFilter): string {\n const raw = (filter.value as { value?: unknown }).value\n const label = (v: unknown) => {\n const keyValue = String(v ?? '')\n return VALUE_LABELS[keyValue] ?? keyValue\n }\n return Array.isArray(raw) ? raw.map(label).join(', ') : label(raw)\n}\n\n/** 一条子句按高级搜索的说法拼成一句,保存视图在任何地方读法一致。 */\nfunction filterTokenLabel(filter: PowerSearchFilter): string {\n const field = fieldDefs.find(f => f.key === filter.field)\n const name = field?.label ?? filter.field\n const operator = OPERATOR_LABELS[filter.operator] ?? filter.operator\n const raw = filter.value as { value?: unknown, unixSeconds?: number }\n let value: string\n if (typeof raw.unixSeconds === 'number') {\n value = formatDate(new Date(raw.unixSeconds * 1000).toISOString(), true)\n }\n else if (Array.isArray(raw.value) && raw.value.length > 1) {\n const [first, ...rest] = raw.value.map(v => VALUE_LABELS[String(v)] ?? String(v))\n value = `${first} +${rest.length}`\n }\n else if (field?.type === 'number' && typeof raw.value === 'number') {\n value = formatMoney(raw.value)\n }\n else {\n value = filterValueText(filter)\n }\n return `${name} ${operator} ${value}`\n}\n\nfunction labelOf(options: { value: string, label: string }[], value: string): string {\n return options.find(option => option.value === value)?.label ?? value\n}\n\n// ============= 批量操作 =============\n\nconst BULK_EDIT_ACTIONS: { key: string, label: string, icon: string }[] = [\n { key: 'assign', label: 'Assign', icon: 'heroicons:user-plus' },\n { key: 'reschedule', label: 'Reschedule', icon: 'heroicons:calendar-days' },\n { key: 'cancel', label: 'Cancel', icon: 'heroicons:no-symbol' },\n]\n\nfunction runBulkAction(key: string): void {\n const ids = selectedJobs.value.map(job => job.id).join(', ')\n if (key === 'assign')\n alert(`Assign technician: ${ids}`)\n else if (key === 'reschedule')\n alert(`Reschedule: ${ids}`)\n else\n alert(`Cancel: ${ids}`)\n}\n\n// ============= 骨架行 =============\n\nconst DENSITY_PADDING: Record<Density, number> = { compact: 8, balanced: 12, spacious: 16 }\n\nfunction skeletonCellStyle(key: string): Record<string, string> {\n const column = COLUMN_DEFS.get(key)\n const width = column?.width as { type?: string, value?: number, minWidth?: number } | undefined\n if (width?.type === 'pixel')\n return { width: `${width.value}px`, flexShrink: '0' }\n if (width?.type === 'proportional')\n return { flex: `${width.value ?? 1} 1 ${width.minWidth ?? 120}px` }\n return { flex: '1 1 120px' }\n}\n\n// ============= 高级搜索切换 =============\n\nconst powerSearchLabel = computed(() =>\n isPowerSearch.value ? 'Switch to filter tokens' : 'Advanced search',\n)\n\n// ============= 选项与工具 =============\n\nconst CUSTOMER_OPTIONS = CUSTOMERS.map(name => ({ value: name, label: name }))\nconst TECHNICIAN_OPTIONS = TECHNICIANS.map(name => ({ value: name, label: name }))\nconst PRIORITY_OPTIONS = (['urgent', 'high', 'normal'] as Priority[]).map(value => ({\n value,\n label: PRIORITY_META[value].label,\n}))\nconst STATUS_OPTIONS = (['scheduled', 'in_progress', 'on_hold', 'overdue', 'completed'] as JobStatus[]).map(value => ({\n value,\n label: STATUS_META[value].label,\n}))\n\n/** 多选的收起文字:第一项加「+N」,和原版一样。 */\nfunction multiLabel(items: { label: string }[]): string {\n return items.length > 1 ? `${items[0].label}, +${items.length - 1}` : items[0].label\n}\n\n/** 筛选行里的每个子句:OverflowList 按下标收起放不下的,面板和行读同一份数组。 */\nconst filterClauses = ['upcoming', 'status', 'customer', 'technician', 'priority', 'quote'].map(key => ({ id: key, label: key }))\n\n/** 搜索框起始位置的放大镜,供 `TextInput` 的 `startIcon` 使用。 */\nfunction SearchIcon() {\n return h(Icon, { icon: 'search', size: 'sm' })\n}\n<\/script>\n\n<template>\n <Layout height=\"fill\" class=\"tf-shell\">\n <template v-if=\"activeJob != null\" #end>\n <ResizeHandle\n :resizable=\"detailWidth.props\"\n is-reversed\n :is-always-visible=\"false\"\n label=\"Resize job details\"\n />\n <LayoutPanel\n :resizable=\"detailWidth.props\"\n has-divider\n :padding=\"0\"\n label=\"Job details\"\n >\n <div class=\"tf-detail\">\n <Section variant=\"transparent\" :padding=\"4\">\n <VStack :gap=\"4\">\n <HStack :gap=\"2\">\n <StackItem size=\"fill\">\n <VStack :gap=\"2\">\n <HStack :gap=\"2\" align=\"center\">\n <Badge\n :variant=\"STATUS_META[activeJob.status].badge\"\n :label=\"STATUS_META[activeJob.status].label\"\n />\n <StatusDot\n :variant=\"PRIORITY_META[activeJob.priority].dot\"\n :label=\"`${PRIORITY_META[activeJob.priority].label} priority`\"\n :is-pulsing=\"activeJob.priority === 'urgent'\"\n />\n <Text type=\"supporting\" color=\"secondary\">\n {{ PRIORITY_META[activeJob.priority].label }} · {{ activeJob.id }}\n </Text>\n </HStack>\n <Heading :level=\"2\">\n {{ activeJob.summary }}\n </Heading>\n </VStack>\n </StackItem>\n <IconButton\n label=\"Close details\"\n tooltip=\"Close details\"\n variant=\"ghost\"\n size=\"sm\"\n @click=\"activeJobId = null\"\n >\n <Icon name=\"heroicons:x-mark\" size=\"sm\" />\n </IconButton>\n </HStack>\n\n <HStack :gap=\"2\">\n <Button :label=\"NEXT_ACTION[activeJob.status]\" size=\"sm\" width=\"100%\" />\n <Button label=\"Edit job\" variant=\"secondary\" size=\"sm\" width=\"100%\" />\n </HStack>\n </VStack>\n </Section>\n\n <Divider />\n\n <Section variant=\"transparent\" :padding=\"4\">\n <MetadataList columns=\"single\" :label=\"{ position: 'start', width: 104 }\">\n <MetadataListItem label=\"Customer\">\n <Text type=\"body\">\n {{ activeJob.customer }}\n </Text>\n </MetadataListItem>\n <MetadataListItem label=\"Site address\">\n <Text type=\"body\">\n {{ activeJob.address }}\n </Text>\n </MetadataListItem>\n <MetadataListItem label=\"Technician\">\n <Text type=\"body\">\n {{ activeJob.technician }}\n </Text>\n </MetadataListItem>\n <MetadataListItem label=\"Scheduled\">\n <Text type=\"body\">\n {{ formatDate(activeJob.scheduledAt, true) }}, {{ formatTime(activeJob.scheduledAt) }}\n </Text>\n </MetadataListItem>\n <MetadataListItem label=\"Equipment\">\n <Text type=\"body\">\n {{ activeJob.equipment }}\n </Text>\n </MetadataListItem>\n <MetadataListItem label=\"Quote\">\n <Text type=\"body\">\n {{ formatMoney(activeJob.quoted) }}\n </Text>\n </MetadataListItem>\n </MetadataList>\n </Section>\n\n <Divider />\n\n <Section variant=\"transparent\" :padding=\"4\">\n <VStack :gap=\"2\">\n <Heading :level=\"3\">\n Site history\n </Heading>\n <Text v-if=\"siteHistory.length === 0\" type=\"supporting\" color=\"secondary\">\n No other visits on file for this site.\n </Text>\n <VStack v-else :gap=\"0\">\n <button\n v-for=\"related in siteHistory\"\n :key=\"related.id\"\n type=\"button\"\n class=\"tf-history-row\"\n @click=\"activeJobId = related.id\"\n >\n <StackItem size=\"fill\">\n <VStack :gap=\"0\">\n <Text type=\"body\">\n {{ related.summary }}\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n {{ formatDate(related.scheduledAt, true) }} · {{ related.technician }}\n </Text>\n </VStack>\n </StackItem>\n <Badge\n :variant=\"STATUS_META[related.status].badge\"\n :label=\"STATUS_META[related.status].label\"\n />\n </button>\n </VStack>\n </VStack>\n </Section>\n </div>\n </LayoutPanel>\n </template>\n\n <template #header>\n <LayoutHeader has-divider label=\"Job filters and table actions\">\n <VStack :gap=\"0\">\n <ProgressBar\n v-if=\"isLoading\"\n label=\"Loading jobs\"\n is-label-hidden\n is-indeterminate\n class=\"tf-progress\"\n />\n\n <Section variant=\"transparent\" :padding=\"4\" class=\"tf-toolbar\">\n <VStack :gap=\"4\">\n <HStack :gap=\"3\" align=\"center\">\n <StackItem size=\"fill\">\n <Heading :level=\"1\">\n Service jobs\n </Heading>\n </StackItem>\n <Button label=\"New job\" variant=\"primary\" />\n </HStack>\n\n <Section v-if=\"selectedCount > 0\" variant=\"muted\" :padding-inline=\"3\" :padding-block=\"1.5\" class=\"tf-bulk\">\n <HStack :gap=\"3\" align=\"center\" wrap=\"wrap\" :min-height=\"32\">\n <StackItem size=\"fill\">\n <HStack :gap=\"1\" align=\"center\">\n <Button\n v-for=\"action in BULK_EDIT_ACTIONS\"\n :key=\"action.key\"\n :label=\"action.label\"\n variant=\"ghost\"\n size=\"sm\"\n @click=\"runBulkAction(action.key)\"\n >\n <template #icon>\n <Icon :name=\"action.icon\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n </StackItem>\n <HStack :gap=\"3\" align=\"center\">\n <Text type=\"body\">\n {{ selectedCount }} {{ selectedCount === 1 ? 'job' : 'jobs' }} selected\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n •\n </Text>\n <Button label=\"Unselect All\" variant=\"ghost\" size=\"sm\" @click=\"unselectAll\" />\n </HStack>\n </HStack>\n </Section>\n\n <HStack v-else :gap=\"3\" align=\"center\" wrap=\"wrap\">\n <StackItem v-if=\"isPowerSearch\" size=\"fill\" class=\"tf-primary\">\n <!-- 高级搜索模式:整行换成 token 输入框,filter 子句写进同一个 filters。 -->\n <PowerSearch\n :config=\"searchConfig\"\n :filters=\"filters\"\n placeholder='Try \"quote > 1000\" or \"status is Overdue\"'\n :result-count=\"`${results.length} ${results.length === 1 ? 'result' : 'results'}`\"\n @update:filters=\"filters = $event\"\n />\n </StackItem>\n <StackItem v-else-if=\"isSavedViewsBarOpen\" size=\"fill\" class=\"tf-primary\">\n <HStack\n :gap=\"2\"\n align=\"center\"\n wrap=\"nowrap\"\n :min-height=\"32\"\n class=\"tf-saved-bar\"\n >\n <Text type=\"label\" class=\"tf-saved-label\">\n Saved views:\n </Text>\n <StackItem size=\"fill\" class=\"tf-saved-slot\">\n <HStack :gap=\"2\" align=\"center\" wrap=\"nowrap\" class=\"tf-saved-scroller\">\n <ToggleButton\n label=\"All\"\n size=\"sm\"\n :is-pressed=\"activeSavedViewId == null\"\n class=\"tf-chrome\"\n :class=\"activeSavedViewId == null ? 'tf-value' : 'tf-empty tf-surface'\"\n @pressed-change=\"applySavedView(null)\"\n >\n <template #icon>\n <Icon name=\"heroicons:bookmark\" size=\"sm\" />\n </template>\n </ToggleButton>\n <ToggleButton\n v-for=\"saved in savedViews\"\n :key=\"saved.id\"\n :label=\"saved.name\"\n size=\"sm\"\n :is-pressed=\"activeSavedViewId === saved.id\"\n class=\"tf-chrome\"\n :class=\"activeSavedViewId === saved.id ? 'tf-value' : 'tf-empty tf-surface'\"\n @pressed-change=\"(next: boolean) => applySavedView(next ? saved : null)\"\n >\n <template #icon>\n <Icon name=\"heroicons:bookmark\" size=\"sm\" />\n </template>\n </ToggleButton>\n <IconButton\n v-if=\"activeSavedView != null\"\n label=\"Edit saved view\"\n tooltip=\"Edit saved view\"\n variant=\"ghost\"\n size=\"sm\"\n class=\"tf-saved-edit\"\n @click=\"startEditSavedView()\"\n >\n <Icon name=\"heroicons:pencil-square\" size=\"sm\" />\n </IconButton>\n </HStack>\n </StackItem>\n </HStack>\n </StackItem>\n <StackItem v-else size=\"fill\" class=\"tf-primary\">\n <HStack :gap=\"2\" align=\"center\" wrap=\"nowrap\" :min-height=\"32\" class=\"tf-filter-row\">\n <StackItem class=\"tf-search\">\n <TextInput\n v-model=\"query\"\n label=\"Search jobs\"\n is-label-hidden\n placeholder=\"Job name\"\n size=\"sm\"\n width=\"100%\"\n :start-icon=\"SearchIcon\"\n />\n </StackItem>\n <StackItem class=\"tf-overflow-slot\">\n <OverflowList :items=\"filterClauses\" behavior=\"observeParent\" :gap=\"2\" :min-visible-items=\"1\">\n <template #item=\"{ item }\">\n <ToggleButton\n v-if=\"item.id === 'upcoming'\"\n :is-pressed=\"isUpcoming\"\n label=\"Upcoming\"\n size=\"sm\"\n class=\"tf-chrome\"\n :class=\"isUpcoming ? 'tf-value' : 'tf-empty tf-surface'\"\n @pressed-change=\"togglePreset(UPCOMING_FILTER)\"\n />\n <MultiSelector\n v-else-if=\"item.id === 'status'\"\n v-model=\"statusFilter\"\n label=\"Status filter\"\n is-label-hidden\n placeholder=\"Status\"\n size=\"sm\"\n has-clear\n trigger-display=\"labels\"\n :format-value=\"multiLabel\"\n :options=\"STATUS_OPTIONS\"\n :control-class=\"statusFilter.length > 0 ? 'tf-fill' : undefined\"\n />\n <MultiSelector\n v-else-if=\"item.id === 'customer'\"\n v-model=\"customerFilter\"\n label=\"Customer filter\"\n is-label-hidden\n placeholder=\"Customer\"\n size=\"sm\"\n has-clear\n trigger-display=\"labels\"\n :format-value=\"multiLabel\"\n :options=\"CUSTOMER_OPTIONS\"\n :control-class=\"customerFilter.length > 0 ? 'tf-fill' : undefined\"\n />\n <MultiSelector\n v-else-if=\"item.id === 'technician'\"\n v-model=\"technicianFilter\"\n label=\"Technician filter\"\n is-label-hidden\n placeholder=\"Technician\"\n size=\"sm\"\n has-clear\n trigger-display=\"labels\"\n :format-value=\"multiLabel\"\n :options=\"TECHNICIAN_OPTIONS\"\n :control-class=\"technicianFilter.length > 0 ? 'tf-fill' : undefined\"\n />\n <Selector\n v-else-if=\"item.id === 'priority'\"\n v-model=\"priorityFilter\"\n label=\"Priority filter\"\n is-label-hidden\n placeholder=\"Priority\"\n size=\"sm\"\n has-clear\n :options=\"PRIORITY_OPTIONS\"\n :control-class=\"priorityFilter != null ? 'tf-fill' : undefined\"\n >\n <template #selected=\"{ option }\">\n {{ option ? `Priority is ${option.label}` : 'Priority' }}\n </template>\n </Selector>\n <ComplexSelector\n v-else\n :model-value=\"quoteDraft\"\n label=\"Quote filter\"\n is-label-hidden\n placeholder=\"Quote\"\n :trigger-label=\"quoteLabel\"\n size=\"sm\"\n :control-class=\"hasQuoteFilter ? 'tf-fill' : undefined\"\n @change=\"commitQuote\"\n >\n <template #default=\"{ commit, close }\">\n <VStack :gap=\"4\" :width=\"268\">\n <VStack :gap=\"0\">\n <Text type=\"label\">\n Quote range\n </Text>\n <Text type=\"large\" has-tabular-numbers>\n {{ formatMoney(quoteDraft[0]) }} – {{ formatMoney(quoteDraft[1]) }}\n </Text>\n </VStack>\n <VStack :gap=\"1\">\n <Slider\n v-model=\"quoteDraft\"\n label=\"Quote range\"\n is-label-hidden\n :min=\"QUOTE_MIN\"\n :max=\"QUOTE_MAX\"\n :step=\"QUOTE_STEP\"\n value-display=\"none\"\n :format-value=\"formatMoney\"\n :min-steps-between-thumbs=\"1\"\n width=\"100%\"\n @change-end=\"commit\"\n />\n <HStack align=\"center\" h-align=\"between\">\n <Text type=\"supporting\" color=\"secondary\">\n {{ formatMoney(QUOTE_MIN) }}\n </Text>\n <Text type=\"supporting\" color=\"secondary\">\n {{ formatMoney(QUOTE_MAX) }}\n </Text>\n </HStack>\n </VStack>\n <Divider />\n <HStack :gap=\"2\" align=\"center\" h-align=\"between\">\n <Button\n label=\"Clear\"\n variant=\"ghost\"\n size=\"sm\"\n :is-disabled=\"!hasQuoteFilter\"\n @click=\"commit([QUOTE_MIN, QUOTE_MAX])\"\n />\n <Button label=\"Done\" variant=\"secondary\" size=\"sm\" @click=\"close\" />\n </HStack>\n </VStack>\n </template>\n </ComplexSelector>\n </template>\n <template #overflow=\"{ items }\">\n <Button\n :label=\"`+${items.length}`\"\n :tooltip=\"`${items.length} more ${items.length === 1 ? 'filter' : 'filters'}`\"\n variant=\"ghost\"\n size=\"sm\"\n class=\"tf-chrome tf-surface\"\n />\n </template>\n </OverflowList>\n <HStack :gap=\"2\" align=\"center\" class=\"tf-meta\">\n <ToggleButton\n :label=\"powerSearchLabel\"\n :tooltip=\"powerSearchLabel\"\n is-icon-only\n size=\"sm\"\n :is-pressed=\"isPowerSearch\"\n @pressed-change=\"isPowerSearch = $event\"\n >\n <template #icon>\n <Icon :name=\"isPowerSearch ? 'heroicons:adjustments-horizontal-solid' : 'heroicons:adjustments-horizontal'\" size=\"sm\" />\n </template>\n </ToggleButton>\n <Text type=\"supporting\" color=\"secondary\">\n {{ results.length }} {{ results.length === 1 ? 'result' : 'results' }}\n </Text>\n <template v-if=\"hasFilters\">\n <Text type=\"supporting\" color=\"secondary\">\n •\n </Text>\n <Text type=\"supporting\">\n <Link color=\"primary\" @click=\"clearAll\">\n Clear all\n </Link>\n </Text>\n </template>\n </HStack>\n </StackItem>\n </HStack>\n </StackItem>\n\n <HStack :gap=\"3\" align=\"center\" class=\"tf-end\">\n <ToggleButton\n v-if=\"isPowerSearch\"\n :label=\"powerSearchLabel\"\n :tooltip=\"powerSearchLabel\"\n is-icon-only\n size=\"sm\"\n :is-pressed=\"isPowerSearch\"\n @pressed-change=\"isPowerSearch = $event\"\n >\n <template #icon>\n <Icon name=\"heroicons:adjustments-horizontal-solid\" size=\"sm\" />\n </template>\n </ToggleButton>\n\n <Popover\n placement=\"below\"\n alignment=\"end\"\n :width=\"660\"\n label=\"View options\"\n >\n <template #content>\n <HStack :gap=\"0\" class=\"tf-view-popover\">\n <Section variant=\"transparent\" :padding=\"1\" :dividers=\"['end']\" class=\"tf-view-rail\">\n <VStack :gap=\"1\">\n <Button\n v-for=\"section in VIEW_SECTIONS\"\n :key=\"section.key\"\n :label=\"section.label\"\n :variant=\"viewSection === section.key ? 'secondary' : 'ghost'\"\n class=\"tf-rail-item\"\n @click=\"viewSection = section.key\"\n >\n <template #icon>\n <Icon :name=\"section.icon\" size=\"sm\" />\n </template>\n </Button>\n </VStack>\n </Section>\n\n <StackItem size=\"fill\" class=\"tf-view-pane\">\n <VStack :gap=\"0\" :padding=\"4\" :padding-block-end=\"3\">\n <Heading :level=\"3\">\n {{ VIEW_SECTIONS.find(s => s.key === viewSection)?.title }}\n </Heading>\n </VStack>\n\n <!-- Columns:两栏穿梭列表,拖动把手排序。 -->\n <VStack v-if=\"viewSection === 'columns'\" :gap=\"0\" class=\"tf-transfer\">\n <HStack :gap=\"0\" class=\"tf-transfer-panels\">\n <VStack :gap=\"0\" class=\"tf-transfer-panel\">\n <HStack\n :gap=\"2\"\n align=\"center\"\n h-align=\"between\"\n :padding-block=\"2\"\n class=\"tf-transfer-head tf-transfer-pad-start\"\n >\n <Text type=\"label\" color=\"secondary\">\n Displayed columns\n </Text>\n <Button\n label=\"Restore\"\n variant=\"ghost\"\n size=\"sm\"\n class=\"tf-transfer-head-action\"\n @click=\"updateView(v => ({ ...v, columnKeys: [...DEFAULT_COLUMN_KEYS] }))\"\n />\n </HStack>\n <VStack :gap=\"0\" class=\"tf-transfer-body\">\n <VStack\n v-if=\"displayedColumns.length === 0\"\n :gap=\"0\"\n align=\"center\"\n h-align=\"center\"\n :padding-block=\"4\"\n class=\"tf-transfer-empty tf-transfer-pad-start\"\n >\n <Text type=\"supporting\" color=\"secondary\">\n No columns are displayed.\n </Text>\n </VStack>\n <template v-else>\n <div\n v-for=\"key in displayedColumns\"\n :key=\"key\"\n :ref=\"columnRowRef(key)\"\n class=\"tf-transfer-row tf-transfer-pad-start\"\n :class=\"{ 'tf-transfer-row--drop': reorderDropKey === key }\"\n >\n <IconButton\n :label=\"`Reorder ${COLUMN_LABELS[key]}`\"\n variant=\"ghost\"\n size=\"sm\"\n class=\"tf-transfer-grip\"\n @pointerdown=\"onGripPointerDown($event, key)\"\n @pointermove=\"onGripPointerMove\"\n @pointerup=\"onGripPointerUp\"\n @pointercancel=\"onGripPointerUp\"\n >\n <Icon name=\"heroicons:grip-vertical\" size=\"sm\" />\n </IconButton>\n <StackItem size=\"fill\">\n <Text type=\"body\">\n {{ COLUMN_LABELS[key] }}\n </Text>\n </StackItem>\n <IconButton\n :label=\"`Remove ${COLUMN_LABELS[key]}`\"\n :tooltip=\"key === LOCKED_COLUMN_KEY ? LOCKED_COLUMN_MESSAGE : undefined\"\n variant=\"ghost\"\n size=\"sm\"\n :is-disabled=\"key === LOCKED_COLUMN_KEY\"\n class=\"tf-transfer-end-action\"\n @click=\"updateView(v => ({ ...v, columnKeys: v.columnKeys.filter(k => k !== key) }))\"\n >\n <Icon name=\"heroicons:x-mark\" size=\"sm\" />\n </IconButton>\n </div>\n </template>\n </VStack>\n </VStack>\n\n <VStack :gap=\"0\" class=\"tf-transfer-panel tf-transfer-panel--divider\">\n <HStack\n :gap=\"2\"\n align=\"center\"\n h-align=\"between\"\n :padding-block=\"2\"\n class=\"tf-transfer-head tf-transfer-pad-end\"\n >\n <Text type=\"label\" color=\"secondary\">\n Available columns\n </Text>\n <Button\n label=\"Select all\"\n variant=\"ghost\"\n size=\"sm\"\n class=\"tf-transfer-head-action\"\n :is-disabled=\"view.columnKeys.length === ALL_COLUMN_KEYS.length\"\n @click=\"updateView(v => ({ ...v, columnKeys: [...v.columnKeys, ...ALL_COLUMN_KEYS.filter(k => !v.columnKeys.includes(k))] }))\"\n />\n </HStack>\n <VStack :gap=\"0\" class=\"tf-transfer-body\">\n <VStack\n v-if=\"availableColumns.length === 0\"\n :gap=\"0\"\n align=\"center\"\n h-align=\"center\"\n :padding-block=\"4\"\n class=\"tf-transfer-empty tf-transfer-pad-end\"\n >\n <Text type=\"supporting\" color=\"secondary\">\n All columns are displayed.\n </Text>\n </VStack>\n <div\n v-for=\"key in availableColumns\"\n :key=\"key\"\n class=\"tf-transfer-row tf-transfer-pad-end\"\n >\n <StackItem size=\"fill\">\n <Text type=\"body\">\n {{ COLUMN_LABELS[key] }}\n </Text>\n </StackItem>\n <IconButton\n :label=\"`Add ${COLUMN_LABELS[key]}`\"\n variant=\"ghost\"\n size=\"sm\"\n class=\"tf-transfer-end-action\"\n @click=\"updateView(v => ({ ...v, columnKeys: [...v.columnKeys, key] }))\"\n >\n <Icon name=\"heroicons:plus\" size=\"sm\" />\n </IconButton>\n </div>\n </VStack>\n </VStack>\n </HStack>\n </VStack>\n\n <VStack v-else-if=\"viewSection === 'density'\" :gap=\"0\" :padding-inline=\"4\" :padding-block-end=\"4\">\n <RadioList v-model=\"view.density\" label=\"Density\" is-label-hidden>\n <RadioListItem\n v-for=\"option in DENSITY_OPTIONS\"\n :key=\"option.value\"\n :value=\"option.value\"\n :label=\"option.label\"\n />\n </RadioList>\n </VStack>\n\n <VStack v-else-if=\"viewSection === 'sticky'\" :gap=\"4\" :padding-inline=\"4\" :padding-block-end=\"4\">\n <RadioList v-model=\"view.stickyStart\" label=\"First columns\">\n <RadioListItem\n v-for=\"option in STICKY_START_OPTIONS\"\n :key=\"option.value\"\n :value=\"option.value\"\n :label=\"option.label\"\n />\n </RadioList>\n <RadioList v-model=\"view.stickyEnd\" label=\"Last columns\">\n <RadioListItem\n v-for=\"option in STICKY_END_OPTIONS\"\n :key=\"option.value\"\n :value=\"option.value\"\n :label=\"option.label\"\n />\n </RadioList>\n </VStack>\n\n <VStack v-else :gap=\"0\" :padding-inline=\"4\" :padding-block-end=\"4\">\n <RadioList v-model=\"view.grouping\" label=\"Grouping\" is-label-hidden>\n <RadioListItem\n v-for=\"option in GROUPING_OPTIONS\"\n :key=\"option.value\"\n :value=\"option.value\"\n :label=\"option.label\"\n />\n </RadioList>\n </VStack>\n </StackItem>\n </HStack>\n </template>\n <Button label=\"View options\" variant=\"ghost\" size=\"sm\">\n <template #end>\n <Icon name=\"heroicons:chevron-down\" size=\"sm\" />\n </template>\n </Button>\n </Popover>\n\n <template v-if=\"!isSavedViewsBarOpen\">\n <IconButton\n label=\"Create saved view\"\n tooltip=\"Create saved view\"\n variant=\"ghost\"\n size=\"sm\"\n @click=\"creatingName = ''\"\n >\n <Icon name=\"heroicons:bookmark\" size=\"sm\" />\n </IconButton>\n </template>\n\n <ToggleButton\n label=\"Saved views\"\n tooltip=\"Saved views\"\n is-icon-only\n size=\"sm\"\n :is-pressed=\"isSavedViewsBarOpen\"\n @pressed-change=\"onSavedViewsToggle\"\n >\n <template #icon>\n <Icon name=\"heroicons:bookmark\" size=\"sm\" />\n </template>\n <template #pressedIcon>\n <Icon name=\"heroicons:bookmark-solid\" size=\"sm\" />\n </template>\n </ToggleButton>\n </HStack>\n </HStack>\n </VStack>\n </Section>\n </VStack>\n </LayoutHeader>\n </template>\n\n <LayoutContent :padding=\"4\" label=\"Service jobs\">\n <EmptyState\n v-if=\"results.length === 0\"\n title=\"No jobs match these filters\"\n description=\"Try clearing a filter, or switch to power search to build a broader query.\"\n >\n <template #icon>\n <Icon name=\"heroicons:magnifying-glass\" size=\"lg\" />\n </template>\n <template #actions>\n <Button label=\"Clear all\" @click=\"clearAll\" />\n <Button label=\"Power search\" variant=\"secondary\" @click=\"isPowerSearch = true\" />\n </template>\n </EmptyState>\n\n <template v-else>\n <Table\n :data=\"tableRows\"\n :columns=\"columns\"\n :id-key=\"tableIdKey\"\n :density=\"view.density\"\n dividers=\"rows\"\n has-hover\n text-overflow=\"wrap\"\n vertical-align=\"top\"\n :row-count=\"results.length\"\n :plugins=\"tablePlugins\"\n />\n\n <div v-if=\"hasNext\" ref=\"sentinelRef\" class=\"tf-sentinel\">\n <div v-if=\"isLoadingNext\" class=\"tf-skeleton\" role=\"status\" aria-label=\"Loading more jobs\">\n <div\n v-for=\"row in SKELETON_ROWS\"\n :key=\"row\"\n class=\"tf-skeleton-row\"\n :style=\"{ paddingInline: `${DENSITY_PADDING[view.density]}px` }\"\n >\n <div class=\"tf-skeleton-cell tf-skeleton-cell--select\">\n <Skeleton :width=\"16\" :height=\"16\" :radius=\"1\" :index=\"row\" />\n </div>\n <div\n v-for=\"(key, index) in view.columnKeys\"\n :key=\"key\"\n class=\"tf-skeleton-cell\"\n :style=\"skeletonCellStyle(key)\"\n >\n <VStack v-if=\"key === 'summary' && view.density !== 'compact'\" :gap=\"2\">\n <Skeleton :height=\"14\" :index=\"row * 4 + index\" />\n <Skeleton width=\"40%\" :height=\"11\" :index=\"row * 4 + index + 1\" />\n </VStack>\n <Skeleton v-else :height=\"12\" :index=\"row * 4 + index\" />\n </div>\n </div>\n </div>\n </div>\n <div v-else-if=\"results.length > PAGE_SIZE\" class=\"tf-loaded\">\n <Text type=\"supporting\" color=\"secondary\">\n All {{ results.length }} jobs loaded\n </Text>\n </div>\n </template>\n </LayoutContent>\n </Layout>\n\n <!-- 创建保存视图 -->\n <Dialog :is-open=\"creatingName != null\" purpose=\"form\" :width=\"400\" @open-change=\"creatingName = $event ? '' : null\">\n <DialogHeader\n title=\"Create new saved view\"\n subtitle=\"Captures the filters and the table configuration as they are now.\"\n has-divider\n @open-change=\"creatingName = $event ? '' : null\"\n />\n <Section variant=\"transparent\" :padding=\"4\">\n <VStack :gap=\"4\">\n <TextInput :model-value=\"creatingName ?? ''\" label=\"Name\" has-auto-focus @update:model-value=\"creatingName = $event\" />\n <VStack :gap=\"1\">\n <Text type=\"label\">\n This view saves\n </Text>\n <MetadataList columns=\"single\" :label=\"{ position: 'start', width: 104 }\">\n <MetadataListItem label=\"Filters\">\n <Text v-if=\"filters.length === 0\" type=\"body\">\n None\n </Text>\n <HStack v-else :gap=\"1\" wrap=\"wrap\">\n <Token\n v-for=\"(filter, index) in filters\"\n :key=\"`${filter.field}-${filter.operator}-${index}`\"\n :label=\"filterTokenLabel(filter)\"\n :description=\"`${filter.field} ${filter.operator} ${filterValueText(filter)}`\"\n size=\"sm\"\n />\n </HStack>\n </MetadataListItem>\n <MetadataListItem label=\"Columns\">\n <Text type=\"body\">\n {{ view.columnKeys.length }} of {{ ALL_COLUMN_KEYS.length }}\n </Text>\n </MetadataListItem>\n <MetadataListItem label=\"Density\">\n <Text type=\"body\">\n {{ labelOf(DENSITY_OPTIONS, view.density) }}\n </Text>\n </MetadataListItem>\n <MetadataListItem label=\"Grouping\">\n <Text type=\"body\">\n {{ labelOf(GROUPING_OPTIONS, view.grouping) }}\n </Text>\n </MetadataListItem>\n <MetadataListItem label=\"Frozen start\">\n <Text type=\"body\">\n {{ labelOf(STICKY_START_OPTIONS, view.stickyStart) }}\n </Text>\n </MetadataListItem>\n <MetadataListItem label=\"Frozen end\">\n <Text type=\"body\">\n {{ labelOf(STICKY_END_OPTIONS, view.stickyEnd) }}\n </Text>\n </MetadataListItem>\n </MetadataList>\n </VStack>\n <HStack h-align=\"end\">\n <Button\n label=\"Create\"\n variant=\"primary\"\n :is-disabled=\"(creatingName ?? '').trim() === ''\"\n @click=\"createSavedView(creatingName ?? '')\"\n />\n </HStack>\n </VStack>\n </Section>\n </Dialog>\n\n <!-- 编辑保存视图(只改名,配置是保存时定格的) -->\n <Dialog :is-open=\"editing != null\" purpose=\"form\" :width=\"400\" @open-change=\"!$event && (editing = null)\">\n <DialogHeader\n title=\"Edit saved view\"\n subtitle=\"Renaming only. The configuration is what was captured when the view was saved.\"\n has-divider\n @open-change=\"!$event && (editing = null)\"\n />\n <Section v-if=\"editing != null\" variant=\"transparent\" :padding=\"4\">\n <VStack :gap=\"4\">\n <TextInput v-model=\"editing.name\" label=\"Name\" />\n <VStack :gap=\"1\">\n <Text type=\"label\">\n This view saves\n </Text>\n <MetadataList columns=\"single\" :label=\"{ position: 'start', width: 104 }\">\n <MetadataListItem label=\"Filters\">\n <Text v-if=\"editing.filters.length === 0\" type=\"body\">\n None\n </Text>\n <HStack v-else :gap=\"1\" wrap=\"wrap\">\n <Token\n v-for=\"(filter, index) in editing.filters\"\n :key=\"`${filter.field}-${filter.operator}-${index}`\"\n :label=\"filterTokenLabel(filter)\"\n :description=\"`${filter.field} ${filter.operator} ${filterValueText(filter)}`\"\n size=\"sm\"\n />\n </HStack>\n </MetadataListItem>\n <MetadataListItem label=\"Columns\">\n <Text type=\"body\">\n {{ editing.view.columnKeys.length }} of {{ ALL_COLUMN_KEYS.length }}\n </Text>\n </MetadataListItem>\n <MetadataListItem label=\"Density\">\n <Text type=\"body\">\n {{ labelOf(DENSITY_OPTIONS, editing.view.density) }}\n </Text>\n </MetadataListItem>\n <MetadataListItem label=\"Grouping\">\n <Text type=\"body\">\n {{ labelOf(GROUPING_OPTIONS, editing.view.grouping) }}\n </Text>\n </MetadataListItem>\n <MetadataListItem label=\"Frozen start\">\n <Text type=\"body\">\n {{ labelOf(STICKY_START_OPTIONS, editing.view.stickyStart) }}\n </Text>\n </MetadataListItem>\n <MetadataListItem label=\"Frozen end\">\n <Text type=\"body\">\n {{ labelOf(STICKY_END_OPTIONS, editing.view.stickyEnd) }}\n </Text>\n </MetadataListItem>\n </MetadataList>\n </VStack>\n <HStack :gap=\"2\" align=\"center\">\n <Button label=\"Delete\" variant=\"destructive\" @click=\"deleteSavedView(editing.id)\" />\n <StackItem size=\"fill\">\n <HStack h-align=\"end\">\n <Button label=\"Save\" variant=\"primary\" @click=\"saveEditedView(editing)\" />\n </HStack>\n </StackItem>\n </HStack>\n </VStack>\n </Section>\n </Dialog>\n</template>\n\n<style scoped>\n/* 原版 Table 默认 `table-layout: fixed`,列宽严格按声明的比例分;core 目前是 auto,列宽按内容算。 */\n.tf-shell :deep(.astryx-table) {\n table-layout: fixed;\n}\n\n.tf-shell {\n background: var(--astryx-color-background-surface);\n}\n\n.tf-progress {\n position: absolute;\n inset-inline: 0;\n inset-block-start: 0;\n}\n\n.tf-toolbar {\n container: toolbar / inline-size;\n}\n\n.tf-primary {\n flex-basis: 0;\n}\n\n@container toolbar (max-width: 860px) {\n .tf-primary {\n flex-basis: 100%;\n }\n}\n\n.tf-end {\n margin-inline-start: auto;\n}\n\n.tf-filter-row {\n row-gap: 6px;\n}\n\n.tf-search {\n flex-shrink: 1;\n width: 120px;\n min-width: 120px;\n transition: width var(--astryx-duration-fast) var(--astryx-ease-standard);\n}\n\n.tf-search:focus-within {\n width: 240px;\n}\n\n.tf-overflow-slot {\n display: flex;\n flex-grow: 1;\n flex-shrink: 100;\n align-items: center;\n min-width: 228px;\n padding-inline-end: 184px;\n}\n\n.tf-meta {\n flex-shrink: 0;\n margin-inline: 8px -184px;\n}\n\n.tf-chrome {\n border: var(--astryx-border-width) solid var(--astryx-color-border-emphasized);\n border-radius: var(--astryx-radius-element);\n}\n\n.tf-surface {\n background-color: var(--astryx-color-background-surface);\n}\n\n.tf-value {\n font-weight: var(--astryx-font-weight-normal);\n}\n\n.tf-empty {\n font-weight: var(--astryx-font-weight-normal);\n color: var(--astryx-color-text-secondary);\n}\n\n.tf-fill {\n background-color: var(--astryx-color-overlay-pressed);\n}\n\n/* 批量操作条:弱化底、上下 6px 内边距。 */\n.tf-bulk {\n border-radius: var(--astryx-radius-element);\n}\n\n/* 保存视图条:标签留在滚动区外,chips 横向滚动。 */\n.tf-saved-bar {\n min-width: 0;\n}\n\n.tf-saved-label {\n flex-shrink: 0;\n}\n\n.tf-saved-slot {\n min-width: 0;\n}\n\n.tf-saved-scroller {\n overflow-x: auto;\n}\n\n/* 视图选项弹层:左侧栏 + 右侧面板。 */\n.tf-view-popover {\n min-height: 280px;\n}\n\n.tf-view-rail {\n flex-shrink: 0;\n}\n\n.tf-rail-item {\n justify-content: start;\n width: 100%;\n}\n\n.tf-view-pane {\n min-width: 0;\n}\n\n.tf-transfer {\n min-height: 0;\n}\n\n.tf-transfer-panels {\n min-height: 0;\n}\n\n.tf-transfer-panel {\n flex: 1 1 0;\n min-width: 0;\n}\n\n.tf-transfer-panel--divider {\n border-inline-start: var(--astryx-border-width) solid var(--astryx-color-border);\n}\n\n.tf-transfer-head {\n padding-inline: 16px;\n}\n\n.tf-transfer-pad-end {\n padding-inline: 16px;\n}\n\n.tf-transfer-head-action {\n flex-shrink: 0;\n}\n\n.tf-transfer-body {\n min-height: 120px;\n}\n\n.tf-transfer-empty {\n flex: 1;\n}\n\n.tf-transfer-row {\n display: flex;\n gap: var(--astryx-spacing-1);\n align-items: center;\n min-height: 32px;\n}\n\n.tf-transfer-row--drop {\n box-shadow: inset 0 2px 0 0 var(--astryx-color-accent);\n}\n\n.tf-transfer-grip {\n cursor: grab;\n touch-action: none;\n}\n\n.tf-transfer-end-action {\n flex-shrink: 0;\n}\n\n/* 详情面板:分区自带 16px gutter,分隔线拉满宽。 */\n.tf-detail {\n height: 100%;\n overflow-y: auto;\n}\n\n.tf-history-row {\n display: flex;\n gap: var(--astryx-spacing-2);\n align-items: center;\n width: 100%;\n padding: var(--astryx-spacing-2);\n font: inherit;\n text-align: start;\n cursor: pointer;\n background: none;\n border: none;\n border-radius: var(--astryx-radius-element);\n}\n\n.tf-history-row:hover {\n background-color: var(--astryx-color-overlay-hover);\n}\n\n/* 无限加载:哨兵给足高度,骨架行按列宽铺满。 */\n.tf-sentinel {\n min-height: 56px;\n}\n\n.tf-skeleton-row {\n display: flex;\n gap: 0;\n align-items: center;\n}\n\n.tf-skeleton-cell {\n display: flex;\n align-items: center;\n min-width: 0;\n padding-block: 14px;\n}\n\n.tf-skeleton-cell--select {\n width: 48px;\n flex-shrink: 0;\n justify-content: center;\n}\n\n.tf-loaded {\n display: flex;\n align-items: center;\n justify-content: center;\n min-height: 56px;\n}\n\n/* 整行可点。 */\n.tf-row {\n cursor: pointer;\n}\n</style>\n",
79309
79688
  "docsUrl": {
79310
79689
  "en": "https://astryx-vue.pages.dev/en/playground/?template=table-filter",
79311
79690
  "zh": "https://astryx-vue.pages.dev/playground/?template=table-filter"
@@ -79348,6 +79727,7 @@ const catalog = {
79348
79727
  "Avatar",
79349
79728
  "Badge",
79350
79729
  "Button",
79730
+ "Center",
79351
79731
  "Dialog",
79352
79732
  "DialogHeader",
79353
79733
  "Divider",
@@ -79355,6 +79735,7 @@ const catalog = {
79355
79735
  "Heading",
79356
79736
  "HStack",
79357
79737
  "Icon",
79738
+ "IconButton",
79358
79739
  "Layout",
79359
79740
  "LayoutContent",
79360
79741
  "LayoutFooter",
@@ -79362,20 +79743,25 @@ const catalog = {
79362
79743
  "LayoutPanel",
79363
79744
  "MetadataList",
79364
79745
  "MetadataListItem",
79746
+ "PowerSearch",
79365
79747
  "Popover",
79366
79748
  "RadioList",
79367
79749
  "RadioListItem",
79368
79750
  "ResizeHandle",
79751
+ "Selector",
79369
79752
  "StatusDot",
79370
79753
  "StackItem",
79371
79754
  "Table",
79755
+ "TableBody",
79756
+ "TableCell",
79757
+ "TableRow",
79372
79758
  "Text",
79373
79759
  "TextInput",
79374
79760
  "VStack"
79375
79761
  ],
79376
79762
  "dependencies": ["@astryx-vue/core", "vue"],
79377
79763
  "isReady": true,
79378
- "source": "<script setup lang=\"ts\">\nimport {\n Avatar,\n Badge,\n Button,\n Dialog,\n DialogHeader,\n Divider,\n DropdownMenu,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n LayoutFooter,\n LayoutHeader,\n LayoutPanel,\n MetadataList,\n MetadataListItem,\n pixel,\n Popover,\n proportional,\n RadioList,\n RadioListItem,\n ResizeHandle,\n StatusDot,\n StackItem,\n Table,\n Text,\n TextInput,\n useResizable,\n VStack,\n} from '@astryx-vue/core'\nimport type { TableColumn } from '@astryx-vue/core'\nimport { computed, h, ref, watch } from 'vue'\n\ntype TaskStatus = 'in_progress' | 'todo' | 'backlog' | 'done'\ntype TaskPriority = 'urgent' | 'high' | 'medium' | 'low' | 'none'\n\ninterface TaskRow extends Record<string, unknown> {\n id: string\n taskId: string\n title: string\n subtitle: string\n status: TaskStatus\n priority: TaskPriority\n project: string\n tags: string[]\n created: string\n updated: string\n assignee: string\n}\n\nconst ALL_TASKS: TaskRow[] = [\n { id: '1', taskId: 'T235040469', title: 'Update user interface', subtitle: 'Update payment gateway integration', status: 'in_progress', priority: 'medium', project: 'Payment gateway integration 2.0', tags: [], created: 'Jul 3', updated: 'Jul 3', assignee: 'Olivia Martin' },\n { id: '2', taskId: 'T235040470', title: 'Use Projects to organize work for features or releases', subtitle: '', status: 'in_progress', priority: 'medium', project: '', tags: [], created: 'Jul 1', updated: 'Jul 3', assignee: 'Jackson Lee' },\n { id: '4', taskId: 'T235040472', title: 'Testing code', subtitle: 'Update payment gateway integration', status: 'todo', priority: 'medium', project: 'Payment gateway integration 2.0', tags: [], created: 'Jul 3', updated: 'Jul 3', assignee: 'William Kim' },\n { id: '7', taskId: 'T235040475', title: 'Update payment gateway integration', subtitle: '', status: 'todo', priority: 'high', project: 'Payment gateway integration 2.0', tags: ['Improvement', '3rd Party'], created: 'Jul 3', updated: 'Jul 3', assignee: 'Lucas Brown' },\n { id: '9', taskId: 'T235040477', title: 'Invite your teammates', subtitle: '', status: 'todo', priority: 'low', project: '', tags: [], created: 'Jul 1', updated: 'Jul 1', assignee: 'Ava Taylor' },\n { id: '11', taskId: 'T235040479', title: 'Welcome to Linear', subtitle: '', status: 'backlog', priority: 'none', project: '', tags: [], created: 'Jul 1', updated: 'Jul 3', assignee: 'Olivia Martin' },\n { id: '14', taskId: 'T235040482', title: 'Try 3 ways to navigate: Command menu, keyboard or mouse', subtitle: '', status: 'done', priority: 'none', project: '', tags: [], created: 'Jul 1', updated: 'Jul 3', assignee: 'William Kim' },\n { id: '16', taskId: 'T235040484', title: 'Migrate database schema to v2', subtitle: 'Payment gateway integration', status: 'in_progress', priority: 'high', project: 'Payment gateway integration 2.0', tags: [], created: 'Jul 4', updated: 'Jul 5', assignee: 'Lucas Brown' },\n { id: '19', taskId: 'T235040487', title: 'Add rate limiting to public API endpoints', subtitle: '', status: 'todo', priority: 'urgent', project: '', tags: [], created: 'Jul 5', updated: 'Jul 5', assignee: 'Noah Garcia' },\n { id: '24', taskId: 'T235040492', title: 'Add dark mode support to dashboard', subtitle: '', status: 'backlog', priority: 'low', project: '', tags: [], created: 'Jul 2', updated: 'Jul 3', assignee: 'Sofia Davis' },\n { id: '28', taskId: 'T235040496', title: 'Write API documentation for v2 endpoints', subtitle: '', status: 'done', priority: 'medium', project: '', tags: [], created: 'Jun 28', updated: 'Jul 3', assignee: 'Noah Garcia' },\n]\n\nconst STATUS_LABEL: Record<TaskStatus, string> = {\n in_progress: 'In Progress',\n todo: 'Todo',\n backlog: 'Backlog',\n done: 'Done',\n}\n\nconst STATUS_DOT: Record<TaskStatus, 'info' | 'warning' | 'neutral' | 'success'> = {\n in_progress: 'info',\n todo: 'warning',\n backlog: 'neutral',\n done: 'success',\n}\n\nconst PRIORITY_LABEL: Record<TaskPriority, string> = {\n urgent: 'Urgent',\n high: 'High',\n medium: 'Medium',\n low: 'Low',\n none: 'None',\n}\n\nconst PRIORITY_COLOR: Record<TaskPriority, 'primary' | 'secondary' | 'disabled'> = {\n urgent: 'primary',\n high: 'primary',\n medium: 'secondary',\n low: 'disabled',\n none: 'disabled',\n}\n\nconst GROUP_ORDER: TaskStatus[] = ['in_progress', 'todo', 'backlog', 'done']\n\ntype GroupByField = 'status' | 'priority' | 'project' | 'assignee' | 'none'\n\nconst GROUP_BY_OPTIONS: { value: GroupByField, label: string }[] = [\n { value: 'none', label: 'None' },\n { value: 'status', label: 'Status' },\n { value: 'priority', label: 'Priority' },\n { value: 'project', label: 'Project' },\n { value: 'assignee', label: 'Assignee' },\n]\n\nfunction groupLabel(field: GroupByField, key: string): string {\n if (field === 'status')\n return STATUS_LABEL[key as TaskStatus] ?? key\n if (field === 'priority') {\n const labels: Record<string, string> = {\n urgent: 'Urgent',\n high: 'High',\n medium: 'Medium',\n low: 'Low',\n none: 'No priority',\n }\n return labels[key] ?? key\n }\n return key\n}\n\nconst query = ref('')\nconst groupBy = ref<GroupByField>('status')\nconst expandedGroups = ref<Set<string>>(new Set(GROUP_ORDER))\nconst selectedTask = ref<TaskRow | null>(null)\nconst dialogOpen = ref(false)\nconst newIssueTitle = ref('')\n\nconst detailPanel = useResizable({\n defaultSize: 360,\n minSize: 280,\n maxSize: 500,\n})\n\nconst filtered = computed(() => {\n const q = query.value.trim().toLowerCase()\n if (!q)\n return ALL_TASKS\n return ALL_TASKS.filter(\n t =>\n t.title.toLowerCase().includes(q)\n || t.taskId.toLowerCase().includes(q)\n || t.subtitle.toLowerCase().includes(q),\n )\n})\n\nconst grouped = computed(() => {\n const map = new Map<string, TaskRow[]>()\n if (groupBy.value === 'none') {\n map.set('All', filtered.value)\n return map\n }\n for (const task of filtered.value) {\n const key = String(task[groupBy.value]) || '—'\n const list = map.get(key) ?? []\n list.push(task)\n map.set(key, list)\n }\n return map\n})\n\nconst groupKeys = computed(() => Array.from(grouped.value.keys()))\n\nwatch(groupKeys, (keys) => {\n expandedGroups.value = new Set(keys)\n}, { immediate: true })\n\nfunction toggleGroup(key: string): void {\n const next = new Set(expandedGroups.value)\n if (next.has(key))\n next.delete(key)\n else\n next.add(key)\n expandedGroups.value = next\n}\n\nconst columns: TableColumn<TaskRow>[] = [\n {\n key: 'status',\n header: '',\n width: pixel(44),\n renderCell: item => h(StatusDot, { variant: STATUS_DOT[item.status], label: STATUS_LABEL[item.status] }),\n },\n {\n key: 'title',\n header: 'Issue',\n width: proportional(1),\n renderCell: item => h(HStack, { gap: 3, vAlign: 'center', style: { cursor: 'pointer' }, onClick: () => onRowActivate(item) }, () => [\n h(Icon, { icon: 'i-heroicons-chart-bar-solid', size: 'sm', color: PRIORITY_COLOR[item.priority] }),\n h(Text, { type: 'supporting', color: 'secondary' }, () => item.taskId),\n h(Text, { type: 'body', maxLines: 1 }, () => item.title),\n item.subtitle\n ? h(Text, { type: 'body', color: 'secondary', maxLines: 1 }, () => `› ${item.subtitle}`)\n : null,\n ]),\n },\n {\n key: 'project',\n header: 'Project',\n width: pixel(180),\n renderCell: item => item.project\n ? h(Text, { type: 'body', maxLines: 1 }, () => item.project)\n : h(Text, { type: 'supporting', color: 'secondary' }, () => '—'),\n },\n { key: 'created', header: 'Created', width: pixel(72), renderCell: item => h(Text, { type: 'supporting', color: 'secondary' }, () => item.created) },\n { key: 'updated', header: 'Updated', width: pixel(72), renderCell: item => h(Text, { type: 'supporting', color: 'secondary' }, () => item.updated) },\n {\n key: 'assignee',\n header: 'Assignee',\n width: pixel(52),\n renderCell: item => h(Avatar, { name: item.assignee, size: 'sm' }),\n },\n {\n key: 'actions',\n header: '',\n width: pixel(56),\n renderCell: () => h(DropdownMenu, {\n button: { label: 'Actions', variant: 'ghost', size: 'sm', isIconOnly: true },\n hasChevron: false,\n items: [{ label: 'Edit issue' }, { label: 'Duplicate' }, { type: 'divider' }, { label: 'Delete issue' }],\n }),\n },\n]\n\nfunction onRowActivate(task: TaskRow): void {\n selectedTask.value = task\n}\n<\/script>\n\n<template>\n <Layout height=\"fill\">\n <template #header>\n <LayoutHeader has-divider :padding=\"4\">\n <VStack :gap=\"4\">\n <HStack :gap=\"3\" align=\"center\">\n <StackItem size=\"fill\">\n <Heading :level=\"1\">\n All Issues\n </Heading>\n </StackItem>\n <Button label=\"Create issue\" variant=\"primary\" size=\"lg\" @click=\"dialogOpen = true\" />\n </HStack>\n <HStack :gap=\"2\" align=\"center\">\n <StackItem size=\"fill\">\n <TextInput\n v-model=\"query\"\n label=\"Filter issues\"\n is-label-hidden\n placeholder=\"Filter issues…\"\n />\n </StackItem>\n <Text type=\"supporting\" color=\"secondary\">\n {{ filtered.length }} issue{{ filtered.length === 1 ? '' : 's' }}\n </Text>\n <Popover\n placement=\"below\"\n alignment=\"end\"\n :width=\"320\"\n label=\"Grouping options\"\n >\n <template #content>\n <VStack :gap=\"4\">\n <RadioList v-model=\"groupBy\" label=\"Group by\">\n <RadioListItem\n v-for=\"opt in GROUP_BY_OPTIONS\"\n :key=\"opt.value\"\n :value=\"opt.value\"\n :label=\"opt.label\"\n />\n </RadioList>\n </VStack>\n </template>\n <Button label=\"View options\" variant=\"secondary\" size=\"md\" />\n </Popover>\n </HStack>\n </VStack>\n </LayoutHeader>\n </template>\n\n <LayoutContent :padding=\"0\" role=\"main\">\n <VStack :gap=\"0\">\n <template v-for=\"key in groupKeys\" :key=\"key\">\n <button\n v-if=\"groupBy !== 'none'\"\n type=\"button\"\n class=\"group-header\"\n @click=\"toggleGroup(key)\"\n >\n <HStack :gap=\"2\" align=\"center\">\n <Icon\n :icon=\"expandedGroups.has(key) ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'\"\n size=\"sm\"\n color=\"secondary\"\n />\n <Text weight=\"bold\">\n {{ groupLabel(groupBy, key) }}\n </Text>\n <Badge variant=\"neutral\" :label=\"String(grouped.get(key)?.length ?? 0)\" />\n </HStack>\n </button>\n <Table\n v-if=\"groupBy === 'none' || expandedGroups.has(key)\"\n :data=\"grouped.get(key) ?? []\"\n :columns=\"columns\"\n id-key=\"id\"\n density=\"balanced\"\n dividers=\"rows\"\n text-overflow=\"truncate\"\n has-hover\n />\n </template>\n </VStack>\n </LayoutContent>\n\n <template v-if=\"selectedTask\" #end>\n <ResizeHandle :resizable=\"detailPanel.props\" is-reversed :is-always-visible=\"false\" />\n <LayoutPanel\n has-divider\n :resizable=\"detailPanel.props\"\n :padding=\"4\"\n role=\"complementary\"\n label=\"Task details\"\n >\n <VStack :gap=\"4\">\n <HStack :gap=\"2\" align=\"center\">\n <StackItem size=\"fill\">\n <Text type=\"supporting\" color=\"secondary\">\n {{ selectedTask.taskId }}\n </Text>\n </StackItem>\n <Button label=\"Close panel\" variant=\"ghost\" size=\"sm\" is-icon-only @click=\"selectedTask = null\">\n <template #icon>\n <Icon icon=\"i-heroicons-x-mark\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n <VStack :gap=\"1\">\n <Heading :level=\"3\">\n {{ selectedTask.title }}\n </Heading>\n <Text v-if=\"selectedTask.subtitle\" type=\"body\" color=\"secondary\">\n {{ selectedTask.subtitle }}\n </Text>\n </VStack>\n <MetadataList :label=\"{ position: 'start' }\">\n <MetadataListItem label=\"Status\">\n <HStack :gap=\"2\" align=\"center\">\n <StatusDot :variant=\"STATUS_DOT[selectedTask.status]\" :label=\"STATUS_LABEL[selectedTask.status]\" />\n <Text>{{ STATUS_LABEL[selectedTask.status] }}</Text>\n </HStack>\n </MetadataListItem>\n <MetadataListItem label=\"Priority\">\n <HStack :gap=\"2\" align=\"center\">\n <Icon icon=\"i-heroicons-chart-bar-solid\" size=\"sm\" :color=\"PRIORITY_COLOR[selectedTask.priority]\" />\n <Text>{{ PRIORITY_LABEL[selectedTask.priority] }}</Text>\n </HStack>\n </MetadataListItem>\n <MetadataListItem label=\"Assignee\">\n <HStack :gap=\"2\" align=\"center\">\n <Avatar :name=\"selectedTask.assignee\" size=\"sm\" />\n <Text>{{ selectedTask.assignee }}</Text>\n </HStack>\n </MetadataListItem>\n <MetadataListItem label=\"Project\">\n {{ selectedTask.project || '—' }}\n </MetadataListItem>\n <MetadataListItem label=\"Created\">\n {{ selectedTask.created }}\n </MetadataListItem>\n <MetadataListItem label=\"Updated\">\n {{ selectedTask.updated }}\n </MetadataListItem>\n </MetadataList>\n <template v-if=\"selectedTask.tags.length > 0\">\n <Divider />\n <VStack :gap=\"2\">\n <Text type=\"label\">\n Labels\n </Text>\n <HStack :gap=\"2\">\n <Badge v-for=\"tag in selectedTask.tags\" :key=\"tag\" variant=\"neutral\" :label=\"tag\" />\n </HStack>\n </VStack>\n </template>\n </VStack>\n </LayoutPanel>\n </template>\n </Layout>\n\n <Dialog v-model:is-open=\"dialogOpen\">\n <Layout>\n <template #header>\n <DialogHeader title=\"Create issue\" @update:is-open=\"dialogOpen = $event\" />\n </template>\n <LayoutContent :padding=\"4\">\n <VStack :gap=\"4\">\n <TextInput v-model=\"newIssueTitle\" label=\"Title\" placeholder=\"Issue title\" />\n </VStack>\n </LayoutContent>\n <template #footer>\n <LayoutFooter has-divider>\n <HStack :gap=\"2\" justify=\"end\">\n <Button label=\"Cancel\" variant=\"secondary\" @click=\"dialogOpen = false\" />\n <Button label=\"Create\" variant=\"primary\" @click=\"dialogOpen = false\" />\n </HStack>\n </LayoutFooter>\n </template>\n </Layout>\n </Dialog>\n</template>\n\n<style scoped>\n.group-header {\n width: 100%;\n border: none;\n cursor: pointer;\n text-align: start;\n background: var(--astryx-color-background-muted);\n padding: var(--astryx-spacing-3) var(--astryx-spacing-4);\n}\n</style>\n",
79764
+ "source": "<script setup lang=\"ts\">\nimport {\n Avatar,\n Badge,\n Button,\n Center,\n Dialog,\n DialogHeader,\n Divider,\n DropdownMenu,\n Heading,\n HStack,\n Icon,\n IconButton,\n Layout,\n LayoutContent,\n LayoutFooter,\n LayoutHeader,\n LayoutPanel,\n MetadataList,\n MetadataListItem,\n PowerSearch,\n pixel,\n Popover,\n proportional,\n RadioList,\n RadioListItem,\n resolveColumnWidths,\n ResizeHandle,\n Selector,\n StatusDot,\n StackItem,\n Table,\n TableBody,\n TableCell,\n TableRow,\n Text,\n TextInput,\n useResizable,\n usePowerSearchConfig,\n VStack,\n} from '@astryx-vue/core'\nimport type { PowerSearchFilter, TableColumn } from '@astryx-vue/core'\nimport { computed, ref, watch } from 'vue'\n\ntype TaskStatus = 'in_progress' | 'todo' | 'backlog' | 'done'\ntype TaskPriority = 'urgent' | 'high' | 'medium' | 'low' | 'none'\n\ninterface TaskRow extends Record<string, unknown> {\n id: string\n taskId: string\n title: string\n subtitle: string\n status: TaskStatus\n priority: TaskPriority\n project: string\n tags: string[]\n created: string\n updated: string\n assignee: string\n}\n\nconst ALL_TASKS: TaskRow[] = [\n { id: '1', taskId: 'T235040469', title: 'Update user interface', subtitle: 'Update payment gateway integration', status: 'in_progress', priority: 'medium', project: 'Payment gateway integration 2.0', tags: [], created: 'Jul 3', updated: 'Jul 3', assignee: 'Olivia Martin' },\n { id: '2', taskId: 'T235040470', title: 'Use Projects to organize work for features or releases', subtitle: '', status: 'in_progress', priority: 'medium', project: '', tags: [], created: 'Jul 1', updated: 'Jul 3', assignee: 'Jackson Lee' },\n { id: '3', taskId: 'T235040471', title: 'Use Cycles to focus work over n-weeks', subtitle: '', status: 'in_progress', priority: 'medium', project: '', tags: [], created: 'Jul 1', updated: 'Jul 3', assignee: 'Isabella Nguyen' },\n { id: '4', taskId: 'T235040472', title: 'Testing code', subtitle: 'Update payment gateway integration', status: 'todo', priority: 'medium', project: 'Payment gateway integration 2.0', tags: [], created: 'Jul 3', updated: 'Jul 3', assignee: 'William Kim' },\n { id: '5', taskId: 'T235040473', title: 'Update backend code', subtitle: 'Update payment gateway integration', status: 'todo', priority: 'medium', project: 'Payment gateway integration 2.0', tags: [], created: 'Jul 3', updated: 'Jul 3', assignee: 'Sofia Davis' },\n { id: '6', taskId: 'T235040474', title: 'Update front end code', subtitle: 'Update payment gateway integration', status: 'todo', priority: 'medium', project: 'Payment gateway integration 2.0', tags: [], created: 'Jul 3', updated: 'Jul 3', assignee: 'Mia Wilson' },\n { id: '7', taskId: 'T235040475', title: 'Update payment gateway integration', subtitle: '', status: 'todo', priority: 'high', project: 'Payment gateway integration 2.0', tags: ['Improvement', '3rd Party'], created: 'Jul 3', updated: 'Jul 3', assignee: 'Lucas Brown' },\n { id: '8', taskId: 'T235040476', title: 'Update payment gateway backend code', subtitle: '', status: 'todo', priority: 'medium', project: '', tags: [], created: 'Jul 3', updated: 'Jul 3', assignee: 'Ethan Jones' },\n { id: '9', taskId: 'T235040477', title: 'Invite your teammates', subtitle: '', status: 'todo', priority: 'low', project: '', tags: [], created: 'Jul 1', updated: 'Jul 1', assignee: 'Ava Taylor' },\n { id: '10', taskId: 'T235040478', title: 'Next steps', subtitle: '', status: 'todo', priority: 'none', project: '', tags: [], created: 'Jul 1', updated: 'Jul 3', assignee: 'Noah Garcia' },\n { id: '11', taskId: 'T235040479', title: 'Welcome to Linear', subtitle: '', status: 'backlog', priority: 'none', project: '', tags: [], created: 'Jul 1', updated: 'Jul 3', assignee: 'Olivia Martin' },\n { id: '12', taskId: 'T235040480', title: 'Connect GitHub or GitLab', subtitle: '', status: 'backlog', priority: 'none', project: '', tags: [], created: 'Jul 1', updated: 'Jul 3', assignee: 'Jackson Lee' },\n { id: '13', taskId: 'T235040481', title: 'Customize settings', subtitle: '', status: 'backlog', priority: 'none', project: '', tags: [], created: 'Jul 1', updated: 'Jul 3', assignee: 'Isabella Nguyen' },\n { id: '14', taskId: 'T235040482', title: 'Try 3 ways to navigate: Command menu, keyboard or mouse', subtitle: '', status: 'done', priority: 'none', project: '', tags: [], created: 'Jul 1', updated: 'Jul 3', assignee: 'William Kim' },\n { id: '15', taskId: 'T235040483', title: 'Connect to Slack', subtitle: '', status: 'done', priority: 'none', project: '', tags: [], created: 'Jul 1', updated: 'Jul 3', assignee: 'Sofia Davis' },\n { id: '16', taskId: 'T235040484', title: 'Migrate database schema to v2', subtitle: 'Payment gateway integration', status: 'in_progress', priority: 'high', project: 'Payment gateway integration 2.0', tags: [], created: 'Jul 4', updated: 'Jul 5', assignee: 'Lucas Brown' },\n { id: '17', taskId: 'T235040485', title: 'Write integration tests for checkout flow', subtitle: '', status: 'in_progress', priority: 'medium', project: 'Payment gateway integration 2.0', tags: [], created: 'Jul 4', updated: 'Jul 5', assignee: 'Ethan Jones' },\n { id: '18', taskId: 'T235040486', title: 'Set up CI/CD pipeline for staging', subtitle: '', status: 'in_progress', priority: 'high', project: '', tags: [], created: 'Jul 2', updated: 'Jul 5', assignee: 'Ava Taylor' },\n { id: '19', taskId: 'T235040487', title: 'Add rate limiting to public API endpoints', subtitle: '', status: 'todo', priority: 'urgent', project: '', tags: [], created: 'Jul 5', updated: 'Jul 5', assignee: 'Noah Garcia' },\n { id: '20', taskId: 'T235040488', title: 'Refactor auth middleware to support OAuth2', subtitle: '', status: 'todo', priority: 'high', project: '', tags: [], created: 'Jul 4', updated: 'Jul 5', assignee: 'Olivia Martin' },\n { id: '21', taskId: 'T235040489', title: 'Design error pages for 404 and 500', subtitle: '', status: 'todo', priority: 'low', project: '', tags: [], created: 'Jul 3', updated: 'Jul 4', assignee: 'Mia Wilson' },\n { id: '22', taskId: 'T235040490', title: 'Audit third-party dependencies for vulnerabilities', subtitle: '', status: 'todo', priority: 'medium', project: '', tags: [], created: 'Jul 5', updated: 'Jul 5', assignee: 'Jackson Lee' },\n { id: '23', taskId: 'T235040491', title: 'Implement webhook retry logic with exponential backoff', subtitle: 'Payment gateway integration', status: 'todo', priority: 'medium', project: 'Payment gateway integration 2.0', tags: [], created: 'Jul 4', updated: 'Jul 5', assignee: 'William Kim' },\n { id: '24', taskId: 'T235040492', title: 'Add dark mode support to dashboard', subtitle: '', status: 'backlog', priority: 'low', project: '', tags: [], created: 'Jul 2', updated: 'Jul 3', assignee: 'Sofia Davis' },\n { id: '25', taskId: 'T235040493', title: 'Create onboarding flow for new team members', subtitle: '', status: 'backlog', priority: 'none', project: '', tags: [], created: 'Jul 1', updated: 'Jul 2', assignee: 'Isabella Nguyen' },\n { id: '26', taskId: 'T235040494', title: 'Set up error tracking with Sentry', subtitle: '', status: 'backlog', priority: 'medium', project: '', tags: [], created: 'Jul 3', updated: 'Jul 4', assignee: 'Ethan Jones' },\n { id: '27', taskId: 'T235040495', title: 'Improve search performance with indexing', subtitle: '', status: 'backlog', priority: 'low', project: '', tags: [], created: 'Jul 2', updated: 'Jul 3', assignee: 'Ava Taylor' },\n { id: '28', taskId: 'T235040496', title: 'Write API documentation for v2 endpoints', subtitle: '', status: 'done', priority: 'medium', project: '', tags: [], created: 'Jun 28', updated: 'Jul 3', assignee: 'Noah Garcia' },\n { id: '29', taskId: 'T235040497', title: 'Set up staging environment', subtitle: '', status: 'done', priority: 'high', project: '', tags: [], created: 'Jun 25', updated: 'Jul 1', assignee: 'Lucas Brown' },\n { id: '30', taskId: 'T235040498', title: 'Fix flaky end-to-end tests in CI', subtitle: '', status: 'done', priority: 'medium', project: '', tags: [], created: 'Jun 30', updated: 'Jul 2', assignee: 'Mia Wilson' },\n]\n\nconst STATUS_LABEL: Record<TaskStatus, string> = {\n in_progress: 'In Progress',\n todo: 'Todo',\n backlog: 'Backlog',\n done: 'Done',\n}\n\nconst STATUS_DOT: Record<TaskStatus, 'accent' | 'warning' | 'neutral' | 'success'> = {\n in_progress: 'accent',\n todo: 'warning',\n backlog: 'neutral',\n done: 'success',\n}\n\nconst PRIORITY_LABEL: Record<TaskPriority, string> = {\n urgent: 'Urgent',\n high: 'High',\n medium: 'Medium',\n low: 'Low',\n none: 'None',\n}\n\nconst PRIORITY_COLOR: Record<TaskPriority, 'primary' | 'secondary' | 'disabled'> = {\n urgent: 'primary',\n high: 'primary',\n medium: 'secondary',\n low: 'disabled',\n none: 'disabled',\n}\n\nconst GROUP_ORDER: TaskStatus[] = ['in_progress', 'todo', 'backlog', 'done']\n\ntype GroupByField = 'status' | 'priority' | 'project' | 'assignee' | 'none'\n\nconst GROUP_BY_OPTIONS: { value: GroupByField, label: string }[] = [\n { value: 'none', label: 'None' },\n { value: 'status', label: 'Status' },\n { value: 'priority', label: 'Priority' },\n { value: 'project', label: 'Project' },\n { value: 'assignee', label: 'Assignee' },\n]\n\nfunction groupLabel(field: GroupByField, key: string): string {\n if (field === 'status')\n return STATUS_LABEL[key as TaskStatus] ?? key\n if (field === 'priority') {\n const labels: Record<string, string> = {\n urgent: 'Urgent',\n high: 'High',\n medium: 'Medium',\n low: 'Low',\n none: 'No priority',\n }\n return labels[key] ?? key\n }\n return key\n}\n\nconst powerSearchFilters = ref<PowerSearchFilter[]>([])\nconst groupBy = ref<GroupByField>('status')\nconst expandedGroups = ref<Set<string>>(new Set(GROUP_ORDER))\nconst selectedTask = ref<TaskRow | null>(null)\nconst dialogOpen = ref(false)\n\nconst detailPanel = useResizable({\n defaultSize: 360,\n minSize: 280,\n maxSize: 500,\n})\n\nconst SEARCH_FIELDS = [\n {\n key: 'status',\n type: 'enum',\n label: 'Status',\n enumValues: GROUP_ORDER.map(value => ({ value, label: STATUS_LABEL[value] })),\n },\n {\n key: 'priority',\n type: 'enum',\n label: 'Priority',\n enumValues: (['urgent', 'high', 'medium', 'low', 'none'] as TaskPriority[]).map(value => ({ value, label: PRIORITY_LABEL[value] })),\n },\n { key: 'title', type: 'string', label: 'Title' },\n { key: 'assignee', type: 'string', label: 'Assignee' },\n { key: 'project', type: 'string', label: 'Project' },\n] as const\n\nconst { config: searchConfig, applyFilters } = usePowerSearchConfig(SEARCH_FIELDS, 'IssueSearch')\n\nconst filtered = computed(() => applyFilters(powerSearchFilters.value, ALL_TASKS))\n\nconst grouped = computed(() => {\n const map = new Map<string, TaskRow[]>()\n if (groupBy.value === 'none') {\n map.set('All', filtered.value)\n return map\n }\n for (const task of filtered.value) {\n const key = String(task[groupBy.value]) || '—'\n const list = map.get(key) ?? []\n list.push(task)\n map.set(key, list)\n }\n return map\n})\n\nconst groupKeys = computed(() => Array.from(grouped.value.keys()))\n\nwatch(groupKeys, (keys) => {\n expandedGroups.value = new Set(keys)\n}, { immediate: true })\n\nfunction toggleGroup(key: string): void {\n const next = new Set(expandedGroups.value)\n if (next.has(key))\n next.delete(key)\n else\n next.add(key)\n expandedGroups.value = next\n}\n\nconst columns: TableColumn<TaskRow>[] = [\n { key: 'status', header: '', width: pixel(44) },\n { key: 'title', header: 'Issue', width: proportional(1) },\n { key: 'project', header: 'Project', width: pixel(180) },\n { key: 'created', header: 'Created', width: pixel(72) },\n { key: 'updated', header: 'Updated', width: pixel(72) },\n { key: 'assignee', header: 'Assignee', width: pixel(52) },\n { key: 'actions', header: '', width: pixel(56) },\n]\n\nconst columnStyles = resolveColumnWidths(columns).columns\n\nconst rowMenuItems = [\n { label: 'Edit issue', icon: 'i-heroicons-pencil' },\n { label: 'Assign to...', icon: 'i-heroicons-user' },\n { label: 'Add label', icon: 'i-heroicons-tag' },\n { label: 'Duplicate', icon: 'i-heroicons-document-duplicate' },\n { label: 'Move to project', icon: 'i-heroicons-arrow-right' },\n { type: 'divider' as const },\n { label: 'Delete issue', icon: 'i-heroicons-trash' },\n]\n\n<\/script>\n\n<template>\n <Layout height=\"fill\">\n <template #header>\n <LayoutHeader has-divider :padding=\"4\">\n <VStack :gap=\"4\">\n <HStack :gap=\"3\" align=\"center\">\n <StackItem size=\"fill\">\n <Heading :level=\"1\">\n All Issues\n </Heading>\n </StackItem>\n <Button label=\"Create issue\" variant=\"primary\" size=\"lg\" @click=\"dialogOpen = true\" />\n </HStack>\n <HStack :gap=\"2\" align=\"center\">\n <StackItem size=\"fill\">\n <PowerSearch\n :config=\"searchConfig\"\n :filters=\"powerSearchFilters\"\n placeholder=\"Filter issues...\"\n :result-count=\"`${filtered.length} issue${filtered.length !== 1 ? 's' : ''}`\"\n @update:filters=\"value => { powerSearchFilters = value }\"\n />\n </StackItem>\n <Popover\n placement=\"below\"\n alignment=\"end\"\n :width=\"320\"\n label=\"Grouping options\"\n >\n <template #content>\n <VStack :gap=\"4\">\n <RadioList v-model=\"groupBy\" label=\"Group by\">\n <RadioListItem\n v-for=\"opt in GROUP_BY_OPTIONS\"\n :key=\"opt.value\"\n :value=\"opt.value\"\n :label=\"opt.label\"\n />\n </RadioList>\n </VStack>\n </template>\n <Button label=\"View Options\" variant=\"secondary\" size=\"md\" />\n </Popover>\n </HStack>\n </VStack>\n </LayoutHeader>\n </template>\n\n <LayoutContent :padding=\"0\" role=\"main\">\n <Table :columns=\"columns\" density=\"balanced\" dividers=\"rows\" text-overflow=\"truncate\" has-hover>\n <colgroup>\n <col v-for=\"col in columns\" :key=\"col.key\" :style=\"columnStyles.get(col.key)?.style\">\n </colgroup>\n <TableBody>\n <template v-for=\"key in groupKeys\" :key=\"key\">\n <template v-if=\"(grouped.get(key) ?? []).length > 0\">\n <TableRow\n v-if=\"groupBy !== 'none'\"\n role=\"button\"\n :tab-index=\"0\"\n @click=\"toggleGroup(key)\"\n @keydown.enter.prevent=\"toggleGroup(key)\"\n @keydown.space.prevent=\"toggleGroup(key)\"\n >\n <TableCell :col-span=\"columns.length\" class=\"group-header-cell\">\n <HStack :gap=\"2\" align=\"center\">\n <Icon\n :icon=\"expandedGroups.has(key) ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'\"\n size=\"sm\"\n color=\"secondary\"\n />\n <Text type=\"body\" weight=\"bold\">\n {{ groupLabel(groupBy, key) }}\n </Text>\n <Badge variant=\"neutral\" :label=\"String(grouped.get(key)?.length ?? 0)\" />\n </HStack>\n </TableCell>\n </TableRow>\n <template v-if=\"groupBy === 'none' || expandedGroups.has(key)\">\n <TableRow v-for=\"task in grouped.get(key)\" :key=\"task.id\" @click=\"selectedTask = task\">\n <TableCell>\n <Center axis=\"horizontal\">\n <StatusDot :variant=\"STATUS_DOT[task.status]\" :label=\"STATUS_LABEL[task.status]\" />\n </Center>\n </TableCell>\n <TableCell>\n <HStack :gap=\"3\" align=\"center\">\n <Icon icon=\"i-heroicons-chart-bar-solid\" size=\"sm\" :color=\"PRIORITY_COLOR[task.priority]\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ task.taskId }}\n </Text>\n <Text type=\"body\" :max-lines=\"1\">\n {{ task.title }}\n </Text>\n <Text v-if=\"task.subtitle\" type=\"body\" color=\"secondary\" :max-lines=\"1\">\n › {{ task.subtitle }}\n </Text>\n </HStack>\n </TableCell>\n <TableCell>\n <Text v-if=\"task.project\" type=\"body\" :max-lines=\"1\">\n {{ task.project }}\n </Text>\n <Text v-else type=\"supporting\" color=\"secondary\">\n —\n </Text>\n </TableCell>\n <TableCell>\n <Text type=\"supporting\" color=\"secondary\">\n {{ task.created }}\n </Text>\n </TableCell>\n <TableCell>\n <Text type=\"supporting\" color=\"secondary\">\n {{ task.updated }}\n </Text>\n </TableCell>\n <TableCell>\n <Avatar :name=\"task.assignee\" size=\"sm\" />\n </TableCell>\n <TableCell>\n <DropdownMenu :has-chevron=\"false\" :items=\"rowMenuItems\">\n <template #trigger=\"triggerProps\">\n <IconButton v-bind=\"triggerProps\" label=\"Actions\" variant=\"ghost\" size=\"sm\">\n <Icon name=\"heroicons:ellipsis-horizontal\" size=\"sm\" />\n </IconButton>\n </template>\n </DropdownMenu>\n </TableCell>\n </TableRow>\n </template>\n </template>\n </template>\n </TableBody>\n </Table>\n </LayoutContent>\n\n <template v-if=\"selectedTask\" #end>\n <ResizeHandle :resizable=\"detailPanel.props\" is-reversed :is-always-visible=\"false\" />\n <LayoutPanel\n has-divider\n :resizable=\"detailPanel.props\"\n :padding=\"4\"\n role=\"complementary\"\n label=\"Task details\"\n >\n <VStack :gap=\"4\">\n <HStack :gap=\"2\" align=\"center\">\n <StackItem size=\"fill\">\n <Text type=\"supporting\" color=\"secondary\">\n {{ selectedTask.taskId }}\n </Text>\n </StackItem>\n <Button label=\"Close panel\" variant=\"ghost\" size=\"sm\" is-icon-only @click=\"selectedTask = null\">\n <template #icon>\n <Icon icon=\"i-heroicons-x-mark\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n <VStack :gap=\"1\">\n <Heading :level=\"3\">\n {{ selectedTask.title }}\n </Heading>\n <Text v-if=\"selectedTask.subtitle\" type=\"body\" color=\"secondary\">\n {{ selectedTask.subtitle }}\n </Text>\n </VStack>\n <MetadataList :label=\"{ position: 'start' }\">\n <MetadataListItem label=\"Status\">\n <HStack :gap=\"2\" align=\"center\">\n <StatusDot :variant=\"STATUS_DOT[selectedTask.status]\" :label=\"STATUS_LABEL[selectedTask.status]\" />\n <Text>{{ STATUS_LABEL[selectedTask.status] }}</Text>\n </HStack>\n </MetadataListItem>\n <MetadataListItem label=\"Priority\">\n <HStack :gap=\"2\" align=\"center\">\n <Icon icon=\"i-heroicons-chart-bar-solid\" size=\"sm\" :color=\"PRIORITY_COLOR[selectedTask.priority]\" />\n <Text>{{ PRIORITY_LABEL[selectedTask.priority] }}</Text>\n </HStack>\n </MetadataListItem>\n <MetadataListItem label=\"Assignee\">\n <HStack :gap=\"2\" align=\"center\">\n <Avatar :name=\"selectedTask.assignee\" size=\"sm\" />\n <Text>{{ selectedTask.assignee }}</Text>\n </HStack>\n </MetadataListItem>\n <MetadataListItem label=\"Project\">\n {{ selectedTask.project || '—' }}\n </MetadataListItem>\n <MetadataListItem label=\"Created\">\n {{ selectedTask.created }}\n </MetadataListItem>\n <MetadataListItem label=\"Updated\">\n {{ selectedTask.updated }}\n </MetadataListItem>\n </MetadataList>\n <template v-if=\"selectedTask.tags.length > 0\">\n <Divider />\n <VStack :gap=\"2\">\n <Text type=\"label\">\n Labels\n </Text>\n <HStack :gap=\"2\">\n <Badge v-for=\"tag in selectedTask.tags\" :key=\"tag\" variant=\"neutral\" :label=\"tag\" />\n </HStack>\n </VStack>\n </template>\n </VStack>\n </LayoutPanel>\n </template>\n </Layout>\n\n <Dialog v-model:is-open=\"dialogOpen\">\n <Layout>\n <template #header>\n <DialogHeader title=\"Create Issue\" @update:is-open=\"dialogOpen = $event\" />\n </template>\n <LayoutContent :padding=\"4\">\n <VStack :gap=\"4\">\n <TextInput model-value=\"\" label=\"Title\" placeholder=\"Issue title\" />\n <Selector label=\"Status\" model-value=\"todo\" :options=\"[{ value: 'in_progress', label: 'In Progress' }, { value: 'todo', label: 'Todo' }, { value: 'backlog', label: 'Backlog' }]\" />\n <Selector label=\"Priority\" model-value=\"none\" :options=\"[{ value: 'urgent', label: 'Urgent' }, { value: 'high', label: 'High' }, { value: 'medium', label: 'Medium' }, { value: 'low', label: 'Low' }, { value: 'none', label: 'No priority' }]\" />\n <TextInput model-value=\"\" label=\"Project\" placeholder=\"Project name\" />\n </VStack>\n </LayoutContent>\n <template #footer>\n <LayoutFooter has-divider>\n <HStack :gap=\"2\" justify=\"end\">\n <Button label=\"Cancel\" variant=\"secondary\" @click=\"dialogOpen = false\" />\n <Button label=\"Create\" variant=\"primary\" @click=\"dialogOpen = false\" />\n </HStack>\n </LayoutFooter>\n </template>\n </Layout>\n </Dialog>\n</template>\n\n<style scoped>\n.group-header-cell {\n cursor: pointer;\n background: var(--astryx-color-background-muted);\n padding: var(--astryx-spacing-3) var(--astryx-spacing-4);\n}\n</style>\n",
79379
79765
  "docsUrl": {
79380
79766
  "en": "https://astryx-vue.pages.dev/en/playground/?template=table-grouped",
79381
79767
  "zh": "https://astryx-vue.pages.dev/playground/?template=table-grouped"
@@ -79443,7 +79829,7 @@ const catalog = {
79443
79829
  ],
79444
79830
  "dependencies": ["@astryx-vue/core", "vue"],
79445
79831
  "isReady": true,
79446
- "source": "<script setup lang=\"ts\">\nimport {\n Avatar,\n Badge,\n Button,\n Collapsible,\n CollapsibleGroup,\n Heading,\n HStack,\n Icon,\n IconButton,\n Layout,\n LayoutContent,\n LayoutHeader,\n LayoutPanel,\n pixel,\n proportional,\n ResizeHandle,\n StackItem,\n Tab,\n TabList,\n Table,\n Text,\n Thumbnail,\n Token,\n Toolbar,\n useResizable,\n useTableRowStatus,\n VStack,\n} from '@astryx-vue/core'\nimport type { TableColumn } from '@astryx-vue/core'\nimport { computed, h, ref } from 'vue'\n\ntype Category = 'inbox' | 'escalations' | 'waiting' | 'resolved'\ntype Priority = 'urgent' | 'high' | 'normal'\n\ninterface Attachment {\n id: string\n name: string\n src: string\n}\n\ninterface ThreadMessage {\n id: string\n sender: string\n senderEmail: string\n receivedFull: string\n body: string[]\n attachments?: Attachment[]\n}\n\ninterface Conversation extends Record<string, unknown> {\n id: string\n sender: string\n senderEmail: string\n subject: string\n preview: string\n tags: string[]\n receivedAt: string\n receivedFull: string\n isUnread: boolean\n priority: Priority\n category: Category\n assignee: string | null\n body: string[]\n attachments: Attachment[]\n replies?: ThreadMessage[]\n}\n\nconst PRIORITY_COLOR: Record<Priority, 'red' | 'orange' | 'default'> = {\n urgent: 'red',\n high: 'orange',\n normal: 'default',\n}\n\nconst PRIORITY_LABEL: Record<Priority, string> = {\n urgent: 'Urgent',\n high: 'High',\n normal: 'Normal',\n}\n\nconst CONVERSATIONS: Conversation[] = [\n {\n id: 'c-01',\n category: 'escalations',\n sender: 'Priya Raman',\n senderEmail: 'priya.raman@northwind.example',\n subject: 'Checkout returns 502 for EU customers',\n preview:\n 'Started around 09:40 UTC. Roughly one in five card payments fails at the confirm step…',\n tags: ['Billing', 'Escalated'],\n receivedAt: '9:52 AM',\n receivedFull: 'Today at 9:52 AM',\n isUnread: true,\n priority: 'urgent',\n assignee: null,\n body: [\n 'Started around 09:40 UTC. Roughly one in five card payments fails at the confirm step, and only for customers billing to an EU address — US and APAC look untouched.',\n 'The response is a bare 502 with no body, so our retry logic treats it as transient and re-submits. Two customers have now been charged twice.',\n 'I have paused the retry worker as a stopgap, so the double charges should stop, but the underlying failures have not.',\n ],\n attachments: [\n {\n id: 'c-01-a1',\n name: 'northwind-checkout-eu.png',\n src: '/template-assets/light-home-horizontal-1.png',\n },\n ],\n replies: [\n {\n id: 'c-01-r1',\n sender: 'Priya Raman',\n senderEmail: 'priya.raman@northwind.example',\n receivedFull: 'Today at 9:44 AM',\n body: [\n 'Flagging early — our payments dashboard has just lit up. No numbers yet, but the failures all look like they are on the confirm step rather than the authorisation.',\n ],\n },\n {\n id: 'c-01-r2',\n sender: 'Lucas Meyer',\n // Support-side. Every address on the team shares this domain, which is\n // how a header says \"this one is us\" without a badge saying so.\n senderEmail: 'lucas.meyer@support.example',\n receivedFull: 'Today at 9:47 AM',\n body: [\n 'Acknowledged, paging the payments on-call. Nothing on our status page, so if this is regional it is sitting under our alerting threshold.',\n 'Is it specific to one card network, or all of them?',\n ],\n },\n ],\n },\n {\n id: 'c-02',\n category: 'inbox',\n sender: 'Marcus Feld',\n senderEmail: 'm.feld@harbourpoint.example',\n subject: 'SSO metadata rotation — need new certificate',\n preview:\n 'Our IdP certificate expires Friday. Can you confirm the fingerprint before we cut over?',\n tags: ['Identity'],\n receivedAt: '9:31 AM',\n receivedFull: 'Today at 9:31 AM',\n isUnread: true,\n priority: 'high',\n assignee: null,\n body: [\n 'Our IdP certificate expires Friday at 23:59 UTC. We have the replacement staged and can publish new metadata whenever you are ready.',\n 'Before we cut over, can you confirm the SHA-256 fingerprint you have on file matches the one we published? Last rotation we mismatched and locked out 400 people for an hour.',\n ],\n attachments: [],\n },\n {\n id: 'c-03',\n category: 'resolved',\n sender: 'Dana Osei',\n senderEmail: 'dana@osei-consulting.example',\n subject: 'Re: Bulk export finished but the file is empty',\n preview:\n 'Thanks — re-running with the date filter cleared did produce rows this time.',\n tags: ['Exports'],\n receivedAt: '8:58 AM',\n receivedFull: 'Today at 8:58 AM',\n isUnread: true,\n priority: 'normal',\n assignee: 'You',\n body: [\n 'Thanks — re-running with the date filter cleared did produce rows this time, so it looks like the filter was the culprit rather than the export itself.',\n 'Worth noting the empty file still came back with a 200 and a success banner, which is what sent me down the wrong path. A warning when an export matches zero records would have saved a morning.',\n ],\n attachments: [],\n replies: [\n {\n id: 'c-03-r1',\n sender: 'Dana Osei',\n senderEmail: 'dana@osei-consulting.example',\n receivedFull: 'Yesterday at 5:12 PM',\n body: [\n 'Ran the full-account export this afternoon. It finished in about forty seconds and handed me a zero-byte CSV — no header row, nothing.',\n ],\n },\n {\n id: 'c-03-r2',\n sender: 'Marta Nowak',\n senderEmail: 'marta.nowak@support.example',\n receivedFull: 'Today at 8:31 AM',\n body: [\n 'A zero-byte file usually means the query matched no rows rather than that the export itself failed. Could you retry with the date filter cleared and tell me whether the file comes back with content?',\n ],\n },\n ],\n },\n {\n id: 'c-04',\n category: 'inbox',\n sender: 'Tomás Ibarra',\n senderEmail: 'tomas.ibarra@meridian.example',\n subject: 'Webhook retries stopped after the 3rd attempt',\n preview:\n 'We expected five retries with backoff. Logs show three and then nothing for order 88214.',\n tags: ['Webhooks', 'Bug'],\n receivedAt: '8:14 AM',\n receivedFull: 'Today at 8:14 AM',\n isUnread: false,\n priority: 'high',\n assignee: 'You',\n body: [\n 'We expected five retries with exponential backoff. Logs show three attempts and then nothing for order 88214.',\n 'The endpoint was returning 503 throughout, so this should have kept retrying to the documented limit. The gap in our delivery log after 08:02 is the part I cannot explain.',\n ],\n attachments: [],\n },\n {\n id: 'c-05',\n category: 'waiting',\n sender: 'Hannah Lindqvist',\n senderEmail: 'h.lindqvist@varda.example',\n subject: 'Homepage refresh — three directions for your review',\n preview:\n 'Attached the three we shortlisted. We need a decision by Thursday to hold the schedule.',\n tags: ['Design', 'Review'],\n receivedAt: 'Yesterday',\n receivedFull: 'Yesterday at 4:22 PM',\n isUnread: false,\n priority: 'high',\n assignee: null,\n body: [\n 'Attached the three directions we shortlisted out of the eleven we explored. They differ in mood rather than structure, so the layout work carries over whichever one you pick.',\n 'Colourful is the boldest and the furthest from the current site. Light is the safest and reads closest to the product itself. Moody photographs best but needs art direction we do not have in-house yet.',\n 'We need a decision by Thursday to hold the build schedule.',\n ],\n attachments: [\n {\n id: 'a-1',\n name: 'direction-1-colourful.png',\n src: '/template-assets/colorful-home-horizontal-1.png',\n },\n {\n id: 'a-2',\n name: 'direction-2-light.png',\n src: '/template-assets/light-home-horizontal-1.png',\n },\n {\n id: 'a-3',\n name: 'direction-3-moody.png',\n src: '/template-assets/moody-home-horizontal-1.png',\n },\n ],\n replies: [\n {\n id: 'c-05-r1',\n sender: 'Hannah Lindqvist',\n senderEmail: 'h.lindqvist@varda.example',\n receivedFull: 'Monday at 3:05 PM',\n body: [\n 'Kicking off the homepage refresh. We have eleven explorations open; I will cut them to three before Thursday so you get something decidable rather than a mood board.',\n ],\n },\n {\n id: 'c-05-r2',\n sender: 'Elliot Nakamura',\n senderEmail: 'elliot.nakamura@support.example',\n receivedFull: 'Tuesday at 10:18 AM',\n body: [\n 'Three is the right number. One request: hold the hero structure identical across all of them, so the thing we are judging is treatment and not layout. Last round nobody could tell which of the two they were reacting to.',\n ],\n },\n {\n // A second voice from the customer's side, and the only one in the\n // fixture. Every other thread is one customer and one agent, where\n // Reply and Reply all address the same person and the difference\n // between the two buttons never shows.\n id: 'c-05-r2b',\n sender: 'Ines Duarte',\n senderEmail: 'i.duarte@varda.example',\n receivedFull: 'Tuesday at 4:52 PM',\n body: [\n 'Copy is written against the current hero, so holding the structure suits us — I will only need to reflow if the headline slot changes length.',\n ],\n },\n {\n id: 'c-05-r3',\n sender: 'Hannah Lindqvist',\n senderEmail: 'h.lindqvist@varda.example',\n receivedFull: 'Yesterday at 11:40 AM',\n body: [\n 'Structure is fixed across all three. Sending the moody direction on its own first — it is the one most likely to split the room, and it reads differently without the other two beside it.',\n ],\n attachments: [\n {\n id: 'c-05-r3-a1',\n name: 'moody-direction-hero.png',\n src: '/template-assets/moody-home-horizontal-1.png',\n },\n ],\n },\n ],\n },\n {\n id: 'c-06',\n category: 'waiting',\n sender: 'Owen Baptiste',\n senderEmail: 'obaptiste@fieldstone.example',\n subject: 'Seat count says 145 but we only provisioned 120',\n preview:\n 'Finance flagged the discrepancy on the March invoice. Can we reconcile before the 30th?',\n tags: ['Billing'],\n receivedAt: 'Yesterday',\n receivedFull: 'Yesterday at 11:07 AM',\n isUnread: false,\n priority: 'high',\n assignee: 'You',\n body: [\n 'Finance flagged the discrepancy on the March invoice. Our directory shows 120 provisioned seats; the invoice bills 145.',\n 'My guess is deactivated accounts are still counted until the end of the billing period, but I cannot find that stated anywhere. Can we reconcile before the 30th?',\n ],\n attachments: [],\n },\n {\n id: 'c-07',\n category: 'inbox',\n sender: 'Aiko Tanaka',\n senderEmail: 'aiko.tanaka@shorebird.example',\n subject: 'Docs: the rate-limit page contradicts the API response header',\n preview:\n 'Page says 600/min, header reports 300/min on our plan. One of them is wrong.',\n tags: ['Docs'],\n receivedAt: 'Yesterday',\n receivedFull: 'Yesterday at 9:15 AM',\n isUnread: false,\n priority: 'normal',\n assignee: null,\n body: [\n 'The rate-limit page says 600 requests per minute on Scale. The X-RateLimit-Limit header on our responses reports 300.',\n 'One of them is wrong, and we have sized a batch job against the larger number.',\n ],\n attachments: [],\n },\n {\n id: 'c-08',\n category: 'resolved',\n sender: 'Reuben Cole',\n senderEmail: 'r.cole@atlasgrid.example',\n subject: 'Re: Scheduled maintenance window confirmation',\n preview: 'Confirmed for Sunday 02:00–04:00 UTC. Nothing further needed.',\n tags: ['Ops'],\n receivedAt: 'Mon',\n receivedFull: 'Monday at 2:40 PM',\n isUnread: false,\n priority: 'normal',\n assignee: 'You',\n body: [\n 'Confirmed for Sunday 02:00–04:00 UTC. We have notified our on-call and paused the nightly sync for that window.',\n 'Nothing further needed from your side.',\n ],\n attachments: [],\n },\n {\n id: 'c-09',\n category: 'inbox',\n sender: 'Sofia Marchetti',\n senderEmail: 'sofia@marchetti-legal.example',\n subject: 'Feature request: per-workspace retention policy',\n preview:\n 'Our legal team needs 30-day retention on one workspace and 400 on another.',\n tags: ['Feature request'],\n receivedAt: 'Mon',\n receivedFull: 'Monday at 10:02 AM',\n isUnread: false,\n priority: 'normal',\n assignee: null,\n body: [\n 'Our legal team needs 30-day retention on the client-intake workspace and 400 days on the matter-archive workspace.',\n 'Today retention is an account-level setting, so we are stuck applying the longer policy everywhere, which is the opposite of what our counsel wants.',\n ],\n attachments: [],\n },\n];\n\nconst CATEGORIES: { value: Category, label: string }[] = [\n { value: 'inbox', label: 'Inbox' },\n { value: 'escalations', label: 'Escalations' },\n { value: 'waiting', label: 'Waiting' },\n { value: 'resolved', label: 'Resolved' },\n]\n\n/** Inbox 是全部,其余三个是它的切面:一封会话同一时刻只有一个归属。 */\nfunction inCategory(item: Conversation, category: Category): boolean {\n return category === 'inbox' || item.category === category\n}\n\n/** 每个标签徽章显示该标签下的未读数。 */\nfunction unreadByCategory(): Record<Category, number> {\n const counts: Record<Category, number> = { inbox: 0, escalations: 0, waiting: 0, resolved: 0 }\n for (const item of CONVERSATIONS) {\n if (!item.isUnread)\n continue\n counts.inbox += 1\n if (item.category !== 'inbox')\n counts[item.category] += 1\n }\n return counts\n}\n\n/**\n * Stack 的内边距写在 CSS 变量上,嵌套的 Stack 会继承同一个值,\n * 所以给容器加内边距时用内联样式,避免子容器被动带上同样的留白。\n */\nfunction spacing(step: number): string {\n return `var(--astryx-spacing-${String(step).replace('.', '-')}, ${step * 4}px)`\n}\n\n/** 头像与姓名之间的间距,正文的缩进也用它。 */\nconst THREAD_HEADER_GAP = 3\n/** 头像的渲染宽度,正文缩进要对齐它。 */\nconst AVATAR_SIZE = 36\n/** 正文的最大阅读宽度。 */\nconst MESSAGE_MEASURE = 680\n\nconst category = ref<Category>('inbox')\n/** `undefined` 表示用户还没选过,按列表首条兜底;`null` 表示用户主动关掉了阅读栏。 */\nconst chosenId = ref<string | null | undefined>(undefined)\n\nconst pane = useResizable({\n defaultSize: 963,\n minSize: 420,\n maxSize: 1100,\n})\n\nconst visible = computed(() => CONVERSATIONS.filter(item => inCategory(item, category.value)))\n\nconst openId = computed<string | null>(() => {\n if (chosenId.value === null)\n return null\n if (chosenId.value != null && visible.value.some(item => item.id === chosenId.value))\n return chosenId.value\n return visible.value[0]?.id ?? null\n})\n\nconst openConversation = computed(() => visible.value.find(item => item.id === openId.value) ?? null)\n\nconst unreadCount = computed(() => CONVERSATIONS.filter(item => item.isUnread).length)\nconst unreadCounts = unreadByCategory()\n\n/** 会话按时间正序展开,最新一条在最后并且默认展开。 */\nfunction threadMessages(conversation: Conversation): ThreadMessage[] {\n return [\n ...(conversation.replies ?? []),\n {\n id: conversation.id,\n sender: conversation.sender,\n senderEmail: conversation.senderEmail,\n receivedFull: conversation.receivedFull,\n body: conversation.body,\n attachments: conversation.attachments,\n },\n ]\n}\n\nfunction openThread(id: string): void {\n chosenId.value = id\n}\n\nconst rowStatus = useTableRowStatus<Conversation>({\n getStatus: item => (item.isUnread ? { color: 'accent', label: 'Unread' } : null),\n})\n\nconst columns: TableColumn<Conversation>[] = [\n {\n key: 'conversation',\n header: 'Conversation',\n width: proportional(1, { minWidth: 160 }),\n renderCell: item => h(VStack, {\n gap: 0.5,\n style: { cursor: 'pointer' },\n onClick: () => openThread(item.id),\n }, () => [\n h(Text, { maxLines: 1, weight: item.isUnread ? 'semibold' : 'normal' }, () => item.sender),\n h(Text, { maxLines: 1, color: 'secondary' }, () => item.subject),\n ]),\n },\n {\n key: 'receivedAt',\n header: 'Received',\n width: pixel(110),\n align: 'end',\n renderCell: item => h(HStack, {\n gap: 1,\n vAlign: 'center',\n hAlign: 'end',\n style: { cursor: 'pointer' },\n onClick: () => openThread(item.id),\n }, () => [\n item.attachments.length === 0\n ? null\n : h(Icon, { name: 'heroicons:paper-clip', size: 'sm', color: 'gray', label: 'Has attachments' }),\n h(Text, {\n color: 'secondary',\n hasTabularNumbers: true,\n justify: 'end',\n maxLines: 1,\n weight: item.isUnread ? 'semibold' : 'normal',\n }, () => item.receivedAt),\n ]),\n },\n]\n\nconst threadActions = [\n { id: 'archive', label: 'Archive', icon: 'heroicons:archive-box' },\n { id: 'move', label: 'Move to folder', icon: 'heroicons:folder-arrow-down' },\n { id: 'delete', label: 'Delete', icon: 'heroicons:trash' },\n]\n<\/script>\n\n<template>\n <Layout height=\"fill\">\n <template #header>\n <LayoutHeader :padding=\"0\">\n <VStack :gap=\"0\">\n <HStack :gap=\"3\" align=\"center\" wrap=\"wrap\" :style=\"{ padding: spacing(4) }\">\n <StackItem size=\"fill\">\n <VStack :gap=\"0.5\">\n <Heading :level=\"1\">\n Inbox\n </Heading>\n <Text type=\"supporting\">\n {{ unreadCount }} unread of {{ CONVERSATIONS.length }} conversations\n </Text>\n </VStack>\n </StackItem>\n <Button label=\"Refresh\" variant=\"ghost\">\n <template #icon>\n <Icon name=\"heroicons:arrow-path\" size=\"sm\" />\n </template>\n </Button>\n <Button label=\"Compose\" variant=\"primary\">\n <template #icon>\n <Icon name=\"heroicons:pencil-square\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n <VStack :style=\"{ paddingInline: spacing(1) }\">\n <TabList v-model:value=\"category\" has-divider>\n <Tab\n v-for=\"option in CATEGORIES\"\n :key=\"option.value\"\n :value=\"option.value\"\n :label=\"option.label\"\n >\n {{ option.label }}\n <template v-if=\"unreadCounts[option.value] > 0\" #endContent>\n <Badge variant=\"neutral\" :label=\"`${unreadCounts[option.value]} unread`\">\n {{ unreadCounts[option.value] }}\n </Badge>\n </template>\n </Tab>\n </TabList>\n </VStack>\n </VStack>\n </LayoutHeader>\n </template>\n\n <LayoutContent :padding=\"4\" is-scrollable>\n <Table\n :data=\"visible\"\n :columns=\"columns\"\n id-key=\"id\"\n density=\"balanced\"\n dividers=\"none\"\n has-hover\n text-overflow=\"truncate\"\n :plugins=\"{ status: rowStatus }\"\n />\n </LayoutContent>\n\n <template v-if=\"openConversation\" #end>\n <ResizeHandle :resizable=\"pane.props\" is-reversed has-divider />\n <LayoutPanel :resizable=\"pane.props\" :padding=\"0\" role=\"complementary\" label=\"Conversation\">\n <VStack height=\"100%\">\n <VStack\n :style=\"{\n paddingBlockStart: spacing(4),\n paddingInlineStart: spacing(3),\n paddingInlineEnd: spacing(2),\n }\"\n >\n <Toolbar label=\"Conversation\">\n <template #start>\n <Heading :level=\"2\" :max-lines=\"2\" has-truncate-tooltip>\n {{ openConversation.subject }}\n </Heading>\n </template>\n <template #end>\n <IconButton\n v-for=\"action in threadActions\"\n :key=\"action.id\"\n variant=\"ghost\"\n :label=\"`${action.label} — ${openConversation.subject}`\"\n :tooltip=\"action.label\"\n >\n <Icon :name=\"action.icon\" size=\"sm\" />\n </IconButton>\n <IconButton\n variant=\"ghost\"\n label=\"Close conversation\"\n tooltip=\"Close\"\n @click=\"chosenId = null\"\n >\n <Icon name=\"heroicons:x-mark\" size=\"sm\" />\n </IconButton>\n </template>\n </Toolbar>\n </VStack>\n\n <StackItem size=\"fill\">\n <VStack\n :style=\"{ paddingInline: spacing(6), paddingBlockEnd: spacing(6) }\"\n :gap=\"4\"\n is-scrollable\n height=\"100%\"\n >\n <HStack :gap=\"2\" align=\"center\" wrap=\"wrap\">\n <Token\n v-if=\"openConversation.priority !== 'normal'\"\n size=\"sm\"\n :color=\"PRIORITY_COLOR[openConversation.priority]\"\n :label=\"PRIORITY_LABEL[openConversation.priority]\"\n />\n <Token\n v-for=\"tag in openConversation.tags\"\n :key=\"tag\"\n size=\"sm\"\n :label=\"tag\"\n />\n </HStack>\n\n <CollapsibleGroup\n :key=\"openConversation.id\"\n type=\"multiple\"\n density=\"spacious\"\n :default-value=\"[openConversation.id]\"\n >\n <Collapsible\n v-for=\"message in threadMessages(openConversation)\"\n :key=\"message.id\"\n :value=\"message.id\"\n >\n <template #trigger>\n <HStack\n :gap=\"THREAD_HEADER_GAP\"\n align=\"center\"\n :style=\"{ paddingInlineEnd: spacing(2) }\"\n >\n <Avatar :name=\"message.sender\" :tooltip=\"false\" />\n <StackItem size=\"fill\">\n <VStack :gap=\"0.5\">\n <Text :max-lines=\"1\" weight=\"semibold\">\n {{ message.sender }}\n </Text>\n <Text :max-lines=\"1\" type=\"supporting\" color=\"secondary\">\n {{ message.senderEmail }}\n </Text>\n </VStack>\n </StackItem>\n <Text type=\"supporting\" color=\"secondary\">\n {{ message.receivedFull }}\n </Text>\n </HStack>\n </template>\n <HStack :gap=\"THREAD_HEADER_GAP\">\n <VStack :width=\"AVATAR_SIZE\" />\n <StackItem size=\"fill\">\n <VStack :gap=\"3\" :max-width=\"MESSAGE_MEASURE\">\n <Text v-for=\"paragraph in message.body\" :key=\"paragraph\">\n {{ paragraph }}\n </Text>\n <VStack v-if=\"message.attachments && message.attachments.length > 0\" :gap=\"2\">\n <HStack :gap=\"1\" align=\"center\">\n <Icon name=\"heroicons:paper-clip\" size=\"sm\" color=\"gray\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ message.attachments.length }} attachment{{ message.attachments.length === 1 ? '' : 's' }}\n </Text>\n </HStack>\n <HStack :gap=\"2\" wrap=\"wrap\">\n <Thumbnail\n v-for=\"attachment in message.attachments\"\n :key=\"attachment.id\"\n :src=\"attachment.src\"\n :alt=\"`Preview of ${attachment.name}`\"\n :label=\"attachment.name\"\n />\n </HStack>\n </VStack>\n </VStack>\n </StackItem>\n </HStack>\n </Collapsible>\n </CollapsibleGroup>\n\n <HStack :gap=\"THREAD_HEADER_GAP\">\n <VStack :width=\"AVATAR_SIZE\" />\n <HStack :gap=\"2\">\n <Button label=\"Reply\" variant=\"primary\">\n <template #icon>\n <Icon name=\"heroicons:arrow-uturn-left\" size=\"sm\" />\n </template>\n </Button>\n <Button label=\"Reply all\" variant=\"secondary\">\n <template #icon>\n <Icon name=\"heroicons:users\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n </HStack>\n </VStack>\n </StackItem>\n </VStack>\n </LayoutPanel>\n </template>\n </Layout>\n</template>\n",
79832
+ "source": "<script setup lang=\"ts\">\nimport {\n Avatar,\n Badge,\n Button,\n Collapsible,\n CollapsibleGroup,\n Heading,\n HStack,\n Icon,\n IconButton,\n Layout,\n LayoutContent,\n LayoutHeader,\n LayoutPanel,\n pixel,\n proportional,\n ResizeHandle,\n StackItem,\n Tab,\n TabList,\n Table,\n Text,\n Thumbnail,\n Token,\n Toolbar,\n useResizable,\n useTableRowStatus,\n VStack,\n} from '@astryx-vue/core'\nimport type { TableColumn, TablePlugin } from '@astryx-vue/core'\nimport { computed, h, ref } from 'vue'\n\ntype Category = 'inbox' | 'escalations' | 'waiting' | 'resolved'\ntype Priority = 'urgent' | 'high' | 'normal'\n\ninterface Attachment {\n id: string\n name: string\n src: string\n}\n\ninterface ThreadMessage {\n id: string\n sender: string\n senderEmail: string\n receivedFull: string\n body: string[]\n attachments?: Attachment[]\n}\n\ninterface Conversation extends Record<string, unknown> {\n id: string\n sender: string\n senderEmail: string\n subject: string\n preview: string\n tags: string[]\n receivedAt: string\n receivedFull: string\n isUnread: boolean\n priority: Priority\n category: Category\n assignee: string | null\n body: string[]\n attachments: Attachment[]\n replies?: ThreadMessage[]\n}\n\nconst PRIORITY_COLOR: Record<Priority, 'red' | 'orange' | 'default'> = {\n urgent: 'red',\n high: 'orange',\n normal: 'default',\n}\n\nconst PRIORITY_LABEL: Record<Priority, string> = {\n urgent: 'Urgent',\n high: 'High',\n normal: 'Normal',\n}\n\nconst CONVERSATIONS: Conversation[] = [\n {\n id: 'c-01',\n category: 'escalations',\n sender: 'Priya Raman',\n senderEmail: 'priya.raman@northwind.example',\n subject: 'Checkout returns 502 for EU customers',\n preview:\n 'Started around 09:40 UTC. Roughly one in five card payments fails at the confirm step…',\n tags: ['Billing', 'Escalated'],\n receivedAt: '9:52 AM',\n receivedFull: 'Today at 9:52 AM',\n isUnread: true,\n priority: 'urgent',\n assignee: null,\n body: [\n 'Started around 09:40 UTC. Roughly one in five card payments fails at the confirm step, and only for customers billing to an EU address — US and APAC look untouched.',\n 'The response is a bare 502 with no body, so our retry logic treats it as transient and re-submits. Two customers have now been charged twice.',\n 'I have paused the retry worker as a stopgap, so the double charges should stop, but the underlying failures have not.',\n ],\n attachments: [\n {\n id: 'c-01-a1',\n name: 'northwind-checkout-eu.png',\n src: '/template-assets/light-home-horizontal-1.png',\n },\n ],\n replies: [\n {\n id: 'c-01-r1',\n sender: 'Priya Raman',\n senderEmail: 'priya.raman@northwind.example',\n receivedFull: 'Today at 9:44 AM',\n body: [\n 'Flagging early — our payments dashboard has just lit up. No numbers yet, but the failures all look like they are on the confirm step rather than the authorisation.',\n ],\n },\n {\n id: 'c-01-r2',\n sender: 'Lucas Meyer',\n // Support-side. Every address on the team shares this domain, which is\n // how a header says \"this one is us\" without a badge saying so.\n senderEmail: 'lucas.meyer@support.example',\n receivedFull: 'Today at 9:47 AM',\n body: [\n 'Acknowledged, paging the payments on-call. Nothing on our status page, so if this is regional it is sitting under our alerting threshold.',\n 'Is it specific to one card network, or all of them?',\n ],\n },\n ],\n },\n {\n id: 'c-02',\n category: 'inbox',\n sender: 'Marcus Feld',\n senderEmail: 'm.feld@harbourpoint.example',\n subject: 'SSO metadata rotation — need new certificate',\n preview:\n 'Our IdP certificate expires Friday. Can you confirm the fingerprint before we cut over?',\n tags: ['Identity'],\n receivedAt: '9:31 AM',\n receivedFull: 'Today at 9:31 AM',\n isUnread: true,\n priority: 'high',\n assignee: null,\n body: [\n 'Our IdP certificate expires Friday at 23:59 UTC. We have the replacement staged and can publish new metadata whenever you are ready.',\n 'Before we cut over, can you confirm the SHA-256 fingerprint you have on file matches the one we published? Last rotation we mismatched and locked out 400 people for an hour.',\n ],\n attachments: [],\n },\n {\n id: 'c-03',\n category: 'resolved',\n sender: 'Dana Osei',\n senderEmail: 'dana@osei-consulting.example',\n subject: 'Re: Bulk export finished but the file is empty',\n preview:\n 'Thanks — re-running with the date filter cleared did produce rows this time.',\n tags: ['Exports'],\n receivedAt: '8:58 AM',\n receivedFull: 'Today at 8:58 AM',\n isUnread: true,\n priority: 'normal',\n assignee: 'You',\n body: [\n 'Thanks — re-running with the date filter cleared did produce rows this time, so it looks like the filter was the culprit rather than the export itself.',\n 'Worth noting the empty file still came back with a 200 and a success banner, which is what sent me down the wrong path. A warning when an export matches zero records would have saved a morning.',\n ],\n attachments: [],\n replies: [\n {\n id: 'c-03-r1',\n sender: 'Dana Osei',\n senderEmail: 'dana@osei-consulting.example',\n receivedFull: 'Yesterday at 5:12 PM',\n body: [\n 'Ran the full-account export this afternoon. It finished in about forty seconds and handed me a zero-byte CSV — no header row, nothing.',\n ],\n },\n {\n id: 'c-03-r2',\n sender: 'Marta Nowak',\n senderEmail: 'marta.nowak@support.example',\n receivedFull: 'Today at 8:31 AM',\n body: [\n 'A zero-byte file usually means the query matched no rows rather than that the export itself failed. Could you retry with the date filter cleared and tell me whether the file comes back with content?',\n ],\n },\n ],\n },\n {\n id: 'c-04',\n category: 'inbox',\n sender: 'Tomás Ibarra',\n senderEmail: 'tomas.ibarra@meridian.example',\n subject: 'Webhook retries stopped after the 3rd attempt',\n preview:\n 'We expected five retries with backoff. Logs show three and then nothing for order 88214.',\n tags: ['Webhooks', 'Bug'],\n receivedAt: '8:14 AM',\n receivedFull: 'Today at 8:14 AM',\n isUnread: false,\n priority: 'high',\n assignee: 'You',\n body: [\n 'We expected five retries with exponential backoff. Logs show three attempts and then nothing for order 88214.',\n 'The endpoint was returning 503 throughout, so this should have kept retrying to the documented limit. The gap in our delivery log after 08:02 is the part I cannot explain.',\n ],\n attachments: [],\n },\n {\n id: 'c-05',\n category: 'waiting',\n sender: 'Hannah Lindqvist',\n senderEmail: 'h.lindqvist@varda.example',\n subject: 'Homepage refresh — three directions for your review',\n preview:\n 'Attached the three we shortlisted. We need a decision by Thursday to hold the schedule.',\n tags: ['Design', 'Review'],\n receivedAt: 'Yesterday',\n receivedFull: 'Yesterday at 4:22 PM',\n isUnread: false,\n priority: 'high',\n assignee: null,\n body: [\n 'Attached the three directions we shortlisted out of the eleven we explored. They differ in mood rather than structure, so the layout work carries over whichever one you pick.',\n 'Colourful is the boldest and the furthest from the current site. Light is the safest and reads closest to the product itself. Moody photographs best but needs art direction we do not have in-house yet.',\n 'We need a decision by Thursday to hold the build schedule.',\n ],\n attachments: [\n {\n id: 'a-1',\n name: 'direction-1-colourful.png',\n src: '/template-assets/colorful-home-horizontal-1.png',\n },\n {\n id: 'a-2',\n name: 'direction-2-light.png',\n src: '/template-assets/light-home-horizontal-1.png',\n },\n {\n id: 'a-3',\n name: 'direction-3-moody.png',\n src: '/template-assets/moody-home-horizontal-1.png',\n },\n ],\n replies: [\n {\n id: 'c-05-r1',\n sender: 'Hannah Lindqvist',\n senderEmail: 'h.lindqvist@varda.example',\n receivedFull: 'Monday at 3:05 PM',\n body: [\n 'Kicking off the homepage refresh. We have eleven explorations open; I will cut them to three before Thursday so you get something decidable rather than a mood board.',\n ],\n },\n {\n id: 'c-05-r2',\n sender: 'Elliot Nakamura',\n senderEmail: 'elliot.nakamura@support.example',\n receivedFull: 'Tuesday at 10:18 AM',\n body: [\n 'Three is the right number. One request: hold the hero structure identical across all of them, so the thing we are judging is treatment and not layout. Last round nobody could tell which of the two they were reacting to.',\n ],\n },\n {\n // A second voice from the customer's side, and the only one in the\n // fixture. Every other thread is one customer and one agent, where\n // Reply and Reply all address the same person and the difference\n // between the two buttons never shows.\n id: 'c-05-r2b',\n sender: 'Ines Duarte',\n senderEmail: 'i.duarte@varda.example',\n receivedFull: 'Tuesday at 4:52 PM',\n body: [\n 'Copy is written against the current hero, so holding the structure suits us — I will only need to reflow if the headline slot changes length.',\n ],\n },\n {\n id: 'c-05-r3',\n sender: 'Hannah Lindqvist',\n senderEmail: 'h.lindqvist@varda.example',\n receivedFull: 'Yesterday at 11:40 AM',\n body: [\n 'Structure is fixed across all three. Sending the moody direction on its own first — it is the one most likely to split the room, and it reads differently without the other two beside it.',\n ],\n attachments: [\n {\n id: 'c-05-r3-a1',\n name: 'moody-direction-hero.png',\n src: '/template-assets/moody-home-horizontal-1.png',\n },\n ],\n },\n ],\n },\n {\n id: 'c-06',\n category: 'waiting',\n sender: 'Owen Baptiste',\n senderEmail: 'obaptiste@fieldstone.example',\n subject: 'Seat count says 145 but we only provisioned 120',\n preview:\n 'Finance flagged the discrepancy on the March invoice. Can we reconcile before the 30th?',\n tags: ['Billing'],\n receivedAt: 'Yesterday',\n receivedFull: 'Yesterday at 11:07 AM',\n isUnread: false,\n priority: 'high',\n assignee: 'You',\n body: [\n 'Finance flagged the discrepancy on the March invoice. Our directory shows 120 provisioned seats; the invoice bills 145.',\n 'My guess is deactivated accounts are still counted until the end of the billing period, but I cannot find that stated anywhere. Can we reconcile before the 30th?',\n ],\n attachments: [],\n },\n {\n id: 'c-07',\n category: 'inbox',\n sender: 'Aiko Tanaka',\n senderEmail: 'aiko.tanaka@shorebird.example',\n subject: 'Docs: the rate-limit page contradicts the API response header',\n preview:\n 'Page says 600/min, header reports 300/min on our plan. One of them is wrong.',\n tags: ['Docs'],\n receivedAt: 'Yesterday',\n receivedFull: 'Yesterday at 9:15 AM',\n isUnread: false,\n priority: 'normal',\n assignee: null,\n body: [\n 'The rate-limit page says 600 requests per minute on Scale. The X-RateLimit-Limit header on our responses reports 300.',\n 'One of them is wrong, and we have sized a batch job against the larger number.',\n ],\n attachments: [],\n },\n {\n id: 'c-08',\n category: 'resolved',\n sender: 'Reuben Cole',\n senderEmail: 'r.cole@atlasgrid.example',\n subject: 'Re: Scheduled maintenance window confirmation',\n preview: 'Confirmed for Sunday 02:00–04:00 UTC. Nothing further needed.',\n tags: ['Ops'],\n receivedAt: 'Mon',\n receivedFull: 'Monday at 2:40 PM',\n isUnread: false,\n priority: 'normal',\n assignee: 'You',\n body: [\n 'Confirmed for Sunday 02:00–04:00 UTC. We have notified our on-call and paused the nightly sync for that window.',\n 'Nothing further needed from your side.',\n ],\n attachments: [],\n },\n {\n id: 'c-09',\n category: 'inbox',\n sender: 'Sofia Marchetti',\n senderEmail: 'sofia@marchetti-legal.example',\n subject: 'Feature request: per-workspace retention policy',\n preview:\n 'Our legal team needs 30-day retention on one workspace and 400 on another.',\n tags: ['Feature request'],\n receivedAt: 'Mon',\n receivedFull: 'Monday at 10:02 AM',\n isUnread: false,\n priority: 'normal',\n assignee: null,\n body: [\n 'Our legal team needs 30-day retention on the client-intake workspace and 400 days on the matter-archive workspace.',\n 'Today retention is an account-level setting, so we are stuck applying the longer policy everywhere, which is the opposite of what our counsel wants.',\n ],\n attachments: [],\n },\n];\n\nconst CATEGORIES: { value: Category, label: string }[] = [\n { value: 'inbox', label: 'Inbox' },\n { value: 'escalations', label: 'Escalations' },\n { value: 'waiting', label: 'Waiting' },\n { value: 'resolved', label: 'Resolved' },\n]\n\n/** Inbox 是全部,其余三个是它的切面:一封会话同一时刻只有一个归属。 */\nfunction inCategory(item: Conversation, category: Category): boolean {\n return category === 'inbox' || item.category === category\n}\n\n/** 每个标签徽章显示该标签下的未读数。 */\nfunction unreadByCategory(): Record<Category, number> {\n const counts: Record<Category, number> = { inbox: 0, escalations: 0, waiting: 0, resolved: 0 }\n for (const item of CONVERSATIONS) {\n if (!item.isUnread)\n continue\n counts.inbox += 1\n if (item.category !== 'inbox')\n counts[item.category] += 1\n }\n return counts\n}\n\n/**\n * Stack 的内边距写在 CSS 变量上,嵌套的 Stack 会继承同一个值,\n * 所以给容器加内边距时用内联样式,避免子容器被动带上同样的留白。\n */\nfunction spacing(step: number): string {\n return `var(--astryx-spacing-${String(step).replace('.', '-')}, ${step * 4}px)`\n}\n\n/** 头像与姓名之间的间距,正文的缩进也用它。 */\nconst THREAD_HEADER_GAP = 3\n/** 头像的渲染宽度,正文缩进要对齐它。 */\nconst AVATAR_SIZE = 36\n/** 正文的最大阅读宽度。 */\nconst MESSAGE_MEASURE = 680\n\nconst category = ref<Category>('inbox')\n/** `undefined` 表示用户还没选过,按列表首条兜底;`null` 表示用户主动关掉了阅读栏。 */\nconst chosenId = ref<string | null | undefined>(undefined)\n\n/** 面板默认占内容区的 2/3,百分比相对内容区算,不写死像素。 */\nconst surfaceEl = ref<HTMLElement | null>(null)\nconst PANE_MIN_WIDTH = 440\n\nconst pane = useResizable({\n containerRef: surfaceEl,\n defaultSize: '66.67%',\n minSize: PANE_MIN_WIDTH,\n maxSize: '100%',\n})\n\n/** Layout 是组件,ref 拿到的是实例;这里取它的根元素当宽度基准。 */\nfunction setSurface(instance: unknown): void {\n surfaceEl.value = (instance as { $el?: HTMLElement } | null)?.$el ?? null\n}\n\nconst visible = computed(() => CONVERSATIONS.filter(item => inCategory(item, category.value)))\n\nconst openId = computed<string | null>(() => {\n if (chosenId.value === null)\n return null\n if (chosenId.value != null && visible.value.some(item => item.id === chosenId.value))\n return chosenId.value\n return visible.value[0]?.id ?? null\n})\n\nconst openConversation = computed(() => visible.value.find(item => item.id === openId.value) ?? null)\n\nconst unreadCount = computed(() => CONVERSATIONS.filter(item => item.isUnread).length)\nconst unreadCounts = unreadByCategory()\n\n/** 会话按时间正序展开,最新一条在最后并且默认展开。 */\nfunction threadMessages(conversation: Conversation): ThreadMessage[] {\n return [\n ...(conversation.replies ?? []),\n {\n id: conversation.id,\n sender: conversation.sender,\n senderEmail: conversation.senderEmail,\n receivedFull: conversation.receivedFull,\n body: conversation.body,\n attachments: conversation.attachments,\n },\n ]\n}\n\nfunction openThread(id: string): void {\n chosenId.value = id\n}\n\nconst rowStatus = useTableRowStatus<Conversation>({\n getStatus: item => (item.isUnread ? { color: 'accent', label: 'Unread' } : null),\n})\n\n/**\n * 状态点列:16px 缩进 + 8px 圆点 + 密度在每格后留的 12px,共 36px。\n * 原版用同样的 `transformColumns` 把插件自带的 28px 改成 36px(列 key 是插件内部的 `__rowStatus`)。\n */\nconst STATUS_GUTTER_WIDTH = 36\nconst alignStatusGutter: TablePlugin<Conversation> = {\n transformColumns: cols => cols.map(col => col.key === '__rowStatus' ? { ...col, width: pixel(STATUS_GUTTER_WIDTH) } : col),\n}\n\n/** 点到按钮、链接、勾选框这类控件时不算点中行本身。 */\nconst INTERACTIVE_WITHIN_ROW = 'button, input, a, label, [role=\"checkbox\"], [role=\"button\"], [role=\"menuitem\"]'\n\nfunction isRowItself(event: { target: EventTarget | null }): boolean {\n return !(event.target as HTMLElement | null)?.closest(INTERACTIVE_WITHIN_ROW)\n}\n\n/** 整行可点、可聚焦;当前打开的那一行整行铺上强调色浅底,并标 aria-current。 */\nconst inboxRows: TablePlugin<Conversation> = {\n rowPatch(item) {\n return {\n attrs: { 'tabindex': '0', 'aria-current': item.id === openId.value ? 'true' : undefined },\n class: 'inbox-row',\n onClick: (event) => {\n if (isRowItself(event))\n openThread(item.id)\n },\n }\n },\n cellPatch({ row, kind }) {\n if (kind !== 'body' || row == null || row.id !== openId.value)\n return null\n return { style: { backgroundColor: 'var(--astryx-color-accent-muted)' } }\n },\n}\n\nconst columns: TableColumn<Conversation>[] = [\n {\n key: 'conversation',\n header: 'Conversation',\n width: proportional(1, { minWidth: 160 }),\n renderCell: item => h(VStack, {\n gap: 0.5,\n }, () => [\n h(Text, { maxLines: 1, weight: item.isUnread ? 'semibold' : 'normal' }, () => item.sender),\n h(Text, { maxLines: 1, color: 'secondary' }, () => item.subject),\n ]),\n },\n {\n key: 'receivedAt',\n header: 'Received',\n width: pixel(120),\n align: 'end',\n renderCell: item => h(HStack, {\n gap: 1,\n vAlign: 'center',\n hAlign: 'end',\n }, () => [\n item.attachments.length === 0\n ? null\n : h(Icon, { name: 'heroicons:paper-clip', size: 'sm', color: 'gray', label: 'Has attachments' }),\n h(Text, {\n color: 'secondary',\n hasTabularNumbers: true,\n justify: 'end',\n maxLines: 1,\n weight: item.isUnread ? 'semibold' : 'normal',\n }, () => item.receivedAt),\n ]),\n },\n]\n\nconst threadActions = [\n { id: 'archive', label: 'Archive', icon: 'heroicons:archive-box' },\n { id: 'move', label: 'Move to folder', icon: 'heroicons:folder-arrow-down' },\n { id: 'delete', label: 'Delete', icon: 'heroicons:trash' },\n]\n<\/script>\n\n<template>\n <Layout :ref=\"setSurface\" height=\"fill\">\n <template #header>\n <LayoutHeader :padding=\"0\">\n <VStack :gap=\"0\">\n <HStack :gap=\"3\" align=\"center\" wrap=\"wrap\" :style=\"{ padding: spacing(4) }\">\n <StackItem size=\"fill\">\n <VStack :gap=\"0.5\">\n <Heading :level=\"1\">\n Inbox\n </Heading>\n <Text type=\"supporting\">\n {{ unreadCount }} unread of {{ CONVERSATIONS.length }} conversations\n </Text>\n </VStack>\n </StackItem>\n <Button label=\"Refresh\" variant=\"ghost\">\n <template #icon>\n <Icon name=\"heroicons:arrow-path\" size=\"sm\" />\n </template>\n </Button>\n <Button label=\"Compose\" variant=\"primary\">\n <template #icon>\n <Icon name=\"heroicons:pencil-square\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n <VStack :style=\"{ paddingInline: spacing(1) }\">\n <TabList v-model:value=\"category\" has-divider>\n <Tab\n v-for=\"option in CATEGORIES\"\n :key=\"option.value\"\n :value=\"option.value\"\n :label=\"option.label\"\n >\n {{ option.label }}\n <template v-if=\"unreadCounts[option.value] > 0\" #endContent>\n <Badge variant=\"neutral\" :label=\"`${unreadCounts[option.value]} unread`\">\n {{ unreadCounts[option.value] }}\n </Badge>\n </template>\n </Tab>\n </TabList>\n </VStack>\n </VStack>\n </LayoutHeader>\n </template>\n\n <LayoutContent :padding=\"4\" is-scrollable class=\"inbox-list\">\n <Table\n :data=\"visible\"\n :columns=\"columns\"\n id-key=\"id\"\n density=\"balanced\"\n dividers=\"none\"\n has-hover\n text-overflow=\"truncate\"\n :plugins=\"{ status: rowStatus, gutter: alignStatusGutter, rows: inboxRows }\"\n />\n </LayoutContent>\n\n <template v-if=\"openConversation\" #end>\n <ResizeHandle :resizable=\"pane.props\" is-reversed has-divider />\n <LayoutPanel :resizable=\"pane.props\" :padding=\"0\" role=\"complementary\" label=\"Conversation\">\n <VStack height=\"100%\">\n <VStack\n :style=\"{\n paddingBlockStart: spacing(4),\n paddingInlineStart: spacing(3),\n paddingInlineEnd: spacing(2),\n }\"\n >\n <Toolbar label=\"Conversation\">\n <template #start>\n <Heading :level=\"2\" :max-lines=\"2\" has-truncate-tooltip>\n {{ openConversation.subject }}\n </Heading>\n </template>\n <template #end>\n <IconButton\n v-for=\"action in threadActions\"\n :key=\"action.id\"\n variant=\"ghost\"\n :label=\"`${action.label} — ${openConversation.subject}`\"\n :tooltip=\"action.label\"\n >\n <Icon :name=\"action.icon\" size=\"sm\" />\n </IconButton>\n <IconButton\n variant=\"ghost\"\n label=\"Close conversation\"\n tooltip=\"Close\"\n @click=\"chosenId = null\"\n >\n <Icon name=\"heroicons:x-mark\" size=\"sm\" />\n </IconButton>\n </template>\n </Toolbar>\n </VStack>\n\n <StackItem size=\"fill\">\n <VStack\n :style=\"{ paddingInline: spacing(6), paddingBlockEnd: spacing(6) }\"\n :gap=\"4\"\n is-scrollable\n height=\"100%\"\n >\n <HStack :gap=\"2\" align=\"center\" wrap=\"wrap\">\n <Token\n v-if=\"openConversation.priority !== 'normal'\"\n size=\"sm\"\n :color=\"PRIORITY_COLOR[openConversation.priority]\"\n :label=\"PRIORITY_LABEL[openConversation.priority]\"\n />\n <Token\n v-for=\"tag in openConversation.tags\"\n :key=\"tag\"\n size=\"sm\"\n :label=\"tag\"\n />\n </HStack>\n\n <VStack :gap=\"2\">\n <CollapsibleGroup\n :key=\"openConversation.id\"\n type=\"multiple\"\n density=\"spacious\"\n :default-value=\"[openConversation.id]\"\n >\n <Collapsible\n v-for=\"message in threadMessages(openConversation)\"\n :key=\"message.id\"\n :value=\"message.id\"\n >\n <template #trigger>\n <HStack\n :gap=\"THREAD_HEADER_GAP\"\n align=\"center\"\n :style=\"{ paddingInlineEnd: spacing(2) }\"\n >\n <Avatar :name=\"message.sender\" :tooltip=\"false\" />\n <StackItem size=\"fill\">\n <VStack :gap=\"0.5\">\n <Text :max-lines=\"1\" weight=\"semibold\">\n {{ message.sender }}\n </Text>\n <Text :max-lines=\"1\" type=\"supporting\" color=\"secondary\">\n {{ message.senderEmail }}\n </Text>\n </VStack>\n </StackItem>\n <Text type=\"supporting\" color=\"secondary\">\n {{ message.receivedFull }}\n </Text>\n </HStack>\n </template>\n <HStack :gap=\"THREAD_HEADER_GAP\">\n <VStack :width=\"AVATAR_SIZE\" />\n <StackItem size=\"fill\">\n <VStack :gap=\"3\" :max-width=\"MESSAGE_MEASURE\">\n <Text v-for=\"paragraph in message.body\" :key=\"paragraph\">\n {{ paragraph }}\n </Text>\n <VStack v-if=\"message.attachments && message.attachments.length > 0\" :gap=\"2\">\n <HStack :gap=\"1\" align=\"center\">\n <Icon name=\"heroicons:paper-clip\" size=\"sm\" color=\"gray\" />\n <Text type=\"supporting\" color=\"secondary\">\n {{ message.attachments.length }} attachment{{ message.attachments.length === 1 ? '' : 's' }}\n </Text>\n </HStack>\n <HStack :gap=\"2\" wrap=\"wrap\">\n <Thumbnail\n v-for=\"attachment in message.attachments\"\n :key=\"attachment.id\"\n :src=\"attachment.src\"\n :alt=\"`Preview of ${attachment.name}`\"\n :label=\"attachment.name\"\n />\n </HStack>\n </VStack>\n </VStack>\n </StackItem>\n </HStack>\n </Collapsible>\n </CollapsibleGroup>\n </VStack>\n\n <HStack :gap=\"THREAD_HEADER_GAP\">\n <VStack :width=\"AVATAR_SIZE\" />\n <HStack :gap=\"2\">\n <Button label=\"Reply\" variant=\"primary\">\n <template #icon>\n <Icon name=\"heroicons:arrow-uturn-left\" size=\"sm\" />\n </template>\n </Button>\n <Button label=\"Reply all\" variant=\"secondary\">\n <template #icon>\n <Icon name=\"heroicons:users\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n </HStack>\n </VStack>\n </StackItem>\n </VStack>\n </LayoutPanel>\n </template>\n </Layout>\n</template>\n\n<style scoped>\n/* 原版 Table 的数据模式默认 `table-layout: fixed`,列宽严格按声明的比例分;core 目前是 auto,列宽按内容算、行高有 0.5px 的小数。 */\n.inbox-list :deep(.astryx-table) {\n table-layout: fixed;\n}\n</style>\n",
79447
79833
  "docsUrl": {
79448
79834
  "en": "https://astryx-vue.pages.dev/en/playground/?template=table-inbox",
79449
79835
  "zh": "https://astryx-vue.pages.dev/playground/?template=table-inbox"
@@ -79508,7 +79894,7 @@ const catalog = {
79508
79894
  ],
79509
79895
  "dependencies": ["@astryx-vue/core", "vue"],
79510
79896
  "isReady": true,
79511
- "source": "<script setup lang=\"ts\">\nimport {\n applyTableFilters,\n Banner,\n Button,\n Divider,\n EmptyState,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n MetadataList,\n MetadataListItem,\n pixel,\n proportional,\n SegmentedControl,\n SegmentedControlItem,\n StackItem,\n Table,\n Text,\n TextInput,\n useTableFiltering,\n useHoverCard,\n useTableFilterState,\n useTableSortable,\n useTableSortableState,\n VStack,\n} from '@astryx-vue/core'\nimport type { Component, PropType } from 'vue'\nimport type { TableColumn, TablePlugin } from '@astryx-vue/core'\nimport { computed, defineComponent, h, ref, shallowReactive, Teleport, watchEffect } from 'vue'\n\ntype LineCategory = 'design' | 'development' | 'content' | 'project'\ntype BillingBasis = 'hourly' | 'fixed'\n\ninterface LineItem extends Record<string, unknown> {\n id: string\n description: string\n category: LineCategory\n basis: BillingBasis\n quantity: number\n unitPriceCents: number\n}\n\ninterface InvoiceRow extends LineItem {\n amountCents: number\n}\n\nconst LINE_ITEMS: LineItem[] = [\n {\n id: 'li-01',\n description: 'Discovery workshop',\n category: 'design',\n basis: 'fixed',\n quantity: 1,\n unitPriceCents: 240_000,\n },\n {\n id: 'li-02',\n description: 'Stakeholder interviews',\n category: 'design',\n basis: 'hourly',\n quantity: 12,\n unitPriceCents: 14_500,\n },\n {\n id: 'li-03',\n description: 'User research synthesis',\n category: 'design',\n basis: 'hourly',\n quantity: 18,\n unitPriceCents: 14_500,\n },\n {\n id: 'li-04',\n description: 'Wireframes — key templates',\n category: 'design',\n basis: 'hourly',\n quantity: 34,\n unitPriceCents: 14_500,\n },\n {\n id: 'li-05',\n description: 'Visual design system',\n category: 'design',\n basis: 'fixed',\n quantity: 1,\n unitPriceCents: 680_000,\n },\n {\n id: 'li-06',\n description: 'Page designs — 14 templates',\n category: 'design',\n basis: 'hourly',\n quantity: 62,\n unitPriceCents: 14_500,\n },\n {\n id: 'li-07',\n description: 'Prototype & usability testing',\n category: 'design',\n basis: 'hourly',\n quantity: 22,\n unitPriceCents: 14_500,\n },\n {\n id: 'li-08',\n description: 'Frontend build — component library',\n category: 'development',\n basis: 'hourly',\n quantity: 88,\n unitPriceCents: 16_500,\n },\n {\n id: 'li-09',\n description: 'Frontend build — page templates',\n category: 'development',\n basis: 'hourly',\n quantity: 104,\n unitPriceCents: 16_500,\n },\n {\n id: 'li-10',\n description: 'CMS setup & content modelling',\n category: 'development',\n basis: 'hourly',\n quantity: 40,\n unitPriceCents: 16_500,\n },\n {\n id: 'li-11',\n description: 'Search & filtering integration',\n category: 'development',\n basis: 'hourly',\n quantity: 26,\n unitPriceCents: 16_500,\n },\n {\n id: 'li-12',\n description: 'Analytics & tag manager setup',\n category: 'development',\n basis: 'hourly',\n quantity: 10,\n unitPriceCents: 16_500,\n },\n {\n id: 'li-13',\n description: 'Accessibility audit & remediation',\n category: 'development',\n basis: 'fixed',\n quantity: 1,\n unitPriceCents: 320_000,\n },\n {\n id: 'li-14',\n description: 'Cross-browser QA',\n category: 'development',\n basis: 'hourly',\n quantity: 24,\n unitPriceCents: 13_500,\n },\n {\n id: 'li-15',\n description: 'Copywriting — 14 pages',\n category: 'content',\n basis: 'hourly',\n quantity: 36,\n unitPriceCents: 12_500,\n },\n {\n id: 'li-16',\n description: 'Content migration',\n category: 'content',\n basis: 'hourly',\n quantity: 28,\n unitPriceCents: 9_500,\n },\n {\n id: 'li-17',\n description: 'Stock photography licences',\n category: 'content',\n basis: 'fixed',\n quantity: 24,\n unitPriceCents: 1_800,\n },\n {\n id: 'li-18',\n description: 'Project management — 12 weeks',\n category: 'project',\n basis: 'fixed',\n quantity: 12,\n unitPriceCents: 85_000,\n },\n {\n id: 'li-19',\n description: 'Training & handover session',\n category: 'project',\n basis: 'fixed',\n quantity: 2,\n unitPriceCents: 120_000,\n },\n];\n\n\n\nconst ROWS: InvoiceRow[] = LINE_ITEMS.map(item => ({\n ...item,\n amountCents: item.quantity * item.unitPriceCents,\n}))\n\nconst CATEGORY_LABEL: Record<LineCategory, string> = {\n design: 'Design',\n development: 'Development',\n content: 'Content',\n project: 'Project management',\n}\n\nconst BASIS_FILTERS = [\n { value: 'all' as const, label: 'All lines' },\n { value: 'hourly' as const, label: 'Hourly' },\n]\n\nconst FILTER_FIELDS = [\n { key: 'description', label: 'Description', type: 'string' as const },\n {\n key: 'category',\n label: 'Category',\n type: 'enum' as const,\n options: [\n { value: 'design', label: 'Design' },\n { value: 'development', label: 'Development' },\n { value: 'content', label: 'Content' },\n { value: 'project', label: 'Project management' },\n ],\n },\n { key: 'quantity', label: 'Quantity', type: 'number' as const, isIntegerOnly: true },\n]\n\nconst TAX_RATE = 0.0875\n\nconst INVOICE_DETAILS = [\n { label: 'Billed to', value: 'Brightwater Coffee Co.' },\n { label: 'Project', value: 'Website redesign' },\n { label: 'Issue date', value: 'Mar 31, 2026' },\n { label: 'Due date', value: 'Apr 30, 2026' },\n { label: 'Payment terms', value: 'Net 30' },\n { label: 'Purchase order', value: 'PO-4471' },\n]\n\nconst currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })\nconst decimal = new Intl.NumberFormat('en-US')\n\n/** 搜索框起始位置的放大镜,供 `TextInput` 的 `startIcon` 使用。 */\nfunction SearchIcon() {\n return h(Icon, { icon: 'search', size: 'sm' })\n}\n\nfunction formatCents(cents: number): string {\n return currency.format(cents / 100)\n}\n\nconst ROWS_TOTAL_CENTS = LINE_ITEMS.reduce((sum, item) => sum + item.quantity * item.unitPriceCents, 0)\n\nconst BASIS_LABEL: Record<BillingBasis, string> = {\n hourly: 'Hourly',\n fixed: 'Fixed fee',\n}\n\n/**\n * 行元素登记处。表格的行级扩展点 `rowPatch.ref` 在 `<tr>` 挂上、卸下时写这里,\n * 单元格按 id 取到自己那一行,再把 `useHoverCard` 挂到整行上。\n */\nconst rowElements = shallowReactive(new Map<string, HTMLTableRowElement>())\n\n/**\n * 整行悬停详情。卡片挂在整行而不是单元格上,指针从行移到卡片上时不会被关掉,\n * 所以卡片里的 Dispute 按钮点得到。\n */\nconst DescriptionCell = defineComponent({\n props: { item: { type: Object as PropType<InvoiceRow>, required: true } },\n setup(props) {\n const card = useHoverCard({\n placement: 'below',\n alignment: 'start',\n label: `${props.item.description} details`,\n // 行挂上 hook 的那一刻还不是可聚焦元素,交给 auto 判断会连焦点事件都绑不上。\n focusTrigger: 'always',\n })\n\n watchEffect(() => {\n const row = rowElements.get(props.item.id) ?? null\n card.triggerRef.value = row\n if (row)\n row.tabIndex = 0\n })\n\n const share = computed(() => (props.item.amountCents / ROWS_TOTAL_CENTS) * 100)\n\n return () => [\n h(Text, { maxLines: 1 }, () => props.item.description),\n h(Teleport, { to: 'body' }, [\n h('div', {\n ...card.contentProps.value,\n 'ref': card.contentRef,\n 'class': 'astryx-hover-card',\n 'style': { ...card.contentStyle.value, display: card.isOpen.value ? undefined : 'none' },\n }, [\n h('div', { class: 'astryx-hover-card__content' }, [\n h(VStack, { gap: 3, width: 280 }, () => [\n h(Heading, { level: 3 }, () => props.item.description),\n h(MetadataList, { label: { position: 'start' } }, () => [\n h(MetadataListItem, { label: 'Category' }, () => CATEGORY_LABEL[props.item.category]),\n h(MetadataListItem, { label: 'Billing' }, () => BASIS_LABEL[props.item.basis]),\n h(MetadataListItem, { label: 'Share of invoice' }, () => `${share.value.toFixed(1)}%`),\n ]),\n h(Button, { label: 'Dispute this line', variant: 'secondary', width: '100%' }),\n ]),\n ]),\n ]),\n ]),\n ]\n },\n})\n\n/** 行级插件:登记每一行的元素,并把详情卡片所在的行标记成可聚焦。 */\nconst rowPlugin: TablePlugin<InvoiceRow> = {\n rowPatch(item) {\n return {\n ref: (element) => {\n if (element)\n rowElements.set(item.id, element)\n else\n rowElements.delete(item.id)\n },\n }\n },\n}\n\nconst query = ref('')\nconst basis = ref<'all' | BillingBasis>('all')\n\nconst { filters: columnFilters, onFilterChange, clearAll } = useTableFilterState()\n\nconst filterPlugin = useTableFiltering<InvoiceRow>(() => ({\n filters: columnFilters.value,\n onFilterChange,\n fields: FILTER_FIELDS,\n variant: 'popover',\n}))\n\nconst visible = computed(() => {\n let matched = applyTableFilters(ROWS, columnFilters.value, FILTER_FIELDS)\n if (basis.value !== 'all')\n matched = matched.filter(item => item.basis === basis.value)\n const term = query.value.trim().toLowerCase()\n if (term)\n matched = matched.filter(item => item.description.toLowerCase().includes(term))\n return matched\n})\n\nconst columns: TableColumn<InvoiceRow>[] = [\n {\n key: 'description',\n header: 'Description',\n width: proportional(3),\n sortable: { compare: (a, b) => a.description.localeCompare(b.description) },\n filter: 'description',\n renderCell: item => h(DescriptionCell as Component, { item }),\n },\n {\n key: 'category',\n header: 'Category',\n width: pixel(160),\n sortable: { compare: (a, b) => CATEGORY_LABEL[a.category].localeCompare(CATEGORY_LABEL[b.category]) },\n filter: 'category',\n renderCell: item => h(Text, { color: 'secondary', maxLines: 1 }, () => CATEGORY_LABEL[item.category]),\n },\n {\n key: 'quantity',\n header: 'Qty',\n width: pixel(80),\n align: 'end',\n sortable: { compare: (a, b) => a.quantity - b.quantity },\n filter: 'quantity',\n renderCell: item => h(Text, { hasTabularNumbers: true }, () => decimal.format(item.quantity)),\n },\n {\n key: 'unitPrice',\n header: 'Rate',\n width: pixel(120),\n align: 'end',\n sortable: { sortKey: 'unitPriceCents', compare: (a, b) => a.unitPriceCents - b.unitPriceCents },\n renderCell: item => h(Text, { hasTabularNumbers: true }, () => formatCents(item.unitPriceCents)),\n },\n {\n key: 'amount',\n header: 'Amount',\n width: pixel(132),\n align: 'end',\n sortable: { sortKey: 'amountCents', compare: (a, b) => a.amountCents - b.amountCents },\n renderCell: item => h(Text, { hasTabularNumbers: true }, () => formatCents(item.amountCents)),\n },\n]\n\nconst { sortedData, sortConfig } = useTableSortableState<InvoiceRow>({ data: visible, columns })\nconst sortPlugin = useTableSortable<InvoiceRow>(sortConfig)\n\nconst subtotalCents = computed(() => visible.value.reduce((sum, item) => sum + item.amountCents, 0))\nconst taxCents = computed(() => Math.round(subtotalCents.value * TAX_RATE))\nconst totalCents = computed(() => subtotalCents.value + taxCents.value)\n\nconst activeFilterCount = computed(() =>\n (query.value.trim() === '' ? 0 : 1)\n + Object.keys(columnFilters.value).length\n + (basis.value === 'all' ? 0 : 1),\n)\n\nfunction clearEverything(): void {\n query.value = ''\n basis.value = 'all'\n clearAll()\n}\n\nconst AMOUNT_COLUMN_WIDTH = 132\n<\/script>\n\n<template>\n <Layout height=\"fill\" :content-width=\"960\">\n <LayoutContent :padding=\"0\">\n <VStack :gap=\"8\" :padding=\"4\">\n <VStack :gap=\"6\" :padding-block-end=\"4\">\n <HStack :gap=\"3\" align=\"center\" wrap=\"wrap\">\n <StackItem size=\"fill\">\n <Heading :level=\"1\">\n Invoice INV-2043\n </Heading>\n </StackItem>\n <Button label=\"Download PDF\" variant=\"secondary\">\n <template #icon>\n <Icon icon=\"i-heroicons-arrow-down-tray\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n <MetadataList columns=\"multi\" orientation=\"vertical\" :label=\"{ position: 'top' }\">\n <MetadataListItem v-for=\"detail in INVOICE_DETAILS\" :key=\"detail.label\" :label=\"detail.label\">\n {{ detail.value }}\n </MetadataListItem>\n </MetadataList>\n </VStack>\n\n <HStack :gap=\"3\" align=\"center\" justify=\"between\" wrap=\"wrap\">\n <SegmentedControl v-model=\"basis\" label=\"Filter by billing basis\">\n <SegmentedControlItem\n v-for=\"filter in BASIS_FILTERS\"\n :key=\"filter.value\"\n :value=\"filter.value\"\n :label=\"filter.label\"\n />\n </SegmentedControl>\n <VStack :width=\"300\">\n <TextInput\n v-model=\"query\"\n label=\"Search line items\"\n is-label-hidden\n placeholder=\"Search…\"\n :start-icon=\"SearchIcon\"\n />\n </VStack>\n </HStack>\n\n <VStack :gap=\"6\">\n <EmptyState\n v-if=\"visible.length === 0\"\n title=\"No matching line items\"\n description=\"No line on this invoice matches the current filters.\"\n >\n <template #actions>\n <Button label=\"Clear filters\" variant=\"secondary\" @click=\"clearEverything\" />\n </template>\n </EmptyState>\n <VStack v-else :gap=\"4\">\n <div class=\"invoice-table\">\n <Table\n :data=\"sortedData\"\n :columns=\"columns\"\n id-key=\"id\"\n density=\"balanced\"\n dividers=\"none\"\n has-hover\n text-overflow=\"truncate\"\n :plugins=\"{ sort: sortPlugin, filter: filterPlugin, rows: rowPlugin }\"\n />\n </div>\n <VStack :gap=\"3\">\n <Divider />\n <HStack :gap=\"6\" justify=\"end\" align=\"center\">\n <Text>Subtotal</Text>\n <VStack :width=\"AMOUNT_COLUMN_WIDTH\">\n <Text justify=\"end\" display=\"block\" has-tabular-numbers>\n {{ formatCents(subtotalCents) }}\n </Text>\n </VStack>\n </HStack>\n <HStack :gap=\"6\" justify=\"end\" align=\"center\">\n <Text color=\"secondary\">\n Tax (8.75%)\n </Text>\n <VStack :width=\"AMOUNT_COLUMN_WIDTH\">\n <Text justify=\"end\" display=\"block\" has-tabular-numbers color=\"secondary\">\n {{ formatCents(taxCents) }}\n </Text>\n </VStack>\n </HStack>\n <Divider />\n <HStack :gap=\"6\" justify=\"end\" align=\"center\">\n <Text weight=\"semibold\">\n Total due\n </Text>\n <VStack :width=\"AMOUNT_COLUMN_WIDTH\">\n <Text justify=\"end\" display=\"block\" has-tabular-numbers weight=\"semibold\">\n {{ formatCents(totalCents) }}\n </Text>\n </VStack>\n </HStack>\n <Divider />\n </VStack>\n </VStack>\n <Banner\n v-if=\"visible.length > 0 && activeFilterCount > 0\"\n status=\"warning\"\n title=\"Filtered view — these totals are partial\"\n :description=\"`Showing ${visible.length} of ${ROWS.length} lines. The subtotal, tax, and total due above cover only the lines currently visible.`\"\n >\n <template #end>\n <Button label=\"Clear all filters\" variant=\"secondary\" @click=\"clearEverything\" />\n </template>\n </Banner>\n </VStack>\n </VStack>\n </LayoutContent>\n </Layout>\n</template>\n",
79897
+ "source": "<script setup lang=\"ts\">\nimport {\n applyTableFilters,\n Banner,\n Button,\n Divider,\n EmptyState,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n MetadataList,\n MetadataListItem,\n pixel,\n proportional,\n SegmentedControl,\n SegmentedControlItem,\n StackItem,\n Table,\n Text,\n TextInput,\n useTableFiltering,\n useHoverCard,\n useTableFilterState,\n useTableSortable,\n useTableSortableState,\n VStack,\n} from '@astryx-vue/core'\nimport type { Component, PropType } from 'vue'\nimport type { TableColumn, TablePlugin } from '@astryx-vue/core'\nimport { computed, defineComponent, h, ref, shallowReactive, Teleport, watchEffect } from 'vue'\n\ntype LineCategory = 'design' | 'development' | 'content' | 'project'\ntype BillingBasis = 'hourly' | 'fixed'\n\ninterface LineItem extends Record<string, unknown> {\n id: string\n description: string\n category: LineCategory\n basis: BillingBasis\n quantity: number\n unitPriceCents: number\n}\n\ninterface InvoiceRow extends LineItem {\n amountCents: number\n}\n\nconst LINE_ITEMS: LineItem[] = [\n {\n id: 'li-01',\n description: 'Discovery workshop',\n category: 'design',\n basis: 'fixed',\n quantity: 1,\n unitPriceCents: 240_000,\n },\n {\n id: 'li-02',\n description: 'Stakeholder interviews',\n category: 'design',\n basis: 'hourly',\n quantity: 12,\n unitPriceCents: 14_500,\n },\n {\n id: 'li-03',\n description: 'User research synthesis',\n category: 'design',\n basis: 'hourly',\n quantity: 18,\n unitPriceCents: 14_500,\n },\n {\n id: 'li-04',\n description: 'Wireframes — key templates',\n category: 'design',\n basis: 'hourly',\n quantity: 34,\n unitPriceCents: 14_500,\n },\n {\n id: 'li-05',\n description: 'Visual design system',\n category: 'design',\n basis: 'fixed',\n quantity: 1,\n unitPriceCents: 680_000,\n },\n {\n id: 'li-06',\n description: 'Page designs — 14 templates',\n category: 'design',\n basis: 'hourly',\n quantity: 62,\n unitPriceCents: 14_500,\n },\n {\n id: 'li-07',\n description: 'Prototype & usability testing',\n category: 'design',\n basis: 'hourly',\n quantity: 22,\n unitPriceCents: 14_500,\n },\n {\n id: 'li-08',\n description: 'Frontend build — component library',\n category: 'development',\n basis: 'hourly',\n quantity: 88,\n unitPriceCents: 16_500,\n },\n {\n id: 'li-09',\n description: 'Frontend build — page templates',\n category: 'development',\n basis: 'hourly',\n quantity: 104,\n unitPriceCents: 16_500,\n },\n {\n id: 'li-10',\n description: 'CMS setup & content modelling',\n category: 'development',\n basis: 'hourly',\n quantity: 40,\n unitPriceCents: 16_500,\n },\n {\n id: 'li-11',\n description: 'Search & filtering integration',\n category: 'development',\n basis: 'hourly',\n quantity: 26,\n unitPriceCents: 16_500,\n },\n {\n id: 'li-12',\n description: 'Analytics & tag manager setup',\n category: 'development',\n basis: 'hourly',\n quantity: 10,\n unitPriceCents: 16_500,\n },\n {\n id: 'li-13',\n description: 'Accessibility audit & remediation',\n category: 'development',\n basis: 'fixed',\n quantity: 1,\n unitPriceCents: 320_000,\n },\n {\n id: 'li-14',\n description: 'Cross-browser QA',\n category: 'development',\n basis: 'hourly',\n quantity: 24,\n unitPriceCents: 13_500,\n },\n {\n id: 'li-15',\n description: 'Copywriting — 14 pages',\n category: 'content',\n basis: 'hourly',\n quantity: 36,\n unitPriceCents: 12_500,\n },\n {\n id: 'li-16',\n description: 'Content migration',\n category: 'content',\n basis: 'hourly',\n quantity: 28,\n unitPriceCents: 9_500,\n },\n {\n id: 'li-17',\n description: 'Stock photography licences',\n category: 'content',\n basis: 'fixed',\n quantity: 24,\n unitPriceCents: 1_800,\n },\n {\n id: 'li-18',\n description: 'Project management — 12 weeks',\n category: 'project',\n basis: 'fixed',\n quantity: 12,\n unitPriceCents: 85_000,\n },\n {\n id: 'li-19',\n description: 'Training & handover session',\n category: 'project',\n basis: 'fixed',\n quantity: 2,\n unitPriceCents: 120_000,\n },\n];\n\n\n\nconst ROWS: InvoiceRow[] = LINE_ITEMS.map(item => ({\n ...item,\n amountCents: item.quantity * item.unitPriceCents,\n}))\n\nconst CATEGORY_LABEL: Record<LineCategory, string> = {\n design: 'Design',\n development: 'Development',\n content: 'Content',\n project: 'Project management',\n}\n\nconst BASIS_FILTERS = [\n { value: 'all' as const, label: 'All lines' },\n { value: 'hourly' as const, label: 'Hourly' },\n]\n\nconst FILTER_FIELDS = [\n { key: 'description', label: 'Description', type: 'string' as const },\n {\n key: 'category',\n label: 'Category',\n type: 'enum' as const,\n options: [\n { value: 'design', label: 'Design' },\n { value: 'development', label: 'Development' },\n { value: 'content', label: 'Content' },\n { value: 'project', label: 'Project management' },\n ],\n },\n { key: 'quantity', label: 'Quantity', type: 'number' as const, isIntegerOnly: true },\n]\n\nconst TAX_RATE = 0.0875\n\nconst INVOICE_DETAILS = [\n { label: 'Billed to', value: 'Brightwater Coffee Co.' },\n { label: 'Project', value: 'Website redesign' },\n { label: 'Issue date', value: 'Mar 31, 2026' },\n { label: 'Due date', value: 'Apr 30, 2026' },\n { label: 'Payment terms', value: 'Net 30' },\n { label: 'Purchase order', value: 'PO-4471' },\n]\n\nconst currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })\nconst decimal = new Intl.NumberFormat('en-US')\n\n/** 搜索框起始位置的放大镜,供 `TextInput` 的 `startIcon` 使用。 */\nfunction SearchIcon() {\n return h(Icon, { icon: 'search', size: 'sm' })\n}\n\nfunction formatCents(cents: number): string {\n return currency.format(cents / 100)\n}\n\nconst ROWS_TOTAL_CENTS = LINE_ITEMS.reduce((sum, item) => sum + item.quantity * item.unitPriceCents, 0)\n\nconst BASIS_LABEL: Record<BillingBasis, string> = {\n hourly: 'Hourly',\n fixed: 'Fixed fee',\n}\n\n/**\n * 行元素登记处。表格的行级扩展点 `rowPatch.ref` 在 `<tr>` 挂上、卸下时写这里,\n * 单元格按 id 取到自己那一行,再把 `useHoverCard` 挂到整行上。\n */\nconst rowElements = shallowReactive(new Map<string, HTMLTableRowElement>())\n\n/**\n * 整行悬停详情。卡片挂在整行而不是单元格上,指针从行移到卡片上时不会被关掉,\n * 所以卡片里的 Dispute 按钮点得到。\n */\nconst DescriptionCell = defineComponent({\n props: { item: { type: Object as PropType<InvoiceRow>, required: true } },\n setup(props) {\n const card = useHoverCard({\n placement: 'below',\n alignment: 'start',\n label: `${props.item.description} details`,\n // 行挂上 hook 的那一刻还不是可聚焦元素,交给 auto 判断会连焦点事件都绑不上。\n focusTrigger: 'always',\n })\n\n watchEffect(() => {\n const row = rowElements.get(props.item.id) ?? null\n card.triggerRef.value = row\n if (row)\n row.tabIndex = 0\n })\n\n const share = computed(() => (props.item.amountCents / ROWS_TOTAL_CENTS) * 100)\n\n return () => [\n h(Text, { maxLines: 1 }, () => props.item.description),\n h(Teleport, { to: 'body' }, [\n h('div', {\n ...card.contentProps.value,\n 'ref': card.contentRef,\n 'class': 'astryx-hover-card',\n 'style': { ...card.contentStyle.value, display: card.isOpen.value ? undefined : 'none' },\n }, [\n h('div', { class: 'astryx-hover-card__content' }, [\n h(VStack, { gap: 3, width: 280 }, () => [\n h(Heading, { level: 3, accessibilityLevel: 2 }, () => props.item.description),\n h(MetadataList, { label: { position: 'start' } }, () => [\n h(MetadataListItem, { label: 'Category' }, () => CATEGORY_LABEL[props.item.category]),\n h(MetadataListItem, { label: 'Billing' }, () => BASIS_LABEL[props.item.basis]),\n h(MetadataListItem, { label: 'Calculation' }, () => `${decimal.format(props.item.quantity)} \\u00D7 ${formatCents(props.item.unitPriceCents)}`),\n h(MetadataListItem, { label: 'Amount' }, () => formatCents(props.item.amountCents)),\n h(MetadataListItem, { label: 'Share of invoice' }, () => `${share.value.toFixed(1)}%`),\n ]),\n h(Button, { label: 'Dispute this line', variant: 'secondary', width: '100%' }),\n ]),\n ]),\n ]),\n ]),\n ]\n },\n})\n\n/** 首尾两列的单元格内边距归零,让表格文字和上面的 MetadataList 落在同一条内容线上。 */\nconst flushEdgesPlugin: TablePlugin<InvoiceRow> = {\n cellPatch({ column, columns }) {\n if (column.key === columns[0]?.key)\n return { style: { paddingInlineStart: 0 } }\n if (column.key === columns[columns.length - 1]?.key)\n return { style: { paddingInlineEnd: 0 } }\n return null\n },\n}\n\n/** 行级插件:登记每一行的元素,并把详情卡片所在的行标记成可聚焦。 */\nconst rowPlugin: TablePlugin<InvoiceRow> = {\n rowPatch(item) {\n return {\n ref: (element) => {\n if (element)\n rowElements.set(item.id, element)\n else\n rowElements.delete(item.id)\n },\n }\n },\n}\n\nconst query = ref('')\nconst basis = ref<'all' | BillingBasis>('all')\n\nconst { filters: columnFilters, onFilterChange, clearAll } = useTableFilterState()\n\nconst filterPlugin = useTableFiltering<InvoiceRow>(() => ({\n filters: columnFilters.value,\n onFilterChange,\n fields: FILTER_FIELDS,\n variant: 'popover',\n}))\n\nconst visible = computed(() => {\n let matched = applyTableFilters(ROWS, columnFilters.value, FILTER_FIELDS)\n if (basis.value !== 'all')\n matched = matched.filter(item => item.basis === basis.value)\n const term = query.value.trim().toLowerCase()\n if (term)\n matched = matched.filter(item => item.description.toLowerCase().includes(term))\n return matched\n})\n\nconst columns: TableColumn<InvoiceRow>[] = [\n {\n key: 'description',\n header: 'Description',\n width: proportional(3),\n sortable: { compare: (a, b) => a.description.localeCompare(b.description) },\n filter: 'description',\n renderCell: item => h(DescriptionCell as Component, { item }),\n },\n {\n key: 'category',\n header: 'Category',\n width: pixel(160),\n sortable: { compare: (a, b) => CATEGORY_LABEL[a.category].localeCompare(CATEGORY_LABEL[b.category]) },\n filter: 'category',\n renderCell: item => h(Text, { color: 'secondary', maxLines: 1 }, () => CATEGORY_LABEL[item.category]),\n },\n {\n key: 'quantity',\n header: 'Qty',\n width: pixel(80),\n align: 'end',\n sortable: { compare: (a, b) => a.quantity - b.quantity },\n filter: 'quantity',\n renderCell: item => h(Text, { hasTabularNumbers: true }, () => decimal.format(item.quantity)),\n },\n {\n key: 'unitPrice',\n header: 'Rate',\n width: pixel(120),\n align: 'end',\n sortable: { sortKey: 'unitPriceCents', compare: (a, b) => a.unitPriceCents - b.unitPriceCents },\n renderCell: item => h(Text, { hasTabularNumbers: true }, () => formatCents(item.unitPriceCents)),\n },\n {\n key: 'amount',\n header: 'Amount',\n width: pixel(132),\n align: 'end',\n sortable: { sortKey: 'amountCents', compare: (a, b) => a.amountCents - b.amountCents },\n renderCell: item => h(Text, { hasTabularNumbers: true }, () => formatCents(item.amountCents)),\n },\n]\n\nconst { sortedData, sortConfig } = useTableSortableState<InvoiceRow>({ data: visible, columns })\nconst sortPlugin = useTableSortable<InvoiceRow>(sortConfig)\n\nconst subtotalCents = computed(() => visible.value.reduce((sum, item) => sum + item.amountCents, 0))\nconst taxCents = computed(() => Math.round(subtotalCents.value * TAX_RATE))\nconst totalCents = computed(() => subtotalCents.value + taxCents.value)\n\nconst activeFilterCount = computed(() =>\n (query.value.trim() === '' ? 0 : 1)\n + Object.keys(columnFilters.value).length\n + (basis.value === 'all' ? 0 : 1),\n)\n\nfunction clearEverything(): void {\n query.value = ''\n basis.value = 'all'\n clearAll()\n}\n\nconst AMOUNT_COLUMN_WIDTH = 132\n<\/script>\n\n<template>\n <Layout height=\"fill\" :content-width=\"960\">\n <LayoutContent :padding=\"0\">\n <VStack :gap=\"8\" :padding=\"4\">\n <VStack :gap=\"6\" :padding-block-end=\"4\">\n <HStack :gap=\"3\" align=\"center\" wrap=\"wrap\">\n <StackItem size=\"fill\">\n <Heading :level=\"1\">\n Invoice INV-2043\n </Heading>\n </StackItem>\n <Button label=\"Download PDF\" variant=\"secondary\">\n <template #icon>\n <Icon icon=\"i-heroicons-arrow-down-tray\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n <MetadataList columns=\"multi\" orientation=\"vertical\" :label=\"{ position: 'top' }\">\n <MetadataListItem v-for=\"detail in INVOICE_DETAILS\" :key=\"detail.label\" :label=\"detail.label\">\n {{ detail.value }}\n </MetadataListItem>\n </MetadataList>\n </VStack>\n\n <HStack :gap=\"3\" align=\"center\" justify=\"between\" wrap=\"wrap\">\n <SegmentedControl v-model=\"basis\" label=\"Filter by billing basis\">\n <SegmentedControlItem\n v-for=\"filter in BASIS_FILTERS\"\n :key=\"filter.value\"\n :value=\"filter.value\"\n :label=\"filter.label\"\n />\n </SegmentedControl>\n <VStack :width=\"300\">\n <TextInput\n v-model=\"query\"\n label=\"Search line items\"\n is-label-hidden\n placeholder=\"Search…\"\n :start-icon=\"SearchIcon\"\n />\n </VStack>\n </HStack>\n\n <VStack :gap=\"6\">\n <EmptyState\n v-if=\"visible.length === 0\"\n title=\"No matching line items\"\n description=\"No line on this invoice matches the current filters.\"\n >\n <template #actions>\n <Button label=\"Clear filters\" variant=\"secondary\" @click=\"clearEverything\" />\n </template>\n </EmptyState>\n <VStack v-else :gap=\"4\">\n <div class=\"invoice-table\">\n <Table\n :data=\"sortedData\"\n :columns=\"columns\"\n id-key=\"id\"\n density=\"balanced\"\n dividers=\"none\"\n has-hover\n text-overflow=\"truncate\"\n :plugins=\"{ sort: sortPlugin, filter: filterPlugin, rows: rowPlugin, flush: flushEdgesPlugin }\"\n />\n </div>\n <VStack :gap=\"3\">\n <Divider />\n <HStack :gap=\"6\" justify=\"end\" align=\"center\">\n <Text>Subtotal</Text>\n <VStack :width=\"AMOUNT_COLUMN_WIDTH\">\n <Text justify=\"end\" display=\"block\" has-tabular-numbers>\n {{ formatCents(subtotalCents) }}\n </Text>\n </VStack>\n </HStack>\n <HStack :gap=\"6\" justify=\"end\" align=\"center\">\n <Text color=\"secondary\">\n Tax (8.75%)\n </Text>\n <VStack :width=\"AMOUNT_COLUMN_WIDTH\">\n <Text justify=\"end\" display=\"block\" has-tabular-numbers color=\"secondary\">\n {{ formatCents(taxCents) }}\n </Text>\n </VStack>\n </HStack>\n <Divider />\n <HStack :gap=\"6\" justify=\"end\" align=\"center\">\n <Text weight=\"semibold\">\n Total due\n </Text>\n <VStack :width=\"AMOUNT_COLUMN_WIDTH\">\n <Text justify=\"end\" display=\"block\" has-tabular-numbers weight=\"semibold\">\n {{ formatCents(totalCents) }}\n </Text>\n </VStack>\n </HStack>\n <Divider />\n </VStack>\n </VStack>\n <Banner\n v-if=\"visible.length > 0 && activeFilterCount > 0\"\n status=\"warning\"\n title=\"Filtered view — these totals are partial\"\n :description=\"`Showing ${visible.length} of ${ROWS.length} lines. The subtotal, tax, and total due above cover only the lines currently visible.`\"\n >\n <template #end>\n <Button label=\"Clear all filters\" variant=\"secondary\" @click=\"clearEverything\" />\n </template>\n </Banner>\n </VStack>\n </VStack>\n </LayoutContent>\n </Layout>\n</template>\n\n<style scoped>\n/* 原版 Table 的数据模式默认 `table-layout: fixed`,列宽严格按声明的比例分;core 目前是 auto,列宽按内容算、行高有 0.5px 的小数。 */\n.invoice-table :deep(.astryx-table) {\n table-layout: fixed;\n}\n</style>\n",
79512
79898
  "docsUrl": {
79513
79899
  "en": "https://astryx-vue.pages.dev/en/playground/?template=table-page",
79514
79900
  "zh": "https://astryx-vue.pages.dev/playground/?template=table-page"
@@ -79562,6 +79948,7 @@ const catalog = {
79562
79948
  "Heading",
79563
79949
  "HStack",
79564
79950
  "Icon",
79951
+ "IconButton",
79565
79952
  "Layout",
79566
79953
  "LayoutContent",
79567
79954
  "LayoutPanel",
@@ -79580,7 +79967,7 @@ const catalog = {
79580
79967
  ],
79581
79968
  "dependencies": ["@astryx-vue/core", "vue"],
79582
79969
  "isReady": true,
79583
- "source": "<script setup lang=\"ts\">\nimport {\n Avatar,\n Badge,\n BreadcrumbItem,\n Breadcrumbs,\n Button,\n Card,\n Divider,\n DropdownMenu,\n EmptyState,\n Heading,\n HStack,\n Icon,\n Layout,\n LayoutContent,\n LayoutPanel,\n Link,\n Markdown,\n pixel,\n proportional,\n Section,\n StackItem,\n Tab,\n TabList,\n Table,\n Text,\n TextInput,\n Token,\n Toolbar,\n useTableSortable,\n useTableSortableState,\n useTableTreeState,\n VStack,\n} from '@astryx-vue/core'\nimport type { TableColumn } from '@astryx-vue/core'\nimport { computed, h, ref } from 'vue'\n\ntype EntryKind = 'folder' | 'typescript' | 'json' | 'yaml' | 'markdown' | 'text'\n\ninterface RepoEntry extends Record<string, unknown> {\n /** 完整路径,同时当父级链接和行标识用。 */\n id: string\n name: string\n kind: EntryKind\n /** 文件夹本身为空,`withRollups` 会把下面最新的一次提交抄上来。 */\n commitMessage: string\n /** 文件夹为 0,`withRollups` 取子项里最新的那个。 */\n committedAt: number\n children?: RepoEntry[]\n}\n\nconst ROOT_NAME = 'canvas-editor'\n\n/** 固定时钟:所有时间是相对它的偏移,任何环境里都显示同一句话。 */\nconst NOW = Date.UTC(2026, 8, 9, 16, 0)\n\nconst minutesAgo = (count: number): number => NOW - count * 60_000\nconst hoursAgo = (count: number): number => NOW - count * 3_600_000\nconst daysAgo = (count: number): number => NOW - count * 86_400_000\n\nfunction file(path: string, kind: EntryKind, commitMessage: string, committedAt: number): RepoEntry {\n return { id: path, name: path.slice(path.lastIndexOf('/') + 1), kind, commitMessage, committedAt }\n}\n\nfunction folder(path: string, children: RepoEntry[]): RepoEntry {\n return { id: path, name: path.slice(path.lastIndexOf('/') + 1), kind: 'folder', commitMessage: '', committedAt: 0, children }\n}\n\n/** 搜索框起始位置的放大镜,供 `TextInput` 的 `startIcon` 使用。 */\nfunction SearchIcon() {\n return h(Icon, { name: 'heroicons:magnifying-glass', size: 'sm' })\n}\nconst REPO_TREE: RepoEntry[] = [\n folder('.github', [\n folder('.github/workflows', [\n file(\n '.github/workflows/ci.yml',\n 'yaml',\n 'ci: cache the pnpm store between runs (#412)',\n hoursAgo(3),\n ),\n file(\n '.github/workflows/release.yml',\n 'yaml',\n 'ci: publish npm provenance on tagged releases (#398)',\n daysAgo(12),\n ),\n ]),\n file(\n '.github/PULL_REQUEST_TEMPLATE.md',\n 'markdown',\n 'docs: ask for a repro link in bug reports (#377)',\n daysAgo(61),\n ),\n ]),\n folder('docs', [\n folder('docs/architecture', [\n file(\n 'docs/architecture/rendering.md',\n 'markdown',\n 'docs: explain the dirty-rect redraw path (#405)',\n daysAgo(5),\n ),\n file(\n 'docs/architecture/state.md',\n 'markdown',\n 'docs: describe undo stack ownership (#390)',\n daysAgo(21),\n ),\n ]),\n // The one nested README. GitHub renders whichever README sits in the\n // folder you are looking at, so this folder gets a preview and the others\n // get none — which is the behaviour worth showing.\n file(\n 'docs/README.md',\n 'markdown',\n 'docs: add an index for the docs folder (#411)',\n daysAgo(3),\n ),\n file(\n 'docs/contributing.md',\n 'markdown',\n 'docs: document the release checklist (#402)',\n daysAgo(8),\n ),\n file(\n 'docs/getting-started.md',\n 'markdown',\n 'docs: add a first-plugin walkthrough (#369)',\n daysAgo(64),\n ),\n ]),\n folder('src', [\n folder('src/components', [\n // Three levels deep, so the header trail can outgrow the bar and the\n // collapse menu has something to collapse.\n folder('src/components/canvas', [\n file(\n 'src/components/canvas/Canvas.tsx',\n 'typescript',\n 'fix: keep pointer capture through a drag (#417)',\n hoursAgo(2),\n ),\n file(\n 'src/components/canvas/layers.ts',\n 'typescript',\n 'feat: let a group be locked without hiding it (#413)',\n hoursAgo(9),\n ),\n file(\n 'src/components/canvas/pointer.ts',\n 'typescript',\n 'fix: release capture when the window blurs (#417)',\n hoursAgo(2),\n ),\n ]),\n file(\n 'src/components/Inspector.tsx',\n 'typescript',\n 'feat: group the transform fields into one panel (#409)',\n daysAgo(4),\n ),\n file(\n 'src/components/Toolbar.tsx',\n 'typescript',\n 'refactor: derive tool state from the store (#396)',\n daysAgo(14),\n ),\n ]),\n folder('src/lib', [\n file(\n 'src/lib/geometry.ts',\n 'typescript',\n 'perf: stop reallocating the bounds buffer (#414)',\n hoursAgo(20),\n ),\n file(\n 'src/lib/serialize.ts',\n 'typescript',\n 'fix: round-trip empty groups without loss (#401)',\n daysAgo(9),\n ),\n file(\n 'src/lib/shapes.ts',\n 'typescript',\n 'feat: add polygon and star primitives (#411)',\n daysAgo(3),\n ),\n ]),\n file(\n 'src/main.ts',\n 'typescript',\n 'chore: move bootstrap behind a feature flag (#415)',\n daysAgo(1),\n ),\n file(\n 'src/ui.tsx',\n 'typescript',\n 'fix: restore focus after the modal closes (#407)',\n daysAgo(6),\n ),\n ]),\n folder('tests', [\n file(\n 'tests/geometry.test.ts',\n 'typescript',\n 'test: cover degenerate bounding boxes (#414)',\n hoursAgo(20),\n ),\n file(\n 'tests/serialize.test.ts',\n 'typescript',\n 'test: add a fixture for nested groups (#401)',\n daysAgo(9),\n ),\n ]),\n file(\n '.gitignore',\n 'text',\n 'chore: ignore local coverage output (#380)',\n daysAgo(49),\n ),\n file(\n 'LICENSE',\n 'text',\n 'chore: bump the copyright year (#352)',\n daysAgo(243),\n ),\n file(\n 'README.md',\n 'markdown',\n 'docs: rewrite the install section (#418)',\n minutesAgo(30),\n ),\n file('package.json', 'json', 'chore: bump vite to 6.2 (#416)', hoursAgo(18)),\n file(\n 'tsconfig.json',\n 'json',\n 'build: turn on verbatimModuleSyntax (#393)',\n daysAgo(35),\n ),\n];\n\n/**\n * Give every folder the newest commit beneath it — message and date together,\n * taken from the same child so the two columns never describe different\n * commits. Runs bottom-up, so a folder always agrees with what is inside it.\n\n// ============= README + 搜索 + 折叠辅助 =============\n\n/** 目录给每个文件取它下面最新的那次提交,两列才不会各说各的。 */\nfunction withRollups(nodes: RepoEntry[]): RepoEntry[] {\n return nodes.map((node) => {\n if (!node.children || node.children.length === 0)\n return node\n const children = withRollups(node.children)\n const newest = children.reduce((latest, child) => (child.committedAt > latest.committedAt ? child : latest))\n return { ...node, children, commitMessage: newest.commitMessage, committedAt: newest.committedAt }\n })\n}\n\nconst ROLLED_UP = withRollups(REPO_TREE)\nconst DEFAULT_EXPANDED = ['src']\n\n/** 搜索命中的判定,行和计数共用一套,两边不会各算各的。 */\nfunction isMatch(node: RepoEntry, needle: string): boolean {\n return node.name.toLowerCase().includes(needle) || node.commitMessage.toLowerCase().includes(needle)\n}\n\nfunction countMatches(nodes: RepoEntry[], needle: string): number {\n return nodes.reduce(\n (total, node) => total + (isMatch(node, needle) ? 1 : 0) + (node.children ? countMatches(node.children, needle) : 0),\n 0,\n )\n}\n\n/** 留下命中的行和它们的祖先,祖先只是路径。 */\nfunction pruneTree(nodes: RepoEntry[], needle: string): RepoEntry[] {\n const out: RepoEntry[] = []\n for (const node of nodes) {\n const children = node.children ? pruneTree(node.children, needle) : []\n if (isMatch(node, needle) || children.length > 0)\n out.push(children.length > 0 ? { ...node, children } : node)\n }\n return out\n}\n\nfunction collectIds(nodes: RepoEntry[], into: Set<string>): Set<string> {\n for (const node of nodes) {\n if (node.children?.length) {\n into.add(node.id)\n collectIds(node.children, into)\n }\n }\n return into\n}\n\n/** 时间用固定时钟倒推,保证每台机器上都是「2 小时前」。 */\nconst relativeFormat = new Intl.RelativeTimeFormat('en-US', { numeric: 'auto' })\n\nconst AGE_UNITS: ReadonlyArray<readonly [Intl.RelativeTimeFormatUnit, number]> = [\n ['year', 365 * 86_400_000],\n ['month', 30 * 86_400_000],\n ['day', 86_400_000],\n ['hour', 3_600_000],\n ['minute', 60_000],\n]\n\nfunction commitAge(epochMs: number): string {\n const elapsed = NOW - epochMs\n for (const [unit, size] of AGE_UNITS) {\n if (elapsed >= size || unit === 'minute')\n return relativeFormat.format(-Math.floor(elapsed / size), unit)\n }\n return 'just now'\n}\n\n/** 当前目录里有 README 才显示预览,表格和预览卡不会互相矛盾。 */\nfunction readmeFor(folderId: string | null): string | null {\n let entries = REPO_TREE\n if (folderId != null) {\n for (const segment of folderId.split('/')) {\n const next: RepoEntry | undefined = entries.find(entry => entry.name === segment && entry.kind === 'folder')\n if (next?.children == null)\n return null\n entries = next.children\n }\n }\n const hasReadme = entries.some(entry => entry.kind !== 'folder' && /^readme\\.md$/i.test(entry.name))\n return hasReadme ? (READMES[folderId ?? ''] ?? null) : null\n}\n\n// ============= 右侧栏文案 =============\n\nconst REPO_DESCRIPTION\n = 'A small, embeddable vector canvas. Draw shapes, group them, and serialise the result to JSON that round-trips without loss.'\n\nconst TOPICS = ['canvas', 'vector-graphics', 'typescript', 'editor', 'svg', '2d-graphics', 'wasm']\n\nconst ABOUT_LINKS = [\n { label: 'Readme', icon: 'heroicons:book-open' },\n { label: 'MIT license', icon: 'heroicons:scale' },\n { label: 'Code of conduct', icon: 'heroicons:shield-check' },\n { label: 'Security policy', icon: 'heroicons:shield-check' },\n { label: 'Activity', icon: 'heroicons:chart-bar' },\n]\n\nconst STAT_ACTION: Record<string, string> = {\n stars: 'Star',\n watching: 'Watch',\n forks: 'Fork',\n}\n\nconst REPO_STATS = [\n { label: 'stars', value: '12.6k', icon: 'heroicons:star' },\n { label: 'watching', value: '97', icon: 'heroicons:eye' },\n { label: 'forks', value: '1.3k', icon: 'carbon:fork' },\n]\n\nconst REF_COUNTS = [\n { label: '171 Branches', icon: 'carbon:branch' },\n { label: '56 Tags', icon: 'heroicons:tag' },\n]\n\nconst CONTRIBUTORS = [\n 'Ada Okafor',\n 'Bo Lindqvist',\n 'Cass Moreau',\n 'Dev Raman',\n 'Elif Aydın',\n 'Finn Ashworth',\n 'Gia Petrova',\n 'Hana Sato',\n 'Ines Duarte',\n 'Jo Mbeki',\n 'Kit Nakamura',\n 'Lena Voss',\n]\n\nconst TAB_PLACEHOLDER = {\n conduct: 'The Contributor Covenant, version 2.1, applies to this project.',\n license: 'MIT. Do what you like, keep the notice, expect no warranty.',\n security: 'Report vulnerabilities privately through a security advisory.',\n}\n\n// ============= 状态 =============\n\nconst query = ref('')\nconst selectedFolderId = ref<string | null>(null)\nconst manualExpandedIds = ref<ReadonlySet<string>>(new Set(DEFAULT_EXPANDED))\nconst readmeTab = ref('readme')\n\nconst needle = computed(() => query.value.trim().toLowerCase())\nconst isSearching = computed(() => needle.value.length > 0)\n\nconst data = computed(() => (isSearching.value ? withRollups(pruneTree(ROLLED_UP, needle.value)) : ROLLED_UP))\nconst expandedIds = computed(() =>\n isSearching.value ? collectIds(data.value, new Set<string>()) : manualExpandedIds.value,\n)\n\nconst { sort, sortConfig } = useTableSortableState<RepoEntry>({ data: () => data.value })\n\nconst SIBLING_COMPARATORS: Record<string, (a: RepoEntry, b: RepoEntry) => number> = {\n name: (a, b) => Number(b.kind === 'folder') - Number(a.kind === 'folder') || a.name.localeCompare(b.name),\n message: (a, b) => a.commitMessage.localeCompare(b.commitMessage),\n committed: (a, b) => a.committedAt - b.committedAt,\n}\n\n/** 只排同一层的兄弟节点,文件夹的层级关系不会被排序打散。 */\nfunction applySiblingSort(siblings: RepoEntry[]): RepoEntry[] {\n if (sort.value.length === 0)\n return siblings\n const list = [...siblings]\n for (const entry of [...sort.value].reverse()) {\n const compare = SIBLING_COMPARATORS[entry.sortKey]\n if (compare == null)\n continue\n const direction = entry.direction === 'descending' ? -1 : 1\n list.sort((a, b) => compare(a, b) * direction)\n }\n return list\n}\n\nconst { visibleData, plugin: treePlugin } = useTableTreeState<RepoEntry>(() => ({\n data: data.value,\n idKey: 'id',\n childrenKey: 'children',\n expandedIds: expandedIds.value,\n onExpandedIdsChange: (ids) => {\n if (!isSearching.value)\n manualExpandedIds.value = ids\n },\n sortSiblings: applySiblingSort,\n}))\n\nconst sortPlugin = useTableSortable<RepoEntry>(sortConfig)\nconst matchCount = computed(() => (isSearching.value ? countMatches(ROLLED_UP, needle.value) : 0))\nconst readme = computed(() => readmeFor(selectedFolderId.value))\nconst hasRepoTabs = computed(() => selectedFolderId.value == null)\nconst activeTab = computed(() => (hasRepoTabs.value ? readmeTab.value : 'readme'))\n\nconst crumbs = computed(() => {\n const segments = selectedFolderId.value?.split('/') ?? []\n return [\n { label: ROOT_NAME, id: null as string | null },\n ...segments.map((segment, index) => ({ label: segment, id: segments.slice(0, index + 1).join('/') })),\n ]\n})\n\nfunction selectFolder(id: string): void {\n selectedFolderId.value = id\n const next = new Set(manualExpandedIds.value)\n next.add(id)\n manualExpandedIds.value = next\n}\n\nconst columns: TableColumn<RepoEntry>[] = [\n {\n key: 'name',\n header: 'Name',\n width: proportional(2, { minWidth: 240 }),\n sortable: true,\n renderCell: entry => h(HStack, { gap: 2, vAlign: 'center' }, () => [\n h(Icon, {\n name: entry.kind === 'folder' ? 'heroicons:folder' : 'heroicons:document',\n size: 'sm',\n color: entry.kind === 'folder' ? 'accent' : 'secondary',\n }),\n entry.kind === 'folder'\n ? h(Link, {\n href: '#',\n onClick: (event: Event) => {\n event.preventDefault()\n selectFolder(entry.id)\n },\n }, () => entry.name)\n : h(Link, { href: '#' }, () => entry.name),\n ]),\n },\n {\n key: 'message',\n header: 'Last commit message',\n width: proportional(3, { minWidth: 200 }),\n sortable: true,\n renderCell: entry => h(Text, { color: 'secondary', maxLines: 1 }, () => entry.commitMessage),\n },\n {\n key: 'committed',\n header: 'Last commit date',\n width: pixel(160),\n align: 'end',\n sortable: true,\n renderCell: entry => h(Text, { color: 'secondary' }, () => commitAge(entry.committedAt)),\n },\n]\n\nconst branchItems = [\n { label: 'main', onClick: () => {} },\n { label: 'next', onClick: () => {} },\n { type: 'divider' as const },\n { label: 'View all branches', onClick: () => {} },\n]\n\nconst addFileItems = [\n { label: 'Create new file', onClick: () => {} },\n { label: 'Upload files', onClick: () => {} },\n]\nconst README = `## canvas-editor\n\nA small, embeddable vector canvas. Draw shapes, group them, and serialise the\nresult to JSON that round-trips without loss.\n\n### Install\n\n\\`\\`\\`bash\nnpm install canvas-editor\n\\`\\`\\`\n\n### Usage\n\n\\`\\`\\`ts\nconst {Canvas} = await import('canvas-editor');\n\nconst canvas = new Canvas(document.querySelector('#root'));\ncanvas.add({kind: 'rect', x: 0, y: 0, width: 120, height: 80});\n\\`\\`\\`\n\n### Status\n\n| Area | State | Notes |\n| ----------- | -------- | -------------------------------------- |\n| Rendering | Stable | Dirty-rect redraw, no full repaints |\n| Serialising | Stable | Round-trips groups and nested shapes |\n| Plugins | Beta | API may change before 1.0 |\n\n### Contributing\n\nRead [the contributing guide](#) first. In short:\n\n- One change per pull request, with a test that fails without it.\n- Run \\`npm test\\` before pushing.\n- Conventional commits — the changelog is generated from them.\n\n> Issues tagged \\`good first issue\\` are a reasonable place to start.\n`;\n\nconst DOCS_README = `## Documentation\n\nHow the canvas is put together, and how to work on it.\n\n### Start here\n\n- **getting-started.md** — install the package and draw your first shape.\n- **contributing.md** — branch naming, the release checklist, how to get review.\n\n### Architecture\n\nThe \\`architecture/\\` folder covers the parts that are hard to infer from the\nsource: how the dirty-rect redraw path decides what to repaint, and who owns\nthe undo stack.\n`;\n\n/*\n * Which folders have a README to show. Keyed by folder id, with the empty\n * string standing for the repo root.\n *\n * Whether a preview appears is decided by the tree, not by this map — a\n * folder shows one only if it actually contains a README file. This just\n * supplies the body for the folders that do, so the two cannot disagree\n * about which folders have one.\n */\nconst READMES: Record<string, string> = {\n '': README,\n docs: DOCS_README,\n};\n<\/script>\n\n<template>\n <Layout height=\"auto\" :content-width=\"1440\">\n <template #header>\n <Toolbar label=\"Repository\" size=\"sm\" :dividers=\"['bottom']\">\n <template #start>\n <HStack :gap=\"2\" align=\"center\">\n <Heading :level=\"1\" class=\"repo-visually-hidden\">\n {{ ROOT_NAME }}\n </Heading>\n <Breadcrumbs label=\"Folder path\">\n <BreadcrumbItem\n v-for=\"(crumb, index) in crumbs\"\n :key=\"crumb.id ?? 'root'\"\n :is-current=\"index === crumbs.length - 1\"\n @click=\"index < crumbs.length - 1 ? selectedFolderId = crumb.id : undefined\"\n >\n {{ crumb.label }}\n </BreadcrumbItem>\n </Breadcrumbs>\n <Badge label=\"Public\" variant=\"neutral\" />\n </HStack>\n </template>\n <template #end>\n <Button\n v-for=\"stat in REPO_STATS\"\n :key=\"stat.label\"\n size=\"sm\"\n :label=\"STAT_ACTION[stat.label]\"\n >\n <template #icon>\n <Icon :name=\"stat.icon\" size=\"sm\" />\n </template>\n <template #end>\n <Badge :label=\"stat.value\" />\n </template>\n </Button>\n </template>\n </Toolbar>\n </template>\n\n <LayoutContent :padding=\"4\">\n <EmptyState\n v-if=\"data.length === 0\"\n title=\"No matching files\"\n description=\"No file or folder in this project matches your search.\"\n >\n <template #actions>\n <Button label=\"Clear search\" variant=\"secondary\" @click=\"query = ''\" />\n </template>\n </EmptyState>\n\n <VStack v-else :gap=\"6\">\n <HStack :gap=\"2\" align=\"center\" wrap=\"wrap\">\n <DropdownMenu has-chevron size=\"sm\" :items=\"branchItems\">\n <template #trigger=\"triggerProps\">\n <Button v-bind=\"triggerProps\" label=\"main\" variant=\"secondary\" size=\"sm\">\n <template #icon>\n <Icon name=\"carbon:branch\" size=\"sm\" />\n </template>\n </Button>\n </template>\n </DropdownMenu>\n <HStack :gap=\"1\" align=\"center\">\n <Button\n v-for=\"ref in REF_COUNTS\"\n :key=\"ref.label\"\n :label=\"ref.label\"\n variant=\"ghost\"\n size=\"sm\"\n >\n <template #icon>\n <Icon :name=\"ref.icon\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n <StackItem size=\"fill\">\n <HStack :gap=\"2\" h-align=\"end\" align=\"center\">\n <Text v-if=\"isSearching && matchCount > 0\" type=\"supporting\" color=\"secondary\">\n {{ matchCount }} {{ matchCount === 1 ? 'result' : 'results' }}\n </Text>\n <TextInput\n v-model=\"query\"\n label=\"Go to file\"\n is-label-hidden\n placeholder=\"Go to file\"\n has-clear\n :start-icon=\"SearchIcon\"\n />\n <DropdownMenu has-chevron size=\"sm\" :items=\"addFileItems\" :button=\"{ label: 'Add file', size: 'sm' }\" />\n <Button label=\"Code\" variant=\"primary\" size=\"sm\">\n <template #icon>\n <Icon name=\"heroicons:code-bracket\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n </StackItem>\n </HStack>\n\n <Card :padding=\"0\">\n <Table\n :data=\"visibleData\"\n :columns=\"columns\"\n id-key=\"id\"\n density=\"compact\"\n dividers=\"none\"\n is-striped\n text-overflow=\"truncate\"\n :plugins=\"{ tree: treePlugin, sort: sortPlugin }\"\n />\n </Card>\n\n <Card v-if=\"readme != null\" :padding=\"0\">\n <Section variant=\"transparent\" :padding=\"0\" :padding-block-start=\"1\">\n <TabList v-model:value=\"readmeTab\" size=\"sm\" has-divider>\n <Tab value=\"readme\" label=\"Readme\" />\n <template v-if=\"hasRepoTabs\">\n <Tab value=\"conduct\" label=\"Code of conduct\" />\n <Tab value=\"license\" label=\"MIT license\" />\n <Tab value=\"security\" label=\"Security\" />\n </template>\n </TabList>\n </Section>\n <Section variant=\"transparent\" :padding=\"6\">\n <Markdown\n v-if=\"activeTab === 'readme'\"\n :heading-level-start=\"2\"\n content-align=\"center\"\n :content=\"readme\"\n />\n <Text v-else color=\"secondary\">\n {{ TAB_PLACEHOLDER[activeTab as keyof typeof TAB_PLACEHOLDER] }}\n </Text>\n </Section>\n </Card>\n </VStack>\n </LayoutContent>\n\n <template #end>\n <LayoutPanel :width=\"296\" :padding=\"4\" :is-scrollable=\"false\" role=\"complementary\" label=\"About this repository\">\n <VStack :gap=\"6\">\n <VStack :gap=\"3\">\n <Heading :level=\"3\">\n About\n </Heading>\n <Text color=\"secondary\">\n {{ REPO_DESCRIPTION }}\n </Text>\n <Link href=\"#\" is-external-link>\n canvas-editor.dev\n </Link>\n <HStack :gap=\"1\" wrap=\"wrap\">\n <Token\n v-for=\"topic in TOPICS\"\n :key=\"topic\"\n size=\"sm\"\n :label=\"topic\"\n @click=\"() => {}\"\n />\n </HStack>\n <VStack :gap=\"2\">\n <HStack v-for=\"link in ABOUT_LINKS\" :key=\"link.label\" :gap=\"2\" align=\"center\">\n <Icon :name=\"link.icon\" size=\"sm\" color=\"secondary\" />\n <Link href=\"#\">\n {{ link.label }}\n </Link>\n </HStack>\n </VStack>\n <VStack :gap=\"2\">\n <HStack v-for=\"stat in REPO_STATS\" :key=\"stat.label\" :gap=\"2\" align=\"center\">\n <Icon :name=\"stat.icon\" size=\"sm\" color=\"secondary\" />\n <Link href=\"#\">\n {{ stat.value }} {{ stat.label }}\n </Link>\n </HStack>\n </VStack>\n </VStack>\n\n <Divider />\n\n <VStack :gap=\"3\">\n <Heading :level=\"3\">\n Releases\n </Heading>\n <HStack :gap=\"2\" align=\"center\">\n <Icon name=\"heroicons:tag\" size=\"sm\" color=\"success\" />\n <Text weight=\"bold\">\n v2.4.0\n </Text>\n <Badge label=\"Latest\" variant=\"success\" />\n </HStack>\n <Text type=\"supporting\" color=\"secondary\">\n {{ commitAge(daysAgo(11)) }} · 36 releases\n </Text>\n </VStack>\n\n <Divider />\n\n <VStack :gap=\"3\">\n <Heading :level=\"3\">\n Contributors\n </Heading>\n <HStack :gap=\"1\" wrap=\"wrap\">\n <Avatar v-for=\"name in CONTRIBUTORS\" :key=\"name\" :name=\"name\" :size=\"32\" />\n </HStack>\n <Link href=\"#\">\n + 61 contributors\n </Link>\n </VStack>\n </VStack>\n </LayoutPanel>\n </template>\n </Layout>\n</template>\n\n<style>\n.repo-visually-hidden {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n border: 0;\n}\n</style>\n",
79970
+ "source": "<script setup lang=\"ts\">\nimport {\n Avatar,\n Badge,\n BreadcrumbItem,\n Breadcrumbs,\n Button,\n Card,\n Divider,\n DropdownMenu,\n EmptyState,\n Heading,\n HStack,\n Icon,\n IconButton,\n Layout,\n LayoutContent,\n LayoutPanel,\n Link,\n Markdown,\n pixel,\n proportional,\n Section,\n StackItem,\n Tab,\n TabList,\n Table,\n Text,\n TextInput,\n Token,\n Toolbar,\n useMediaQuery,\n useTableColumnResize,\n useTableSortable,\n useTableSortableState,\n useTableTreeState,\n VStack,\n} from '@astryx-vue/core'\nimport type { TableColumn } from '@astryx-vue/core'\nimport { computed, defineComponent, h, nextTick, onBeforeUnmount, ref, watch } from 'vue'\n\ntype EntryKind = 'folder' | 'typescript' | 'json' | 'yaml' | 'markdown' | 'text'\n\ninterface RepoEntry extends Record<string, unknown> {\n /** 完整路径,同时当父级链接和行标识用。 */\n id: string\n name: string\n kind: EntryKind\n /** 文件夹本身为空,`withRollups` 会把下面最新的一次提交抄上来。 */\n commitMessage: string\n /** 文件夹为 0,`withRollups` 取子项里最新的那个。 */\n committedAt: number\n children?: RepoEntry[]\n}\n\nconst ROOT_NAME = 'canvas-editor'\n\n/** 固定时钟:所有时间是相对它的偏移,任何环境里都显示同一句话。 */\nconst NOW = Date.UTC(2026, 8, 9, 16, 0)\n\nconst minutesAgo = (count: number): number => NOW - count * 60_000\nconst hoursAgo = (count: number): number => NOW - count * 3_600_000\nconst daysAgo = (count: number): number => NOW - count * 86_400_000\n\nfunction file(path: string, kind: EntryKind, commitMessage: string, committedAt: number): RepoEntry {\n return { id: path, name: path.slice(path.lastIndexOf('/') + 1), kind, commitMessage, committedAt }\n}\n\nfunction folder(path: string, children: RepoEntry[]): RepoEntry {\n return { id: path, name: path.slice(path.lastIndexOf('/') + 1), kind: 'folder', commitMessage: '', committedAt: 0, children }\n}\n\n/** 搜索框起始位置的放大镜,供 `TextInput` 的 `startIcon` 使用。 */\nfunction SearchIcon() {\n return h(Icon, { name: 'heroicons:magnifying-glass', size: 'sm' })\n}\nconst REPO_TREE: RepoEntry[] = [\n folder('.github', [\n folder('.github/workflows', [\n file(\n '.github/workflows/ci.yml',\n 'yaml',\n 'ci: cache the pnpm store between runs (#412)',\n hoursAgo(3),\n ),\n file(\n '.github/workflows/release.yml',\n 'yaml',\n 'ci: publish npm provenance on tagged releases (#398)',\n daysAgo(12),\n ),\n ]),\n file(\n '.github/PULL_REQUEST_TEMPLATE.md',\n 'markdown',\n 'docs: ask for a repro link in bug reports (#377)',\n daysAgo(61),\n ),\n ]),\n folder('docs', [\n folder('docs/architecture', [\n file(\n 'docs/architecture/rendering.md',\n 'markdown',\n 'docs: explain the dirty-rect redraw path (#405)',\n daysAgo(5),\n ),\n file(\n 'docs/architecture/state.md',\n 'markdown',\n 'docs: describe undo stack ownership (#390)',\n daysAgo(21),\n ),\n ]),\n // The one nested README. GitHub renders whichever README sits in the\n // folder you are looking at, so this folder gets a preview and the others\n // get none — which is the behaviour worth showing.\n file(\n 'docs/README.md',\n 'markdown',\n 'docs: add an index for the docs folder (#411)',\n daysAgo(3),\n ),\n file(\n 'docs/contributing.md',\n 'markdown',\n 'docs: document the release checklist (#402)',\n daysAgo(8),\n ),\n file(\n 'docs/getting-started.md',\n 'markdown',\n 'docs: add a first-plugin walkthrough (#369)',\n daysAgo(64),\n ),\n ]),\n folder('src', [\n folder('src/components', [\n // Three levels deep, so the header trail can outgrow the bar and the\n // collapse menu has something to collapse.\n folder('src/components/canvas', [\n file(\n 'src/components/canvas/Canvas.tsx',\n 'typescript',\n 'fix: keep pointer capture through a drag (#417)',\n hoursAgo(2),\n ),\n file(\n 'src/components/canvas/layers.ts',\n 'typescript',\n 'feat: let a group be locked without hiding it (#413)',\n hoursAgo(9),\n ),\n file(\n 'src/components/canvas/pointer.ts',\n 'typescript',\n 'fix: release capture when the window blurs (#417)',\n hoursAgo(2),\n ),\n ]),\n file(\n 'src/components/Inspector.tsx',\n 'typescript',\n 'feat: group the transform fields into one panel (#409)',\n daysAgo(4),\n ),\n file(\n 'src/components/Toolbar.tsx',\n 'typescript',\n 'refactor: derive tool state from the store (#396)',\n daysAgo(14),\n ),\n ]),\n folder('src/lib', [\n file(\n 'src/lib/geometry.ts',\n 'typescript',\n 'perf: stop reallocating the bounds buffer (#414)',\n hoursAgo(20),\n ),\n file(\n 'src/lib/serialize.ts',\n 'typescript',\n 'fix: round-trip empty groups without loss (#401)',\n daysAgo(9),\n ),\n file(\n 'src/lib/shapes.ts',\n 'typescript',\n 'feat: add polygon and star primitives (#411)',\n daysAgo(3),\n ),\n ]),\n file(\n 'src/main.ts',\n 'typescript',\n 'chore: move bootstrap behind a feature flag (#415)',\n daysAgo(1),\n ),\n file(\n 'src/ui.tsx',\n 'typescript',\n 'fix: restore focus after the modal closes (#407)',\n daysAgo(6),\n ),\n ]),\n folder('tests', [\n file(\n 'tests/geometry.test.ts',\n 'typescript',\n 'test: cover degenerate bounding boxes (#414)',\n hoursAgo(20),\n ),\n file(\n 'tests/serialize.test.ts',\n 'typescript',\n 'test: add a fixture for nested groups (#401)',\n daysAgo(9),\n ),\n ]),\n file(\n '.gitignore',\n 'text',\n 'chore: ignore local coverage output (#380)',\n daysAgo(49),\n ),\n file(\n 'LICENSE',\n 'text',\n 'chore: bump the copyright year (#352)',\n daysAgo(243),\n ),\n file(\n 'README.md',\n 'markdown',\n 'docs: rewrite the install section (#418)',\n minutesAgo(30),\n ),\n file('package.json', 'json', 'chore: bump vite to 6.2 (#416)', hoursAgo(18)),\n file(\n 'tsconfig.json',\n 'json',\n 'build: turn on verbatimModuleSyntax (#393)',\n daysAgo(35),\n ),\n];\n\n/**\n * Give every folder the newest commit beneath it — message and date together,\n * taken from the same child so the two columns never describe different\n * commits. Runs bottom-up, so a folder always agrees with what is inside it.\n\n// ============= README + 搜索 + 折叠辅助 =============\n\n/** 目录给每个文件取它下面最新的那次提交,两列才不会各说各的。 */\nfunction withRollups(nodes: RepoEntry[]): RepoEntry[] {\n return nodes.map((node) => {\n if (!node.children || node.children.length === 0)\n return node\n const children = withRollups(node.children)\n const newest = children.reduce((latest, child) => (child.committedAt > latest.committedAt ? child : latest))\n return { ...node, children, commitMessage: newest.commitMessage, committedAt: newest.committedAt }\n })\n}\n\nconst ROLLED_UP = withRollups(REPO_TREE)\nconst DEFAULT_EXPANDED = ['src']\n\n/** 搜索命中的判定,行和计数共用一套,两边不会各算各的。 */\nfunction isMatch(node: RepoEntry, needle: string): boolean {\n return node.name.toLowerCase().includes(needle) || node.commitMessage.toLowerCase().includes(needle)\n}\n\nfunction countMatches(nodes: RepoEntry[], needle: string): number {\n return nodes.reduce(\n (total, node) => total + (isMatch(node, needle) ? 1 : 0) + (node.children ? countMatches(node.children, needle) : 0),\n 0,\n )\n}\n\n/** 留下命中的行和它们的祖先,祖先只是路径。 */\nfunction pruneTree(nodes: RepoEntry[], needle: string): RepoEntry[] {\n const out: RepoEntry[] = []\n for (const node of nodes) {\n const children = node.children ? pruneTree(node.children, needle) : []\n if (isMatch(node, needle) || children.length > 0)\n out.push(children.length > 0 ? { ...node, children } : node)\n }\n return out\n}\n\nfunction collectIds(nodes: RepoEntry[], into: Set<string>): Set<string> {\n for (const node of nodes) {\n if (node.children?.length) {\n into.add(node.id)\n collectIds(node.children, into)\n }\n }\n return into\n}\n\n/** 时间用固定时钟倒推,保证每台机器上都是「2 小时前」。 */\nconst relativeFormat = new Intl.RelativeTimeFormat('en-US', { numeric: 'auto' })\n\nconst AGE_UNITS: ReadonlyArray<readonly [Intl.RelativeTimeFormatUnit, number]> = [\n ['year', 365 * 86_400_000],\n ['month', 30 * 86_400_000],\n ['day', 86_400_000],\n ['hour', 3_600_000],\n ['minute', 60_000],\n]\n\nfunction commitAge(epochMs: number): string {\n const elapsed = NOW - epochMs\n for (const [unit, size] of AGE_UNITS) {\n if (elapsed >= size || unit === 'minute')\n return relativeFormat.format(-Math.floor(elapsed / size), unit)\n }\n return 'just now'\n}\n\n/** 当前目录里有 README 才显示预览,表格和预览卡不会互相矛盾。 */\nfunction readmeFor(folderId: string | null): string | null {\n let entries = REPO_TREE\n if (folderId != null) {\n for (const segment of folderId.split('/')) {\n const next: RepoEntry | undefined = entries.find(entry => entry.name === segment && entry.kind === 'folder')\n if (next?.children == null)\n return null\n entries = next.children\n }\n }\n const hasReadme = entries.some(entry => entry.kind !== 'folder' && /^readme\\.md$/i.test(entry.name))\n return hasReadme ? (READMES[folderId ?? ''] ?? null) : null\n}\n\n// ============= 右侧栏文案 =============\n\nconst REPO_DESCRIPTION\n = 'A small, embeddable vector canvas. Draw shapes, group them, and serialise the result to JSON that round-trips without loss.'\n\nconst TOPICS = ['canvas', 'vector-graphics', 'typescript', 'editor', 'svg', '2d-graphics', 'wasm']\n\nconst ABOUT_LINKS = [\n { label: 'Readme', icon: 'heroicons:book-open' },\n { label: 'MIT license', icon: 'heroicons:scale' },\n { label: 'Code of conduct', icon: 'heroicons:shield-check' },\n { label: 'Security policy', icon: 'heroicons:shield-check' },\n { label: 'Activity', icon: 'heroicons:chart-bar' },\n]\n\n/** 文件夹图标:和原版(lucide Folder)一样是实心的,文件图标是描边。 */\nfunction lucideGlyph(paths: string[], isFilled: boolean) {\n return () => h('svg', {\n 'xmlns': 'http://www.w3.org/2000/svg',\n 'viewBox': '0 0 24 24',\n 'fill': isFilled ? 'currentColor' : 'none',\n 'stroke': 'currentColor',\n 'stroke-width': 2,\n 'stroke-linecap': 'round',\n 'stroke-linejoin': 'round',\n 'width': '1em',\n 'height': '1em',\n 'aria-hidden': 'true',\n }, paths.map(d => h('path', { d })))\n}\nconst FolderGlyph = lucideGlyph(['M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z'], true)\n\nconst STAT_ACTION: Record<string, string> = {\n stars: 'Star',\n watching: 'Watch',\n forks: 'Fork',\n}\n\nconst REPO_STATS = [\n { label: 'stars', value: '12.6k', icon: 'heroicons:star' },\n { label: 'watching', value: '97', icon: 'heroicons:eye' },\n { label: 'forks', value: '1.3k', icon: 'carbon:fork' },\n]\n\n/** 侧栏在视口 ≥1024 时占右边 296px,窄了就挪到 README 下面。 */\nconst isSidebarInline = useMediaQuery('(min-width: 1024px)')\n\n/** 表头和正文各自量宽,≤720 时按钮收成图标、搜索框收成图标按钮。 */\nconst NARROW_WIDTH = 720\nconst headerEl = ref<HTMLElement | null>(null)\nconst bodyEl = ref<HTMLElement | null>(null)\nconst headerWidth = ref<number | null>(null)\nconst bodyWidth = ref<number | null>(null)\nconst isNarrowHeader = computed(() => headerWidth.value != null && headerWidth.value <= NARROW_WIDTH)\nconst isNarrowBody = computed(() => bodyWidth.value != null && bodyWidth.value <= NARROW_WIDTH)\nconst isSearchOpen = ref(false)\n\nfunction setHeader(instance: unknown): void {\n headerEl.value = (instance as { $el?: HTMLElement } | null)?.$el ?? null\n}\n\nconst searchInput = ref<{ $el?: HTMLElement } | null>(null)\n\n/** 窄屏下搜索收成图标按钮;点开后把焦点给输入框。 */\nfunction openSearch(): void {\n isSearchOpen.value = true\n void nextTick(() => searchInput.value?.$el?.querySelector('input')?.focus())\n}\n\nfunction setBody(instance: unknown): void {\n bodyEl.value = (instance as { $el?: HTMLElement } | null)?.$el ?? null\n}\n\nfunction watchWidth(target: typeof headerEl, width: typeof headerWidth): void {\n let observer: ResizeObserver | null = null\n watch(target, (element) => {\n observer?.disconnect()\n observer = null\n if (!element || typeof ResizeObserver === 'undefined')\n return\n observer = new ResizeObserver(([entry]) => {\n width.value = entry?.contentRect.width ?? null\n })\n observer.observe(element)\n }, { immediate: true, flush: 'post' })\n onBeforeUnmount(() => observer?.disconnect())\n}\nwatchWidth(headerEl, headerWidth)\nwatchWidth(bodyEl, bodyWidth)\n\nconst REF_COUNTS = [\n { label: '171 Branches', icon: 'carbon:branch' },\n { label: '56 Tags', icon: 'heroicons:tag' },\n]\n\nconst CONTRIBUTORS = [\n 'Ada Okafor',\n 'Bo Lindqvist',\n 'Cass Moreau',\n 'Dev Raman',\n 'Elif Aydın',\n 'Finn Ashworth',\n 'Gia Petrova',\n 'Hana Sato',\n 'Ines Duarte',\n 'Jo Mbeki',\n 'Kit Nakamura',\n 'Lena Voss',\n]\n\n/** 右侧 About 栏的内容。宽屏放在 Layout 的 end 槽,窄屏挪到 README 下面,所以抽成一个组件复用。 */\nconst AboutPanel = defineComponent({\n setup() {\n const row = (icon: string, color: 'secondary' | undefined, text: string, key: string) =>\n h(HStack, { key, gap: 2, vAlign: 'center' }, () => [\n h(Icon, { name: icon, size: 'sm', color }),\n h(Link, { href: '#' }, () => text),\n ])\n return () => h(VStack, { gap: 6 }, () => [\n h(VStack, { gap: 3 }, () => [\n h(Heading, { level: 3 }, () => 'About'),\n h(Text, { color: 'secondary' }, () => REPO_DESCRIPTION),\n h(Link, { href: '#', isExternalLink: true }, () => 'canvas-editor.dev'),\n h(HStack, { gap: 1, wrap: 'wrap' }, () => TOPICS.map(topic => h(Token, { key: topic, size: 'sm', label: topic, onClick: () => {} }))),\n h(VStack, { gap: 2 }, () => ABOUT_LINKS.map(link => row(link.icon, 'secondary', link.label, link.label))),\n h(VStack, { gap: 2 }, () => REPO_STATS.map(stat => row(stat.icon, 'secondary', `${stat.value} ${stat.label}`, stat.label))),\n ]),\n h(Divider),\n h(VStack, { gap: 3 }, () => [\n h(Heading, { level: 3 }, () => 'Releases'),\n h(HStack, { gap: 2, vAlign: 'center' }, () => [\n h(Icon, { name: 'heroicons:tag', size: 'sm', color: 'success' }),\n h(Text, { weight: 'bold' }, () => 'v2.4.0'),\n h(Badge, { label: 'Latest', variant: 'success' }),\n ]),\n h(Text, { type: 'supporting', color: 'secondary' }, () => `${commitAge(daysAgo(11))} · 36 releases`),\n ]),\n h(Divider),\n h(VStack, { gap: 3 }, () => [\n h(Heading, { level: 3 }, () => 'Contributors'),\n h(HStack, { gap: 1, wrap: 'wrap' }, () => CONTRIBUTORS.map(name => h(Avatar, { key: name, name, size: 32 }))),\n h(Link, { href: '#' }, () => '+ 61 contributors'),\n ]),\n ])\n },\n})\n\n\nconst TAB_PLACEHOLDER = {\n conduct: 'The Contributor Covenant, version 2.1, applies to this project.',\n license: 'MIT. Do what you like, keep the notice, expect no warranty.',\n security: 'Report vulnerabilities privately through a security advisory.',\n}\n\n// ============= 状态 =============\n\nconst query = ref('')\nconst selectedFolderId = ref<string | null>(null)\nconst manualExpandedIds = ref<ReadonlySet<string>>(new Set(DEFAULT_EXPANDED))\nconst readmeTab = ref('readme')\n\nconst needle = computed(() => query.value.trim().toLowerCase())\nconst isSearching = computed(() => needle.value.length > 0)\n\nconst data = computed(() => (isSearching.value ? withRollups(pruneTree(ROLLED_UP, needle.value)) : ROLLED_UP))\nconst expandedIds = computed(() =>\n isSearching.value ? collectIds(data.value, new Set<string>()) : manualExpandedIds.value,\n)\n\nconst { sort, sortConfig } = useTableSortableState<RepoEntry>({ data: () => data.value })\n\nconst SIBLING_COMPARATORS: Record<string, (a: RepoEntry, b: RepoEntry) => number> = {\n name: (a, b) => Number(b.kind === 'folder') - Number(a.kind === 'folder') || a.name.localeCompare(b.name),\n message: (a, b) => a.commitMessage.localeCompare(b.commitMessage),\n committed: (a, b) => a.committedAt - b.committedAt,\n}\n\n/** 只排同一层的兄弟节点,文件夹的层级关系不会被排序打散。 */\nfunction applySiblingSort(siblings: RepoEntry[]): RepoEntry[] {\n if (sort.value.length === 0)\n return siblings\n const list = [...siblings]\n for (const entry of [...sort.value].reverse()) {\n const compare = SIBLING_COMPARATORS[entry.sortKey]\n if (compare == null)\n continue\n const direction = entry.direction === 'descending' ? -1 : 1\n list.sort((a, b) => compare(a, b) * direction)\n }\n return list\n}\n\nconst { visibleData, plugin: treePlugin } = useTableTreeState<RepoEntry>(() => ({\n data: data.value,\n idKey: 'id',\n hasExpandAllControl: true,\n childrenKey: 'children',\n expandedIds: expandedIds.value,\n onExpandedIdsChange: (ids) => {\n if (!isSearching.value)\n manualExpandedIds.value = ids\n },\n sortSiblings: applySiblingSort,\n}))\n\nconst sortPlugin = useTableSortable<RepoEntry>(sortConfig)\n\n/** 列宽可以拖;拖完记下来,下次渲染沿用。 */\nconst columnWidths = ref<Record<string, number>>({})\nconst resizePlugin = useTableColumnResize<RepoEntry>(() => ({\n columnWidths: columnWidths.value,\n onColumnResizeEnd: (updates) => {\n columnWidths.value = { ...columnWidths.value, ...updates }\n },\n columns,\n}))\nconst matchCount = computed(() => (isSearching.value ? countMatches(ROLLED_UP, needle.value) : 0))\nconst readme = computed(() => readmeFor(selectedFolderId.value))\nconst hasRepoTabs = computed(() => selectedFolderId.value == null)\nconst activeTab = computed(() => (hasRepoTabs.value ? readmeTab.value : 'readme'))\n\nconst crumbs = computed(() => {\n const segments = selectedFolderId.value?.split('/') ?? []\n return [\n { label: ROOT_NAME, id: null as string | null },\n ...segments.map((segment, index) => ({ label: segment, id: segments.slice(0, index + 1).join('/') })),\n ]\n})\n\nfunction selectFolder(id: string): void {\n selectedFolderId.value = id\n const next = new Set(manualExpandedIds.value)\n next.add(id)\n manualExpandedIds.value = next\n}\n\nconst columns: TableColumn<RepoEntry>[] = [\n {\n key: 'name',\n header: 'Name',\n width: proportional(2, { minWidth: 240 }),\n sortable: true,\n renderCell: entry => h(HStack, { gap: 2, vAlign: 'center' }, () => [\n entry.kind === 'folder'\n ? h(Icon, { icon: FolderGlyph, size: 'sm', color: 'accent' })\n : h(Icon, { name: 'heroicons:document', size: 'sm', color: 'secondary' }),\n entry.kind === 'folder'\n ? h(Link, {\n href: '#',\n onClick: (event: Event) => {\n event.preventDefault()\n selectFolder(entry.id)\n },\n }, () => entry.name)\n : h(Link, { href: '#' }, () => entry.name),\n ]),\n },\n {\n key: 'message',\n header: 'Last commit message',\n width: proportional(3, { minWidth: 200 }),\n sortable: true,\n renderCell: entry => h(Text, { color: 'secondary', maxLines: 1 }, () => entry.commitMessage),\n },\n {\n key: 'committed',\n header: 'Last commit date',\n width: pixel(160),\n align: 'end',\n sortable: true,\n renderCell: entry => h(Text, { color: 'secondary' }, () => commitAge(entry.committedAt)),\n },\n]\n\n/** 分支菜单触发按钮的图标(Iconify 名字要走 `name`,所以包成渲染函数)。 */\nconst BranchIcon = () => h(Icon, { name: 'carbon:branch', size: 'sm' })\n\nconst branchItems = [\n { label: 'main', onClick: () => {} },\n { label: 'next', onClick: () => {} },\n { type: 'divider' as const },\n { label: 'View all branches', onClick: () => {} },\n]\n\nconst addFileItems = [\n { label: 'Create new file', onClick: () => {} },\n { label: 'Upload files', onClick: () => {} },\n]\nconst README = `## canvas-editor\n\nA small, embeddable vector canvas. Draw shapes, group them, and serialise the\nresult to JSON that round-trips without loss.\n\n### Install\n\n\\`\\`\\`bash\nnpm install canvas-editor\n\\`\\`\\`\n\n### Usage\n\n\\`\\`\\`ts\nconst {Canvas} = await import('canvas-editor');\n\nconst canvas = new Canvas(document.querySelector('#root'));\ncanvas.add({kind: 'rect', x: 0, y: 0, width: 120, height: 80});\n\\`\\`\\`\n\n### Status\n\n| Area | State | Notes |\n| ----------- | -------- | -------------------------------------- |\n| Rendering | Stable | Dirty-rect redraw, no full repaints |\n| Serialising | Stable | Round-trips groups and nested shapes |\n| Plugins | Beta | API may change before 1.0 |\n\n### Contributing\n\nRead [the contributing guide](#) first. In short:\n\n- One change per pull request, with a test that fails without it.\n- Run \\`npm test\\` before pushing.\n- Conventional commits — the changelog is generated from them.\n\n> Issues tagged \\`good first issue\\` are a reasonable place to start.\n`;\n\nconst DOCS_README = `## Documentation\n\nHow the canvas is put together, and how to work on it.\n\n### Start here\n\n- **getting-started.md** — install the package and draw your first shape.\n- **contributing.md** — branch naming, the release checklist, how to get review.\n\n### Architecture\n\nThe \\`architecture/\\` folder covers the parts that are hard to infer from the\nsource: how the dirty-rect redraw path decides what to repaint, and who owns\nthe undo stack.\n`;\n\n/*\n * Which folders have a README to show. Keyed by folder id, with the empty\n * string standing for the repo root.\n *\n * Whether a preview appears is decided by the tree, not by this map — a\n * folder shows one only if it actually contains a README file. This just\n * supplies the body for the folders that do, so the two cannot disagree\n * about which folders have one.\n */\nconst READMES: Record<string, string> = {\n '': README,\n docs: DOCS_README,\n};\n<\/script>\n\n<template>\n <Layout height=\"auto\" :content-width=\"1440\">\n <template #header>\n <Toolbar :ref=\"setHeader\" label=\"Repository\" size=\"sm\" :dividers=\"['bottom']\">\n <template #start>\n <HStack :gap=\"2\" align=\"center\">\n <Heading :level=\"1\" class=\"repo-visually-hidden\">\n {{ ROOT_NAME }}\n </Heading>\n <Breadcrumbs label=\"Folder path\">\n <BreadcrumbItem\n v-for=\"(crumb, index) in crumbs\"\n :key=\"crumb.id ?? 'root'\"\n :is-current=\"index === crumbs.length - 1\"\n @click=\"index < crumbs.length - 1 ? selectedFolderId = crumb.id : undefined\"\n >\n {{ crumb.label }}\n </BreadcrumbItem>\n </Breadcrumbs>\n <Badge label=\"Public\" variant=\"neutral\" />\n </HStack>\n </template>\n <template #end>\n <template v-for=\"stat in REPO_STATS\" :key=\"stat.label\">\n <IconButton\n v-if=\"isNarrowHeader\"\n variant=\"secondary\"\n size=\"sm\"\n :label=\"`${STAT_ACTION[stat.label]}: ${stat.value}`\"\n >\n <Icon :name=\"stat.icon\" size=\"sm\" />\n </IconButton>\n <Button v-else size=\"sm\" :label=\"STAT_ACTION[stat.label]\">\n <template #icon>\n <Icon :name=\"stat.icon\" size=\"sm\" />\n </template>\n <template #end>\n <Badge :label=\"stat.value\" />\n </template>\n </Button>\n </template>\n </template>\n </Toolbar>\n </template>\n\n <LayoutContent :padding=\"4\">\n <EmptyState\n v-if=\"data.length === 0\"\n title=\"No matching files\"\n description=\"No file or folder in this project matches your search.\"\n >\n <template #actions>\n <Button label=\"Clear search\" variant=\"secondary\" @click=\"query = ''\" />\n </template>\n </EmptyState>\n\n <VStack v-else :ref=\"setBody\" :gap=\"6\">\n <HStack :gap=\"2\" align=\"center\" wrap=\"wrap\">\n <DropdownMenu has-chevron size=\"sm\" :items=\"branchItems\" :button=\"{ label: 'main', size: 'sm', icon: BranchIcon }\" />\n <HStack :gap=\"1\" align=\"center\">\n <template v-for=\"ref in REF_COUNTS\" :key=\"ref.label\">\n <IconButton v-if=\"isNarrowBody\" :label=\"ref.label\" variant=\"ghost\" size=\"sm\">\n <Icon :name=\"ref.icon\" size=\"sm\" />\n </IconButton>\n <Button v-else :label=\"ref.label\" variant=\"ghost\" size=\"sm\" />\n </template>\n </HStack>\n <StackItem size=\"fill\">\n <HStack :gap=\"2\" h-align=\"end\" align=\"center\">\n <Text v-if=\"isSearching && matchCount > 0\" type=\"supporting\" color=\"secondary\">\n {{ matchCount }} {{ matchCount === 1 ? 'result' : 'results' }}\n </Text>\n <IconButton v-if=\"isNarrowBody && !isSearchOpen\" variant=\"ghost\" size=\"sm\" label=\"Go to file\" @click=\"openSearch\">\n <Icon name=\"heroicons:magnifying-glass\" size=\"sm\" />\n </IconButton>\n <TextInput\n v-else\n ref=\"searchInput\"\n v-model=\"query\"\n label=\"Go to file\"\n is-label-hidden\n placeholder=\"Go to file\"\n has-clear\n :start-icon=\"SearchIcon\"\n />\n <DropdownMenu has-chevron size=\"sm\" :items=\"addFileItems\" :button=\"{ label: 'Add file', size: 'sm' }\" />\n <Button label=\"Code\" variant=\"primary\" size=\"sm\">\n <template #icon>\n <Icon name=\"heroicons:code-bracket\" size=\"sm\" />\n </template>\n </Button>\n </HStack>\n </StackItem>\n </HStack>\n\n <Card :padding=\"0\" class=\"tt-fixed-layout\">\n <Table\n :data=\"visibleData\"\n :columns=\"columns\"\n id-key=\"id\"\n density=\"compact\"\n dividers=\"none\"\n is-striped\n text-overflow=\"truncate\"\n :plugins=\"{ tree: treePlugin, sort: sortPlugin, resize: resizePlugin }\"\n />\n </Card>\n\n <Card v-if=\"readme != null\" :padding=\"0\">\n <Section variant=\"transparent\" :padding=\"0\" :padding-block-start=\"1\">\n <TabList v-model:value=\"readmeTab\" size=\"sm\" has-divider>\n <Tab value=\"readme\" label=\"Readme\" />\n <template v-if=\"hasRepoTabs\">\n <Tab value=\"conduct\" label=\"Code of conduct\" />\n <Tab value=\"license\" label=\"MIT license\" />\n <Tab value=\"security\" label=\"Security\" />\n </template>\n </TabList>\n </Section>\n <Section variant=\"transparent\" :padding=\"6\">\n <Markdown\n v-if=\"activeTab === 'readme'\"\n :heading-level-start=\"2\"\n content-align=\"center\"\n :content-width=\"680\"\n :content=\"readme\"\n />\n <Text v-else color=\"secondary\">\n {{ TAB_PLACEHOLDER[activeTab as keyof typeof TAB_PLACEHOLDER] }}\n </Text>\n </Section>\n </Card>\n\n <template v-if=\"!isSidebarInline\">\n <Divider />\n <AboutPanel />\n </template>\n </VStack>\n </LayoutContent>\n\n <template v-if=\"isSidebarInline\" #end>\n <LayoutPanel :width=\"296\" :padding=\"4\" :is-scrollable=\"false\" role=\"complementary\" label=\"About this repository\">\n <AboutPanel />\n </LayoutPanel>\n </template>\n </Layout>\n</template>\n\n<style scoped>\n/* 原版 Table 默认 `table-layout: fixed`,列宽严格按声明的比例分;core 目前是 auto,列宽按内容算。 */\n.tt-fixed-layout :deep(.astryx-table) {\n table-layout: fixed;\n}\n</style>\n\n<style>\n.repo-visually-hidden {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n border: 0;\n}\n</style>\n",
79584
79971
  "docsUrl": {
79585
79972
  "en": "https://astryx-vue.pages.dev/en/playground/?template=table-tree",
79586
79973
  "zh": "https://astryx-vue.pages.dev/playground/?template=table-tree"
@@ -79711,7 +80098,7 @@ const catalog = {
79711
80098
  ],
79712
80099
  "dependencies": ["@astryx-vue/core", "vue"],
79713
80100
  "isReady": true,
79714
- "source": "<script setup lang=\"ts\">\nimport {\n Avatar,\n HStack,\n Icon,\n IconButton,\n Layout,\n LayoutContent,\n List,\n ListItem,\n MetadataList,\n MetadataListItem,\n Section,\n SegmentedControl,\n SegmentedControlItem,\n Text,\n Toolbar,\n VStack,\n} from '@astryx-vue/core'\nimport { computed, ref } from 'vue'\n\ninterface FileSystemItem {\n id: string\n name: string\n type: 'file' | 'folder'\n children?: FileSystemItem[]\n}\n\nconst FILESYSTEM: FileSystemItem[] = [\n {\n id: 'applications',\n name: 'Applications',\n type: 'folder',\n children: [\n {\n id: 'chrome-apps',\n name: 'Chrome Apps',\n type: 'folder',\n children: [\n { id: 'component-lab', name: 'Component Lab.app', type: 'file' },\n { id: 'google-chat', name: 'Google Chat.app', type: 'file' },\n { id: 'workchat', name: 'Workchat.app', type: 'file' },\n ],\n },\n { id: 'figma', name: 'Figma.app', type: 'file' },\n { id: 'safari', name: 'Safari.app', type: 'file' },\n { id: 'slack', name: 'Slack.app', type: 'file' },\n { id: 'terminal', name: 'Terminal.app', type: 'file' },\n { id: 'vscode', name: 'Visual Studio Code.app', type: 'file' },\n { id: 'xcode', name: 'Xcode.app', type: 'file' },\n ],\n },\n { id: 'debug-log', name: 'debug-storybook.log', type: 'file' },\n {\n id: 'desktop',\n name: 'Desktop',\n type: 'folder',\n children: [\n { id: 'screenshot1', name: 'Screenshot 2026-03-28.png', type: 'file' },\n { id: 'notes-txt', name: 'meeting-notes.txt', type: 'file' },\n {\n id: 'projects',\n name: 'Projects',\n type: 'folder',\n children: [\n { id: 'readme-proj', name: 'README.md', type: 'file' },\n {\n id: 'src-folder',\n name: 'src',\n type: 'folder',\n children: [\n { id: 'index-ts', name: 'index.ts', type: 'file' },\n { id: 'app-tsx', name: 'App.tsx', type: 'file' },\n ],\n },\n ],\n },\n ],\n },\n {\n id: 'documents',\n name: 'Documents',\n type: 'folder',\n children: [\n { id: 'design-spec', name: 'design-spec.pdf', type: 'file' },\n { id: 'resume', name: 'resume.docx', type: 'file' },\n {\n id: 'work',\n name: 'Work',\n type: 'folder',\n children: [\n { id: 'q1-report', name: 'Q1-report.xlsx', type: 'file' },\n { id: 'presentation', name: 'team-presentation.pptx', type: 'file' },\n ],\n },\n ],\n },\n {\n id: 'downloads',\n name: 'Downloads',\n type: 'folder',\n children: [\n { id: 'archive', name: 'archive.zip', type: 'file' },\n { id: 'installer', name: 'installer.dmg', type: 'file' },\n { id: 'photo', name: 'photo-2026.jpg', type: 'file' },\n ],\n },\n { id: 'login-screenshot', name: 'login-02-screenshot.png', type: 'file' },\n {\n id: 'movies',\n name: 'Movies',\n type: 'folder',\n children: [{ id: 'recording', name: 'screen-recording.mov', type: 'file' }],\n },\n {\n id: 'music',\n name: 'Music',\n type: 'folder',\n children: [{ id: 'playlist', name: 'favorites.m3u', type: 'file' }],\n },\n {\n id: 'node-modules',\n name: 'node_modules',\n type: 'folder',\n children: [\n {\n id: 'react',\n name: 'react',\n type: 'folder',\n children: [{ id: 'react-index', name: 'index.js', type: 'file' }],\n },\n {\n id: 'react-dom',\n name: 'react-dom',\n type: 'folder',\n children: [{ id: 'react-dom-index', name: 'index.js', type: 'file' }],\n },\n ],\n },\n {\n id: 'pictures',\n name: 'Pictures',\n type: 'folder',\n children: [\n {\n id: 'vacation',\n name: 'vacation-2026',\n type: 'folder',\n children: [\n { id: 'img1', name: 'IMG_0001.jpg', type: 'file' },\n { id: 'img2', name: 'IMG_0002.jpg', type: 'file' },\n { id: 'img3', name: 'IMG_0003.jpg', type: 'file' },\n ],\n },\n {\n id: 'screenshots-folder',\n name: 'Screenshots',\n type: 'folder',\n children: [\n { id: 'ss1', name: 'Screen Shot 1.png', type: 'file' },\n { id: 'ss2', name: 'Screen Shot 2.png', type: 'file' },\n ],\n },\n ],\n },\n {\n id: 'public',\n name: 'Public',\n type: 'folder',\n children: [\n { id: 'drop-box', name: 'Drop Box', type: 'folder', children: [] },\n ],\n },\n {\n id: 'astryx',\n name: 'astryx',\n type: 'folder',\n children: [\n { id: 'astryx-readme', name: 'README.md', type: 'file' },\n { id: 'astryx-pkg', name: 'package.json', type: 'file' },\n {\n id: 'astryx-packages',\n name: 'packages',\n type: 'folder',\n children: [\n {\n id: 'astryx-core',\n name: 'core',\n type: 'folder',\n children: [\n {\n id: 'core-src',\n name: 'src',\n type: 'folder',\n children: [\n { id: 'button-tsx', name: 'Button.tsx', type: 'file' },\n { id: 'card-tsx', name: 'Card.tsx', type: 'file' },\n { id: 'text-tsx', name: 'Text.tsx', type: 'file' },\n ],\n },\n ],\n },\n {\n id: 'astryx-cli',\n name: 'cli',\n type: 'folder',\n children: [{ id: 'cli-index', name: 'index.ts', type: 'file' }],\n },\n ],\n },\n {\n id: 'astryx-apps',\n name: 'apps',\n type: 'folder',\n children: [\n {\n id: 'storybook',\n name: 'storybook',\n type: 'folder',\n children: [\n {\n id: 'sb-config',\n name: '.storybook',\n type: 'folder',\n children: [],\n },\n ],\n },\n {\n id: 'sandbox-app',\n name: 'sandbox',\n type: 'folder',\n children: [\n {\n id: 'sandbox-src',\n name: 'src',\n type: 'folder',\n children: [],\n },\n ],\n },\n ],\n },\n ],\n },\n]\n\nconst viewMode = ref('column')\nconst selectedPath = ref<string[]>(['applications', 'chrome-apps', 'component-lab'])\n\nfunction findItem(items: FileSystemItem[], id: string): FileSystemItem | null {\n for (const item of items) {\n if (item.id === id)\n return item\n if (item.children) {\n const found = findItem(item.children, id)\n if (found)\n return found\n }\n }\n return null\n}\n\nfunction getFileExtension(name: string): string {\n const dot = name.lastIndexOf('.')\n return dot > 0 ? name.substring(dot + 1).toUpperCase() : 'File'\n}\n\nconst columns = computed(() => {\n const cols: { items: FileSystemItem[], selectedId: string | null }[] = []\n cols.push({ items: FILESYSTEM, selectedId: selectedPath.value[0] ?? null })\n let currentItems: FileSystemItem[] = FILESYSTEM\n for (let i = 0; i < selectedPath.value.length; i++) {\n const selected = currentItems.find(item => item.id === selectedPath.value[i])\n if (selected?.children && selected.children.length > 0) {\n cols.push({\n items: selected.children,\n selectedId: selectedPath.value[i + 1] ?? null,\n })\n currentItems = selected.children\n }\n else {\n break\n }\n }\n return cols\n})\n\nconst currentFolderName = computed(() => {\n if (selectedPath.value.length === 0)\n return 'Home'\n const lastId = selectedPath.value[selectedPath.value.length - 1]\n const item = findItem(FILESYSTEM, lastId)\n if (item?.type === 'folder')\n return item.name\n if (selectedPath.value.length >= 2) {\n const parent = findItem(FILESYSTEM, selectedPath.value[selectedPath.value.length - 2])\n return parent?.name ?? 'Home'\n }\n return 'Home'\n})\n\nconst selectedFile = computed(() => {\n if (selectedPath.value.length === 0)\n return null\n const lastId = selectedPath.value[selectedPath.value.length - 1]\n const item = findItem(FILESYSTEM, lastId)\n return item?.type === 'file' ? item : null\n})\n\nfunction handleSelect(columnIndex: number, itemId: string): void {\n selectedPath.value = [...selectedPath.value.slice(0, columnIndex), itemId]\n}\n\nfunction goBack(): void {\n if (selectedPath.value.length > 0)\n selectedPath.value = selectedPath.value.slice(0, -1)\n}\n<\/script>\n\n<template>\n <Layout class=\"page\" height=\"fill\">\n <template #header>\n <Toolbar label=\"File Explorer\" size=\"sm\" :dividers=\"['bottom']\">\n <template #start>\n <IconButton\n variant=\"ghost\"\n size=\"sm\"\n label=\"Go back\"\n :is-disabled=\"selectedPath.length === 0\"\n @click=\"goBack\"\n >\n <Icon icon=\"i-heroicons-chevron-left\" size=\"sm\" />\n </IconButton>\n <IconButton variant=\"ghost\" size=\"sm\" label=\"Go forward\" is-disabled>\n <Icon icon=\"i-heroicons-chevron-right\" size=\"sm\" />\n </IconButton>\n <Text type=\"label\">\n {{ currentFolderName }}\n </Text>\n </template>\n <template #center>\n <SegmentedControl v-model=\"viewMode\" label=\"View mode\">\n <SegmentedControlItem value=\"grid\" label=\"Grid\" is-label-hidden>\n <template #icon>\n <Icon icon=\"i-heroicons-squares-2x2\" size=\"sm\" />\n </template>\n </SegmentedControlItem>\n <SegmentedControlItem value=\"list\" label=\"List\" is-label-hidden>\n <template #icon>\n <Icon icon=\"i-heroicons-bars-4\" size=\"sm\" />\n </template>\n </SegmentedControlItem>\n <SegmentedControlItem value=\"column\" label=\"Column\" is-label-hidden>\n <template #icon>\n <Icon icon=\"i-heroicons-view-columns\" size=\"sm\" />\n </template>\n </SegmentedControlItem>\n <SegmentedControlItem value=\"gallery\" label=\"Gallery\" is-label-hidden>\n <template #icon>\n <Icon icon=\"i-heroicons-table-cells\" size=\"sm\" />\n </template>\n </SegmentedControlItem>\n </SegmentedControl>\n </template>\n <template #end>\n <IconButton variant=\"ghost\" size=\"sm\" label=\"Group\">\n <Icon icon=\"i-heroicons-adjustments-horizontal\" size=\"sm\" />\n </IconButton>\n <IconButton variant=\"ghost\" size=\"sm\" label=\"Share\">\n <Icon icon=\"i-heroicons-share\" size=\"sm\" />\n </IconButton>\n <IconButton variant=\"ghost\" size=\"sm\" label=\"Tags\">\n <Icon icon=\"i-heroicons-tag\" size=\"sm\" />\n </IconButton>\n <IconButton variant=\"ghost\" size=\"sm\" label=\"More\">\n <Icon icon=\"i-heroicons-ellipsis-horizontal\" size=\"sm\" />\n </IconButton>\n <IconButton variant=\"ghost\" size=\"sm\" label=\"Search\">\n <Icon icon=\"i-heroicons-magnifying-glass\" size=\"sm\" />\n </IconButton>\n </template>\n </Toolbar>\n </template>\n\n <LayoutContent :padding=\"0\" :is-scrollable=\"false\">\n <HStack class=\"column-row\" height=\"100%\">\n <Section\n v-for=\"(col, colIndex) in columns\"\n :key=\"colIndex\"\n :width=\"240\"\n :padding=\"2\"\n variant=\"transparent\"\n :dividers=\"colIndex < columns.length - 1 || selectedFile != null ? ['end'] : undefined\"\n class=\"scrollable fixed-column\"\n >\n <List density=\"compact\" :has-dividers=\"false\">\n <ListItem\n v-for=\"item in col.items\"\n :key=\"item.id\"\n :label=\"item.name\"\n :is-selected=\"col.selectedId === item.id\"\n :on-click=\"() => handleSelect(colIndex, item.id)\"\n >\n <template #startContent>\n <Icon\n :icon=\"item.type === 'folder' ? 'i-heroicons-folder-solid' : 'i-heroicons-document'\"\n :color=\"item.type === 'folder' ? 'accent' : 'secondary'\"\n size=\"sm\"\n />\n </template>\n <template\n v-if=\"item.type === 'folder' && item.children != null && item.children.length > 0\"\n #endContent\n >\n <Icon icon=\"i-heroicons-chevron-right\" size=\"xsm\" color=\"secondary\" />\n </template>\n </ListItem>\n </List>\n </Section>\n\n <Section\n v-if=\"selectedFile\"\n :padding=\"6\"\n variant=\"transparent\"\n class=\"scrollable detail-column\"\n >\n <VStack :gap=\"4\" justify=\"center\">\n <Avatar :name=\"selectedFile.name\" :size=\"96\" />\n <VStack :gap=\"1\" justify=\"center\">\n <Text type=\"label\">\n {{ selectedFile.name }}\n </Text>\n <Text type=\"supporting\">\n {{ getFileExtension(selectedFile.name) }} Document\n </Text>\n </VStack>\n <MetadataList title=\"Information\">\n <MetadataListItem label=\"Created\">\n March 28, 2026 at 2:15 PM\n </MetadataListItem>\n <MetadataListItem label=\"Modified\">\n Yesterday, 10:27 PM\n </MetadataListItem>\n <MetadataListItem label=\"Kind\">\n {{ getFileExtension(selectedFile.name) }} Document\n </MetadataListItem>\n </MetadataList>\n </VStack>\n </Section>\n </HStack>\n </LayoutContent>\n </Layout>\n</template>\n\n<style scoped>\n.page {\n height: 100dvh;\n}\n\n.column-row {\n overflow-x: auto;\n overflow-y: hidden;\n}\n\n.scrollable {\n overflow-y: auto;\n}\n\n.fixed-column {\n flex-shrink: 0;\n}\n\n.detail-column {\n flex-grow: 1;\n flex-shrink: 0;\n flex-basis: 320px;\n}\n</style>\n",
80101
+ "source": "<script setup lang=\"ts\">\nimport {\n Avatar,\n HStack,\n Icon,\n IconButton,\n Layout,\n LayoutContent,\n List,\n ListItem,\n MetadataList,\n MetadataListItem,\n Section,\n SegmentedControl,\n SegmentedControlItem,\n Text,\n Toolbar,\n VStack,\n} from '@astryx-vue/core'\nimport { computed, ref } from 'vue'\n\ninterface FileSystemItem {\n id: string\n name: string\n type: 'file' | 'folder'\n children?: FileSystemItem[]\n}\n\nconst FILESYSTEM: FileSystemItem[] = [\n {\n id: 'applications',\n name: 'Applications',\n type: 'folder',\n children: [\n {\n id: 'chrome-apps',\n name: 'Chrome Apps',\n type: 'folder',\n children: [\n { id: 'component-lab', name: 'Component Lab.app', type: 'file' },\n { id: 'google-chat', name: 'Google Chat.app', type: 'file' },\n { id: 'workchat', name: 'Workchat.app', type: 'file' },\n ],\n },\n { id: 'figma', name: 'Figma.app', type: 'file' },\n { id: 'safari', name: 'Safari.app', type: 'file' },\n { id: 'slack', name: 'Slack.app', type: 'file' },\n { id: 'terminal', name: 'Terminal.app', type: 'file' },\n { id: 'vscode', name: 'Visual Studio Code.app', type: 'file' },\n { id: 'xcode', name: 'Xcode.app', type: 'file' },\n ],\n },\n { id: 'debug-log', name: 'debug-storybook.log', type: 'file' },\n {\n id: 'desktop',\n name: 'Desktop',\n type: 'folder',\n children: [\n { id: 'screenshot1', name: 'Screenshot 2026-03-28.png', type: 'file' },\n { id: 'notes-txt', name: 'meeting-notes.txt', type: 'file' },\n {\n id: 'projects',\n name: 'Projects',\n type: 'folder',\n children: [\n { id: 'readme-proj', name: 'README.md', type: 'file' },\n {\n id: 'src-folder',\n name: 'src',\n type: 'folder',\n children: [\n { id: 'index-ts', name: 'index.ts', type: 'file' },\n { id: 'app-tsx', name: 'App.tsx', type: 'file' },\n ],\n },\n ],\n },\n ],\n },\n {\n id: 'documents',\n name: 'Documents',\n type: 'folder',\n children: [\n { id: 'design-spec', name: 'design-spec.pdf', type: 'file' },\n { id: 'resume', name: 'resume.docx', type: 'file' },\n {\n id: 'work',\n name: 'Work',\n type: 'folder',\n children: [\n { id: 'q1-report', name: 'Q1-report.xlsx', type: 'file' },\n { id: 'presentation', name: 'team-presentation.pptx', type: 'file' },\n ],\n },\n ],\n },\n {\n id: 'downloads',\n name: 'Downloads',\n type: 'folder',\n children: [\n { id: 'archive', name: 'archive.zip', type: 'file' },\n { id: 'installer', name: 'installer.dmg', type: 'file' },\n { id: 'photo', name: 'photo-2026.jpg', type: 'file' },\n ],\n },\n { id: 'login-screenshot', name: 'login-02-screenshot.png', type: 'file' },\n {\n id: 'movies',\n name: 'Movies',\n type: 'folder',\n children: [{ id: 'recording', name: 'screen-recording.mov', type: 'file' }],\n },\n {\n id: 'music',\n name: 'Music',\n type: 'folder',\n children: [{ id: 'playlist', name: 'favorites.m3u', type: 'file' }],\n },\n {\n id: 'node-modules',\n name: 'node_modules',\n type: 'folder',\n children: [\n {\n id: 'react',\n name: 'react',\n type: 'folder',\n children: [{ id: 'react-index', name: 'index.js', type: 'file' }],\n },\n {\n id: 'react-dom',\n name: 'react-dom',\n type: 'folder',\n children: [{ id: 'react-dom-index', name: 'index.js', type: 'file' }],\n },\n ],\n },\n {\n id: 'pictures',\n name: 'Pictures',\n type: 'folder',\n children: [\n {\n id: 'vacation',\n name: 'vacation-2026',\n type: 'folder',\n children: [\n { id: 'img1', name: 'IMG_0001.jpg', type: 'file' },\n { id: 'img2', name: 'IMG_0002.jpg', type: 'file' },\n { id: 'img3', name: 'IMG_0003.jpg', type: 'file' },\n ],\n },\n {\n id: 'screenshots-folder',\n name: 'Screenshots',\n type: 'folder',\n children: [\n { id: 'ss1', name: 'Screen Shot 1.png', type: 'file' },\n { id: 'ss2', name: 'Screen Shot 2.png', type: 'file' },\n ],\n },\n ],\n },\n {\n id: 'public',\n name: 'Public',\n type: 'folder',\n children: [\n { id: 'drop-box', name: 'Drop Box', type: 'folder', children: [] },\n ],\n },\n {\n id: 'astryx',\n name: 'astryx',\n type: 'folder',\n children: [\n { id: 'astryx-readme', name: 'README.md', type: 'file' },\n { id: 'astryx-pkg', name: 'package.json', type: 'file' },\n {\n id: 'astryx-packages',\n name: 'packages',\n type: 'folder',\n children: [\n {\n id: 'astryx-core',\n name: 'core',\n type: 'folder',\n children: [\n {\n id: 'core-src',\n name: 'src',\n type: 'folder',\n children: [\n { id: 'button-tsx', name: 'Button.tsx', type: 'file' },\n { id: 'card-tsx', name: 'Card.tsx', type: 'file' },\n { id: 'text-tsx', name: 'Text.tsx', type: 'file' },\n ],\n },\n ],\n },\n {\n id: 'astryx-cli',\n name: 'cli',\n type: 'folder',\n children: [{ id: 'cli-index', name: 'index.ts', type: 'file' }],\n },\n ],\n },\n {\n id: 'astryx-apps',\n name: 'apps',\n type: 'folder',\n children: [\n {\n id: 'storybook',\n name: 'storybook',\n type: 'folder',\n children: [\n {\n id: 'sb-config',\n name: '.storybook',\n type: 'folder',\n children: [],\n },\n ],\n },\n {\n id: 'sandbox-app',\n name: 'sandbox',\n type: 'folder',\n children: [\n {\n id: 'sandbox-src',\n name: 'src',\n type: 'folder',\n children: [],\n },\n ],\n },\n ],\n },\n ],\n },\n]\n\nconst viewMode = ref('column')\nconst selectedPath = ref<string[]>(['applications', 'chrome-apps', 'component-lab'])\n\nfunction findItem(items: FileSystemItem[], id: string): FileSystemItem | null {\n for (const item of items) {\n if (item.id === id)\n return item\n if (item.children) {\n const found = findItem(item.children, id)\n if (found)\n return found\n }\n }\n return null\n}\n\nfunction getFileExtension(name: string): string {\n const dot = name.lastIndexOf('.')\n return dot > 0 ? name.substring(dot + 1).toUpperCase() : 'File'\n}\n\nconst columns = computed(() => {\n const cols: { items: FileSystemItem[], selectedId: string | null }[] = []\n cols.push({ items: FILESYSTEM, selectedId: selectedPath.value[0] ?? null })\n let currentItems: FileSystemItem[] = FILESYSTEM\n for (let i = 0; i < selectedPath.value.length; i++) {\n const selected = currentItems.find(item => item.id === selectedPath.value[i])\n if (selected?.children && selected.children.length > 0) {\n cols.push({\n items: selected.children,\n selectedId: selectedPath.value[i + 1] ?? null,\n })\n currentItems = selected.children\n }\n else {\n break\n }\n }\n return cols\n})\n\nconst currentFolderName = computed(() => {\n if (selectedPath.value.length === 0)\n return 'Home'\n const lastId = selectedPath.value[selectedPath.value.length - 1]\n const item = findItem(FILESYSTEM, lastId)\n if (item?.type === 'folder')\n return item.name\n if (selectedPath.value.length >= 2) {\n const parent = findItem(FILESYSTEM, selectedPath.value[selectedPath.value.length - 2])\n return parent?.name ?? 'Home'\n }\n return 'Home'\n})\n\nconst selectedFile = computed(() => {\n if (selectedPath.value.length === 0)\n return null\n const lastId = selectedPath.value[selectedPath.value.length - 1]\n const item = findItem(FILESYSTEM, lastId)\n return item?.type === 'file' ? item : null\n})\n\nfunction handleSelect(columnIndex: number, itemId: string): void {\n selectedPath.value = [...selectedPath.value.slice(0, columnIndex), itemId]\n}\n\nfunction goBack(): void {\n if (selectedPath.value.length > 0)\n selectedPath.value = selectedPath.value.slice(0, -1)\n}\n<\/script>\n\n<template>\n <Layout class=\"page\" height=\"fill\">\n <template #header>\n <Toolbar label=\"File Explorer\" size=\"sm\" :dividers=\"['bottom']\">\n <template #start>\n <IconButton\n variant=\"ghost\"\n size=\"sm\"\n label=\"Go back\"\n :is-disabled=\"selectedPath.length === 0\"\n @click=\"goBack\"\n >\n <Icon icon=\"i-heroicons-chevron-left\" size=\"sm\" />\n </IconButton>\n <IconButton variant=\"ghost\" size=\"sm\" label=\"Go forward\" is-disabled>\n <Icon icon=\"i-heroicons-chevron-right\" size=\"sm\" />\n </IconButton>\n <Text type=\"label\">\n {{ currentFolderName }}\n </Text>\n </template>\n <template #center>\n <SegmentedControl v-model=\"viewMode\" label=\"View mode\">\n <SegmentedControlItem value=\"grid\" label=\"Grid\" is-label-hidden>\n <template #icon>\n <Icon icon=\"i-heroicons-squares-2x2\" size=\"sm\" />\n </template>\n </SegmentedControlItem>\n <SegmentedControlItem value=\"list\" label=\"List\" is-label-hidden>\n <template #icon>\n <Icon icon=\"i-heroicons-bars-4\" size=\"sm\" />\n </template>\n </SegmentedControlItem>\n <SegmentedControlItem value=\"column\" label=\"Column\" is-label-hidden>\n <template #icon>\n <Icon icon=\"i-heroicons-view-columns\" size=\"sm\" />\n </template>\n </SegmentedControlItem>\n <SegmentedControlItem value=\"gallery\" label=\"Gallery\" is-label-hidden>\n <template #icon>\n <Icon icon=\"i-heroicons-table-cells\" size=\"sm\" />\n </template>\n </SegmentedControlItem>\n </SegmentedControl>\n </template>\n <template #end>\n <IconButton variant=\"ghost\" size=\"sm\" label=\"Group\">\n <Icon icon=\"i-heroicons-adjustments-horizontal\" size=\"sm\" />\n </IconButton>\n <IconButton variant=\"ghost\" size=\"sm\" label=\"Share\">\n <Icon icon=\"i-heroicons-share\" size=\"sm\" />\n </IconButton>\n <IconButton variant=\"ghost\" size=\"sm\" label=\"Tags\">\n <Icon icon=\"i-heroicons-tag\" size=\"sm\" />\n </IconButton>\n <IconButton variant=\"ghost\" size=\"sm\" label=\"More\">\n <Icon icon=\"i-heroicons-ellipsis-horizontal\" size=\"sm\" />\n </IconButton>\n <IconButton variant=\"ghost\" size=\"sm\" label=\"Search\">\n <Icon icon=\"i-heroicons-magnifying-glass\" size=\"sm\" />\n </IconButton>\n </template>\n </Toolbar>\n </template>\n\n <LayoutContent :padding=\"0\" :is-scrollable=\"false\">\n <HStack class=\"column-row\" height=\"100%\">\n <Section\n v-for=\"(col, colIndex) in columns\"\n :key=\"colIndex\"\n :width=\"240\"\n :padding=\"2\"\n variant=\"transparent\"\n :dividers=\"colIndex < columns.length - 1 || selectedFile != null ? ['end'] : undefined\"\n class=\"scrollable fixed-column\"\n >\n <List density=\"compact\" :has-dividers=\"false\">\n <ListItem\n v-for=\"item in col.items\"\n :key=\"item.id\"\n :label=\"item.name\"\n :is-selected=\"col.selectedId === item.id\"\n :on-click=\"() => handleSelect(colIndex, item.id)\"\n >\n <template #startContent>\n <Icon\n :icon=\"item.type === 'folder' ? 'i-heroicons-folder-solid' : 'i-heroicons-document'\"\n :color=\"item.type === 'folder' ? 'accent' : 'secondary'\"\n size=\"sm\"\n />\n </template>\n <template\n v-if=\"item.type === 'folder' && item.children != null && item.children.length > 0\"\n #endContent\n >\n <Icon icon=\"i-heroicons-chevron-right\" size=\"xsm\" color=\"secondary\" />\n </template>\n </ListItem>\n </List>\n </Section>\n\n <Section\n v-if=\"selectedFile\"\n :padding=\"6\"\n variant=\"transparent\"\n class=\"scrollable detail-column\"\n >\n <VStack :gap=\"4\" hAlign=\"center\">\n <Avatar :name=\"selectedFile.name\" :size=\"96\" />\n <VStack :gap=\"1\" hAlign=\"center\">\n <Text type=\"label\">\n {{ selectedFile.name }}\n </Text>\n <Text type=\"supporting\">\n {{ getFileExtension(selectedFile.name) }} Document\n </Text>\n </VStack>\n <MetadataList title=\"Information\">\n <MetadataListItem label=\"Created\">\n March 28, 2026 at 2:15 PM\n </MetadataListItem>\n <MetadataListItem label=\"Modified\">\n Yesterday, 10:27 PM\n </MetadataListItem>\n <MetadataListItem label=\"Kind\">\n {{ getFileExtension(selectedFile.name) }} Document\n </MetadataListItem>\n </MetadataList>\n </VStack>\n </Section>\n </HStack>\n </LayoutContent>\n </Layout>\n</template>\n\n<style scoped>\n.page {\n height: 100dvh;\n}\n\n.column-row {\n overflow-x: auto;\n overflow-y: hidden;\n}\n\n.scrollable {\n overflow-y: auto;\n}\n\n.fixed-column {\n flex-shrink: 0;\n}\n\n.detail-column {\n flex-grow: 1;\n flex-shrink: 0;\n flex-basis: 320px;\n}\n</style>\n",
79715
80102
  "docsUrl": {
79716
80103
  "en": "https://astryx-vue.pages.dev/en/playground/?template=file-explorer",
79717
80104
  "zh": "https://astryx-vue.pages.dev/playground/?template=file-explorer"
@@ -80537,7 +80924,7 @@ function wantsJson(argv) {
80537
80924
  }
80538
80925
  //#endregion
80539
80926
  //#region package.json
80540
- var version = "0.4.0";
80927
+ var version = "0.4.1";
80541
80928
  //#endregion
80542
80929
  //#region src/envelope.ts
80543
80930
  function successEnvelope(type, data, packageName) {