fix(custom-widgets): finish v2 hardening

This commit is contained in:
Thomas Camlong committed 2026-07-20 23:39:57 +02:00
1 parent 7c66c912e7
commit 1c02678a7e
97 files changed
+2833 -12393

No files matched your search

+13 -9
View File
@@ -16,16 +16,19 @@ When connected to Homarr, retrieve the live widget schema and component catalog
For offline authoring, read these installed, release-matched references in order:
1. [Schema](references/schema.md)
2. [JSX runtime](references/runtime.md)
3. [Security boundary](references/security.md)
4. [Component catalog](references/components.md)
5. [Mantine Core](references/mantine-core.md)
6. [Mantine Dates](references/mantine-dates.md)
7. [Mantine Charts](references/mantine-charts.md)
8. [Homarr components](references/homarr-components.md)
1. [Schema guide](references/schema.md)
2. [Canonical widget JSON Schema](references/widget-schema.json)
3. [JSX runtime](references/runtime.md)
4. [Security boundary](references/security.md)
5. [Component catalog guide](references/components.md)
6. [Canonical component catalog](references/component-catalog.json)
7. [Canonical examples](references/examples.json)
8. [Official Mantine LLM index](references/mantine-core.md)
9. [Official Mantine Dates guide](references/mantine-dates.md)
10. [Mantine Charts links](references/mantine-charts.md)
11. [Homarr components](references/homarr-components.md)
Together, this file and those references are the complete offline authoring bundle for the matching Homarr release. Do not require MCP tools, repository access, or external component documentation to produce a best-effort widget. If live metadata is available, prefer it over the installed static bundle.
Together, this file and its references are the complete offline authoring bundle for the matching Homarr release. The JSON catalog contains the exact enabled component names, prop metadata, bindings, blocked capabilities, and versions. If live metadata is available, prefer it because it reflects the installed Homarr and Mantine versions.
## Authoring workflow
@@ -40,6 +43,7 @@ Together, this file and those references are the complete offline authoring bund
- Use inline sources, named requests, definition-owned options, temporary bound inputs, and one safe JSX template.
- Use `kind`, not the HTTP method, to distinguish reads from user-triggered actions.
- Give every declared request parameter an explicit source. Load queries use `optionsBinding` with an option reference or primitive literal; manual queries and actions receive values only from the invoking component's `params` prop. Never infer bindings from matching names.
- Treat `status.<requestId>` as `{ loading, ok, status, statusText, error }`. Never compare it with `"loading"`, `"success"`, or `"error"`; check `status.list?.loading`, `status.list?.ok === false`, or `status.list?.ok === true`.
- Never put a credential in JSX, options, headers, URLs, examples, exports, logs, or chat output.
- Use declarative `bind` controls and Homarr request components. Controlled collection callbacks, zero-argument derived-value IIFEs, and the `RecursiveList` child template may use the restricted safe-block grammar documented in the runtime reference. Never emit imports, hooks, refs, raw callback props, browser requests, or arbitrary JavaScript.
- Include responsive loading, empty, error, and success states.
@@ -0,0 +1 @@
{"schemaVersion":1,"mantineVersion":"9.4.1","customWidgetVersion":"2.0.0","types":["\"\" | \"anonymous\" | \"use-credentials\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"auto\" | \"fixed\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"auto\" | \"all\" | \"fill\" | \"stroke\" | \"painted\" | \"visible\" | \"visibleFill\" | \"visiblePainted\" | \"visibleStroke\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"block\" | \"inline\" | \"both\" | \"horizontal\" | \"vertical\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"contain\" | \"cover\" | \"fill\" | \"scale-down\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"wrap\" | \"nowrap\" | \"wrap-reverse\"","\"12h\" | \"24h\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"area\" | \"radial\" | \"scatter\"","\"auto\" | \"scroll\" | \"always\" | \"hover\" | \"never\"","\"auto-fit\" | \"auto-fill\"","\"block\" | \"grid\"","\"both\" | \"rightSection\" | \"clear\"","\"bottom\" | \"top\"","\"bottom\" | \"top\" | \"baseline\" | \"middle\"","\"bottom\" | \"top\" | \"middle\"","\"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\" | \"bottom-center\" | \"top-center\" | \"middle-center\" | \"middle-end\" | \"middle-start\"","\"button\" | \"submit\" | \"reset\"","\"center\" | \"bottom\"","\"center\" | \"left\" | \"right\"","\"center\" | \"left\" | \"right\" | \"justify\" | \"char\"","\"center\" | \"side\" | \"merge\"","\"checkbox\" | \"radio\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"gradient\"","\"default\" | \"responsive\"","\"default\" | \"stacked\" | \"percent\" | \"split\"","\"default\" | \"stacked\" | \"percent\" | \"waterfall\"","\"div\" | \"label\"","\"end\" | \"start\"","\"filled\" | \"light\"","\"fixed\" | \"absolute\"","\"hex\" | \"hexa\" | \"rgba\" | \"rgb\" | \"hsl\" | \"hsla\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\" | \"click-hover\"","\"inherit\" | \"auto\" | \"baseline\" | \"before-edge\" | \"text-before-edge\" | \"middle\" | \"central\" | \"after-edge\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\"","\"inherit\" | \"auto\" | \"sRGB\" | \"linearRGB\"","\"inherit\" | \"auto\" | \"text-before-edge\" | \"middle\" | \"central\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\" | \"use-script\" | \"no-change\" | \"reset-size\"","\"inherit\" | \"end\" | \"start\" | \"middle\"","\"inherit\" | \"nonzero\" | \"evenodd\"","\"inherit\" | \"round\" | \"butt\" | \"square\"","\"inherit\" | \"round\" | \"miter\" | \"bevel\"","\"initial\" | \"medial\" | \"terminal\" | \"isolated\"","\"inside\" | \"outside\"","\"left\" | \"right\"","\"left\" | \"right\" | \"bottom\" | \"top\" | \"left-end\" | \"left-start\" | \"right-end\" | \"right-start\" | \"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\"","\"left\" | \"right\" | \"inside\"","\"link\" | \"none\" | \"copy\" | \"execute\" | \"move\" | \"popup\"","\"media\" | \"container\"","\"month\" | \"year\" | \"decade\"","\"native\" | \"scrollarea\"","\"none\" | \"all\" | \"rows\" | \"groups\" | \"columns\"","\"none\" | \"all\" | \"with-indicators\"","\"none\" | \"ascending\" | \"descending\" | \"other\"","\"none\" | \"inline\" | \"list\" | \"both\"","\"none\" | \"strict\" | \"blur\"","\"none\" | \"sum\"","\"none\" | \"thousand\" | \"lakh\" | \"wan\"","\"none\" | \"x\" | \"y\" | \"xy\"","\"number\" | RegExp | \"alphanumeric\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"ordered\" | \"unordered\"","\"right-to-left\" | \"left-to-right\"","\"search\" | \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"send\"","\"search\" | \"text\" | \"none\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"","\"step\" | \"bump\" | \"linear\" | \"natural\" | \"monotone\" | \"stepBefore\" | \"stepAfter\"","\"stepInactive\" | \"stepProgress\" | \"stepCompleted\"","\"sum\" | \"replace\"","\"td\" | \"th\"","\"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"","\"text\" | \"tel\" | \"password\"","\"time\" | \"duration\"","\"up\" | \"down\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | (string & {})","\"yes\" | \"no\"","'Text' | 'Title' | 'Badge' | 'Code' | 'Image'","'auto' | 'manual'","'json' | 'text'","'xs' | 'sm' | 'md' | 'lg' | 'xl'","(\"input\" | \"label\" | \"description\" | \"error\")[]","(\"next\" | \"previLine truncated
@@ -2,7 +2,7 @@
> Generated from `component-catalog-v1.json`. Catalog schema v1 · Custom Widget 2.0.0 · Mantine 9.4.1. Do not edit this file by hand.
This is the complete offline Custom JSX component contract for this Homarr release. When connected to Homarr, prefer the live catalog because the installed release may differ. The catalog is descriptive, not a prop allowlist: ordinary serializable props pass through unless the capability boundary explicitly blocks them.
This is the complete Custom JSX component boundary for this Homarr release. The machine-readable `component-catalog.json` contains exact generated component, prop, binding, and safety metadata; these Markdown pages provide the human-readable inventory and upstream Mantine indexes. Ordinary serializable props pass through unless the capability boundary explicitly blocks them.
## Package references
@@ -14,7 +14,7 @@ This is the complete offline Custom JSX component contract for this Homarr relea
## Authoring boundary
- Use the exact component names below. Compound names such as `Tabs.List`, `Card.Section`, and `Radio.Group` are distinct catalog entries.
- The effective prop API is the shared prop table plus the component-specific table in the package reference. A component-specific record overrides the shared record with the same name.
- Use the official Mantine LLM links for component props and examples. Homarr's live catalog is authoritative for availability, bindings, and blocked capabilities.
- Use `bind="name"` only when a component record declares bind metadata. Read the temporary in-memory value from `inputs.name`; it is never persisted.
- Ordinary authored callbacks remain blocked. Restricted collection callbacks, directly invoked zero-argument derived-value IIFEs, and `RecursiveList`'s child template may use the immutable local `const` grammar in [JSX runtime](runtime.md#immutable-local-bindings).
- Use [`RecursiveList`](homarr-components.md#component-recursivelist) for custom arbitrary-depth trees. Do not author recursion or pass a renderer callback to Mantine Tree.
@@ -24,15 +24,15 @@ This is the complete offline Custom JSX component contract for this Homarr relea
### Mantine Core
[`Accordion`](mantine-core.md#component-accordion), [`Accordion.Chevron`](mantine-core.md#component-accordion-2e-chevron), [`Accordion.Control`](mantine-core.md#component-accordion-2e-control), [`Accordion.Item`](mantine-core.md#component-accordion-2e-item), [`Accordion.Panel`](mantine-core.md#component-accordion-2e-panel), [`AccordionChevron`](mantine-core.md#component-accordionchevron), [`AccordionControl`](mantine-core.md#component-accordioncontrol), [`AccordionItem`](mantine-core.md#component-accordionitem), [`AccordionPanel`](mantine-core.md#component-accordionpanel), [`ActionIcon`](mantine-core.md#component-actionicon), [`ActionIcon.Group`](mantine-core.md#component-actionicon-2e-group), [`ActionIcon.GroupSection`](mantine-core.md#component-actionicon-2e-groupsection), [`ActionIconGroup`](mantine-core.md#component-actionicongroup), [`ActionIconGroupSection`](mantine-core.md#component-actionicongroupsection), [`Alert`](mantine-core.md#component-alert), [`AlphaSlider`](mantine-core.md#component-alphaslider), [`Anchor`](mantine-core.md#component-anchor), [`AngleSlider`](mantine-core.md#component-angleslider), [`AspectRatio`](mantine-core.md#component-aspectratio), [`Autocomplete`](mantine-core.md#component-autocomplete), [`Avatar`](mantine-core.md#component-avatar), [`Avatar.Group`](mantine-core.md#component-avatar-2e-group), [`AvatarGroup`](mantine-core.md#component-avatargroup), [`BackgroundImage`](mantine-core.md#component-backgroundimage), [`Badge`](mantine-core.md#component-badge), [`Blockquote`](mantine-core.md#component-blockquote), [`Box`](mantine-core.md#component-box), [`Breadcrumbs`](mantine-core.md#component-breadcrumbs), [`Burger`](mantine-core.md#component-burger), [`Button`](mantine-core.md#component-button), [`Button.Group`](mantine-core.md#component-button-2e-group), [`Button.GroupSection`](mantine-core.md#component-button-2e-groupsection), [`ButtonGroup`](mantine-core.md#component-buttongroup), [`ButtonGroupSection`](mantine-core.md#component-buttongroupsection), [`Card`](mantine-core.md#component-card), [`Card.Section`](mantine-core.md#component-card-2e-section), [`CardSection`](mantine-core.md#component-cardsection), [`Center`](mantine-core.md#component-center), [`CheckIcon`](mantine-core.md#component-checkicon), [`Checkbox`](mantine-core.md#component-checkbox), [`Checkbox.Card`](mantine-core.md#component-checkbox-2e-card), [`Checkbox.Group`](mantine-core.md#component-checkbox-2e-group), [`Checkbox.Indicator`](mantine-core.md#component-checkbox-2e-indicator), [`CheckboxCard`](mantine-core.md#component-checkboxcard), [`CheckboxGroup`](mantine-core.md#component-checkboxgroup), [`CheckboxIndicator`](mantine-core.md#component-checkboxindicator), [`Chip`](mantine-core.md#component-chip), [`Chip.Group`](mantine-core.md#component-chip-2e-group), [`ChipGroup`](mantine-core.md#component-chipgroup), [`CloseButton`](mantine-core.md#component-closebutton), [`CloseIcon`](mantine-core.md#component-closeicon), [`Code`](mantine-core.md#component-code), [`Collapse`](mantine-core.md#component-collapse), [`ColorInput`](mantine-core.md#component-colorinput), [`ColorPicker`](mantine-core.md#component-colorpicker), [`ColorSwatch`](mantine-core.md#component-colorswatch), [`Container`](mantine-core.md#component-container), [`CopyButton`](mantine-core.md#component-copybutton), [`DataList`](mantine-core.md#component-datalist), [`DataList.Item`](mantine-core.md#component-datalist-2e-item), [`DataList.ItemLabel`](mantine-core.md#component-datalist-2e-itemlabel), [`DataList.ItemValue`](mantine-core.md#component-datalist-2e-itemvalue), [`DataListItem`](mantine-core.md#component-datalistitem), [`DataListItemLabel`](mantine-core.md#component-datalistitemlabel), [`DataListItemValue`](mantine-core.md#component-datalistitemvalue), [`Divider`](mantine-core.md#component-divider), [`EmptyState`](mantine-core.md#component-emptystate), [`EmptyState.Actions`](mantine-core.md#component-emptystate-2e-actions), [`EmptyState.Description`](mantine-core.md#component-emptystate-2e-description), [`EmptyState.Indicator`](mantine-core.md#component-emptystate-2e-indicator), [`EmptyState.Title`](mantine-core.md#component-emptystate-2e-title), [`EmptyStateActions`](mantine-core.md#component-emptystateactions), [`EmptyStateDescription`](mantine-core.md#component-emptystatedescription), [`EmptyStateIndicator`](mantine-core.md#component-emptystateindicator), [`EmptyStateTitle`](mantine-core.md#component-emptystatetitle), [`Fieldset`](mantine-core.md#component-fieldset), [`FlatTreeNode`](mantine-core.md#component-flattreenode), [`Flex`](mantine-core.md#component-flex), [`FloatingArrow`](mantine-core.md#component-floatingarrow), [`FloatingIndicator`](mantine-core.md#component-floatingindicator), [`Grid`](mantine-core.md#component-grid), [`Grid.Col`](mantine-core.md#component-grid-2e-col), [`GridCol`](mantine-core.md#component-gridcol), [`Group`](mantine-core.md#component-group), [`Highlight`](mantine-core.md#component-highlight), [`HoverCardLine truncated
[`Accordion`](https://mantine.dev/core/accordion/), [`Accordion.Chevron`](https://mantine.dev/core/accordion/), [`Accordion.Control`](https://mantine.dev/core/accordion/), [`Accordion.Item`](https://mantine.dev/core/accordion/), [`Accordion.Panel`](https://mantine.dev/core/accordion/), [`AccordionChevron`](https://mantine.dev/core/accordion-chevron/), [`AccordionControl`](https://mantine.dev/core/accordion-control/), [`AccordionItem`](https://mantine.dev/core/accordion-item/), [`AccordionPanel`](https://mantine.dev/core/accordion-panel/), [`ActionIcon`](https://mantine.dev/core/action-icon/), [`ActionIcon.Group`](https://mantine.dev/core/action-icon/), [`ActionIcon.GroupSection`](https://mantine.dev/core/action-icon/), [`ActionIconGroup`](https://mantine.dev/core/action-icon-group/), [`ActionIconGroupSection`](https://mantine.dev/core/action-icon-group-section/), [`Alert`](https://mantine.dev/core/alert/), [`AlphaSlider`](https://mantine.dev/core/alpha-slider/), [`Anchor`](https://mantine.dev/core/anchor/), [`AngleSlider`](https://mantine.dev/core/angle-slider/), [`AspectRatio`](https://mantine.dev/core/aspect-ratio/), [`Autocomplete`](https://mantine.dev/core/autocomplete/), [`Avatar`](https://mantine.dev/core/avatar/), [`Avatar.Group`](https://mantine.dev/core/avatar/), [`AvatarGroup`](https://mantine.dev/core/avatar-group/), [`BackgroundImage`](https://mantine.dev/core/background-image/), [`Badge`](https://mantine.dev/core/badge/), [`Blockquote`](https://mantine.dev/core/blockquote/), [`Box`](https://mantine.dev/core/box/), [`Breadcrumbs`](https://mantine.dev/core/breadcrumbs/), [`Burger`](https://mantine.dev/core/burger/), [`Button`](https://mantine.dev/core/button/), [`Button.Group`](https://mantine.dev/core/button/), [`Button.GroupSection`](https://mantine.dev/core/button/), [`ButtonGroup`](https://mantine.dev/core/button-group/), [`ButtonGroupSection`](https://mantine.dev/core/button-group-section/), [`Card`](https://mantine.dev/core/card/), [`Card.Section`](https://mantine.dev/core/card/), [`CardSection`](https://mantine.dev/core/card-section/), [`Center`](https://mantine.dev/core/center/), [`CheckIcon`](https://mantine.dev/core/check-icon/), [`Checkbox`](https://mantine.dev/core/checkbox/), [`Checkbox.Card`](https://mantine.dev/core/checkbox/), [`Checkbox.Group`](https://mantine.dev/core/checkbox/), [`Checkbox.Indicator`](https://mantine.dev/core/checkbox/), [`CheckboxCard`](https://mantine.dev/core/checkbox-card/), [`CheckboxGroup`](https://mantine.dev/core/checkbox-group/), [`CheckboxIndicator`](https://mantine.dev/core/checkbox-indicator/), [`Chip`](https://mantine.dev/core/chip/), [`Chip.Group`](https://mantine.dev/core/chip/), [`ChipGroup`](https://mantine.dev/core/chip-group/), [`CloseButton`](https://mantine.dev/core/close-button/), [`CloseIcon`](https://mantine.dev/core/close-icon/), [`Code`](https://mantine.dev/core/code/), [`Collapse`](https://mantine.dev/core/collapse/), [`ColorInput`](https://mantine.dev/core/color-input/), [`ColorPicker`](https://mantine.dev/core/color-picker/), [`ColorSwatch`](https://mantine.dev/core/color-swatch/), [`Container`](https://mantine.dev/core/container/), [`CopyButton`](https://mantine.dev/core/copy-button/), [`DataList`](https://mantine.dev/core/data-list/), [`DataList.Item`](https://mantine.dev/core/data-list/), [`DataList.ItemLabel`](https://mantine.dev/core/data-list/), [`DataList.ItemValue`](https://mantine.dev/core/data-list/), [`DataListItem`](https://mantine.dev/core/data-list-item/), [`DataListItemLabel`](https://mantine.dev/core/data-list-item-label/), [`DataListItemValue`](https://mantine.dev/core/data-list-item-value/), [`Divider`](https://mantine.dev/core/divider/), [`EmptyState`](https://mantine.dev/core/empty-state/), [`EmptyState.Actions`](https://mantine.dev/core/empty-state/), [`EmptyState.Description`](https://mantine.dev/core/empty-state/), [`EmptyState.Indicator`](https://mantine.dev/core/empty-state/), [`EmptyState.Title`](https://mantine.dev/core/empty-state/), [`EmptyStateActions`](https://mantine.dev/core/empty-state-actions/), [`EmptyStateDescription`](https://mantine.dev/core/empty-state-description/), [`EmptyStateIndicator`](https://mantine.dev/core/empty-state-indicator/), [`EmptyStateTitle`](https://mantine.dev/core/empty-state-title/), [`Fieldset`](https://mantine.dev/core/fieldset/), [`FlatTreeNode`](https://mantine.dev/core/flat-tree-node/), [`Flex`](https://mantine.dev/core/flex/), [`FloatingArrow`](https://mantine.dev/core/floating-arrow/), [`FloatingIndicator`](https://mantine.dev/core/floating-indicator/), [`Grid`](https://mantine.dev/core/grid/), [`Grid.Col`](https://mantine.dev/core/grid/), [`GridCol`](https://mantine.dev/core/grid-col/), [`Group`](https://mantine.dev/core/group/), [`Highlight`](https://mantine.dev/core/highlight/), [`HoverCard`](https://mantine.dev/core/hover-card/), [`HoverCard.Dropdown`](https://mantine.dev/core/hover-card/), [`HoverCard.Group`](https://mantine.dev/core/hover-card/), [`HoverCard.Line truncated
### Mantine Dates
[`Calendar`](mantine-dates.md#component-calendar), [`CalendarHeader`](mantine-dates.md#component-calendarheader), [`DateInput`](mantine-dates.md#component-dateinput), [`DatePicker`](mantine-dates.md#component-datepicker), [`DatePickerInput`](mantine-dates.md#component-datepickerinput), [`DateTimePicker`](mantine-dates.md#component-datetimepicker), [`Day`](mantine-dates.md#component-day), [`DecadeLevel`](mantine-dates.md#component-decadelevel), [`DecadeLevelGroup`](mantine-dates.md#component-decadelevelgroup), [`InlineDateTimePicker`](mantine-dates.md#component-inlinedatetimepicker), [`LevelsGroup`](mantine-dates.md#component-levelsgroup), [`MiniCalendar`](mantine-dates.md#component-minicalendar), [`Month`](mantine-dates.md#component-month), [`MonthLevel`](mantine-dates.md#component-monthlevel), [`MonthLevelGroup`](mantine-dates.md#component-monthlevelgroup), [`MonthPicker`](mantine-dates.md#component-monthpicker), [`MonthPickerInput`](mantine-dates.md#component-monthpickerinput), [`MonthsList`](mantine-dates.md#component-monthslist), [`PickerControl`](mantine-dates.md#component-pickercontrol), [`TimeGrid`](mantine-dates.md#component-timegrid), [`TimeInput`](mantine-dates.md#component-timeinput), [`TimePicker`](mantine-dates.md#component-timepicker), [`TimeValue`](mantine-dates.md#component-timevalue), [`WeekdaysRow`](mantine-dates.md#component-weekdaysrow), [`YearLevel`](mantine-dates.md#component-yearlevel), [`YearLevelGroup`](mantine-dates.md#component-yearlevelgroup), [`YearPicker`](mantine-dates.md#component-yearpicker), [`YearPickerInput`](mantine-dates.md#component-yearpickerinput), [`YearsList`](mantine-dates.md#component-yearslist)
[`Calendar`](https://mantine.dev/dates/calendar/), [`CalendarHeader`](https://mantine.dev/dates/calendar-header/), [`DateInput`](https://mantine.dev/dates/date-input/), [`DatePicker`](https://mantine.dev/dates/date-picker/), [`DatePickerInput`](https://mantine.dev/dates/date-picker-input/), [`DateTimePicker`](https://mantine.dev/dates/date-time-picker/), [`Day`](https://mantine.dev/dates/day/), [`DecadeLevel`](https://mantine.dev/dates/decade-level/), [`DecadeLevelGroup`](https://mantine.dev/dates/decade-level-group/), [`InlineDateTimePicker`](https://mantine.dev/dates/inline-date-time-picker/), [`LevelsGroup`](https://mantine.dev/dates/levels-group/), [`MiniCalendar`](https://mantine.dev/dates/mini-calendar/), [`Month`](https://mantine.dev/dates/month/), [`MonthLevel`](https://mantine.dev/dates/month-level/), [`MonthLevelGroup`](https://mantine.dev/dates/month-level-group/), [`MonthPicker`](https://mantine.dev/dates/month-picker/), [`MonthPickerInput`](https://mantine.dev/dates/month-picker-input/), [`MonthsList`](https://mantine.dev/dates/months-list/), [`PickerControl`](https://mantine.dev/dates/picker-control/), [`TimeGrid`](https://mantine.dev/dates/time-grid/), [`TimeInput`](https://mantine.dev/dates/time-input/), [`TimePicker`](https://mantine.dev/dates/time-picker/), [`TimeValue`](https://mantine.dev/dates/time-value/), [`WeekdaysRow`](https://mantine.dev/dates/weekdays-row/), [`YearLevel`](https://mantine.dev/dates/year-level/), [`YearLevelGroup`](https://mantine.dev/dates/year-level-group/), [`YearPicker`](https://mantine.dev/dates/year-picker/), [`YearPickerInput`](https://mantine.dev/dates/year-picker-input/), [`YearsList`](https://mantine.dev/dates/years-list/)
### Mantine Charts
[`AreaChart`](mantine-charts.md#component-areachart), [`BarChart`](mantine-charts.md#component-barchart), [`BarsList`](mantine-charts.md#component-barslist), [`BubbleChart`](mantine-charts.md#component-bubblechart), [`ChartLegend`](mantine-charts.md#component-chartlegend), [`ChartTooltip`](mantine-charts.md#component-charttooltip), [`CompositeChart`](mantine-charts.md#component-compositechart), [`DonutChart`](mantine-charts.md#component-donutchart), [`FunnelChart`](mantine-charts.md#component-funnelchart), [`Heatmap`](mantine-charts.md#component-heatmap), [`LineChart`](mantine-charts.md#component-linechart), [`PieChart`](mantine-charts.md#component-piechart), [`RadarChart`](mantine-charts.md#component-radarchart), [`RadialBarChart`](mantine-charts.md#component-radialbarchart), [`SankeyChart`](mantine-charts.md#component-sankeychart), [`ScatterChart`](mantine-charts.md#component-scatterchart), [`Sparkline`](mantine-charts.md#component-sparkline), [`Treemap`](mantine-charts.md#component-treemap)
[`AreaChart`](https://mantine.dev/charts/area-chart/), [`BarChart`](https://mantine.dev/charts/bar-chart/), [`BarsList`](https://mantine.dev/charts/bars-list/), [`BubbleChart`](https://mantine.dev/charts/bubble-chart/), [`ChartLegend`](https://mantine.dev/charts/chart-legend/), [`ChartTooltip`](https://mantine.dev/charts/chart-tooltip/), [`CompositeChart`](https://mantine.dev/charts/composite-chart/), [`DonutChart`](https://mantine.dev/charts/donut-chart/), [`FunnelChart`](https://mantine.dev/charts/funnel-chart/), [`Heatmap`](https://mantine.dev/charts/heatmap/), [`LineChart`](https://mantine.dev/charts/line-chart/), [`PieChart`](https://mantine.dev/charts/pie-chart/), [`RadarChart`](https://mantine.dev/charts/radar-chart/), [`RadialBarChart`](https://mantine.dev/charts/radial-bar-chart/), [`SankeyChart`](https://mantine.dev/charts/sankey-chart/), [`ScatterChart`](https://mantine.dev/charts/scatter-chart/), [`Sparkline`](https://mantine.dev/charts/sparkline/), [`Treemap`](https://mantine.dev/charts/treemap/)
### Homarr components
@@ -43,468 +43,21 @@ This is the complete offline Custom JSX component contract for this Homarr relea
### Mantine Core
[`Affix`](mantine-core.md#component-affix) — Escapes the widget layout boundary, [`AppShell`](mantine-core.md#component-appshell) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Aside`](mantine-core.md#component-appshell-2e-aside) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Footer`](mantine-core.md#component-appshell-2e-footer) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Header`](mantine-core.md#component-appshell-2e-header) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Main`](mantine-core.md#component-appshell-2e-main) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Navbar`](mantine-core.md#component-appshell-2e-navbar) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Section`](mantine-core.md#component-appshell-2e-section) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellAside`](mantine-core.md#component-appshellaside) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellFooter`](mantine-core.md#component-appshellfooter) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellHeader`](mantine-core.md#component-appshellheader) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellMain`](mantine-core.md#component-appshellmain) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellNavbar`](mantine-core.md#component-appshellnavbar) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellSection`](mantine-core.md#component-appshellsection) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`ColorSchemeScript`](mantine-core.md#component-colorschemescript) — Writes global document state, [`Combobox`](mantine-core.md#component-combobox) — Low-level composition primitive that requires authored callbacks, [`Combobox.Chevron`](mantine-core.md#component-combobox-2e-chevron) — Low-level composition primitive that requires authored callbacks, [`Combobox.ClearButton`](mantine-core.md#component-combobox-2e-clearbutton) — Low-level composition primitive that requires authored callbacks, [`Combobox.Dropdown`](mantine-core.md#component-combobox-2e-dropdown) — Low-level composition primitive that requires authored callbacks, [`Combobox.DropdownTarget`](mantine-core.md#component-combobox-2e-dropdowntarget) — Low-level composition primitive that requires authored callbacks, [`Combobox.Empty`](mantine-core.md#component-combobox-2e-empty) — Low-level composition primitive that requires authored callbacks, [`Combobox.EventsTarget`](mantine-core.md#component-combobox-2e-eventstarget) — Low-level composition primitive that requires authored callbacks, [`Combobox.Footer`](mantine-core.md#component-combobox-2e-footer) — Low-level composition primitive that requires authored callbacks, [`Combobox.Group`](mantine-core.md#component-combobox-2e-group) — Low-level composition primitive that requires authored callbacks, [`Combobox.Header`](mantine-core.md#component-combobox-2e-header) — Low-level composition primitive that requires authored callbacks, [`Combobox.HiddenInput`](mantine-core.md#component-combobox-2e-hiddeninput) — Low-level composition primitive that requires authored callbacks, [`Combobox.Option`](mantine-core.md#component-combobox-2e-option) — Low-level composition primitive that requires authored callbacks, [`Combobox.Options`](mantine-core.md#component-combobox-2e-options) — Low-level composition primitive that requires authored callbacks, [`Combobox.Search`](mantine-core.md#component-combobox-2e-search) — Low-level composition primitive that requires authored callbacks, [`Combobox.Target`](mantine-core.md#component-combobox-2e-target) — Low-level composition primitive that requires authored callbacks, [`ComboboxChevron`](mantine-core.md#component-comboboxchevron) — Low-level composition primitive that requires authored callbacks, [`ComboboxClearButton`](mantine-core.md#component-comboboxclearbutton) — Low-level composition primitive that requires authored callbacks, [`ComboboxDropdown`](mantine-core.md#component-comboboxdropdown) — Low-level composition primitive that requires authored callbacks, [`ComboboxDropdownTarget`](mantine-core.md#component-comboboxdropdowntarget) — Low-level composition primitive that requires authored callbacks, [`ComboboxEmpty`](mantine-core.md#component-comboboxempty) — Low-level composition primitive that requires authored callbacks, [`ComboboxEventsTarget`](mantine-core.md#component-comboboxeventstarget) — Low-level composition primitive that requires authored callbacks, [`ComboboxFooter`](mantine-core.md#componenLine truncated
[`Affix`](https://mantine.dev/core/affix/) — Escapes the widget layout boundary, [`AppShell`](https://mantine.dev/core/app-shell/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Aside`](https://mantine.dev/core/app-shell/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Footer`](https://mantine.dev/core/app-shell/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Header`](https://mantine.dev/core/app-shell/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Main`](https://mantine.dev/core/app-shell/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Navbar`](https://mantine.dev/core/app-shell/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Section`](https://mantine.dev/core/app-shell/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellAside`](https://mantine.dev/core/app-shell-aside/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellFooter`](https://mantine.dev/core/app-shell-footer/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellHeader`](https://mantine.dev/core/app-shell-header/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellMain`](https://mantine.dev/core/app-shell-main/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellNavbar`](https://mantine.dev/core/app-shell-navbar/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellSection`](https://mantine.dev/core/app-shell-section/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`ColorSchemeScript`](https://mantine.dev/core/color-scheme-script/) — Writes global document state, [`Combobox`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.Chevron`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.ClearButton`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.Dropdown`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.DropdownTarget`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.Empty`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.EventsTarget`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.Footer`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.Group`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.Header`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.HiddenInput`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.Option`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.Options`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.Search`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.Target`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`ComboboxChevron`](https://mantine.dev/core/combobox-chevron/) — Low-level composition primitive that requires authored callbacks, [`ComboboxClearButton`](https://mantine.dev/core/combobox-clear-button/) — Low-level composition primitive that requires authored callbacks, [`ComboboxDropdown`](https://mantine.dev/core/combobox-dropdown/) — Low-level composition primitive that requires authored callbacks, [`ComboboxDropdownTarget`](https://mantine.dev/core/combobox-dropdown-target/) — Low-level composition primitive that requires authored callbacks, [`ComboboxEmpty`](https://mantine.dev/core/combobox-empty/) — Low-level composition primitive that requires authored callbacks, [`ComboboxEventsTarget`](https://mantine.dev/core/combobox-events-target/) — Low-level composition primitive that requires authored callbacks, [`ComboboxFooter`](https://mantine.dev/core/combobox-footer/) — Low-level composition primitive that requires authored callbacks, [`ComboboxGroup`](https://mantine.dev/core/combobox-group/) — Low-level composition primitive that requLine truncated
### Mantine Dates
[`DatesProvider`](mantine-dates.md#component-datesprovider) — Replaces a Homarr-owned provider boundary, [`HiddenDatesInput`](mantine-dates.md#component-hiddendatesinput) — Internal date-input primitive, [`PickerInputBase`](mantine-dates.md#component-pickerinputbase) — Internal date-input primitive
[`DatesProvider`](https://mantine.dev/dates/dates-provider/) — Replaces a Homarr-owned provider boundary, [`HiddenDatesInput`](https://mantine.dev/dates/hidden-dates-input/) — Internal date-input primitive, [`PickerInputBase`](https://mantine.dev/dates/picker-input-base/) — Internal date-input primitive
### Mantine Charts
[`AreaGradient`](mantine-charts.md#component-areagradient) — Low-level chart implementation helper
[`AreaGradient`](https://mantine.dev/charts/area-gradient/) — Low-level chart implementation helper
### Homarr components
_None._
## Shared prop API
These props are available to enabled components unless a component-specific record overrides the same name.
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>abbr</code> | no | <code>string</code> | — | — |
| <code>about</code> | no | <code>string</code> | — | — |
| <code>accentHeight</code> | no | <code>string &#124; number</code> | — | — |
| <code>accept</code> | no | <code>string</code> | — | — |
| <code>accessKey</code> | no | <code>string</code> | — | — |
| <code>accumulate</code> | no | <code>"none" &#124; "sum"</code> | <code>"none"</code>, <code>"sum"</code> | — |
| <code>additive</code> | no | <code>"sum" &#124; "replace"</code> | <code>"replace"</code>, <code>"sum"</code> | — |
| <code>align</code> | no | <code>"center" &#124; "left" &#124; "right"</code> | <code>"center"</code>, <code>"char"</code>, <code>"justify"</code>, <code>"left"</code>, <code>"right"</code> | — |
| <code>alignmentBaseline</code> | no | <code>"inherit" &#124; "auto" &#124; "baseline" &#124; "before-edge" &#124; "text-before-edge" &#124; "middle" &#124; "central" &#124; "after-edge" &#124; "text-after-edge" &#124; "ideographic" &#124; "alphabetic" &#124; "hanging" &#124; "mathematical"</code> | <code>"after-edge"</code>, <code>"alphabetic"</code>, <code>"auto"</code>, <code>"baseline"</code>, <code>"before-edge"</code>, <code>"central"</code>, <code>"hanging"</code>, <code>"ideographic"</code>, <code>"inherit"</code>, <code>"mathematical"</code>, <code>"middle"</code>, <code>"text-after-edge"</code>, <code>"text-before-edge"</code> | — |
| <code>allowReorder</code> | no | <code>"yes" &#124; "no"</code> | <code>"no"</code>, <code>"yes"</code> | — |
| <code>alphabetic</code> | no | <code>string &#124; number</code> | — | — |
| <code>alt</code> | no | <code>string</code> | — | — |
| <code>amplitude</code> | no | <code>string &#124; number</code> | — | — |
| <code>arabicForm</code> | no | <code>"initial" &#124; "medial" &#124; "terminal" &#124; "isolated"</code> | <code>"initial"</code>, <code>"isolated"</code>, <code>"medial"</code>, <code>"terminal"</code> | — |
| <code>aria-activedescendant</code> | no | <code>string</code> | — | Identifies the currently active element when DOM focus is on a composite widget, textbox, group, or application. |
| <code>aria-atomic</code> | no | <code>false &#124; true &#124; "true" &#124; "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | Indicates whether assistive technologies will present all, or only parts of, the changed region based on the change notifications defined by the aria-relevant attribute. |
| <code>aria-autocomplete</code> | no | <code>"none" &#124; "inline" &#124; "list" &#124; "both"</code> | <code>"both"</code>, <code>"inline"</code>, <code>"list"</code>, <code>"none"</code> | Indicates whether inputting text could trigger display of one or more predictions of the user's intended value for an input and specifies how predictions would be presented if they are made. |
| <code>aria-braillelabel</code> | no | <code>string</code> | — | Defines a string value that labels the current element, which is intended to be converted into Braille. |
| <code>aria-brailleroledescription</code> | no | <code>string</code> | — | Defines a human-readable, author-localized abbreviated description for the role of an element, which is intended to be converted into Braille. |
| <code>aria-busy</code> | no | <code>false &#124; true &#124; "true" &#124; "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | — |
| <code>aria-checked</code> | no | <code>false &#124; true &#124; "true" &#124; "false" &#124; "mixed"</code> | <code>false</code>, <code>"false"</code>, <code>"mixed"</code>, <code>true</code>, <code>"true"</code> | Indicates the current "checked" state of checkboxes, radio buttons, and other widgets. |
| <code>aria-colcount</code> | no | <code>number</code> | — | Defines the total number of columns in a table, grid, or treegrid. |
| <code>aria-colindex</code> | no | <code>number</code> | — | Defines an element's column index or position with respect to the total number of columns within a table, grid, or treegrid. |
| <code>aria-colindextext</code> | no | <code>string</code> | — | Defines a human readable text alternative of aria-colindex. |
| <code>aria-colspan</code> | no | <code>number</code> | — | Defines the number of columns spanned by a cell or gridcell within a table, grid, or treegrid. |
| <code>aria-controls</code> | no | <code>string</code> | — | Identifies the element (or elements) whose contents or presence are controlled by the current element. |
| <code>aria-current</code> | no | <code>false &#124; true &#124; "time" &#124; "true" &#124; "false" &#124; "page" &#124; "step" &#124; "location" &#124; "date"</code> | <code>"date"</code>, <code>false</code>, <code>"false"</code>, <code>"location"</code>, <code>"page"</code>, <code>"step"</code>, <code>"time"</code>, <code>true</code>, <code>"true"</code> | Indicates the element that represents the current item within a container or set of related elements. |
| <code>aria-describedby</code> | no | <code>string</code> | — | Identifies the element (or elements) that describes the object. |
| <code>aria-description</code> | no | <code>string</code> | — | Defines a string value that describes or annotates the current element. |
| <code>aria-details</code> | no | <code>string</code> | — | Identifies the element that provides a detailed, extended description for the object. |
| <code>aria-disabled</code> | no | <code>false &#124; true &#124; "true" &#124; "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | Indicates that the element is perceivable but disabled, so it is not editable or otherwise operable. |
| <code>aria-dropeffect</code> | no | <code>"link" &#124; "none" &#124; "copy" &#124; "execute" &#124; "move" &#124; "popup"</code> | <code>"copy"</code>, <code>"execute"</code>, <code>"link"</code>, <code>"move"</code>, <code>"none"</code>, <code>"popup"</code> | Indicates what functions can be performed when a dragged object is released on the drop target. |
| <code>aria-errormessage</code> | no | <code>string</code> | — | Identifies the element that provides an error message for the object. |
| <code>aria-expanded</code> | no | <code>false &#124; true &#124; "true" &#124; "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | Indicates whether the element, or another grouping element it controls, is currently expanded or collapsed. |
| <code>aria-flowto</code> | no | <code>string</code> | — | Identifies the next element (or elements) in an alternate reading order of content which, at the user's discretion, allows assistive technology to override the general default of reading in document source order. |
| <code>aria-grabbed</code> | no | <code>false &#124; true &#124; "true" &#124; "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | Indicates an element's "grabbed" state in a drag-and-drop operation. |
| <code>aria-haspopup</code> | no | <code>false &#124; true &#124; "dialog" &#124; "menu" &#124; "grid" &#124; "true" &#124; "false" &#124; "listbox" &#124; "tree"</code> | <code>"dialog"</code>, <code>false</code>, <code>"false"</code>, <code>"grid"</code>, <code>"listbox"</code>, <code>"menu"</code>, <code>"tree"</code>, <code>true</code>, <code>"true"</code> | Indicates the availability and type of interactive popup element, such as menu or dialog, that can be triggered by an element. |
| <code>aria-hidden</code> | no | <code>false &#124; true &#124; "true" &#124; "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | Indicates whether the element is exposed to an accessibility API. |
| <code>aria-invalid</code> | no | <code>false &#124; true &#124; "true" &#124; "false" &#124; "grammar" &#124; "spelling"</code> | <code>false</code>, <code>"false"</code>, <code>"grammar"</code>, <code>"spelling"</code>, <code>true</code>, <code>"true"</code> | Indicates the entered value does not conform to the format expected by the application. |
| <code>aria-keyshortcuts</code> | no | <code>string</code> | — | Indicates keyboard shortcuts that an author has implemented to activate or give focus to an element. |
| <code>aria-label</code> | no | <code>string</code> | — | Defines a string value that labels the current element. |
| <code>aria-labelledby</code> | no | <code>string</code> | — | Identifies the element (or elements) that labels the current element. |
| <code>aria-level</code> | no | <code>number</code> | — | Defines the hierarchical level of an element within a structure. |
| <code>aria-live</code> | no | <code>"off" &#124; "assertive" &#124; "polite"</code> | <code>"assertive"</code>, <code>"off"</code>, <code>"polite"</code> | Indicates that an element will be updated, and describes the types of updates the user agents, assistive technologies, and user can expect from the live region. |
| <code>aria-modal</code> | no | <code>false &#124; true &#124; "true" &#124; "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | Indicates whether an element is modal when displayed. |
| <code>aria-multiline</code> | no | <code>false &#124; true &#124; "true" &#124; "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | Indicates whether a text box accepts multiple lines of input or only a single line. |
| <code>aria-multiselectable</code> | no | <code>false &#124; true &#124; "true" &#124; "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | Indicates that the user may select more than one item from the current selectable descendants. |
| <code>aria-orientation</code> | no | <code>"horizontal" &#124; "vertical"</code> | <code>"horizontal"</code>, <code>"vertical"</code> | Indicates whether the element's orientation is horizontal, vertical, or unknown/ambiguous. |
| <code>aria-owns</code> | no | <code>string</code> | — | Identifies an element (or elements) in order to define a visual, functional, or contextual parent/child relationship between DOM elements where the DOM hierarchy cannot be used to represent the relationship. |
| <code>aria-placeholder</code> | no | <code>string</code> | — | Defines a short hint (a word or short phrase) intended to aid the user with data entry when the control has no value. A hint could be a sample value or a brief description of the expected format. |
| <code>aria-posinset</code> | no | <code>number</code> | — | Defines an element's number or position in the current set of listitems or treeitems. Not required if all elements in the set are present in the DOM. |
| <code>aria-pressed</code> | no | <code>false &#124; true &#124; "true" &#124; "false" &#124; "mixed"</code> | <code>false</code>, <code>"false"</code>, <code>"mixed"</code>, <code>true</code>, <code>"true"</code> | Indicates the current "pressed" state of toggle buttons. |
| <code>aria-readonly</code> | no | <code>false &#124; true &#124; "true" &#124; "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | Indicates that the element is not editable, but is otherwise operable. |
| <code>aria-relevant</code> | no | <code>"text" &#124; "additions" &#124; "additions removals" &#124; "additions text" &#124; "all" &#124; "removals" &#124; "removals additions" &#124; "removals text" &#124; "text additions" &#124; "text removals"</code> | <code>"additions"</code>, <code>"additions removals"</code>, <code>"additions text"</code>, <code>"all"</code>, <code>"removals"</code>, <code>"removals additions"</code>, <code>"removals text"</code>, <code>"text"</code>, <code>"text additions"</code>, <code>"text removals"</code> | Indicates what notifications the user agent will trigger when the accessibility tree within a live region is modified. |
| <code>aria-required</code> | no | <code>false &#124; true &#124; "true" &#124; "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | Indicates that user input is required on the element before a form may be submitted. |
| <code>aria-roledescription</code> | no | <code>string</code> | — | Defines a human-readable, author-localized description for the role of an element. |
| <code>aria-rowcount</code> | no | <code>number</code> | — | Defines the total number of rows in a table, grid, or treegrid. |
| <code>aria-rowindex</code> | no | <code>number</code> | — | Defines an element's row index or position with respect to the total number of rows within a table, grid, or treegrid. |
| <code>aria-rowindextext</code> | no | <code>string</code> | — | Defines a human readable text alternative of aria-rowindex. |
| <code>aria-rowspan</code> | no | <code>number</code> | — | Defines the number of rows spanned by a cell or gridcell within a table, grid, or treegrid. |
| <code>aria-selected</code> | no | <code>false &#124; true &#124; "true" &#124; "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | Indicates the current "selected" state of various widgets. |
| <code>aria-setsize</code> | no | <code>number</code> | — | Defines the number of items in the current set of listitems or treeitems. Not required if all elements in the set are present in the DOM. |
| <code>aria-sort</code> | no | <code>"none" &#124; "ascending" &#124; "descending" &#124; "other"</code> | <code>"ascending"</code>, <code>"descending"</code>, <code>"none"</code>, <code>"other"</code> | Indicates if items in a table or grid are sorted in ascending or descending order. |
| <code>aria-valuemax</code> | no | <code>number</code> | — | Defines the maximum allowed value for a range widget. |
| <code>aria-valuemin</code> | no | <code>number</code> | — | Defines the minimum allowed value for a range widget. |
| <code>aria-valuenow</code> | no | <code>number</code> | — | Defines the current value for a range widget. |
| <code>aria-valuetext</code> | no | <code>string</code> | — | Defines the human readable text alternative of aria-valuenow for a range widget. |
| <code>ascent</code> | no | <code>string &#124; number</code> | — | — |
| <code>attributeName</code> | no | <code>string</code> | — | — |
| <code>attributeType</code> | no | <code>string</code> | — | — |
| <code>autoCapitalize</code> | no | <code>(string &amp; {}) &#124; "none" &#124; "off" &#124; "on" &#124; "sentences" &#124; "words" &#124; "characters"</code> | <code>"characters"</code>, <code>"none"</code>, <code>"off"</code>, <code>"on"</code>, <code>"sentences"</code>, <code>"words"</code> | — |
| <code>autoComplete</code> | no | <code>HTMLInputAutoCompleteAttribute</code> | — | — |
| <code>autoCorrect</code> | no | <code>string</code> | — | — |
| <code>autoReverse</code> | no | <code>false &#124; true &#124; "true" &#124; "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | — |
| <code>autoSave</code> | no | <code>string</code> | — | — |
| <code>azimuth</code> | no | <code>string &#124; number</code> | — | — |
| <code>baseFrequency</code> | no | <code>string &#124; number</code> | — | — |
| <code>baseProfile</code> | no | <code>string &#124; number</code> | — | — |
| <code>baselineShift</code> | no | <code>string &#124; number</code> | — | — |
| <code>bbox</code> | no | <code>string &#124; number</code> | — | — |
| <code>bd</code> | no | <code>StyleProp&lt;Property.Border&lt;string &#124; number&gt;&gt;</code> | — | Border |
| <code>bdrs</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | BorderRadius, theme key: theme.radius |
| <code>begin</code> | no | <code>string &#124; number</code> | — | — |
| <code>bg</code> | no | <code>StyleProp&lt;DefaultMantineColor&gt;</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Background, theme key: theme.colors |
| <code>bga</code> | no | <code>StyleProp&lt;Property.BackgroundAttachment&gt;</code> | <code>"-moz-initial"</code>, <code>"fixed"</code>, <code>"inherit"</code>, <code>"initial"</code>, <code>"local"</code>, <code>"revert"</code>, <code>"revert-layer"</code>, <code>"scroll"</code>, <code>"unset"</code> | BackgroundAttachment |
| <code>bgcolor</code> | no | <code>string</code> | — | — |
| <code>bgp</code> | no | <code>StyleProp&lt;Property.BackgroundPosition&lt;string &#124; number&gt;&gt;</code> | — | BackgroundPosition |
| <code>bgr</code> | no | <code>StyleProp&lt;Property.BackgroundRepeat&gt;</code> | <code>"-moz-initial"</code>, <code>"inherit"</code>, <code>"initial"</code>, <code>"no-repeat"</code>, <code>"repeat"</code>, <code>"repeat-x"</code>, <code>"repeat-y"</code>, <code>"revert"</code>, <code>"revert-layer"</code>, <code>"round"</code>, <code>"space"</code>, <code>"unset"</code> | BackgroundRepeat |
| <code>bgsz</code> | no | <code>StyleProp&lt;Property.BackgroundSize&lt;string &#124; number&gt;&gt;</code> | — | BackgroundSize |
| <code>bias</code> | no | <code>string &#124; number</code> | — | — |
| <code>border</code> | no | <code>number</code> | — | — |
| <code>by</code> | no | <code>string &#124; number</code> | — | — |
| <code>c</code> | no | <code>StyleProp&lt;DefaultMantineColor&gt;</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color |
| <code>calcMode</code> | no | <code>string &#124; number</code> | — | — |
| <code>capHeight</code> | no | <code>string &#124; number</code> | — | — |
| <code>capture</code> | no | <code>false &#124; true &#124; "user" &#124; "environment"</code> | <code>"environment"</code>, <code>false</code>, <code>true</code>, <code>"user"</code> | — |
| <code>cellPadding</code> | no | <code>string &#124; number</code> | — | — |
| <code>cellSpacing</code> | no | <code>string &#124; number</code> | — | — |
| <code>checked</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>clip</code> | no | <code>string &#124; number</code> | — | — |
| <code>clipPath</code> | no | <code>string</code> | — | — |
| <code>clipPathUnits</code> | no | <code>string &#124; number</code> | — | — |
| <code>clipRule</code> | no | <code>string &#124; number</code> | — | — |
| <code>colSpan</code> | no | <code>number</code> | — | — |
| <code>color</code> | no | <code>string</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"initials"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Key of `theme.colors` or any valid CSS color |
| <code>colorInterpolation</code> | no | <code>string &#124; number</code> | — | — |
| <code>colorInterpolationFilters</code> | no | <code>"inherit" &#124; "auto" &#124; "sRGB" &#124; "linearRGB"</code> | <code>"auto"</code>, <code>"inherit"</code>, <code>"linearRGB"</code>, <code>"sRGB"</code> | — |
| <code>colorProfile</code> | no | <code>string &#124; number</code> | — | — |
| <code>colorRendering</code> | no | <code>string &#124; number</code> | — | — |
| <code>cols</code> | no | <code>number</code> | — | — |
| <code>content</code> | no | <code>string</code> | — | — |
| <code>contentScriptType</code> | no | <code>string &#124; number</code> | — | — |
| <code>contentStyleType</code> | no | <code>string &#124; number</code> | — | — |
| <code>contextMenu</code> | no | <code>string</code> | — | — |
| <code>crossOrigin</code> | no | <code>"" &#124; "anonymous" &#124; "use-credentials"</code> | <code>""</code>, <code>"anonymous"</code>, <code>"use-credentials"</code> | — |
| <code>cursor</code> | no | <code>string &#124; number</code> | — | — |
| <code>cx</code> | no | <code>string &#124; number</code> | — | — |
| <code>cy</code> | no | <code>string &#124; number</code> | — | — |
| <code>d</code> | no | <code>string</code> | — | — |
| <code>darkHidden</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether component should be hidden in dark color scheme with `display: none` |
| <code>datatype</code> | no | <code>string</code> | — | — |
| <code>decelerate</code> | no | <code>string &#124; number</code> | — | — |
| <code>defaultChecked</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>defaultValue</code> | no | <code>string &#124; number &#124; readonly string[]</code> | — | — |
| <code>descent</code> | no | <code>string &#124; number</code> | — | — |
| <code>diffuseConstant</code> | no | <code>string &#124; number</code> | — | — |
| <code>dir</code> | no | <code>string</code> | — | — |
| <code>dirName</code> | no | <code>string</code> | — | — |
| <code>direction</code> | no | <code>string &#124; number</code> | — | — |
| <code>disabled</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>display</code> | no | <code>StyleProp&lt;Property.Display&gt;</code> | <code>"-moz-initial"</code>, <code>"-ms-flexbox"</code>, <code>"-ms-grid"</code>, <code>"-ms-inline-flexbox"</code>, <code>"-ms-inline-grid"</code>, <code>"-webkit-flex"</code>, <code>"-webkit-inline-flex"</code>, <code>"block"</code>, <code>"contents"</code>, <code>"flex"</code>, <code>"flow"</code>, <code>"flow-root"</code>, <code>"grid"</code>, <code>"inherit"</code>, <code>"initial"</code>, <code>"inline"</code>, <code>"inline-block"</code>, <code>"inline-flex"</code>, <code>"inline-grid"</code>, <code>"inline-list-item"</code>, <code>"inline-table"</code>, <code>"list-item"</code>, <code>"none"</code>, <code>"revert"</code>, <code>"revert-layer"</code>, <code>"ruby"</code>, <code>"ruby-base"</code>, <code>"ruby-base-container"</code>, <code>"ruby-text"</code>, <code>"ruby-text-container"</code>, <code>"run-in"</code>, <code>"table"</code>, <code>"table-caption"</code>, <code>"table-cell"</code>, <code>"table-column"</code>, <code>"table-column-group"</code>, <code>"table-footer-group"</code>, <code>"table-header-group"</code>, <code>"table-row"</code>, <code>"table-row-group"</code>, <code>"unset"</code> | — |
| <code>divisor</code> | no | <code>string &#124; number</code> | — | — |
| <code>dominantBaseline</code> | no | <code>"inherit" &#124; "auto" &#124; "text-before-edge" &#124; "middle" &#124; "central" &#124; "text-after-edge" &#124; "ideographic" &#124; "alphabetic" &#124; "hanging" &#124; "mathematical" &#124; "use-script" &#124; "no-change" &#124; "reset-size"</code> | <code>"alphabetic"</code>, <code>"auto"</code>, <code>"central"</code>, <code>"hanging"</code>, <code>"ideographic"</code>, <code>"inherit"</code>, <code>"mathematical"</code>, <code>"middle"</code>, <code>"no-change"</code>, <code>"reset-size"</code>, <code>"text-after-edge"</code>, <code>"text-before-edge"</code>, <code>"use-script"</code> | — |
| <code>draggable</code> | no | <code>false &#124; true &#124; "true" &#124; "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | — |
| <code>dur</code> | no | <code>string &#124; number</code> | — | — |
| <code>dx</code> | no | <code>string &#124; number</code> | — | — |
| <code>dy</code> | no | <code>string &#124; number</code> | — | — |
| <code>edgeMode</code> | no | <code>string &#124; number</code> | — | — |
| <code>elevation</code> | no | <code>string &#124; number</code> | — | — |
| <code>enableBackground</code> | no | <code>string &#124; number</code> | — | — |
| <code>end</code> | no | <code>string &#124; number</code> | — | — |
| <code>enterKeyHint</code> | no | <code>"search" &#124; "enter" &#124; "done" &#124; "go" &#124; "next" &#124; "previous" &#124; "send"</code> | <code>"done"</code>, <code>"enter"</code>, <code>"go"</code>, <code>"next"</code>, <code>"previous"</code>, <code>"search"</code>, <code>"send"</code> | — |
| <code>exponent</code> | no | <code>string &#124; number</code> | — | — |
| <code>exportparts</code> | no | <code>string</code> | — | — |
| <code>externalResourcesRequired</code> | no | <code>false &#124; true &#124; "true" &#124; "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | — |
| <code>ff</code> | no | <code>StyleProp&lt;"text" &#124; (string &amp; {}) &#124; "monospace" &#124; "heading"&gt;</code> | <code>"heading"</code>, <code>"monospace"</code>, <code>"text"</code> | FontFamily |
| <code>fill</code> | no | <code>string</code> | — | — |
| <code>fillOpacity</code> | no | <code>string &#124; number</code> | — | — |
| <code>fillRule</code> | no | <code>"inherit" &#124; "nonzero" &#124; "evenodd"</code> | <code>"evenodd"</code>, <code>"inherit"</code>, <code>"nonzero"</code> | — |
| <code>filter</code> | no | <code>string</code> | — | — |
| <code>filterRes</code> | no | <code>string &#124; number</code> | — | — |
| <code>filterUnits</code> | no | <code>string &#124; number</code> | — | — |
| <code>flex</code> | no | <code>StyleProp&lt;Property.Flex&lt;string &#124; number&gt;&gt;</code> | — | — |
| <code>floodColor</code> | no | <code>string &#124; number</code> | — | — |
| <code>floodOpacity</code> | no | <code>string &#124; number</code> | — | — |
| <code>focusable</code> | no | <code>false &#124; true &#124; "auto" &#124; "true" &#124; "false"</code> | <code>"auto"</code>, <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | — |
| <code>fontFamily</code> | no | <code>string</code> | — | — |
| <code>fontSize</code> | no | <code>string &#124; number</code> | — | — |
| <code>fontSizeAdjust</code> | no | <code>string &#124; number</code> | — | — |
| <code>fontStretch</code> | no | <code>string &#124; number</code> | — | — |
| <code>fontStyle</code> | no | <code>string &#124; number</code> | — | — |
| <code>fontVariant</code> | no | <code>string &#124; number</code> | — | — |
| <code>fontWeight</code> | no | <code>string &#124; number</code> | — | — |
| <code>format</code> | no | <code>string &#124; number</code> | — | — |
| <code>fr</code> | no | <code>string &#124; number</code> | — | — |
| <code>frame</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>from</code> | no | <code>string &#124; number</code> | — | — |
| <code>fs</code> | no | <code>StyleProp&lt;Property.FontStyle&gt;</code> | <code>"-moz-initial"</code>, <code>"inherit"</code>, <code>"initial"</code>, <code>"italic"</code>, <code>"normal"</code>, <code>"oblique"</code>, <code>"revert"</code>, <code>"revert-layer"</code>, <code>"unset"</code> | FontStyle |
| <code>fw</code> | no | <code>StyleProp&lt;Property.FontWeight&gt;</code> | <code>"-moz-initial"</code>, <code>"bold"</code>, <code>"bolder"</code>, <code>"inherit"</code>, <code>"initial"</code>, <code>"lighter"</code>, <code>"normal"</code>, <code>"revert"</code>, <code>"revert-layer"</code>, <code>"unset"</code> | FontWeight |
| <code>fx</code> | no | <code>string &#124; number</code> | — | — |
| <code>fy</code> | no | <code>string &#124; number</code> | — | — |
| <code>fz</code> | no | <code>StyleProp&lt;number &#124; "h1" &#124; "h2" &#124; "h3" &#124; "h4" &#124; "h5" &#124; "h6" &#124; MantineFontSize&gt;</code> | <code>"h1"</code>, <code>"h2"</code>, <code>"h3"</code>, <code>"h4"</code>, <code>"h5"</code>, <code>"h6"</code>, <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | FontSize, theme key: theme.fontSizes |
| <code>g1</code> | no | <code>string &#124; number</code> | — | — |
| <code>g2</code> | no | <code>string &#124; number</code> | — | — |
| <code>glyphName</code> | no | <code>string &#124; number</code> | — | — |
| <code>glyphOrientationHorizontal</code> | no | <code>string &#124; number</code> | — | — |
| <code>glyphOrientationVertical</code> | no | <code>string &#124; number</code> | — | — |
| <code>gradientTransform</code> | no | <code>string</code> | — | — |
| <code>gradientUnits</code> | no | <code>string</code> | — | — |
| <code>h</code> | no | <code>StyleProp&lt;Property.Height&lt;string &#124; number&gt;&gt;</code> | — | Height, theme key: theme.spacing |
| <code>hanging</code> | no | <code>string &#124; number</code> | — | — |
| <code>headers</code> | no | <code>string</code> | — | — |
| <code>height</code> | no | <code>string &#124; number</code> | — | — |
| <code>hidden</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>hiddenFrom</code> | no | <code>MantineBreakpoint</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | Breakpoint above which the component is hidden with `display: none` |
| <code>horizAdvX</code> | no | <code>string &#124; number</code> | — | — |
| <code>horizOriginX</code> | no | <code>string &#124; number</code> | — | — |
| <code>href</code> | no | <code>string</code> | — | — |
| <code>id</code> | no | <code>string</code> | — | Static id used as base to generate `aria-` attributes, by default generates random id |
| <code>ideographic</code> | no | <code>string &#124; number</code> | — | — |
| <code>imageRendering</code> | no | <code>string &#124; number</code> | — | — |
| <code>in</code> | no | <code>string</code> | — | — |
| <code>in2</code> | no | <code>string &#124; number</code> | — | — |
| <code>inert</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>inlist</code> | no | <code>unknown</code> | — | — |
| <code>inputMode</code> | no | <code>"search" &#124; "text" &#124; "none" &#124; "tel" &#124; "url" &#124; "email" &#124; "numeric" &#124; "decimal"</code> | <code>"decimal"</code>, <code>"email"</code>, <code>"none"</code>, <code>"numeric"</code>, <code>"search"</code>, <code>"tel"</code>, <code>"text"</code>, <code>"url"</code> | Hints at the type of data that might be entered by the user while editing the element or its contents |
| <code>intercept</code> | no | <code>string &#124; number</code> | — | — |
| <code>is</code> | no | <code>string</code> | — | Specify that a standard HTML element should behave like a defined custom built-in element |
| <code>itemID</code> | no | <code>string</code> | — | — |
| <code>itemProp</code> | no | <code>string</code> | — | — |
| <code>itemScope</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>itemType</code> | no | <code>string</code> | — | — |
| <code>k</code> | no | <code>string &#124; number</code> | — | — |
| <code>k1</code> | no | <code>string &#124; number</code> | — | — |
| <code>k2</code> | no | <code>string &#124; number</code> | — | — |
| <code>k3</code> | no | <code>string &#124; number</code> | — | — |
| <code>k4</code> | no | <code>string &#124; number</code> | — | — |
| <code>kernelMatrix</code> | no | <code>string &#124; number</code> | — | — |
| <code>kernelUnitLength</code> | no | <code>string &#124; number</code> | — | — |
| <code>kerning</code> | no | <code>string &#124; number</code> | — | — |
| <code>key</code> | no | <code>Key &#124; null</code> | — | Stable identity used by React when rendering collections. |
| <code>keyPoints</code> | no | <code>string &#124; number</code> | — | — |
| <code>keySplines</code> | no | <code>string &#124; number</code> | — | — |
| <code>keyTimes</code> | no | <code>string &#124; number</code> | — | — |
| <code>lang</code> | no | <code>string</code> | — | — |
| <code>lengthAdjust</code> | no | <code>string &#124; number</code> | — | — |
| <code>letterSpacing</code> | no | <code>string &#124; number</code> | — | — |
| <code>lh</code> | no | <code>StyleProp&lt;number &#124; "h1" &#124; "h2" &#124; "h3" &#124; "h4" &#124; "h5" &#124; "h6" &#124; MantineLineHeight&gt;</code> | <code>"h1"</code>, <code>"h2"</code>, <code>"h3"</code>, <code>"h4"</code>, <code>"h5"</code>, <code>"h6"</code>, <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | LineHeight, theme key: lineHeights |
| <code>lightHidden</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether component should be hidden in light color scheme with `display: none` |
| <code>lightingColor</code> | no | <code>string &#124; number</code> | — | — |
| <code>limitingConeAngle</code> | no | <code>string &#124; number</code> | — | — |
| <code>list</code> | no | <code>string</code> | — | — |
| <code>local</code> | no | <code>string &#124; number</code> | — | — |
| <code>lts</code> | no | <code>StyleProp&lt;Property.LetterSpacing&lt;string &#124; number&gt;&gt;</code> | — | LetterSpacing |
| <code>m</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | Margin, theme key: theme.spacing |
| <code>mah</code> | no | <code>StyleProp&lt;Property.MaxHeight&lt;string &#124; number&gt;&gt;</code> | — | MaxHeight, theme key: theme.spacing |
| <code>markerEnd</code> | no | <code>string</code> | — | — |
| <code>markerHeight</code> | no | <code>string &#124; number</code> | — | — |
| <code>markerMid</code> | no | <code>string</code> | — | — |
| <code>markerStart</code> | no | <code>string</code> | — | — |
| <code>markerUnits</code> | no | <code>string &#124; number</code> | — | — |
| <code>markerWidth</code> | no | <code>string &#124; number</code> | — | — |
| <code>mask</code> | no | <code>string</code> | — | — |
| <code>maskContentUnits</code> | no | <code>string &#124; number</code> | — | — |
| <code>maskUnits</code> | no | <code>string &#124; number</code> | — | — |
| <code>mathematical</code> | no | <code>string &#124; number</code> | — | — |
| <code>maw</code> | no | <code>StyleProp&lt;Property.MaxWidth&lt;string &#124; number&gt;&gt;</code> | — | MaxWidth, theme key: theme.spacing |
| <code>max</code> | no | <code>string &#124; number</code> | — | — |
| <code>maxLength</code> | no | <code>number</code> | — | — |
| <code>mb</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | MarginBottom, theme key: theme.spacing |
| <code>me</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | MarginInlineEnd, theme key: theme.spacing |
| <code>media</code> | no | <code>string</code> | — | — |
| <code>method</code> | no | <code>string</code> | — | — |
| <code>mie</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | MarginInlineEnd, theme key: theme.spacing |
| <code>mih</code> | no | <code>StyleProp&lt;Property.MinHeight&lt;string &#124; number&gt;&gt;</code> | — | MinHeight, theme key: theme.spacing |
| <code>min</code> | no | <code>string &#124; number</code> | — | — |
| <code>minLength</code> | no | <code>number</code> | — | — |
| <code>mis</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | MarginInlineStart, theme key: theme.spacing |
| <code>miw</code> | no | <code>StyleProp&lt;Property.MinWidth&lt;string &#124; number&gt;&gt;</code> | — | MinWidth, theme key: theme.spacing |
| <code>ml</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | MarginLeft, theme key: theme.spacing |
| <code>mod</code> | no | <code>BoxMod</code> | — | Element modifiers transformed into `data-` attributes, for example, `{ 'data-size': 'xl' }`, falsy values are removed |
| <code>mode</code> | no | <code>string &#124; number</code> | — | — |
| <code>mr</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | MarginRight, theme key: theme.spacing |
| <code>ms</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | MarginInlineStart, theme key: theme.spacing |
| <code>mt</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | MarginTop, theme key: theme.spacing |
| <code>multiple</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>mx</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | MarginInline, theme key: theme.spacing |
| <code>my</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | MarginBlock, theme key: theme.spacing |
| <code>name</code> | no | <code>string</code> | — | — |
| <code>nonce</code> | no | <code>string</code> | — | — |
| <code>numOctaves</code> | no | <code>string &#124; number</code> | — | — |
| <code>offset</code> | no | <code>string &#124; number</code> | — | — |
| <code>opacity</code> | no | <code>StyleProp&lt;Property.Opacity&gt;</code> | <code>"-moz-initial"</code>, <code>"inherit"</code>, <code>"initial"</code>, <code>"revert"</code>, <code>"revert-layer"</code>, <code>"unset"</code> | — |
| <code>operator</code> | no | <code>string &#124; number</code> | — | — |
| <code>order</code> | no | <code>string &#124; number</code> | — | — |
| <code>orient</code> | no | <code>string &#124; number</code> | — | — |
| <code>orientation</code> | no | <code>string &#124; number</code> | — | — |
| <code>origin</code> | no | <code>string &#124; number</code> | — | — |
| <code>overflow</code> | no | <code>string &#124; number</code> | — | — |
| <code>overlinePosition</code> | no | <code>string &#124; number</code> | — | — |
| <code>overlineThickness</code> | no | <code>string &#124; number</code> | — | — |
| <code>p</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | Padding, theme key: theme.spacing |
| <code>paintOrder</code> | no | <code>string &#124; number</code> | — | — |
| <code>panose1</code> | no | <code>string &#124; number</code> | — | — |
| <code>part</code> | no | <code>string</code> | — | — |
| <code>path</code> | no | <code>string</code> | — | — |
| <code>pathLength</code> | no | <code>string &#124; number</code> | — | — |
| <code>pattern</code> | no | <code>string</code> | — | — |
| <code>patternContentUnits</code> | no | <code>string</code> | — | — |
| <code>patternTransform</code> | no | <code>string &#124; number</code> | — | — |
| <code>patternUnits</code> | no | <code>string</code> | — | — |
| <code>pb</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | PaddingBottom, theme key: theme.spacing |
| <code>pe</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | PaddingInlineEnd, theme key: theme.spacing |
| <code>pie</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | PaddingInlineEnd, theme key: theme.spacing |
| <code>pis</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | PaddingInlineStart, theme key: theme.spacing |
| <code>pl</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | PaddingLeft, theme key: theme.spacing |
| <code>placeholder</code> | no | <code>string</code> | — | — |
| <code>pointerEvents</code> | no | <code>string &#124; number</code> | — | — |
| <code>points</code> | no | <code>string</code> | — | — |
| <code>pointsAtX</code> | no | <code>string &#124; number</code> | — | — |
| <code>pointsAtY</code> | no | <code>string &#124; number</code> | — | — |
| <code>pointsAtZ</code> | no | <code>string &#124; number</code> | — | — |
| <code>pr</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | PaddingRight, theme key: theme.spacing |
| <code>prefix</code> | no | <code>string</code> | — | — |
| <code>preserveAlpha</code> | no | <code>false &#124; true &#124; "true" &#124; "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | — |
| <code>preserveAspectRatio</code> | no | <code>string</code> | — | — |
| <code>primitiveUnits</code> | no | <code>string &#124; number</code> | — | — |
| <code>property</code> | no | <code>string</code> | — | — |
| <code>ps</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | PaddingInlineStart, theme key: theme.spacing |
| <code>pt</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | PaddingTop, theme key: theme.spacing |
| <code>px</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | PaddingInline, theme key: theme.spacing |
| <code>py</code> | no | <code>StyleProp&lt;MantineSpacing&gt;</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | PaddingBlock, theme key: theme.spacing |
| <code>r</code> | no | <code>string &#124; number</code> | — | — |
| <code>radioGroup</code> | no | <code>string</code> | — | — |
| <code>radius</code> | no | <code>MantineRadius</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | Key of `theme.radius` or any valid CSS value to set `border-radius`, numbers are converted to rem |
| <code>readOnly</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>rel</code> | no | <code>string</code> | — | — |
| <code>renderingIntent</code> | no | <code>string &#124; number</code> | — | — |
| <code>repeatCount</code> | no | <code>string &#124; number</code> | — | — |
| <code>repeatDur</code> | no | <code>string &#124; number</code> | — | — |
| <code>required</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>requiredExtensions</code> | no | <code>string &#124; number</code> | — | — |
| <code>requiredFeatures</code> | no | <code>string &#124; number</code> | — | — |
| <code>resource</code> | no | <code>string</code> | — | — |
| <code>restart</code> | no | <code>string &#124; number</code> | — | — |
| <code>result</code> | no | <code>string</code> | — | — |
| <code>results</code> | no | <code>number</code> | — | — |
| <code>rev</code> | no | <code>string</code> | — | — |
| <code>role</code> | no | <code>AriaRole</code> | — | — |
| <code>rotate</code> | no | <code>string &#124; number</code> | — | — |
| <code>rowSpan</code> | no | <code>number</code> | — | — |
| <code>rows</code> | no | <code>number</code> | — | — |
| <code>rules</code> | no | <code>"none" &#124; "all" &#124; "rows" &#124; "groups" &#124; "columns"</code> | <code>"all"</code>, <code>"columns"</code>, <code>"groups"</code>, <code>"none"</code>, <code>"rows"</code> | — |
| <code>rx</code> | no | <code>string &#124; number</code> | — | — |
| <code>ry</code> | no | <code>string &#124; number</code> | — | — |
| <code>scale</code> | no | <code>string &#124; number</code> | — | — |
| <code>scope</code> | no | <code>string</code> | — | — |
| <code>security</code> | no | <code>string</code> | — | — |
| <code>seed</code> | no | <code>string &#124; number</code> | — | — |
| <code>shadow</code> | no | <code>MantineShadow</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | Key of `theme.shadows` or any other valid CSS `box-shadow` value |
| <code>shapeRendering</code> | no | <code>string &#124; number</code> | — | — |
| <code>size</code> | no | <code>"xs" &#124; "sm" &#124; "md" &#124; "lg" &#124; "xl"</code> | <code>"compact-lg"</code>, <code>"compact-md"</code>, <code>"compact-sm"</code>, <code>"compact-xl"</code>, <code>"compact-xs"</code>, <code>"input-lg"</code>, <code>"input-md"</code>, <code>"input-sm"</code>, <code>"input-xl"</code>, <code>"input-xs"</code>, <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | Component size |
| <code>slope</code> | no | <code>string &#124; number</code> | — | — |
| <code>slot</code> | no | <code>string</code> | — | — |
| <code>spacing</code> | no | <code>string &#124; number</code> | — | — |
| <code>specularConstant</code> | no | <code>string &#124; number</code> | — | — |
| <code>specularExponent</code> | no | <code>string &#124; number</code> | — | — |
| <code>speed</code> | no | <code>string &#124; number</code> | — | — |
| <code>spellCheck</code> | no | <code>false &#124; true &#124; "true" &#124; "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | — |
| <code>spreadMethod</code> | no | <code>string</code> | — | — |
| <code>src</code> | no | <code>string</code> | — | — |
| <code>startOffset</code> | no | <code>string &#124; number</code> | — | — |
| <code>stdDeviation</code> | no | <code>string &#124; number</code> | — | — |
| <code>stemh</code> | no | <code>string &#124; number</code> | — | — |
| <code>stemv</code> | no | <code>string &#124; number</code> | — | — |
| <code>step</code> | no | <code>string &#124; number</code> | — | — |
| <code>stitchTiles</code> | no | <code>string &#124; number</code> | — | — |
| <code>stopColor</code> | no | <code>string</code> | — | — |
| <code>stopOpacity</code> | no | <code>string &#124; number</code> | — | — |
| <code>strikethroughPosition</code> | no | <code>string &#124; number</code> | — | — |
| <code>strikethroughThickness</code> | no | <code>string &#124; number</code> | — | — |
| <code>string</code> | no | <code>string &#124; number</code> | — | — |
| <code>stroke</code> | no | <code>string</code> | — | — |
| <code>strokeDasharray</code> | no | <code>string &#124; number</code> | — | — |
| <code>strokeDashoffset</code> | no | <code>string &#124; number</code> | — | — |
| <code>strokeLinecap</code> | no | <code>"inherit" &#124; "round" &#124; "butt" &#124; "square"</code> | <code>"butt"</code>, <code>"inherit"</code>, <code>"round"</code>, <code>"square"</code> | — |
| <code>strokeLinejoin</code> | no | <code>"inherit" &#124; "round" &#124; "miter" &#124; "bevel"</code> | <code>"bevel"</code>, <code>"inherit"</code>, <code>"miter"</code>, <code>"round"</code> | — |
| <code>strokeMiterlimit</code> | no | <code>string &#124; number</code> | — | — |
| <code>strokeOpacity</code> | no | <code>string &#124; number</code> | — | — |
| <code>strokeWidth</code> | no | <code>string &#124; number</code> | — | — |
| <code>style</code> | no | <code>CSSProperties</code> | — | Style to apply to the root element, used for virtualizer positioning |
| <code>styles</code> | no | <code>Partial&lt;Record&lt;"label", CSSProperties&gt;&gt;</code> | — | — |
| <code>summary</code> | no | <code>string</code> | — | — |
| <code>surfaceScale</code> | no | <code>string &#124; number</code> | — | — |
| <code>systemLanguage</code> | no | <code>string &#124; number</code> | — | — |
| <code>ta</code> | no | <code>StyleProp&lt;Property.TextAlign&gt;</code> | <code>"-khtml-center"</code>, <code>"-khtml-left"</code>, <code>"-khtml-right"</code>, <code>"-moz-center"</code>, <code>"-moz-initial"</code>, <code>"-moz-left"</code>, <code>"-moz-right"</code>, <code>"-webkit-center"</code>, <code>"-webkit-left"</code>, <code>"-webkit-match-parent"</code>, <code>"-webkit-right"</code>, <code>"center"</code>, <code>"end"</code>, <code>"inherit"</code>, <code>"initial"</code>, <code>"justify"</code>, <code>"left"</code>, <code>"match-parent"</code>, <code>"revert"</code>, <code>"revert-layer"</code>, <code>"right"</code>, <code>"start"</code>, <code>"unset"</code> | TextAlign |
| <code>tabIndex</code> | no | <code>number</code> | — | Tab index for the node |
| <code>tableValues</code> | no | <code>string &#124; number</code> | — | — |
| <code>target</code> | no | <code>string</code> | — | — |
| <code>targetX</code> | no | <code>string &#124; number</code> | — | — |
| <code>targetY</code> | no | <code>string &#124; number</code> | — | — |
| <code>td</code> | no | <code>StyleProp&lt;Property.TextDecoration&lt;string &#124; number&gt;&gt;</code> | — | TextDecoration |
| <code>textAnchor</code> | no | <code>"inherit" &#124; "end" &#124; "start" &#124; "middle"</code> | <code>"end"</code>, <code>"inherit"</code>, <code>"middle"</code>, <code>"start"</code> | — |
| <code>textDecoration</code> | no | <code>string &#124; number</code> | — | — |
| <code>textLength</code> | no | <code>string &#124; number</code> | — | — |
| <code>textRendering</code> | no | <code>string &#124; number</code> | — | — |
| <code>title</code> | no | <code>string</code> | — | Item title, displayed next to the bullet |
| <code>to</code> | no | <code>string &#124; number</code> | — | — |
| <code>transform</code> | no | <code>string</code> | — | — |
| <code>translate</code> | no | <code>"yes" &#124; "no"</code> | <code>"no"</code>, <code>"yes"</code> | — |
| <code>tt</code> | no | <code>StyleProp&lt;Property.TextTransform&gt;</code> | <code>"-moz-initial"</code>, <code>"capitalize"</code>, <code>"full-size-kana"</code>, <code>"full-width"</code>, <code>"inherit"</code>, <code>"initial"</code>, <code>"lowercase"</code>, <code>"math-auto"</code>, <code>"none"</code>, <code>"revert"</code>, <code>"revert-layer"</code>, <code>"unset"</code>, <code>"uppercase"</code> | TextTransform |
| <code>type</code> | no | <code>"button" &#124; "submit" &#124; "reset"</code> | <code>"button"</code>, <code>"checkbox"</code>, <code>"color"</code>, <code>"date"</code>, <code>"datetime-local"</code>, <code>"email"</code>, <code>"file"</code>, <code>"hidden"</code>, <code>"image"</code>, <code>"month"</code>, <code>"number"</code>, <code>"password"</code>, <code>"radio"</code>, <code>"range"</code>, <code>"reset"</code>, <code>"search"</code>, <code>"submit"</code>, <code>"tel"</code>, <code>"text"</code>, <code>"time"</code>, <code>"url"</code>, <code>"week"</code> | — |
| <code>typeof</code> | no | <code>string</code> | — | — |
| <code>u1</code> | no | <code>string &#124; number</code> | — | — |
| <code>u2</code> | no | <code>string &#124; number</code> | — | — |
| <code>underlinePosition</code> | no | <code>string &#124; number</code> | — | — |
| <code>underlineThickness</code> | no | <code>string &#124; number</code> | — | — |
| <code>unicode</code> | no | <code>string &#124; number</code> | — | — |
| <code>unicodeBidi</code> | no | <code>string &#124; number</code> | — | — |
| <code>unicodeRange</code> | no | <code>string &#124; number</code> | — | — |
| <code>unitsPerEm</code> | no | <code>string &#124; number</code> | — | — |
| <code>unselectable</code> | no | <code>"off" &#124; "on"</code> | <code>"off"</code>, <code>"on"</code> | — |
| <code>vAlphabetic</code> | no | <code>string &#124; number</code> | — | — |
| <code>vHanging</code> | no | <code>string &#124; number</code> | — | — |
| <code>vIdeographic</code> | no | <code>string &#124; number</code> | — | — |
| <code>vMathematical</code> | no | <code>string &#124; number</code> | — | — |
| <code>valign</code> | no | <code>"bottom" &#124; "top" &#124; "baseline" &#124; "middle"</code> | <code>"baseline"</code>, <code>"bottom"</code>, <code>"middle"</code>, <code>"top"</code> | — |
| <code>value</code> | no | <code>string &#124; number &#124; readonly string[]</code> | — | — |
| <code>values</code> | no | <code>string</code> | — | — |
| <code>variant</code> | no | <code>string</code> | <code>"contained"</code>, <code>"contrast"</code>, <code>"dashed"</code>, <code>"default"</code>, <code>"dot"</code>, <code>"dotted"</code>, <code>"filled"</code>, <code>"gradient"</code>, <code>"light"</code>, <code>"outline"</code>, <code>"pills"</code>, <code>"separated"</code>, <code>"solid"</code>, <code>"subtle"</code>, <code>"text"</code>, <code>"transparent"</code>, <code>"unstyled"</code>, <code>"vertical"</code>, <code>"white"</code> | Variant passed from parent component, sets `data-variant` |
| <code>vectorEffect</code> | no | <code>string &#124; number</code> | — | — |
| <code>version</code> | no | <code>string</code> | — | — |
| <code>vertAdvY</code> | no | <code>string &#124; number</code> | — | — |
| <code>vertOriginX</code> | no | <code>string &#124; number</code> | — | — |
| <code>vertOriginY</code> | no | <code>string &#124; number</code> | — | — |
| <code>viewBox</code> | no | <code>string</code> | — | — |
| <code>viewTarget</code> | no | <code>string &#124; number</code> | — | — |
| <code>visibility</code> | no | <code>string &#124; number</code> | — | — |
| <code>visibleFrom</code> | no | <code>MantineBreakpoint</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | Breakpoint below which the component is hidden with `display: none` |
| <code>vocab</code> | no | <code>string</code> | — | — |
| <code>w</code> | no | <code>StyleProp&lt;Property.Width&lt;string &#124; number&gt;&gt;</code> | — | Width, theme key: theme.spacing |
| <code>width</code> | no | <code>string &#124; number</code> | — | — |
| <code>widths</code> | no | <code>string &#124; number</code> | — | — |
| <code>withBorder</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Adds border to the root element |
| <code>wordSpacing</code> | no | <code>string &#124; number</code> | — | — |
| <code>wrap</code> | no | <code>string</code> | — | — |
| <code>writingMode</code> | no | <code>string &#124; number</code> | — | — |
| <code>x</code> | no | <code>string &#124; number</code> | — | — |
| <code>x1</code> | no | <code>string &#124; number</code> | — | — |
| <code>x2</code> | no | <code>string &#124; number</code> | — | — |
| <code>xChannelSelector</code> | no | <code>string</code> | — | — |
| <code>xHeight</code> | no | <code>string &#124; number</code> | — | — |
| <code>xlinkActuate</code> | no | <code>string</code> | — | — |
| <code>xlinkArcrole</code> | no | <code>string</code> | — | — |
| <code>xlinkHref</code> | no | <code>string</code> | — | — |
| <code>xlinkRole</code> | no | <code>string</code> | — | — |
| <code>xlinkShow</code> | no | <code>string</code> | — | — |
| <code>xlinkTitle</code> | no | <code>string</code> | — | — |
| <code>xlinkType</code> | no | <code>string</code> | — | — |
| <code>xmlBase</code> | no | <code>string</code> | — | — |
| <code>xmlLang</code> | no | <code>string</code> | — | — |
| <code>xmlSpace</code> | no | <code>string</code> | — | — |
| <code>xmlns</code> | no | <code>string</code> | — | — |
| <code>xmlnsXlink</code> | no | <code>string</code> | — | — |
| <code>y</code> | no | <code>string &#124; number</code> | — | — |
| <code>y1</code> | no | <code>string &#124; number</code> | — | — |
| <code>y2</code> | no | <code>string &#124; number</code> | — | — |
| <code>yChannelSelector</code> | no | <code>string</code> | — | — |
| <code>z</code> | no | <code>string &#124; number</code> | — | — |
| <code>zoomAndPan</code> | no | <code>string</code> | — | — |
## Blocked capabilities
| Kind | Name | Reason |
@@ -0,0 +1,82 @@
[
{
"id": "service-status",
"title": "Service status",
"description": "A compact status widget backed by one load query.",
"widget": {
"$schema": "homarr-custom-widget-v2",
"name": "Service status",
"sources": [
{
"id": "default",
"name": "Service API",
"baseUrl": "https://example.com",
"networkScope": "public",
"auth": {
"type": "none"
}
}
],
"requests": [
{
"id": "status",
"sourceId": "default",
"kind": "query",
"method": "GET",
"pathTemplate": "/api/status",
"parameters": {},
"auth": "inherit",
"minimumBoardPermission": "view",
"trigger": "load"
}
],
"optionsSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
},
"defaultOptions": {},
"template": "<Stack gap=\"xs\" p=\"sm\">\n <Group justify=\"space-between\">\n <Text fw={600}>{data.status.name ?? \"Service\"}</Text>\n <Badge color={data.status.online ? \"green\" : \"red\"}>{data.status.online ? \"Online\" : \"Offline\"}</Badge>\n </Group>\n <Text size=\"sm\" c=\"dimmed\">{data.status.message ?? \"No additional details\"}</Text>\n </Stack>"
}
},
{
"id": "pokemon-evolution-tree",
"title": "Pokémon evolution tree",
"description": "A trusted arbitrary-depth tree with one immutable derived-value block per node.",
"widget": {
"$schema": "homarr-custom-widget-v2",
"name": "Pokémon evolution tree",
"sources": [
{
"id": "default",
"name": "PokeAPI",
"baseUrl": "https://pokeapi.co",
"networkScope": "public",
"auth": {
"type": "none"
}
}
],
"requests": [
{
"id": "evolution",
"sourceId": "default",
"kind": "query",
"method": "GET",
"pathTemplate": "/api/v2/evolution-chain/1",
"parameters": {},
"auth": "inherit",
"minimumBoardPermission": "view",
"trigger": "load"
}
],
"optionsSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
},
"defaultOptions": {},
"template": "<Stack gap=\"sm\" p=\"sm\">\n <Title order={3}>Evolution chain</Title>\n {status.evolution?.loading ? <Skeleton height={160} radius=\"md\" /> : status.evolution?.error ? <Alert color=\"red\">{status.evolution.error}</Alert> : data.evolution?.chain ?\n <RecursiveList data={data.evolution.chain} childrenPath=\"evolves_to\" keyPath=\"species.name\" defaultExpandedDepth={8} showLines>\n {(node, meta) => {\n const segments = node.species.url.split(\"/\").filter((part) => part);\n const dexId = segments.at(-1);\n return <Group gap=\"xs\"><Badge variant=\"light\">#{dexId}</Badge><Text tt=\"capitalize\" fw={600}>{node.species.name}</Text>{meta.hasChildren && <Text size=\"xs\" c=\"dimmed\">{meta.childCount} next</Text>}</Group>;\n }}\n </RecursiveList> : <Text c=\"dimmed\">No evolution chain returned.</Text>}\n</Stack>"
}
}
]
@@ -2,7 +2,7 @@
> Generated from `component-catalog-v1.json`. Catalog schema v1 · Custom Widget 2.0.0 · Mantine 9.4.1. Do not edit this file by hand.
Package: `@homarr/widgets`. Effective props combine the [shared prop API](components.md#shared-prop-api) with each component's specific props below. Component-specific records override shared records with the same name.
Package: `@homarr/widgets`. The component-specific props below supplement ordinary safe serializable Mantine props and style props. Component-specific records override ordinary behavior with the same name.
## Package guidance
@@ -36,6 +36,7 @@ Runs a named user-triggered action.
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>color</code> | no | <code>string</code> | — | — |
| <code>confirmMessage</code> | no | <code>string</code> | — | — |
| <code>disabled</code> | no | <code>boolean &#124; string</code> | — | — |
| <code>errorMessage</code> | no | <code>string</code> | — | — |
@@ -46,6 +47,7 @@ Runs a named user-triggered action.
| <code>requestId</code> | no | <code>string</code> | — | Identifier of the named Custom Widget action. |
| <code>size</code> | no | <code>string</code> | — | — |
| <code>successMessage</code> | no | <code>string</code> | — | — |
| <code>variant</code> | no | <code>string</code> | — | — |
<a id="component-collapsible"></a>
### `Collapsible`
@@ -134,8 +136,10 @@ Refreshes load-triggered widget queries.
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>color</code> | no | <code>string</code> | — | — |
| <code>label</code> | no | <code>string</code> | — | — |
| <code>size</code> | no | <code>string</code> | — | — |
| <code>variant</code> | no | <code>string</code> | — | — |
<a id="component-statbar"></a>
### `StatBar`
@@ -155,6 +159,7 @@ Displays a compact labeled statistic and progress value.
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>color</code> | no | <code>string</code> | — | — |
| <code>label</code> | no | <code>string</code> | — | — |
| <code>max</code> | no | <code>number</code> | — | — |
| <code>value</code> | yes | <code>number</code> | — | — |
@@ -179,12 +184,14 @@ Reads a nested value from a named manual-query result.
| --- | --- | --- | --- | --- |
| <code>as</code> | no | <code>'Text' &#124; 'Title' &#124; 'Badge' &#124; 'Code' &#124; 'Image'</code> | <code>"Text"</code>, <code>"Title"</code>, <code>"Badge"</code>, <code>"Code"</code>, <code>"Image"</code> | — |
| <code>c</code> | no | <code>string</code> | — | — |
| <code>color</code> | no | <code>string</code> | — | — |
| <code>fit</code> | no | <code>string</code> | — | — |
| <code>fw</code> | no | <code>number</code> | — | — |
| <code>h</code> | no | <code>string &#124; number</code> | — | — |
| <code>order</code> | no | <code>1 &#124; 2 &#124; 3 &#124; 4 &#124; 5 &#124; 6</code> | <code>1</code>, <code>2</code>, <code>3</code>, <code>4</code>, <code>5</code>, <code>6</code> | — |
| <code>radius</code> | no | <code>string &#124; number</code> | — | — |
| <code>size</code> | no | <code>string</code> | — | — |
| <code>variant</code> | no | <code>string</code> | — | — |
| <code>w</code> | no | <code>string &#124; number</code> | — | — |
<a id="component-subfetch"></a>
@@ -253,6 +260,7 @@ Resolves an installed Tabler icon by its safe name.
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>color</code> | no | <code>string</code> | — | — |
| <code>name</code> | yes | <code>string</code> | — | — |
| <code>size</code> | no | <code>string &#124; number</code> | — | — |
| <code>stroke</code> | no | <code>string &#124; number</code> | — | — |
@@ -295,11 +303,13 @@ Runs named actions for the enabled and disabled states.
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>color</code> | no | <code>string</code> | — | — |
| <code>disabled</code> | no | <code>boolean &#124; string</code> | — | — |
| <code>disabledParams</code> | no | <code>Record&lt;string, string &#124; number &#124; boolean&gt;</code> | — | — |
| <code>enabledParams</code> | no | <code>Record&lt;string, string &#124; number &#124; boolean&gt;</code> | — | — |
| <code>errorMessage</code> | no | <code>string</code> | — | — |
| <code>initialValue</code> | no | <code>boolean &#124; string</code> | — | — |
| <code>label</code> | no | <code>string</code> | — | — |
| <code>offParams</code> | no | <code>Record&lt;string, string &#124; number &#124; boolean&gt;</code> | — | — |
| <code>requestId</code> | no | <code>string</code> | — | Identifier of the named Custom Widget action. |
| <code>size</code> | no | <code>string</code> | — | — |
@@ -1,703 +1,5 @@
# Mantine Charts
> Generated from `component-catalog-v1.json`. Catalog schema v1 · Custom Widget 2.0.0 · Mantine 9.4.1. Do not edit this file by hand.
Use the official Mantine LLM index in [Mantine Core](mantine-core.md#charts) to discover chart documentation.
Package: `@mantine/charts`. Effective props combine the [shared prop API](components.md#shared-prop-api) with each component's specific props below. Component-specific records override shared records with the same name.
## Package guidance
Pass sanitized JSON data through serializable props. Use [immutable local `const` blocks](runtime.md#immutable-local-bindings) for repeated derived chart values; authored formatters and renderer callbacks remain blocked. Include a textual summary and do not rely on color alone.
## Enabled inventory
<code>AreaChart</code>, <code>BarChart</code>, <code>BarsList</code>, <code>BubbleChart</code>, <code>ChartLegend</code>, <code>ChartTooltip</code>, <code>CompositeChart</code>, <code>DonutChart</code>, <code>FunnelChart</code>, <code>Heatmap</code>, <code>LineChart</code>, <code>PieChart</code>, <code>RadarChart</code>, <code>RadialBarChart</code>, <code>SankeyChart</code>, <code>ScatterChart</code>, <code>Sparkline</code>, <code>Treemap</code>
## Denied inventory
<code>AreaGradient</code>
## Component records
<a id="component-areachart"></a>
### `AreaChart`
- Safety: `wrapped`
- Category: `charts`
- Documentation: [upstream component API](https://mantine.dev/charts/area-chart/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Include a nearby text summary of the chart data.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>activeDotProps</code> | no | <code>MantineChartDotProps</code> | — | Props passed down to all active dots. Ignored if `withDots={false}` is set. |
| <code>areaChartProps</code> | no | <code>(CartesianChartProps&lt;unknown&gt; &amp; { ref?: React.Ref&lt;SVGSVGElement&gt;; })</code> | — | Props passed down to recharts `AreaChart` component |
| <code>attributes</code> | no | <code>{ area?: Record&lt;string, any&gt;; legend?: Record&lt;string, any&gt;; grid?: Record&lt;string, any&gt;; root?: Record&lt;string, any&gt;; ... 14 more ...; tooltipBody?: Record&lt;...&gt;; }</code> | — | — |
| <code>connectNulls</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | If set, points with `null` values are connected |
| <code>curveType</code> | no | <code>"step" &#124; "bump" &#124; "linear" &#124; "natural" &#124; "monotone" &#124; "stepBefore" &#124; "stepAfter"</code> | <code>"bump"</code>, <code>"linear"</code>, <code>"monotone"</code>, <code>"natural"</code>, <code>"step"</code>, <code>"stepAfter"</code>, <code>"stepBefore"</code> | Type of the curve |
| <code>data</code> | yes | <code>ChartData</code> | — | Data used to display chart |
| <code>dataKey</code> | yes | <code>string</code> | — | Key of the `data` object for x-axis values |
| <code>dotProps</code> | no | <code>MantineChartDotProps</code> | — | Props passed down to all dots. Ignored if `withDots={false}` is set. |
| <code>gridAxis</code> | no | <code>"none" &#124; "x" &#124; "y" &#124; "xy"</code> | <code>"none"</code>, <code>"x"</code>, <code>"xy"</code>, <code>"y"</code> | Specifies which lines should be displayed in the grid, `'x'` by default |
| <code>gridColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the grid and cursor lines, by default depends on color scheme |
| <code>gridProps</code> | no | <code>Omit&lt;CartesianGridProps, "ref"&gt;</code> | — | Props passed down to the `CartesianGrid` component |
| <code>legendProps</code> | no | <code>Omit&lt;LegendProps, "ref"&gt;</code> | — | Props passed down to the `Legend` component |
| <code>referenceLines</code> | no | <code>ChartReferenceLineProps[]</code> | — | Reference lines that should be displayed on the chart |
| <code>rightYAxisLabel</code> | no | <code>string</code> | — | A label to display next to the right y-axis |
| <code>rightYAxisProps</code> | no | <code>Omit&lt;YAxisProps, "ref"&gt;</code> | — | Props passed down to the `YAxis` recharts component rendered on the right side |
| <code>series</code> | yes | <code>AreaChartSeries[]</code> | — | An array of objects with `name` and `color` keys. Determines which data should be consumed from the `data` array. |
| <code>splitColors</code> | no | <code>[DefaultMantineColor, DefaultMantineColor]</code> | — | A tuple of colors used when `type="split"` is set, ignored in all other cases. A tuple may include theme colors reference or any valid CSS colors |
| <code>splitOffset</code> | no | <code>number</code> | — | Offset for the split gradient. By default, value is inferred from `data` and `series` if possible. Must be generated from the data array with `getSplitOffset` function. |
| <code>textColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the text displayed inside the chart, `'dimmed'` by default |
| <code>tickLine</code> | no | <code>"none" &#124; "x" &#124; "y" &#124; "xy"</code> | <code>"none"</code>, <code>"x"</code>, <code>"xy"</code>, <code>"y"</code> | Specifies which axis should have tick line, `'y'` by default |
| <code>tooltipAnimationDuration</code> | no | <code>number</code> | — | Tooltip position animation duration in ms, `0` by default |
| <code>tooltipProps</code> | no | <code>Omit&lt;TooltipProps&lt;any, any&gt;, "ref"&gt;</code> | — | Props passed down to the `Tooltip` component |
| <code>unit</code> | no | <code>string</code> | — | Unit displayed next to each tick in y-axis |
| <code>unstyled</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>withDots</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether dots should be displayed |
| <code>withGradient</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether the chart area should be represented with a gradient instead of the solid color |
| <code>withLegend</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether chart legend should be displayed, `false` by default |
| <code>withPointLabels</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | If set, each point has an associated label |
| <code>withRightYAxis</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether additional y-axis should be displayed on the right side of the chart, `false` by default |
| <code>withTooltip</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether chart tooltip should be displayed, `true` by default |
| <code>withXAxis</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether x-axis should be displayed, `true` by default |
| <code>withYAxis</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether y-axis should be displayed, `true` by default |
| <code>xAxisLabel</code> | no | <code>string</code> | — | A label to display below the x-axis |
| <code>xAxisProps</code> | no | <code>Omit&lt;XAxisProps, "ref"&gt;</code> | — | Props passed down to the `XAxis` recharts component |
| <code>yAxisLabel</code> | no | <code>string</code> | — | A label to display next to the y-axis |
| <code>yAxisProps</code> | no | <code>Omit&lt;YAxisProps, "ref"&gt;</code> | — | Props passed down to the `YAxis` recharts component |
<a id="component-areagradient"></a>
### `AreaGradient`
- Safety: `denied`
- Category: `blocked`
- Documentation: [upstream component API](https://mantine.dev/charts/area-gradient/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- No additional catalog requirement; preserve semantic labels and text alternatives.
Denied reason: Low-level chart implementation helper.
#### Component-specific props
_No authorable props because this component is denied._
<a id="component-barchart"></a>
### `BarChart`
- Safety: `wrapped`
- Category: `charts`
- Documentation: [upstream component API](https://mantine.dev/charts/bar-chart/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Include a nearby text summary of the chart data.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>attributes</code> | no | <code>{ legend?: Record&lt;string, any&gt;; grid?: Record&lt;string, any&gt;; root?: Record&lt;string, any&gt;; tooltip?: Record&lt;string, any&gt;; ... 14 more ...; tooltipBody?: Record&lt;...&gt;; }</code> | — | — |
| <code>barChartProps</code> | no | <code>(CartesianChartProps&lt;unknown&gt; &amp; { ref?: React.Ref&lt;SVGSVGElement&gt;; })</code> | — | Props passed down to recharts `BarChart` component |
| <code>barLabelColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls color of the bar label, by default the value is determined by the chart orientation |
| <code>cursorFill</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Fill of hovered bar section, by default value is based on color scheme |
| <code>data</code> | yes | <code>Record&lt;string, any&gt;[]</code> | — | Data used to display chart. |
| <code>dataKey</code> | yes | <code>string</code> | — | Key of the `data` object for x-axis values |
| <code>gridAxis</code> | no | <code>"none" &#124; "x" &#124; "y" &#124; "xy"</code> | <code>"none"</code>, <code>"x"</code>, <code>"xy"</code>, <code>"y"</code> | Specifies which lines should be displayed in the grid, `'x'` by default |
| <code>gridColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the grid and cursor lines, by default depends on color scheme |
| <code>gridProps</code> | no | <code>Omit&lt;CartesianGridProps, "ref"&gt;</code> | — | Props passed down to the `CartesianGrid` component |
| <code>legendProps</code> | no | <code>Omit&lt;LegendProps, "ref"&gt;</code> | — | Props passed down to the `Legend` component |
| <code>maxBarWidth</code> | no | <code>number</code> | — | Maximum bar width in px |
| <code>minBarSize</code> | no | <code>number</code> | — | Sets minimum height of the bar in px |
| <code>referenceLines</code> | no | <code>ChartReferenceLineProps[]</code> | — | Reference lines that should be displayed on the chart |
| <code>rightYAxisLabel</code> | no | <code>string</code> | — | A label to display next to the right y-axis |
| <code>rightYAxisProps</code> | no | <code>Omit&lt;YAxisProps, "ref"&gt;</code> | — | Props passed down to the `YAxis` recharts component rendered on the right side |
| <code>series</code> | yes | <code>BarChartSeries[]</code> | — | An array of objects with `name` and `color` keys. Determines which data should be consumed from the `data` array. |
| <code>textColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the text displayed inside the chart, `'dimmed'` by default |
| <code>tickLine</code> | no | <code>"none" &#124; "x" &#124; "y" &#124; "xy"</code> | <code>"none"</code>, <code>"x"</code>, <code>"xy"</code>, <code>"y"</code> | Specifies which axis should have tick line, `'y'` by default |
| <code>tooltipAnimationDuration</code> | no | <code>number</code> | — | Tooltip position animation duration in ms, `0` by default |
| <code>tooltipProps</code> | no | <code>Omit&lt;TooltipProps&lt;any, any&gt;, "ref"&gt;</code> | — | Props passed down to the `Tooltip` component |
| <code>unit</code> | no | <code>string</code> | — | Unit displayed next to each tick in y-axis |
| <code>unstyled</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>withBarValueLabel</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether a label with bar value should be displayed on top of each bar, incompatible with `type="stacked"` and `type="percent"` |
| <code>withLegend</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether chart legend should be displayed, `false` by default |
| <code>withRightYAxis</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether additional y-axis should be displayed on the right side of the chart, `false` by default |
| <code>withTooltip</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether chart tooltip should be displayed, `true` by default |
| <code>withXAxis</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether x-axis should be displayed, `true` by default |
| <code>withYAxis</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether y-axis should be displayed, `true` by default |
| <code>xAxisLabel</code> | no | <code>string</code> | — | A label to display below the x-axis |
| <code>xAxisProps</code> | no | <code>Omit&lt;XAxisProps, "ref"&gt;</code> | — | Props passed down to the `XAxis` recharts component |
| <code>yAxisLabel</code> | no | <code>string</code> | — | A label to display next to the y-axis |
| <code>yAxisProps</code> | no | <code>Omit&lt;YAxisProps, "ref"&gt;</code> | — | Props passed down to the `YAxis` recharts component |
<a id="component-barslist"></a>
### `BarsList`
- Safety: `wrapped`
- Category: `charts`
- Documentation: [upstream component API](https://mantine.dev/charts/bars-list/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Include a nearby text summary of the chart data.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>attributes</code> | no | <code>{ root?: Record&lt;string, any&gt;; bar?: Record&lt;string, any&gt;; barLabel?: Record&lt;string, any&gt;; barValue?: Record&lt;string, any&gt;; labelsRow?: Record&lt;...&gt;; }</code> | — | — |
| <code>autoContrast</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | If set, adjusts text color based on background color |
| <code>barColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Default bar background color, used when item does not have color specified |
| <code>barGap</code> | no | <code>MantineSpacing</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | Controls gap between bars |
| <code>barHeight</code> | no | <code>string &#124; number</code> | — | Bar height |
| <code>barTextColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Bar text color, overrides autoContrast |
| <code>barsLabel</code> | no | <code>string</code> | — | Label displayed above the bars column |
| <code>data</code> | yes | <code>BarsListBarData[]</code> | — | Data for bars |
| <code>minBarSize</code> | no | <code>string &#124; number</code> | — | Minimum bar width |
| <code>unstyled</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>valueLabel</code> | no | <code>string</code> | — | Label displayed above the values column |
<a id="component-bubblechart"></a>
### `BubbleChart`
- Safety: `wrapped`
- Category: `charts`
- Documentation: [upstream component API](https://mantine.dev/charts/bubble-chart/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Include a nearby text summary of the chart data.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>attributes</code> | no | <code>{ root?: Record&lt;string, any&gt;; tooltip?: Record&lt;string, any&gt;; axis?: Record&lt;string, any&gt;; }</code> | — | — |
| <code>data</code> | yes | <code>Record&lt;string, any&gt;[]</code> | — | Chart data |
| <code>dataKey</code> | yes | <code>BubbleChartDataKey</code> | — | Data keys for x, y and z axis |
| <code>gridColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the grid and cursor lines, by default depends on color scheme |
| <code>label</code> | no | <code>string</code> | — | Chart label displayed next to the x axis |
| <code>range</code> | yes | <code>[number, number]</code> | — | Z axis range |
| <code>scatterProps</code> | no | <code>Partial&lt;Omit&lt;ScatterProps, "ref"&gt;&gt;</code> | — | Props passed down to the `Scatter` component |
| <code>textColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the text displayed inside the chart |
| <code>tooltipProps</code> | no | <code>Omit&lt;TooltipProps&lt;any, any&gt;, "ref"&gt;</code> | — | Props passed down to the `Tooltip` component |
| <code>unstyled</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>withTooltip</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether the tooltip should be displayed |
| <code>xAxisProps</code> | no | <code>Omit&lt;XAxisProps, "ref"&gt;</code> | — | Props passed down to the `XAxis` recharts component |
| <code>yAxisProps</code> | no | <code>Omit&lt;YAxisProps, "ref"&gt;</code> | — | Props passed down to the `YAxis` recharts component |
| <code>zAxisProps</code> | no | <code>Omit&lt;ZAxisProps&lt;any, any&gt;, "ref"&gt;</code> | — | Props passed down to the `ZAxis` recharts component |
<a id="component-chartlegend"></a>
### `ChartLegend`
- Safety: `wrapped`
- Category: `charts`
- Documentation: [upstream component API](https://mantine.dev/charts/chart-legend/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Include a nearby text summary of the chart data.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>attributes</code> | no | <code>{ legend?: Record&lt;string, any&gt;; legendItem?: Record&lt;string, any&gt;; legendItemColor?: Record&lt;string, any&gt;; legendItemName?: Record&lt;...&gt;; }</code> | — | — |
| <code>centered</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether the legend should be centered |
| <code>legendPosition</code> | yes | <code>"bottom" &#124; "top" &#124; "middle"</code> | <code>"bottom"</code>, <code>"middle"</code>, <code>"top"</code> | Position of the legend relative to the chart, used to apply margin on the corresponding side |
| <code>payload</code> | no | <code>readonly Record&lt;string, any&gt;[]</code> | — | Chart data provided by recharts |
| <code>series</code> | no | <code>ChartSeries[]</code> | — | Data used for labels, only applicable for area charts: AreaChart, LineChart, BarChart |
| <code>showColor</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether color swatch should be shown next to the label |
| <code>unstyled</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
<a id="component-charttooltip"></a>
### `ChartTooltip`
- Safety: `wrapped`
- Category: `charts`
- Documentation: [upstream component API](https://mantine.dev/charts/chart-tooltip/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Include a nearby text summary of the chart data.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>attributes</code> | no | <code>{ tooltip?: Record&lt;string, any&gt;; tooltipItem?: Record&lt;string, any&gt;; tooltipItemBody?: Record&lt;string, any&gt;; tooltipItemColor?: Record&lt;...&gt;; tooltipItemName?: Record&lt;...&gt;; tooltipItemData?: Record&lt;...&gt;; tooltipLabel?: Record&lt;...&gt;; tool...</code> | — | — |
| <code>label</code> | no | <code>React.ReactNode</code> | — | Main tooltip label |
| <code>payload</code> | no | <code>Record&lt;string, any&gt;[] &#124; readonly Record&lt;string, any&gt;[]</code> | — | Chart data provided by recharts |
| <code>segmentId</code> | no | <code>string &#124; number</code> | — | Segment to display data for, identified by its index in the data array (preferred, isolates duplicate names) or by its name. Only applicable when `type="radial"`. If not set, all data is rendered. |
| <code>series</code> | no | <code>ChartSeries[]</code> | — | Chart series data, applicable only for `area` type |
| <code>showColor</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether the color swatch should be visible |
| <code>unit</code> | no | <code>string</code> | — | Data units, provided by parent component |
| <code>unstyled</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
<a id="component-compositechart"></a>
### `CompositeChart`
- Safety: `wrapped`
- Category: `charts`
- Documentation: [upstream component API](https://mantine.dev/charts/composite-chart/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Include a nearby text summary of the chart data.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>activeDotProps</code> | no | <code>MantineChartDotProps</code> | — | Props passed down to all active dots. Ignored if `withDots={false}` is set. |
| <code>attributes</code> | no | <code>{ area?: Record&lt;string, any&gt;; legend?: Record&lt;string, any&gt;; line?: Record&lt;string, any&gt;; grid?: Record&lt;string, any&gt;; ... 16 more ...; tooltipBody?: Record&lt;...&gt;; }</code> | — | — |
| <code>composedChartProps</code> | no | <code>(CartesianChartProps&lt;unknown&gt; &amp; { ref?: React.Ref&lt;SVGSVGElement&gt;; })</code> | — | Props passed down to recharts `AreaChart` component |
| <code>connectNulls</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether points with `null` values should be connected |
| <code>curveType</code> | no | <code>"step" &#124; "bump" &#124; "linear" &#124; "natural" &#124; "monotone" &#124; "stepBefore" &#124; "stepAfter"</code> | <code>"bump"</code>, <code>"linear"</code>, <code>"monotone"</code>, <code>"natural"</code>, <code>"step"</code>, <code>"stepAfter"</code>, <code>"stepBefore"</code> | Type of the curve |
| <code>data</code> | yes | <code>Record&lt;string, any&gt;[]</code> | — | Data used to display chart |
| <code>dataKey</code> | yes | <code>string</code> | — | Key of the `data` object for x-axis values |
| <code>dotProps</code> | no | <code>MantineChartDotProps</code> | — | Props passed down to all dots. Ignored if `withDots={false}` is set. |
| <code>gridAxis</code> | no | <code>"none" &#124; "x" &#124; "y" &#124; "xy"</code> | <code>"none"</code>, <code>"x"</code>, <code>"xy"</code>, <code>"y"</code> | Specifies which lines should be displayed in the grid, `'x'` by default |
| <code>gridColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the grid and cursor lines, by default depends on color scheme |
| <code>gridProps</code> | no | <code>Omit&lt;CartesianGridProps, "ref"&gt;</code> | — | Props passed down to the `CartesianGrid` component |
| <code>legendProps</code> | no | <code>Omit&lt;LegendProps, "ref"&gt;</code> | — | Props passed down to the `Legend` component |
| <code>maxBarWidth</code> | no | <code>number</code> | — | Maximum bar width in px |
| <code>minBarSize</code> | no | <code>number</code> | — | Sets minimum height of the bar in px |
| <code>referenceLines</code> | no | <code>ChartReferenceLineProps[]</code> | — | Reference lines that should be displayed on the chart |
| <code>rightYAxisLabel</code> | no | <code>string</code> | — | A label to display next to the right y-axis |
| <code>rightYAxisProps</code> | no | <code>Omit&lt;YAxisProps, "ref"&gt;</code> | — | Props passed down to the `YAxis` recharts component rendered on the right side |
| <code>series</code> | yes | <code>CompositeChartSeries[]</code> | — | An array of objects with `name` and `color` keys. Determines which data should be consumed from the `data` array. |
| <code>textColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the text displayed inside the chart, `'dimmed'` by default |
| <code>tickLine</code> | no | <code>"none" &#124; "x" &#124; "y" &#124; "xy"</code> | <code>"none"</code>, <code>"x"</code>, <code>"xy"</code>, <code>"y"</code> | Specifies which axis should have tick line, `'y'` by default |
| <code>tooltipAnimationDuration</code> | no | <code>number</code> | — | Tooltip position animation duration in ms, `0` by default |
| <code>tooltipProps</code> | no | <code>Omit&lt;TooltipProps&lt;any, any&gt;, "ref"&gt;</code> | — | Props passed down to the `Tooltip` component |
| <code>unit</code> | no | <code>string</code> | — | Unit displayed next to each tick in y-axis |
| <code>unstyled</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>withBarValueLabel</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether a label with bar value should be displayed on top of each bar |
| <code>withDots</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether dots should be displayed |
| <code>withLegend</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether chart legend should be displayed, `false` by default |
| <code>withPointLabels</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether each point should have associated label |
| <code>withRightYAxis</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether additional y-axis should be displayed on the right side of the chart, `false` by default |
| <code>withTooltip</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether chart tooltip should be displayed, `true` by default |
| <code>withXAxis</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether x-axis should be displayed, `true` by default |
| <code>withYAxis</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether y-axis should be displayed, `true` by default |
| <code>xAxisLabel</code> | no | <code>string</code> | — | A label to display below the x-axis |
| <code>xAxisProps</code> | no | <code>Omit&lt;XAxisProps, "ref"&gt;</code> | — | Props passed down to the `XAxis` recharts component |
| <code>yAxisLabel</code> | no | <code>string</code> | — | A label to display next to the y-axis |
| <code>yAxisProps</code> | no | <code>Omit&lt;YAxisProps, "ref"&gt;</code> | — | Props passed down to the `YAxis` recharts component |
<a id="component-donutchart"></a>
### `DonutChart`
- Safety: `wrapped`
- Category: `charts`
- Documentation: [upstream component API](https://mantine.dev/charts/donut-chart/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Include a nearby text summary of the chart data.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>attributes</code> | no | <code>{ label?: Record&lt;string, any&gt;; legend?: Record&lt;string, any&gt;; root?: Record&lt;string, any&gt;; tooltip?: Record&lt;string, any&gt;; ... 9 more ...; tooltipBody?: Record&lt;...&gt;; }</code> | — | — |
| <code>chartLabel</code> | no | <code>string &#124; number</code> | — | Chart label, displayed in the center of the chart |
| <code>data</code> | yes | <code>DonutChartCell[]</code> | — | Data used to render chart |
| <code>endAngle</code> | no | <code>number</code> | — | Controls angle at which charts ends. Set to `0` to render the chart as semicircle. |
| <code>labelColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls text color of all labels, by default depends on color scheme |
| <code>labelsType</code> | no | <code>"value" &#124; "name" &#124; "percent"</code> | <code>"name"</code>, <code>"percent"</code>, <code>"value"</code> | Type of labels to display, `'value'` by default |
| <code>legendProps</code> | no | <code>Omit&lt;LegendProps, "ref"&gt;</code> | — | Props passed down to recharts `Legend` component |
| <code>paddingAngle</code> | no | <code>number</code> | — | Controls padding between segments |
| <code>pieChartProps</code> | no | <code>(PolarChartProps&lt;unknown&gt; &amp; { ref?: React.Ref&lt;SVGSVGElement&gt;; })</code> | — | Props passed down to recharts `PieChart` component |
| <code>pieProps</code> | no | <code>Partial&lt;Omit&lt;PieProps, "ref"&gt;&gt;</code> | — | Props passed down to recharts `Pie` component |
| <code>startAngle</code> | no | <code>number</code> | — | Controls angle at which chart starts. Set to `180` to render the chart as semicircle. |
| <code>strokeColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls color of the segments stroke, by default depends on color scheme |
| <code>thickness</code> | no | <code>number</code> | — | Controls thickness of the chart segments |
| <code>tooltipAnimationDuration</code> | no | <code>number</code> | — | Tooltip animation duration in ms |
| <code>tooltipDataSource</code> | no | <code>"all" &#124; "segment"</code> | <code>"all"</code>, <code>"segment"</code> | Determines which data is displayed in the tooltip. `'all'` – display all values, `'segment'` – display only hovered segment. |
| <code>tooltipProps</code> | no | <code>Omit&lt;TooltipProps&lt;any, any&gt;, "ref"&gt;</code> | — | Props passed down to `Tooltip` recharts component |
| <code>unstyled</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>withLabels</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether each segment should have associated label |
| <code>withLabelsLine</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether segments labels should have lines that connect the segment with the label |
| <code>withLegend</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether the legend should be displayed |
| <code>withTooltip</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether the tooltip should be displayed when one of the section is hovered |
<a id="component-funnelchart"></a>
### `FunnelChart`
- Safety: `wrapped`
- Category: `charts`
- Documentation: [upstream component API](https://mantine.dev/charts/funnel-chart/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Include a nearby text summary of the chart data.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>attributes</code> | no | <code>{ legend?: Record&lt;string, any&gt;; root?: Record&lt;string, any&gt;; tooltip?: Record&lt;string, any&gt;; legendItem?: Record&lt;string, any&gt;; ... 8 more ...; tooltipBody?: Record&lt;...&gt;; }</code> | — | — |
| <code>data</code> | yes | <code>FunnelChartCell[]</code> | — | Data used to render chart |
| <code>funnelChartProps</code> | no | <code>(CartesianChartProps&lt;unknown&gt; &amp; { ref?: React.Ref&lt;SVGSVGElement&gt;; })</code> | — | Props passed down to recharts `FunnelChart` component |
| <code>funnelProps</code> | no | <code>Partial&lt;Omit&lt;FunnelProps, "ref"&gt;&gt;</code> | — | Props passed down to recharts `Pie` component |
| <code>labelColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls text color of all labels |
| <code>labelsPosition</code> | no | <code>"left" &#124; "right" &#124; "inside"</code> | <code>"inside"</code>, <code>"left"</code>, <code>"right"</code> | Controls labels position relative to the segment |
| <code>legendProps</code> | no | <code>Omit&lt;LegendProps, "ref"&gt;</code> | — | Props passed down to recharts `Legend` component |
| <code>strokeColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls color of the segments stroke, by default depends on color scheme |
| <code>tooltipAnimationDuration</code> | no | <code>number</code> | — | Tooltip animation duration in ms |
| <code>tooltipDataSource</code> | no | <code>"all" &#124; "segment"</code> | <code>"all"</code>, <code>"segment"</code> | Determines which data is displayed in the tooltip. `'all'` – display all values, `'segment'` – display only hovered segment. |
| <code>tooltipProps</code> | no | <code>Omit&lt;TooltipProps&lt;any, any&gt;, "ref"&gt;</code> | — | Props passed down to `Tooltip` recharts component |
| <code>unstyled</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>withLabels</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether each segment should have associated label |
| <code>withLegend</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether the legend should be displayed |
| <code>withTooltip</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether the tooltip should be displayed when a section is hovered |
<a id="component-heatmap"></a>
### `Heatmap`
- Safety: `wrapped`
- Category: `charts`
- Documentation: [upstream component API](https://mantine.dev/charts/heatmap/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Include a nearby text summary of the chart data.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>attributes</code> | no | <code>{ legend?: Record&lt;string, any&gt;; rect?: Record&lt;string, any&gt;; root?: Record&lt;string, any&gt;; weekdayLabel?: Record&lt;string, any&gt;; monthLabel?: Record&lt;...&gt;; legendLabel?: Record&lt;...&gt;; legendRect?: Record&lt;...&gt;; }</code> | — | — |
| <code>colors</code> | no | <code>string[]</code> | — | Colors array, used to calculate color for each value, by default 4 shades of green colors are used |
| <code>data</code> | yes | <code>Record&lt;string, number&gt;</code> | — | Heatmap data, key is date in `YYYY-MM-DD` format (interpreted as a UTC calendar day) |
| <code>domain</code> | no | <code>[number, number]</code> | — | Heatmap domain, array of 2 numbers, min and max values, calculated from data by default |
| <code>endDate</code> | no | <code>string &#124; Date</code> | — | Heatmap end date. Current date by default. Date is normalized to UTC midnight of the intended calendar day. |
| <code>firstDayOfWeek</code> | no | <code>0 &#124; 2 &#124; 3 &#124; 4 &#124; 5 &#124; 6 &#124; 1</code> | <code>0</code>, <code>1</code>, <code>2</code>, <code>3</code>, <code>4</code>, <code>5</code>, <code>6</code> | First day of week, 0 – Sunday, 1 – Monday. |
| <code>gap</code> | no | <code>number</code> | — | Gap between rects in px |
| <code>legendLabels</code> | no | <code>[string, string]</code> | — | Legend labels, array of 2 elements: [min label, max label] |
| <code>monthLabels</code> | no | <code>string[]</code> | — | Month labels, array of 12 elements, can be used for localization |
| <code>monthsLabelsHeight</code> | no | <code>number</code> | — | Height of month labels row |
| <code>rectRadius</code> | no | <code>number</code> | — | Rect radius in px |
| <code>rectSize</code> | no | <code>number</code> | — | Size of day rect in px |
| <code>splitMonths</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | If set, inserts a spacer column between months |
| <code>startDate</code> | no | <code>string &#124; Date</code> | — | Heatmap start date. Current date - 1 year by default. Date is normalized to UTC midnight of the intended calendar day. |
| <code>tooltipProps</code> | no | <code>Partial&lt;TooltipFloatingProps&gt;</code> | — | Props passed down to the `Tooltip.Floating` component |
| <code>unstyled</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>weekdayLabels</code> | no | <code>string[]</code> | — | Weekday labels, array of 7 elements, can be used for localization |
| <code>weekdaysLabelsWidth</code> | no | <code>number</code> | — | Width of weekday labels column |
| <code>withLegend</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | If set, legend with color levels is displayed below the heatmap |
| <code>withMonthLabels</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | If set, month labels are displayed |
| <code>withOutsideDates</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | If set, trailing dates that do not fall into the given `startDate` – `endDate` range are displayed to fill empty space. |
| <code>withTooltip</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | If set, tooltip is displayed on rect hover |
| <code>withWeekdayLabels</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | If set, weekday labels are displayed |
<a id="component-linechart"></a>
### `LineChart`
- Safety: `wrapped`
- Category: `charts`
- Documentation: [upstream component API](https://mantine.dev/charts/line-chart/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Include a nearby text summary of the chart data.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>activeDotProps</code> | no | <code>MantineChartDotProps</code> | — | Props passed down to all active dots. Ignored if `withDots={false}` is set. |
| <code>attributes</code> | no | <code>{ legend?: Record&lt;string, any&gt;; line?: Record&lt;string, any&gt;; grid?: Record&lt;string, any&gt;; root?: Record&lt;string, any&gt;; ... 14 more ...; tooltipBody?: Record&lt;...&gt;; }</code> | — | — |
| <code>connectNulls</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether points with `null` values should be connected |
| <code>curveType</code> | no | <code>"step" &#124; "bump" &#124; "linear" &#124; "natural" &#124; "monotone" &#124; "stepBefore" &#124; "stepAfter"</code> | <code>"bump"</code>, <code>"linear"</code>, <code>"monotone"</code>, <code>"natural"</code>, <code>"step"</code>, <code>"stepAfter"</code>, <code>"stepBefore"</code> | Type of the curve |
| <code>data</code> | yes | <code>Record&lt;string, any&gt;[]</code> | — | Data used to display chart |
| <code>dataKey</code> | yes | <code>string</code> | — | Key of the `data` object for x-axis values |
| <code>dotProps</code> | no | <code>MantineChartDotProps</code> | — | Props passed down to all dots. Ignored if `withDots={false}` is set. |
| <code>gradientStops</code> | no | <code>LineChartGradientStop[]</code> | — | Data used to generate gradient stops |
| <code>gridAxis</code> | no | <code>"none" &#124; "x" &#124; "y" &#124; "xy"</code> | <code>"none"</code>, <code>"x"</code>, <code>"xy"</code>, <code>"y"</code> | Specifies which lines should be displayed in the grid, `'x'` by default |
| <code>gridColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the grid and cursor lines, by default depends on color scheme |
| <code>gridProps</code> | no | <code>Omit&lt;CartesianGridProps, "ref"&gt;</code> | — | Props passed down to the `CartesianGrid` component |
| <code>legendProps</code> | no | <code>Omit&lt;LegendProps, "ref"&gt;</code> | — | Props passed down to the `Legend` component |
| <code>lineChartProps</code> | no | <code>(CartesianChartProps&lt;unknown&gt; &amp; { ref?: React.Ref&lt;SVGSVGElement&gt;; })</code> | — | Props passed down to recharts `LineChart` component |
| <code>referenceLines</code> | no | <code>ChartReferenceLineProps[]</code> | — | Reference lines that should be displayed on the chart |
| <code>rightYAxisLabel</code> | no | <code>string</code> | — | A label to display next to the right y-axis |
| <code>rightYAxisProps</code> | no | <code>Omit&lt;YAxisProps, "ref"&gt;</code> | — | Props passed down to the `YAxis` recharts component rendered on the right side |
| <code>series</code> | yes | <code>LineChartSeries[]</code> | — | An array of objects with `name` and `color` keys. Determines which data should be consumed from the `data` array. |
| <code>textColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the text displayed inside the chart, `'dimmed'` by default |
| <code>tickLine</code> | no | <code>"none" &#124; "x" &#124; "y" &#124; "xy"</code> | <code>"none"</code>, <code>"x"</code>, <code>"xy"</code>, <code>"y"</code> | Specifies which axis should have tick line, `'y'` by default |
| <code>tooltipAnimationDuration</code> | no | <code>number</code> | — | Tooltip position animation duration in ms, `0` by default |
| <code>tooltipProps</code> | no | <code>Omit&lt;TooltipProps&lt;any, any&gt;, "ref"&gt;</code> | — | Props passed down to the `Tooltip` component |
| <code>unit</code> | no | <code>string</code> | — | Unit displayed next to each tick in y-axis |
| <code>unstyled</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>withDots</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether dots should be displayed |
| <code>withLegend</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether chart legend should be displayed, `false` by default |
| <code>withPointLabels</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether each point should have associated label |
| <code>withRightYAxis</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether additional y-axis should be displayed on the right side of the chart, `false` by default |
| <code>withTooltip</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether chart tooltip should be displayed, `true` by default |
| <code>withXAxis</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether x-axis should be displayed, `true` by default |
| <code>withYAxis</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether y-axis should be displayed, `true` by default |
| <code>xAxisLabel</code> | no | <code>string</code> | — | A label to display below the x-axis |
| <code>xAxisProps</code> | no | <code>Omit&lt;XAxisProps, "ref"&gt;</code> | — | Props passed down to the `XAxis` recharts component |
| <code>yAxisLabel</code> | no | <code>string</code> | — | A label to display next to the y-axis |
| <code>yAxisProps</code> | no | <code>Omit&lt;YAxisProps, "ref"&gt;</code> | — | Props passed down to the `YAxis` recharts component |
<a id="component-piechart"></a>
### `PieChart`
- Safety: `wrapped`
- Category: `charts`
- Documentation: [upstream component API](https://mantine.dev/charts/pie-chart/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Include a nearby text summary of the chart data.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>attributes</code> | no | <code>{ legend?: Record&lt;string, any&gt;; root?: Record&lt;string, any&gt;; tooltip?: Record&lt;string, any&gt;; legendItem?: Record&lt;string, any&gt;; ... 8 more ...; tooltipBody?: Record&lt;...&gt;; }</code> | — | — |
| <code>data</code> | yes | <code>PieChartCell[]</code> | — | Data used to render chart |
| <code>endAngle</code> | no | <code>number</code> | — | Controls angle at which charts ends. Set to `0` to render the chart as semicircle. |
| <code>labelColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls text color of all labels, white by default |
| <code>labelsPosition</code> | no | <code>"inside" &#124; "outside"</code> | <code>"inside"</code>, <code>"outside"</code> | Controls labels position relative to the segment |
| <code>labelsType</code> | no | <code>"value" &#124; "name" &#124; "percent"</code> | <code>"name"</code>, <code>"percent"</code>, <code>"value"</code> | Type of labels to display |
| <code>legendProps</code> | no | <code>Omit&lt;LegendProps, "ref"&gt;</code> | — | Props passed down to recharts `Legend` component |
| <code>paddingAngle</code> | no | <code>number</code> | — | Controls padding between segments |
| <code>pieChartProps</code> | no | <code>(PolarChartProps&lt;unknown&gt; &amp; { ref?: React.Ref&lt;SVGSVGElement&gt;; })</code> | — | Props passed down to recharts `PieChart` component |
| <code>pieProps</code> | no | <code>Partial&lt;Omit&lt;PieProps, "ref"&gt;&gt;</code> | — | Props passed down to recharts `Pie` component |
| <code>startAngle</code> | no | <code>number</code> | — | Controls angle at which chart starts. Set to `180` to render the chart as semicircle. |
| <code>strokeColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls color of the segments stroke, by default depends on color scheme |
| <code>tooltipAnimationDuration</code> | no | <code>number</code> | — | Tooltip animation duration in ms |
| <code>tooltipDataSource</code> | no | <code>"all" &#124; "segment"</code> | <code>"all"</code>, <code>"segment"</code> | Determines which data is displayed in the tooltip. `'all'` – display all values, `'segment'` – display only hovered segment. |
| <code>tooltipProps</code> | no | <code>Omit&lt;TooltipProps&lt;any, any&gt;, "ref"&gt;</code> | — | Props passed down to `Tooltip` recharts component |
| <code>unstyled</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>withLabels</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether each segment should have associated label |
| <code>withLabelsLine</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether segments labels should have lines that connect the segment with the label |
| <code>withLegend</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether the legend should be displayed |
| <code>withTooltip</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether the tooltip should be displayed when one of the section is hovered |
<a id="component-radarchart"></a>
### `RadarChart`
- Safety: `wrapped`
- Category: `charts`
- Documentation: [upstream component API](https://mantine.dev/charts/radar-chart/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Include a nearby text summary of the chart data.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>activeDotProps</code> | no | <code>MantineChartDotProps</code> | — | Props passed down to all active dots. Ignored if `withDots={false}` is set. |
| <code>attributes</code> | no | <code>{ legend?: Record&lt;string, any&gt;; root?: Record&lt;string, any&gt;; tooltip?: Record&lt;string, any&gt;; container?: Record&lt;string, any&gt;; ... 9 more ...; tooltipBody?: Record&lt;...&gt;; }</code> | — | — |
| <code>data</code> | yes | <code>Record&lt;string, any&gt;[]</code> | — | Data used in the chart |
| <code>dataKey</code> | yes | <code>string</code> | — | Key of the `data` object for axis values |
| <code>dotProps</code> | no | <code>MantineChartDotProps</code> | — | Props passed down to all dots. Ignored if `withDots={false}` is set. |
| <code>gridColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls color of the grid lines. By default, color depends on the color scheme. |
| <code>legendProps</code> | no | <code>Omit&lt;LegendProps, "ref"&gt;</code> | — | Props passed down to recharts Legend component |
| <code>polarAngleAxisProps</code> | no | <code>Omit&lt;PolarAngleAxisProps, "ref"&gt;</code> | — | Props passed down to recharts PolarAngleAxis component |
| <code>polarGridProps</code> | no | <code>Omit&lt;PolarGridProps, "ref"&gt;</code> | — | Props passed down to recharts PolarGrid component |
| <code>polarRadiusAxisProps</code> | no | <code>Omit&lt;PolarRadiusAxisProps, "ref"&gt;</code> | — | Props passed down to recharts PolarRadiusAxis component |
| <code>radarChartProps</code> | no | <code>(PolarChartProps&lt;unknown&gt; &amp; { ref?: React.Ref&lt;SVGSVGElement&gt;; })</code> | — | Props passed down to recharts RadarChart component |
| <code>series</code> | yes | <code>RadarChartSeries[]</code> | — | Determines which data should be consumed from the `data` array. |
| <code>textColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls color of all text elements. By default, color depends on the color scheme. |
| <code>tooltipAnimationDuration</code> | no | <code>number</code> | — | Tooltip position animation duration in ms |
| <code>tooltipProps</code> | no | <code>Omit&lt;TooltipProps&lt;any, any&gt;, "ref"&gt;</code> | — | Props passed down to recharts Tooltip component |
| <code>unstyled</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>withDots</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether dots should be displayed |
| <code>withLegend</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether the legend should be displayed |
| <code>withPolarAngleAxis</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether PolarAngleAxis component should be displayed |
| <code>withPolarGrid</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether PolarGrid component should be displayed |
| <code>withPolarRadiusAxis</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether PolarRadiusAxisProps component should be displayed |
| <code>withTooltip</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether Tooltip component should be displayed |
<a id="component-radialbarchart"></a>
### `RadialBarChart`
- Safety: `wrapped`
- Category: `charts`
- Documentation: [upstream component API](https://mantine.dev/charts/radial-bar-chart/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Include a nearby text summary of the chart data.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>attributes</code> | no | <code>{ legend?: Record&lt;string, any&gt;; root?: Record&lt;string, any&gt;; tooltip?: Record&lt;string, any&gt;; legendItem?: Record&lt;string, any&gt;; legendItemColor?: Record&lt;...&gt;; legendItemName?: Record&lt;...&gt;; }</code> | — | — |
| <code>barSize</code> | no | <code>number</code> | — | Size of bars in px, `20` by default |
| <code>data</code> | yes | <code>Record&lt;string, any&gt;[]</code> | — | Chart data |
| <code>dataKey</code> | yes | <code>string</code> | — | Key from data object to use as data key |
| <code>emptyBackgroundColor</code> | no | <code>string</code> | — | Color of the empty background, by default depends on the color scheme |
| <code>endAngle</code> | no | <code>number</code> | — | Angle at which chart ends |
| <code>legendProps</code> | no | <code>Omit&lt;LegendProps, "ref"&gt;</code> | — | Props passed down to recharts Legend component |
| <code>radialBarChartProps</code> | no | <code>(PolarChartProps&lt;unknown&gt; &amp; { ref?: React.Ref&lt;SVGSVGElement&gt;; })</code> | — | Props passed down to recharts RadarChartChart component |
| <code>radialBarProps</code> | no | <code>Omit&lt;RadialBarProps, "ref"&gt;</code> | — | Props passed down to recharts RadialBar component |
| <code>startAngle</code> | no | <code>number</code> | — | Angle at which chart starts |
| <code>tooltipProps</code> | no | <code>Omit&lt;TooltipProps&lt;any, any&gt;, "ref"&gt;</code> | — | Props passed down to `Tooltip` recharts component |
| <code>unstyled</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>withBackground</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether empty bars area should be visible |
| <code>withLabels</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether labels should be displayed |
| <code>withLegend</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether the legend should be displayed |
| <code>withTooltip</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether the tooltip should be displayed when one of the bars is hovered |
<a id="component-sankeychart"></a>
### `SankeyChart`
- Safety: `wrapped`
- Category: `charts`
- Documentation: [upstream component API](https://mantine.dev/charts/sankey-chart/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Include a nearby text summary of the chart data.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>attributes</code> | no | <code>{ root?: Record&lt;string, any&gt;; tooltip?: Record&lt;string, any&gt;; tooltipItem?: Record&lt;string, any&gt;; tooltipItemBody?: Record&lt;...&gt;; ... 4 more ...; tooltipBody?: Record&lt;...&gt;; }</code> | — | — |
| <code>colors</code> | no | <code>DefaultMantineColor[]</code> | — | An array of colors used for nodes when no `color` is set on individual nodes |
| <code>data</code> | yes | <code>SankeyChartData</code> | — | Data used to render chart, must include `nodes` and `links` arrays |
| <code>iterations</code> | no | <code>number</code> | — | The number of relaxation iterations |
| <code>linkColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Default color for links, by default depends on color scheme |
| <code>linkCurvature</code> | no | <code>number</code> | — | The curvature of links between nodes |
| <code>linkOpacity</code> | no | <code>number</code> | — | Opacity of the link fill |
| <code>nodeColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Default color for nodes, by default depends on color scheme |
| <code>nodePadding</code> | no | <code>number</code> | — | The padding between nodes |
| <code>nodeWidth</code> | no | <code>number</code> | — | The width of each node |
| <code>sankeyProps</code> | no | <code>Partial&lt;Omit&lt;RechartsSankeyProps, "data" &#124; "ref"&gt;&gt;</code> | — | Props passed down to recharts `Sankey` component |
| <code>textColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls text color of labels |
| <code>tooltipAnimationDuration</code> | no | <code>number</code> | — | Tooltip animation duration in ms |
| <code>tooltipProps</code> | no | <code>Omit&lt;TooltipProps&lt;any, any&gt;, "ref"&gt;</code> | — | Props passed down to `Tooltip` recharts component |
| <code>unstyled</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>withTooltip</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether the tooltip should be displayed when hovered |
<a id="component-scatterchart"></a>
### `ScatterChart`
- Safety: `wrapped`
- Category: `charts`
- Documentation: [upstream component API](https://mantine.dev/charts/scatter-chart/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Include a nearby text summary of the chart data.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>attributes</code> | no | <code>{ legend?: Record&lt;string, any&gt;; grid?: Record&lt;string, any&gt;; root?: Record&lt;string, any&gt;; tooltip?: Record&lt;string, any&gt;; ... 14 more ...; scatter?: Record&lt;...&gt;; }</code> | — | — |
| <code>data</code> | yes | <code>ScatterChartSeries[]</code> | — | Data that is used to build the chart |
| <code>dataKey</code> | yes | <code>{ x: string; y: string; }</code> | — | Keys that should be used to retrieve data from the data array on x and y axis |
| <code>gridAxis</code> | no | <code>"none" &#124; "x" &#124; "y" &#124; "xy"</code> | <code>"none"</code>, <code>"x"</code>, <code>"xy"</code>, <code>"y"</code> | Specifies which lines should be displayed in the grid, `'x'` by default |
| <code>gridColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the grid and cursor lines, by default depends on color scheme |
| <code>gridProps</code> | no | <code>Omit&lt;CartesianGridProps, "ref"&gt;</code> | — | Props passed down to the `CartesianGrid` component |
| <code>labels</code> | no | <code>{ x?: string; y?: string; }</code> | — | Labels that should be used instead of keys names in the tooltip |
| <code>legendProps</code> | no | <code>Omit&lt;LegendProps, "ref"&gt;</code> | — | Props passed down to the `Legend` component |
| <code>pointLabels</code> | no | <code>"x" &#124; "y"</code> | <code>"x"</code>, <code>"y"</code> | If set, displays labels next to points for the given axis |
| <code>referenceLines</code> | no | <code>ChartReferenceLineProps[]</code> | — | Reference lines that should be displayed on the chart |
| <code>rightYAxisLabel</code> | no | <code>string</code> | — | A label to display next to the right y-axis |
| <code>rightYAxisProps</code> | no | <code>Omit&lt;YAxisProps, "ref"&gt;</code> | — | Props passed down to the `YAxis` recharts component rendered on the right side |
| <code>scatterChartProps</code> | no | <code>(CartesianChartProps&lt;unknown&gt; &amp; { ref?: React.Ref&lt;SVGSVGElement&gt;; })</code> | — | Props passed down to recharts `ScatterChart` component |
| <code>scatterProps</code> | no | <code>Partial&lt;Omit&lt;ScatterProps, "ref"&gt;&gt;</code> | — | Props passed down to recharts `Scatter` component |
| <code>textColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the text displayed inside the chart, `'dimmed'` by default |
| <code>tickLine</code> | no | <code>"none" &#124; "x" &#124; "y" &#124; "xy"</code> | <code>"none"</code>, <code>"x"</code>, <code>"xy"</code>, <code>"y"</code> | Specifies which axis should have tick line, `'y'` by default |
| <code>tooltipAnimationDuration</code> | no | <code>number</code> | — | Tooltip position animation duration in ms, `0` by default |
| <code>tooltipProps</code> | no | <code>Omit&lt;TooltipProps&lt;any, any&gt;, "ref"&gt;</code> | — | Props passed down to the `Tooltip` component |
| <code>unit</code> | no | <code>{ x?: string; y?: string; }</code> | — | Units displayed after value on axis and inside the tooltip |
| <code>unstyled</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>withLegend</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether chart legend should be displayed, `false` by default |
| <code>withRightYAxis</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether additional y-axis should be displayed on the right side of the chart, `false` by default |
| <code>withTooltip</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether chart tooltip should be displayed, `true` by default |
| <code>withXAxis</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether x-axis should be displayed, `true` by default |
| <code>withYAxis</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether y-axis should be displayed, `true` by default |
| <code>xAxisLabel</code> | no | <code>string</code> | — | A label to display below the x-axis |
| <code>xAxisProps</code> | no | <code>Omit&lt;XAxisProps, "ref"&gt;</code> | — | Props passed down to the `XAxis` recharts component |
| <code>yAxisLabel</code> | no | <code>string</code> | — | A label to display next to the y-axis |
| <code>yAxisProps</code> | no | <code>Omit&lt;YAxisProps, "ref"&gt;</code> | — | Props passed down to the `YAxis` recharts component |
<a id="component-sparkline"></a>
### `Sparkline`
- Safety: `wrapped`
- Category: `charts`
- Documentation: [upstream component API](https://mantine.dev/charts/sparkline/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Include a nearby text summary of the chart data.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>areaProps</code> | no | <code>Omit&lt;AreaProps&lt;any, any&gt;, "data" &#124; "ref" &#124; "dataKey"&gt;</code> | — | Props passed down to the underlying recharts `Area` component |
| <code>attributes</code> | no | <code>{ root?: Record&lt;string, any&gt;; }</code> | — | — |
| <code>connectNulls</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether null values should be connected with other values |
| <code>curveType</code> | no | <code>"step" &#124; "bump" &#124; "linear" &#124; "natural" &#124; "monotone" &#124; "stepBefore" &#124; "stepAfter"</code> | <code>"bump"</code>, <code>"linear"</code>, <code>"monotone"</code>, <code>"natural"</code>, <code>"step"</code>, <code>"stepAfter"</code>, <code>"stepBefore"</code> | Type of the curve |
| <code>data</code> | yes | <code>(number &#124; null)[]</code> | — | Data used to render the chart |
| <code>trendColors</code> | no | <code>SparklineTrendColors</code> | — | If set, `color` prop is ignored and chart color is determined by the difference between first and last value. |
| <code>unstyled</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>withGradient</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether the chart fill should be a gradient |
<a id="component-treemap"></a>
### `Treemap`
- Safety: `wrapped`
- Category: `charts`
- Documentation: [upstream component API](https://mantine.dev/charts/treemap/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Include a nearby text summary of the chart data.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| --- | --- | --- | --- | --- |
| <code>aspectRatio</code> | no | <code>number</code> | — | The treemap will try to keep every single rectangle's aspect ratio near the aspectRatio given |
| <code>attributes</code> | no | <code>{ root?: Record&lt;string, any&gt;; tooltip?: Record&lt;string, any&gt;; tooltipItem?: Record&lt;string, any&gt;; tooltipItemBody?: Record&lt;...&gt;; ... 4 more ...; tooltipBody?: Record&lt;...&gt;; }</code> | — | — |
| <code>autoContrast</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether text color should be adjusted based on background color to improve contrast |
| <code>data</code> | yes | <code>TreemapData[]</code> | — | Data used to render chart |
| <code>dataKey</code> | no | <code>string</code> | — | Key in data object for the value |
| <code>strokeColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls color of the node stroke, by default depends on color scheme |
| <code>textColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls text color of labels |
| <code>tooltipAnimationDuration</code> | no | <code>number</code> | — | Tooltip animation duration in ms |
| <code>tooltipProps</code> | no | <code>Omit&lt;TooltipProps&lt;any, any&gt;, "ref"&gt;</code> | — | Props passed down to `Tooltip` recharts component |
| <code>treemapProps</code> | no | <code>Partial&lt;Omit&lt;RechartsTreemapProps&lt;TreemapDataType, any&gt;, "data" &#124; "ref" &#124; "dataKey"&gt;&gt;</code> | — | Props passed down to recharts `Treemap` component |
| <code>unstyled</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | — |
| <code>withTooltip</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether the tooltip should be displayed when a node is hovered |
The upstream source is https://mantine.dev/llms.txt. Follow its `## Charts` links for current component documentation. Homarr's live component catalog remains authoritative for the installed component names and blocked capabilities.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -2,6 +2,20 @@
Templates read four roots: `data.<requestId>`, `status.<requestId>`, `options`, and `inputs`.
Each request status is an object:
```ts
status.<requestId> = {
loading: boolean;
ok?: boolean;
status?: number;
statusText?: string;
error?: string;
};
```
Use `status.list?.loading` for loading, `status.list?.ok === false` for failure, and `status.list?.ok === true` for success. Do not compare `status.list` with the strings `"loading"`, `"success"`, or `"error"`; those comparisons are always false and are rejected by validation.
Use Mantine Core, Dates, Charts, safe Tabler icons, and Homarr runtime components. Safe installed Mantine exports are discovered automatically. Ordinary serializable props and Mantine style props pass through; `style` and `styles` are scoped and sanitized.
Render icons as `<TablerIcon name="server" size={18} />`. When connected, retrieve `homarr://custom-widgets/components/TablerIcon` for the supported icon names.
@@ -0,0 +1,478 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"$schema": {
"type": "string",
"const": "homarr-custom-widget-v2"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"description": {
"type": "string",
"maxLength": 512
},
"iconUrl": {
"type": "string",
"format": "uri"
},
"sources": {
"minItems": 1,
"maxItems": 8,
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[a-z][a-z0-9_-]*$"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"baseUrl": {
"type": "string",
"maxLength": 2048,
"format": "uri"
},
"networkScope": {
"type": "string",
"enum": [
"public",
"private",
"loopback"
]
},
"auth": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "none"
}
},
"required": [
"type"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "bearer"
}
},
"required": [
"type"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "basic"
}
},
"required": [
"type"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "apiKeyHeader"
},
"headerName": {
"type": "string",
"minLength": 1,
"maxLength": 256,
"pattern": "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$"
}
},
"required": [
"type",
"headerName"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "apiKeyQuery"
},
"parameterName": {
"type": "string",
"minLength": 1,
"maxLength": 256
}
},
"required": [
"type",
"parameterName"
],
"additionalProperties": false
}
]
}
},
"required": [
"id",
"name",
"baseUrl",
"networkScope",
"auth"
],
"additionalProperties": false
}
},
"requests": {
"maxItems": 64,
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[a-z][a-z0-9_-]*$"
},
"sourceId": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[a-z][a-z0-9_-]*$"
},
"kind": {
"type": "string",
"enum": [
"query",
"action"
]
},
"method": {
"type": "string",
"enum": [
"GET",
"POST",
"PUT",
"DELETE",
"PATCH"
]
},
"pathTemplate": {
"type": "string",
"minLength": 1,
"maxLength": 2048,
"pattern": "^\\/.*"
},
"parameters": {
"type": "object",
"propertyNames": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"additionalProperties": {
"type": "string",
"enum": [
"string",
"number",
"boolean"
]
}
},
"optionsBinding": {
"type": "object",
"propertyNames": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"additionalProperties": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "object",
"properties": {
"$option": {
"type": "string",
"minLength": 1,
"maxLength": 64
}
},
"required": [
"$option"
],
"additionalProperties": false
}
]
}
},
"queryTemplate": {
"type": "object",
"propertyNames": {
"type": "string",
"minLength": 1,
"maxLength": 256
},
"additionalProperties": {
"$ref": "#/$defs/__schema0"
}
},
"bodyTemplate": {
"$ref": "#/$defs/__schema1"
},
"staticHeaders": {
"type": "object",
"propertyNames": {
"type": "string",
"minLength": 1,
"maxLength": 256,
"pattern": "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$"
},
"additionalProperties": {
"type": "string",
"maxLength": 8192
}
},
"auth": {
"type": "string",
"enum": [
"inherit",
"none"
]
},
"minimumBoardPermission": {
"type": "string",
"enum": [
"view",
"modify",
"full"
]
},
"trigger": {
"default": "manual",
"type": "string",
"enum": [
"load",
"manual"
]
},
"cacheTtlSeconds": {
"type": "integer",
"minimum": 0,
"maximum": 3600
},
"confirmation": {
"type": "object",
"properties": {
"title": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"message": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"confirmLabel": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"destructive": {
"type": "boolean"
}
},
"required": [
"title",
"message"
],
"additionalProperties": false
},
"invalidates": {
"maxItems": 32,
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[a-z][a-z0-9_-]*$"
}
}
},
"required": [
"id",
"sourceId",
"kind",
"method",
"pathTemplate",
"parameters",
"auth",
"minimumBoardPermission",
"trigger"
],
"additionalProperties": false
}
},
"optionsSchema": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "object"
},
"properties": {
"default": {},
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"required": {
"type": "array",
"items": {
"type": "string"
}
},
"additionalProperties": {
"default": false,
"type": "boolean",
"const": false
},
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"if": {},
"then": {},
"else": {}
},
"required": [
"type",
"properties",
"additionalProperties"
],
"additionalProperties": false
},
"defaultOptions": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"template": {
"type": "string",
"minLength": 1,
"maxLength": 50000
}
},
"required": [
"$schema",
"name",
"sources",
"requests",
"optionsSchema",
"defaultOptions",
"template"
],
"additionalProperties": false,
"$defs": {
"__schema0": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"type": "array",
"items": {
"$ref": "#/$defs/__schema0"
}
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"$ref": "#/$defs/__schema0"
}
}
]
},
"__schema1": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"type": "array",
"items": {
"$ref": "#/$defs/__schema1"
}
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"$ref": "#/$defs/__schema1"
}
}
]
}
},
"title": "Homarr Custom JSX v2 widget",
"description": "The only supported Homarr custom-widget format. Secrets are configured separately."
}
-1
View File
@@ -12,7 +12,6 @@
"apps/docs/build/**",
"apps/docs/blog/**",
"packages/custom-widgets/src/core/component-catalog.generated.json",
"packages/custom-widgets/src/core/component-runtime.generated.json",
"apps/docs/static/custom-widgets/component-catalog-v1.json"
]
}
+5 -3
View File
@@ -86,10 +86,12 @@ Use the interactive API reference to inspect the exact request body for each end
## Get Custom Widget authoring resources
Custom JSX v2 provides two HTTP discovery endpoints:
Custom JSX v2 provides four HTTP discovery endpoints:
- `GET /api/custom-widgets/authoring-prompt` returns the current authoring prompt and live resource identifiers.
- `GET /api/custom-widgets/skill` returns information about the Agent Skill.
- `GET /api/custom-widgets/authoring-prompt` returns the current prompt plus MCP and HTTP resource identifiers.
- `GET /api/custom-widgets/schema` returns the canonical widget JSON Schema.
- `GET /api/custom-widgets/components` returns the release-matched static component catalog.
- `GET /api/custom-widgets/skill` returns the complete Agent Skill files, catalog, examples, and skills.sh metadata.
Call `customWidget_secretRequestUser` to create a temporary credential entry URL. Do not construct or save this URL.
@@ -16,7 +16,7 @@ npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widge
The portable skill gives authoring instructions that do not require a tool. The Homarr MCP prompt gives instructions for a connected workflow.
The offline copy prompt includes the complete portable skill and all eight release-matched references. Those references contain the generated component APIs and runtime examples, so Homarr does not append duplicate raw catalog data. A model can author directly from this bundle without MCP tools or repository access.
The offline copy prompt includes the complete portable skill and all eleven release-matched references, including the canonical widget JSON Schema, minified component catalog, and canonical runtime examples. A model can author directly from this bundle without MCP tools or repository access.
The prompt starts with the user's widget request. It ends with:
@@ -39,23 +39,28 @@ Repository code cannot give this attribution.
- Prompt: `homarr-custom-widget-author`
- Resources: `homarr://custom-widgets/schema`, `components`, `components/{name}`, `examples/{name}`, and `skill`
- HTTP: `GET /api/custom-widgets/authoring-prompt` and `GET /api/custom-widgets/skill`
- HTTP: `GET /api/custom-widgets/authoring-prompt`, `schema`, `components`, and `skill`
The authoring-prompt tool description contains one sentence. The prompt contains the workflow instructions and resource links.
`GET /api/custom-widgets/skill`, the MCP tool, and the MCP resource return the live `SKILL.md` with:
`GET /api/custom-widgets/skill` and the MCP tool return the complete release-matched skill discovery payload:
```ts
{
name: "homarr-custom-widget";
version: string;
skillMd: string;
files: Array<{ path: string; content: string }>;
componentCatalog: CustomJsxAuthoringCatalog;
examples: CustomJsxExample[];
skillsShUrl: string;
sourceUrl: string;
installCommand: string;
}
```
The `homarr://custom-widgets/skill` MCP resource returns the same material as one plain-text offline bundle, ending with the truncation sentinel and exact character count.
These interfaces do not return an archive URL or archive hash.
## Create a Custom Widget with MCP
@@ -1,6 +1,16 @@
# Create a Custom Widget
import { CanonicalCustomWidgetExample } from "@site/src/components/custom-widget-code";
import { CanonicalCustomWidgetExample, CustomWidgetCodeExample } from "@site/src/components/custom-widget-code";
export const explicitOptionsBindingExample = `{
"pathTemplate": "/api/endpoints/{endpointId}/docker/containers/json",
"parameters": { "endpointId": "string", "showAll": "boolean" },
"queryTemplate": { "all": { "$param": "showAll" } },
"optionsBinding": {
"endpointId": { "$option": "endpointId" },
"showAll": false
}
}`;
The desktop workbench shows the code and preview next to each other. On a mobile device, use the Configure and Preview tabs.
@@ -68,10 +78,12 @@ The standard prompt contains fewer than 8,000 characters. It includes the instal
It does not include a complete prop list, MCP workflow, server URL, or credentials. The prompt with the embedded skill is intentionally longer. It starts with your widget request, then includes:
- `SKILL.md` and all eight release-matched reference files
- `SKILL.md` and all eleven release-matched reference files, including the canonical widget JSON Schema
- The Custom Widget, Mantine, skill, and catalog-schema versions
- The compact canonical component catalog
- Canonical runtime examples
The generated reference files already contain the exact component and prop APIs. Homarr does not append a second raw catalog or a duplicate examples payload.
The generated Markdown references remain readable offline. The compact JSON catalog gives tools the identical machine-readable component records used by Homarr.
The embedded prompt ends with this sentinel and an exact total character count:
@@ -101,7 +113,7 @@ Widget options use a restricted object JSON Schema. Homarr supports the followin
- URL
- Nested group
- Conditional field
- Advanced JSON
- JSON value
Credentials are not widget options. Homarr rejects credential names and static credential values in widget options.
@@ -121,17 +133,12 @@ A parameter declaration does not give a value to the parameter. A matching widge
For a load query, bind each parameter in `optionsBinding`. Use an option reference or a type-compatible primitive literal:
```json
{
"pathTemplate": "/api/endpoints/{endpointId}/docker/containers/json",
"parameters": { "endpointId": "string", "showAll": "boolean" },
"queryTemplate": { "all": { "$param": "showAll" } },
"optionsBinding": {
"endpointId": { "$option": "endpointId" },
"showAll": false
}
}
```
<CustomWidgetCodeExample
id="explicit-options-binding"
label="Explicit load-query option bindings"
language="json"
code={explicitOptionsBindingExample}
/>
For a manual query or action, supply all parameters with the component `params` prop. Use `SubFetch`, `ActionButton`, or `ToggleSwitch`.
@@ -1,13 +1,9 @@
# Use the Custom JSX runtime
import { CustomJsxComponentReference } from "@site/src/components/custom-jsx-component-reference";
import { CustomWidgetCodeExample } from "@site/src/components/custom-widget-code";
Homarr interprets declarative JSX in the error boundary of the widget. A template cannot import a module or run arbitrary JavaScript.
## Use data roots
```jsx
<Stack>
export const dataRootsExample = `<Stack>
<TextInput bind="search" label="Search TV shows" />
<SubFetch requestId="search-series" params={{ query: inputs.search }} />
{data["search-series"]?.map((series) => (
@@ -18,14 +14,87 @@ Homarr interprets declarative JSX in the error boundary of the widget. A templat
</ActionButton>
</Group>
))}
</Stack>`;
export const localBindingExample = `{data.pokemon.map((pokemon) => {
const segments = pokemon.url.split("/").filter((part) => part);
const dexId = segments.at(-1);
const isSelected = inputs.selectedPokemon === pokemon.name;
return (
<Card key={dexId} withBorder={isSelected}>
<Text>#{dexId}</Text>
<Text tt="capitalize">{pokemon.name}</Text>
</Card>
);
})}`;
export const derivedValueExample = `{(() => {
const visiblePokemon = data.pokemon.filter(
(pokemon) => options.types.length === 0 || options.types.includes(pokemon.type),
);
return (
<Stack>
{visiblePokemon.map((pokemon) => (
<Text>{pokemon.name}</Text>
))}
</Stack>
```
);
})()}`;
export const recursiveListExample = `<RecursiveList
data={data.evolution.chain}
childrenPath="evolves_to"
keyPath="species.name"
defaultExpandedDepth={4}
maxDepth={16}
showLines
>
{(node, meta) => {
const segments = node.species.url.split("/").filter((part) => part);
const dexId = segments.at(-1);
return (
<Group gap="xs">
<Badge variant="light">#{dexId}</Badge>
<Text tt="capitalize" fw={600}>
{node.species.name}
</Text>
{meta.hasChildren && <Text c="dimmed">{meta.childCount} evolutions</Text>}
</Group>
);
}}
</RecursiveList>`;
export const temporaryInputsExample = `<TextInput bind="search" defaultValue="" />
<DatePicker bind="selectedDate" />
<Switch bind="enabled" defaultChecked={false} />
<MultiSelect bind="selectedEntities" data={options.entities} />
<SubFetch requestId="search" params={{ query: inputs.search }} />`;
Homarr interprets declarative JSX in the error boundary of the widget. A template cannot import a module or run arbitrary JavaScript.
## Use data roots
<CustomWidgetCodeExample
id="custom-jsx-data-roots"
label="Data roots and manual requests"
language="jsx"
code={dataRootsExample}
/>
- `data.<requestId>` contains sanitized JSON or text results.
- `status.<requestId>` contains the request status.
- `status.<requestId>` contains `{ loading, ok, status, statusText, error }`.
- `options` contains the validated board item configuration.
- `inputs` contains temporary values from controls that use `bind`.
Check `status.list?.loading` while loading, `status.list?.ok === false` on failure, and `status.list?.ok === true` on success. Do not compare `status.list` with `"loading"`, `"success"`, or `"error"`; the status is an object, and validation rejects those comparisons.
Load queries get data automatically. They run at the widget refresh interval.
`SubFetch` runs a manual query. `ActionButton` and `ToggleSwitch` run actions.
@@ -73,44 +142,23 @@ The explorer fetches only the plain, versioned static catalog. It does not impor
A collection callback can use a restricted block when the returned JSX needs the same derived value more than once:
```jsx
{
data.pokemon.map((pokemon) => {
const segments = pokemon.url.split("/").filter((part) => part);
const dexId = segments.at(-1);
const isSelected = inputs.selectedPokemon === pokemon.name;
return (
<Card key={dexId} withBorder={isSelected}>
<Text>#{dexId}</Text>
<Text tt="capitalize">{pokemon.name}</Text>
</Card>
);
});
}
```
<CustomWidgetCodeExample
id="custom-jsx-local-bindings"
label="Immutable local bindings"
language="jsx"
code={localBindingExample}
/>
The block must contain one or more `const` declarations followed by exactly one final `return`. A declaration uses one simple identifier and a required initializer. An earlier local can be used by later declarations and the final JSX.
For one page-level derived value, directly invoke a zero-argument inline arrow:
```jsx
{
(() => {
const visiblePokemon = data.pokemon.filter(
(pokemon) => options.types.length === 0 || options.types.includes(pokemon.type),
);
return (
<Stack>
{visiblePokemon.map((pokemon) => (
<Text>{pokemon.name}</Text>
))}
</Stack>
);
})();
}
```
<CustomWidgetCodeExample
id="custom-jsx-derived-iife"
label="Page-level derived value"
language="jsx"
code={derivedValueExample}
/>
This is not general JavaScript execution. The runtime rejects:
@@ -130,31 +178,13 @@ Relevant diagnostics include `INVALID_LOCAL_DECLARATION`, `LOCAL_BINDING_REQUIRE
Use the trusted `RecursiveList` special form for evolution chains, directory trees, nested comments, and other unknown-depth data. Do not write an authored recursive function or pass Mantine a render callback.
```jsx
<RecursiveList
data={data.evolution.chain}
childrenPath="evolves_to"
keyPath="species.name"
defaultExpandedDepth={4}
maxDepth={16}
showLines
>
{(node, meta) => {
const segments = node.species.url.split("/").filter((part) => part);
const dexId = segments.at(-1);
return (
<Group gap="xs">
<Badge variant="light">#{dexId}</Badge>
<Text tt="capitalize" fw={600}>
{node.species.name}
</Text>
{meta.hasChildren && <Text c="dimmed">{meta.childCount} evolutions</Text>}
</Group>
);
}}
</RecursiveList>
```
<CustomWidgetCodeExample
id="custom-jsx-recursive-list"
label="Trusted recursive list"
language="jsx"
code={recursiveListExample}
height="420px"
/>
The child template receives the current `node` and this metadata:
@@ -180,13 +210,12 @@ Homarr detects cycles per ancestor chain. A malformed branch, render failure, or
Bind a control to a literal name. Read its value from `inputs`:
```jsx
<TextInput bind="search" defaultValue="" />
<DatePicker bind="selectedDate" />
<Switch bind="enabled" defaultChecked={false} />
<MultiSelect bind="selectedEntities" data={options.entities} />
<SubFetch requestId="search" params={{ query: inputs.search }} />
```
<CustomWidgetCodeExample
id="custom-jsx-temporary-inputs"
label="Temporary inputs"
language="jsx"
code={temporaryInputsExample}
/>
These values exist in React memory while the widget is mounted. Homarr does not save them in any storage location.
+2
View File
@@ -193,6 +193,8 @@ Discovery resources:
- `homarr://custom-widgets/examples/{name}`
- `homarr://custom-widgets/skill`
An HTTP-only harness can use the matching `/api/custom-widgets/authoring-prompt`, `/schema`, `/components`, and `/skill` endpoints instead of MCP resource URIs.
The Custom Widget tools are:
- `customWidget_schema`
+9 -3
View File
@@ -9,6 +9,10 @@ self-hosted Homarr instance.
Workshop validates widget JSON in the browser before publishing. Homarr validates it again before installation. Widget
exports must not contain credentials; users configure required credentials only after importing a widget.
Each submission stores the manifest's `$schema` value as separate compatibility metadata. Workshop marks the current
Custom JSX schema and unsupported schemas in listings, while Homarr blocks installation if this metadata disagrees with
the downloaded manifest. The manifest remains the source of truth and is always validated again locally.
Votes and reports are unique per user and submission. Listings expose vote totals without exposing voter identities.
Reports include a category and explanation. Administrators can see the reporter, dismiss the report, or delete the
reported submission.
@@ -40,10 +44,12 @@ The central Workshop PocketBase service controls authentication. Register this c
https://<workshop-host>/api/oauth2-redirect
```
Self-hosted Homarr origins, including localhost and custom domains, do not need separate GitHub callbacks. The Workshop
host completes authentication in a popup and returns the result to the calling Homarr page.
Self-hosted Homarr origins, including localhost and custom domains, do not need separate GitHub callbacks. Homarr opens
the popup synchronously, while PocketBase completes authentication through its realtime OAuth flow on the Workshop host.
On successful sign-in, Homarr copies the GitHub display name or login and HTTPS avatar into that user's Workshop profile.
The collection rules let users update their own profile fields but prevent them from setting `isAdmin`.
PocketBase permits all origins by default. If the deployment uses `--origins` or a restrictive reverse-proxy CORS policy,
it must permit self-hosted Homarr origins. See [PocketBase routing and CORS](https://pocketbase.io/docs/go-routing/).
Homarr shows a visible error when the provider is misconfigured or the popup is blocked, cancelled, or times out.
Homarr shows a visible error when the provider is misconfigured or the popup is blocked or cancelled.
-1
View File
@@ -18,7 +18,6 @@
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"api-dump-contributors": "node ./scripts/dump-contributors.mjs",
"check:custom-widgets-ste": "node ./scripts/check-custom-widgets-ste.mjs",
"verify:search": "node ./scripts/verify-algolia-search.mjs"
},
"dependencies": {
@@ -1,217 +0,0 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { britishSpellings, imperativeVerbs, nonSteTerms, technicalTerms } from "./custom-widgets-ste-terms.mjs";
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const docsDirectory = resolve(scriptDirectory, "../docs");
const files = [
{ path: "management/custom-widgets/agent-authoring.mdx" },
{ path: "management/custom-widgets/authoring.mdx" },
{ path: "management/custom-widgets/custom-jsx.mdx" },
{ path: "management/custom-widgets/index.mdx" },
{ path: "management/custom-widgets/requests-and-security.mdx" },
{ path: "management/custom-widgets/troubleshooting.mdx" },
{ path: "management/workshop/index.mdx" },
{ path: "widgets/custom-api/index.mdx" },
{ path: "management/api/index.mdx", section: "Get Custom Widget authoring resources" },
{ path: "management/mcp.mdx", section: "Create a Custom Widget with an AI agent", includeMatching: /Custom Widget/ },
{ path: "advanced/environment-variables/index.mdx", includeMatching: /WORKSHOP_API_URL/ },
{ path: "community/faq.mdx", section: "Can I add my own widgets? Do I need programming skills?" },
{ path: "management/backup.mdx", includeMatching: /Custom Widget/ },
];
const imperativeSet = new Set(imperativeVerbs);
const issues = [];
const overview = await readFile(resolve(docsDirectory, "management/custom-widgets/index.mdx"), "utf8");
if (!overview.includes("## Audience and prerequisites")) {
addIssue("management/custom-widgets/index.mdx", 1, "Add the required audience and prerequisites section.");
}
for (const file of files) {
const absolutePath = resolve(docsDirectory, file.path);
const source = await readFile(absolutePath, "utf8");
const selectedLines = selectLines(source, file);
const paragraphs = getProseParagraphs(selectedLines);
for (const paragraph of paragraphs) {
const sentences = splitSentences(paragraph.text);
if (sentences.length > 6) {
addIssue(file.path, paragraph.line, `Paragraph has ${sentences.length} sentences. The limit is 6.`);
}
for (const sentence of sentences) {
const wordCount = countWords(sentence);
const isProcedure = paragraph.isList || startsWithImperative(sentence);
const limit = isProcedure ? 20 : 25;
if (wordCount > limit) {
const type = isProcedure ? "Procedural" : "Descriptive";
addIssue(file.path, paragraph.line, `${type} sentence has ${wordCount} words. The limit is ${limit}.`);
}
}
checkTerms(file.path, paragraph.line, paragraph.text);
}
}
if (issues.length > 0) {
console.error("Custom Widget STE check failed:\n");
for (const issue of issues) console.error(`- ${issue}`);
process.exitCode = 1;
} else {
console.log(`Custom Widget STE check passed. ${technicalTerms.length} technical terms are registered.`);
}
function selectLines(source, file) {
const lines = source.split("\n").map((text, index) => ({ text, line: index + 1 }));
if (!file.section && !file.includeMatching) return lines;
const selected = [];
if (file.section) {
const start = lines.findIndex(({ text }) => text.trim().replace(/^#{2,6}\s+/, "") === file.section);
if (start === -1) throw new Error(`Section '${file.section}' was not found in ${file.path}`);
const headingLevel = lines[start].text.trim().match(/^#+/)?.[0].length ?? 2;
for (let index = start; index < lines.length; index += 1) {
const nextHeadingLevel = lines[index].text.trim().match(/^#+(?=\s)/)?.[0].length;
if (index > start && nextHeadingLevel && nextHeadingLevel <= headingLevel) break;
selected.push(lines[index]);
}
}
if (file.includeMatching) {
for (const line of lines) {
if (file.includeMatching.test(line.text) && !selected.some((item) => item.line === line.line))
selected.push(line);
}
}
return selected.toSorted((left, right) => left.line - right.line);
}
function getProseParagraphs(lines) {
const paragraphs = [];
let paragraph = null;
let inFence = false;
let inFrontmatter = false;
let inTemplateExport = false;
let inJsxTag = false;
const flush = () => {
if (paragraph?.text.trim()) paragraphs.push({ ...paragraph, text: paragraph.text.trim() });
paragraph = null;
};
for (const { text: originalText, line } of lines) {
const trimmed = originalText.trim();
if (line === 1 && trimmed === "---") {
inFrontmatter = true;
continue;
}
if (inFrontmatter && trimmed === "---") {
inFrontmatter = false;
continue;
}
if (trimmed.startsWith("```")) {
flush();
inFence = !inFence;
continue;
}
if (inFence) continue;
if (/^export const .*?=\s*`/.test(trimmed)) inTemplateExport = true;
if (inTemplateExport) {
if (/`;\s*$/.test(trimmed)) inTemplateExport = false;
continue;
}
if (/^(import|export)\s/.test(trimmed)) continue;
if (trimmed.startsWith("<")) inJsxTag = true;
if (inJsxTag) {
if (trimmed.includes(">")) inJsxTag = false;
continue;
}
let text = trimmed;
if (inFrontmatter) {
const match = text.match(/^(title|description|sidebar_label):\s*["']?(.*?)["']?$/);
if (!match) continue;
text = match[2];
}
if (!text || /^\|?\s*:?-+:?/.test(text)) {
flush();
continue;
}
const isList = /^\s*(?:[-*]|\d+\.)\s+/.test(text);
text = cleanMarkdown(text);
if (!text) {
flush();
continue;
}
if (isList || text.startsWith("#") || text.startsWith("|")) flush();
text = text
.replace(/^#{1,6}\s+/, "")
.replace(/^\s*(?:[-*]|\d+\.)\s+/, "")
.replace(/^\|\s*/, "")
.replace(/\s*\|\s*$/, "");
if (!paragraph) paragraph = { text, line, isList };
else paragraph.text += ` ${text}`;
if (isList || trimmed.startsWith("#") || trimmed.startsWith("|")) flush();
}
flush();
return paragraphs;
}
function cleanMarkdown(text) {
return text
.replace(/\[([^\]]+)]\([^\s)]+(?:\s+"[^"]*")?\)/g, "$1")
.replace(/`[^`]*`/g, " ")
.replace(/<[^>]+>/g, " ")
.replace(/[*_~]/g, "")
.replace(/https?:\/\/\S+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function splitSentences(text) {
const normalized = text.replace(/\b(?:for example|such as)\b/gi, "example");
const matches = normalized.match(/[^.!?]+[.!?]+|[^.!?]+$/g) ?? [];
return matches.map((sentence) => sentence.trim()).filter(Boolean);
}
function countWords(sentence) {
return sentence.match(/[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)*/g)?.length ?? 0;
}
function startsWithImperative(sentence) {
const firstWord = sentence.toLowerCase().match(/[a-z]+/)?.[0];
return firstWord ? imperativeSet.has(firstWord) : false;
}
function checkTerms(path, line, text) {
const lowerText = text.toLowerCase();
for (const spelling of britishSpellings) {
if (new RegExp(`\\b${spelling}\\b`, "i").test(text)) {
addIssue(path, line, `Use American English instead of '${spelling}'.`);
}
}
for (const term of nonSteTerms) {
if (new RegExp(`\\b${term}\\b`, "i").test(lowerText)) {
addIssue(path, line, `Replace the non-STE term '${term}'.`);
}
}
}
function addIssue(path, line, message) {
issues.push(`${path}:${line} ${message}`);
}
@@ -1,125 +0,0 @@
export const technicalTerms = [
"Homarr",
"Custom Widget",
"Custom JSX v2",
"Workshop",
"API source",
"named request",
"load query",
"manual query",
"action",
"widget option",
"temporary input",
"CodeMirror",
"Mantine",
"PocketBase",
"MCP",
"JSON Schema",
"API key",
"network scope",
"preview session",
"error boundary",
"HTTP",
"JSON",
"JSX",
"React",
"TypeScript",
"Zod",
"TLS",
"DNS",
"CORS",
"OAuth",
"SSRF",
"tRPC",
"GitHub",
"Docker",
"reverse proxy",
"container networking",
"API authentication",
"bearer token",
"basic authentication",
"primitive literal",
"prop metadata",
"allowlist",
"denylist",
];
export const imperativeVerbs = [
"add",
"bind",
"build",
"call",
"check",
"configure",
"correct",
"create",
"declare",
"define",
"delete",
"disable",
"download",
"enable",
"enter",
"examine",
"export",
"get",
"give",
"import",
"inspect",
"install",
"keep",
"open",
"paste",
"read",
"register",
"remove",
"repeat",
"request",
"run",
"save",
"select",
"set",
"start",
"supply",
"test",
"use",
"verify",
"wait",
"write",
];
export const britishSpellings = [
"authorise",
"authorised",
"behaviour",
"cancelled",
"centre",
"colour",
"customise",
"customised",
"initialise",
"labelling",
"licence",
"organisation",
"recognise",
"serialise",
"synchronise",
"utilise",
"visualise",
];
export const nonSteTerms = [
"easy",
"easily",
"genuinely",
"leverage",
"magic",
"obviously",
"seamless",
"seamlessly",
"simply",
"straightforward",
"utilize",
"utilized",
"wizard",
];
@@ -323,14 +323,6 @@
margin-bottom: 0.6rem;
}
.codeBlock {
font-size: 0.75rem;
margin: 0;
max-height: 320px;
overflow: auto;
white-space: pre;
}
.a11yList {
font-size: 0.86rem;
margin: 0 0 0.7rem;
@@ -389,7 +381,7 @@
font-weight: 650;
}
.rawComponent .codeBlock {
.rawComponent > div {
margin-top: 0.6rem;
}
@@ -2,48 +2,19 @@ import BrowserOnly from "@docusaurus/BrowserOnly";
import useBaseUrl from "@docusaurus/useBaseUrl";
import { useEffect, useMemo, useState } from "react";
import { buildCustomJsxComponentUsageExample } from "@homarr/custom-widgets/catalog-example";
import type {
CustomJsxAuthoringCatalog,
CustomJsxComponentApi,
CustomJsxPropDescriptor,
} from "@homarr/custom-widgets/catalog";
import { CustomWidgetCodeExample } from "./custom-widget-code";
import styles from "./custom-jsx-component-reference.module.css";
type ComponentSafety = "allowed" | "wrapped" | "denied";
type ComponentPackage = "@mantine/core" | "@mantine/dates" | "@mantine/charts" | "@homarr/widgets";
type ComponentBindingType = "string" | "number" | "boolean" | "string[]" | "number[]";
interface CatalogProp {
name: string;
typeRef: number;
required: boolean;
source: "global" | "component";
literalValues?: Array<string | number | boolean | null>;
description?: string;
}
interface CatalogComponent {
name: string;
package: ComponentPackage;
category: string;
safety: ComponentSafety;
description?: string;
documentationUrl: string;
props: CatalogProp[];
blockedProps: Array<{ name: string; reason: string }>;
subcomponents: string[];
bind?: {
type: ComponentBindingType;
initialProp: "defaultValue" | "defaultChecked";
};
accessibilityRequirements: string[];
deniedReason?: string;
}
interface ComponentCatalog {
schemaVersion: number;
mantineVersion: string;
customWidgetVersion: string;
types: string[];
globalProps: CatalogProp[];
blockedCapabilities: Array<{ kind: string; name: string; reason: string }>;
components: CatalogComponent[];
}
type ComponentCatalog = CustomJsxAuthoringCatalog;
type CatalogComponent = CustomJsxComponentApi;
type CatalogProp = CustomJsxPropDescriptor;
const PAGE_SIZE = 40;
@@ -395,10 +366,13 @@ function ComponentDetails({ id, component, types }: { id: string; component: Cat
<div className={styles.details} id={id}>
<div className={styles.detailsGrid}>
<section>
<h5>Usage example</h5>
<pre className={styles.codeBlock}>
<code>{buildUsageExample(component, types)}</code>
</pre>
<CustomWidgetCodeExample
id={`${componentAnchor(component.name)}-usage`}
label="Usage example"
language="jsx"
code={buildCustomJsxComponentUsageExample(component, { types })}
height="180px"
/>
</section>
<section>
@@ -457,9 +431,12 @@ function ComponentDetails({ id, component, types }: { id: string; component: Cat
<details className={styles.rawComponent}>
<summary>Raw component JSON</summary>
<pre className={styles.codeBlock}>
<code>{JSON.stringify(component, null, 2)}</code>
</pre>
<CustomWidgetCodeExample
id={`${componentAnchor(component.name)}-json`}
label="Component JSON"
code={JSON.stringify(component, null, 2)}
height="320px"
/>
</details>
</div>
);
@@ -518,47 +495,3 @@ function humanize(value: string) {
function formatLiteral(value: string | number | boolean | null) {
return typeof value === "string" ? JSON.stringify(value) : String(value);
}
function buildUsageExample(component: CatalogComponent, types: string[]) {
if (component.name === "RecursiveList") {
return `<RecursiveList data={data.tree} childrenPath="children" keyPath="id" showLines>
{(node, meta) => (
<Group gap="xs">
<Text>{node.label}</Text>
{meta.hasChildren && <Badge>{meta.childCount}</Badge>}
</Group>
)}
</RecursiveList>`;
}
const props = component.props
.filter((prop) => prop.required)
.slice(0, 4)
.map((prop) => `${prop.name}=${examplePropValue(prop, types[prop.typeRef] ?? "unknown")}`);
if (component.bind) {
props.unshift(
`bind="value"`,
`${component.bind.initialProp}=${bindingInitialValue(component.bind.type, component.bind.initialProp)}`,
);
}
const propText = props.length > 0 ? ` ${props.join(" ")}` : "";
return `<${component.name}${propText} />`;
}
function bindingInitialValue(type: ComponentBindingType, initialProp: "defaultValue" | "defaultChecked") {
if (initialProp === "defaultChecked") return "{false}";
if (type === "string") return '""';
if (type === "number") return "{0}";
if (type === "boolean") return "{false}";
return "{[]}";
}
function examplePropValue(prop: CatalogProp, type: string) {
const literal = prop.literalValues?.[0];
if (literal !== undefined) return `{${formatLiteral(literal)}}`;
if (type.includes("string")) return `"${prop.name}"`;
if (type.includes("boolean")) return "{true}";
if (type.includes("number")) return "{0}";
if (type.includes("[]") || type.includes("Array<")) return "{[]}";
return `{options.${prop.name}}`;
}
@@ -7,6 +7,7 @@ import type {
WorkshopUser,
} from "@homarr/workshop";
import { validateWorkshopWidget, WorkshopClient, workshopExportFilename } from "@homarr/workshop";
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import { CustomWidgetCodeExample } from "../custom-widget-code";
import styles from "./workshop.module.css";
@@ -31,6 +32,7 @@ export function WorkshopPage() {
const [reportCategory, setReportCategory] = useState<WorkshopReport["category"]>("other");
const [reportText, setReportText] = useState("");
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const draftValidation = useMemo(() => validateWorkshopWidget(draft.content), [draft.content]);
const reportCounts = useMemo(() => {
@@ -41,6 +43,7 @@ export function WorkshopPage() {
const selectedReports = selected ? reports.filter((report) => report.submission === selected.id) : [];
const load = useCallback(async () => {
setLoading(true);
try {
const [nextReports, nextItems] = await Promise.all([
user?.isAdmin ? client.listReports() : Promise.resolve([]),
@@ -58,6 +61,8 @@ export function WorkshopPage() {
setError("");
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Workshop is unavailable");
} finally {
setLoading(false);
}
}, [client, search, sort, user?.isAdmin]);
@@ -252,7 +257,7 @@ export function WorkshopPage() {
</select>
</div>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.grid}>
<div className={styles.grid} aria-busy={loading}>
{items.map((item) => {
const reportCount = reportCounts.get(item.id) ?? 0;
return (
@@ -273,6 +278,9 @@ export function WorkshopPage() {
▲ {item.upvotes} · ▼ {item.downvotes}
</span>
</div>
<span className={item.widgetSchema === CUSTOM_WIDGET_SCHEMA ? styles.success : styles.error}>
{item.widgetSchema === CUSTOM_WIDGET_SCHEMA ? "Current" : "Unsupported"} · {item.widgetSchema}
</span>
{user?.isAdmin && reportCount > 0 && (
<span className={styles.reportCount}>
{reportCount} {reportCount === 1 ? "report" : "reports"}
@@ -288,7 +296,8 @@ export function WorkshopPage() {
);
})}
</div>
{items.length === 0 && <p className={styles.empty}>No widgets match this search.</p>}
{loading && items.length === 0 && <p className={styles.empty}>Loading Workshop…</p>}
{!loading && items.length === 0 && <p className={styles.empty}>No widgets match this search.</p>}
{selected && (
<div
@@ -308,6 +317,9 @@ export function WorkshopPage() {
▲ {selected.upvotes} · ▼ {selected.downvotes}
</span>
</div>
<span className={selected.widgetSchema === CUSTOM_WIDGET_SCHEMA ? styles.success : styles.error}>
{selected.widgetSchema === CUSTOM_WIDGET_SCHEMA ? "Current" : "Unsupported"} · {selected.widgetSchema}
</span>
{user?.isAdmin && selectedReports.length > 0 && (
<section className={styles.moderation} aria-labelledby="submission-reports-title">
<h3 id="submission-reports-title">
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"mantineVersion":"9.4.1","customWidgetVersion":"2.0.0","types":["\"\" | \"anonymous\" | \"use-credentials\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"auto\" | \"fixed\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"auto\" | \"all\" | \"fill\" | \"stroke\" | \"painted\" | \"visible\" | \"visibleFill\" | \"visiblePainted\" | \"visibleStroke\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"block\" | \"inline\" | \"both\" | \"horizontal\" | \"vertical\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"contain\" | \"cover\" | \"fill\" | \"scale-down\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"auto-fit\" | \"auto-fill\"","\"block\" | \"grid\"","\"both\" | \"rightSection\" | \"clear\"","\"bottom\" | \"top\"","\"bottom\" | \"top\" | \"baseline\" | \"middle\"","\"bottom\" | \"top\" | \"middle\"","\"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\" | \"bottom-center\" | \"top-center\" | \"middle-center\" | \"middle-end\" | \"middle-start\"","\"button\" | \"submit\" | \"reset\"","\"center\" | \"bottom\"","\"center\" | \"left\" | \"right\"","\"center\" | \"side\" | \"merge\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"responsive\"","\"div\" | \"label\"","\"end\" | \"start\"","\"fixed\" | \"absolute\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\" | \"click-hover\"","\"inherit\" | \"auto\" | \"baseline\" | \"before-edge\" | \"text-before-edge\" | \"middle\" | \"central\" | \"after-edge\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\"","\"inherit\" | \"auto\" | \"sRGB\" | \"linearRGB\"","\"inherit\" | \"auto\" | \"text-before-edge\" | \"middle\" | \"central\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\" | \"use-script\" | \"no-change\" | \"reset-size\"","\"inherit\" | \"end\" | \"start\" | \"middle\"","\"inherit\" | \"nonzero\" | \"evenodd\"","\"inherit\" | \"round\" | \"butt\" | \"square\"","\"inherit\" | \"round\" | \"miter\" | \"bevel\"","\"initial\" | \"medial\" | \"terminal\" | \"isolated\"","\"inside\" | \"outside\"","\"left\" | \"right\"","\"left\" | \"right\" | \"bottom\" | \"top\" | \"left-end\" | \"left-start\" | \"right-end\" | \"right-start\" | \"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\"","\"left\" | \"right\" | \"inside\"","\"link\" | \"none\" | \"copy\" | \"execute\" | \"move\" | \"popup\"","\"month\" | \"year\" | \"decade\"","\"none\" | \"all\" | \"rows\" | \"groups\" | \"columns\"","\"none\" | \"all\" | \"with-indicators\"","\"none\" | \"ascending\" | \"descending\" | \"other\"","\"none\" | \"inline\" | \"list\" | \"both\"","\"none\" | \"strict\" | \"blur\"","\"none\" | \"sum\"","\"none\" | \"thousand\" | \"lakh\" | \"wan\"","\"none\" | \"x\" | \"y\" | \"xy\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"right-to-left\" | \"left-to-right\"","\"search\" | \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"send\"","\"search\" | \"text\" | \"none\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"","\"step\" | \"bump\" | \"linear\" | \"natural\" | \"monotone\" | \"stepBefore\" | \"stepAfter\"","\"stepInactive\" | \"stepProgress\" | \"stepCompleted\"","\"sum\" | \"replace\"","\"td\" | \"th\"","\"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"yes\" | \"no\"","'Text' | 'Title' | 'Badge' | 'Code' | 'Image'","'auto' | 'manual'","'json' | 'text'","'xs' | 'sm' | 'md' | 'lg' | 'xl'","(\"input\" | \"label\" | \"description\" | \"error\")[]","(\"next\" | \"previous\" | \"level\")[]","(ActionIconProps & React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement>)","(CartesianChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })","(CloseButtonProps & ElementProps<\"button\"> & DataAttributes)","(CloseButtonProps & React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement>)","(ElementProps<\"button\"> & DataAttributes)","(InputDescriptionProps & DataAttributes)","(InputErrorProps & DataAttributes)","(InputLabelProps & DataAttributes)","(InputSuccessProps & DataAttributes)","(OverlayProps & ElementProps<\"div\">)","(PolarChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })","(React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement> & DataAttributes)","(React.ClassAttributes<HTMLDivElement> &Line truncated
{"schemaVersion":1,"mantineVersion":"9.4.1","customWidgetVersion":"2.0.0","types":["\"\" | \"anonymous\" | \"use-credentials\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"auto\" | \"fixed\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"auto\" | \"all\" | \"fill\" | \"stroke\" | \"painted\" | \"visible\" | \"visibleFill\" | \"visiblePainted\" | \"visibleStroke\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"block\" | \"inline\" | \"both\" | \"horizontal\" | \"vertical\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"contain\" | \"cover\" | \"fill\" | \"scale-down\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"wrap\" | \"nowrap\" | \"wrap-reverse\"","\"12h\" | \"24h\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"area\" | \"radial\" | \"scatter\"","\"auto\" | \"scroll\" | \"always\" | \"hover\" | \"never\"","\"auto-fit\" | \"auto-fill\"","\"block\" | \"grid\"","\"both\" | \"rightSection\" | \"clear\"","\"bottom\" | \"top\"","\"bottom\" | \"top\" | \"baseline\" | \"middle\"","\"bottom\" | \"top\" | \"middle\"","\"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\" | \"bottom-center\" | \"top-center\" | \"middle-center\" | \"middle-end\" | \"middle-start\"","\"button\" | \"submit\" | \"reset\"","\"center\" | \"bottom\"","\"center\" | \"left\" | \"right\"","\"center\" | \"left\" | \"right\" | \"justify\" | \"char\"","\"center\" | \"side\" | \"merge\"","\"checkbox\" | \"radio\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"gradient\"","\"default\" | \"responsive\"","\"default\" | \"stacked\" | \"percent\" | \"split\"","\"default\" | \"stacked\" | \"percent\" | \"waterfall\"","\"div\" | \"label\"","\"end\" | \"start\"","\"filled\" | \"light\"","\"fixed\" | \"absolute\"","\"hex\" | \"hexa\" | \"rgba\" | \"rgb\" | \"hsl\" | \"hsla\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\" | \"click-hover\"","\"inherit\" | \"auto\" | \"baseline\" | \"before-edge\" | \"text-before-edge\" | \"middle\" | \"central\" | \"after-edge\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\"","\"inherit\" | \"auto\" | \"sRGB\" | \"linearRGB\"","\"inherit\" | \"auto\" | \"text-before-edge\" | \"middle\" | \"central\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\" | \"use-script\" | \"no-change\" | \"reset-size\"","\"inherit\" | \"end\" | \"start\" | \"middle\"","\"inherit\" | \"nonzero\" | \"evenodd\"","\"inherit\" | \"round\" | \"butt\" | \"square\"","\"inherit\" | \"round\" | \"miter\" | \"bevel\"","\"initial\" | \"medial\" | \"terminal\" | \"isolated\"","\"inside\" | \"outside\"","\"left\" | \"right\"","\"left\" | \"right\" | \"bottom\" | \"top\" | \"left-end\" | \"left-start\" | \"right-end\" | \"right-start\" | \"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\"","\"left\" | \"right\" | \"inside\"","\"link\" | \"none\" | \"copy\" | \"execute\" | \"move\" | \"popup\"","\"media\" | \"container\"","\"month\" | \"year\" | \"decade\"","\"native\" | \"scrollarea\"","\"none\" | \"all\" | \"rows\" | \"groups\" | \"columns\"","\"none\" | \"all\" | \"with-indicators\"","\"none\" | \"ascending\" | \"descending\" | \"other\"","\"none\" | \"inline\" | \"list\" | \"both\"","\"none\" | \"strict\" | \"blur\"","\"none\" | \"sum\"","\"none\" | \"thousand\" | \"lakh\" | \"wan\"","\"none\" | \"x\" | \"y\" | \"xy\"","\"number\" | RegExp | \"alphanumeric\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"ordered\" | \"unordered\"","\"right-to-left\" | \"left-to-right\"","\"search\" | \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"send\"","\"search\" | \"text\" | \"none\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"","\"step\" | \"bump\" | \"linear\" | \"natural\" | \"monotone\" | \"stepBefore\" | \"stepAfter\"","\"stepInactive\" | \"stepProgress\" | \"stepCompleted\"","\"sum\" | \"replace\"","\"td\" | \"th\"","\"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"","\"text\" | \"tel\" | \"password\"","\"time\" | \"duration\"","\"up\" | \"down\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | (string & {})","\"yes\" | \"no\"","'Text' | 'Title' | 'Badge' | 'Code' | 'Image'","'auto' | 'manual'","'json' | 'text'","'xs' | 'sm' | 'md' | 'lg' | 'xl'","(\"input\" | \"label\" | \"description\" | \"error\")[]","(\"next\" | \"previLine truncated
+1
View File
@@ -55,6 +55,7 @@
"@mantine/colors-generator": "catalog:",
"@mantine/core": "catalog:",
"@mantine/dropzone": "catalog:",
"@mantine/form": "catalog:",
"@mantine/hooks": "catalog:",
"@mantine/modals": "catalog:",
"@mantine/tiptap": "catalog:",
@@ -95,7 +95,7 @@ describe("CopyAiPromptButton", () => {
});
});
test("copies the complete offline bundle and reports its exact character count", async () => {
test("copies the complete embedded skill and reports its exact character count", async () => {
await act(async () => {
root.render(
<MantineProvider>
@@ -120,10 +120,26 @@ describe("CopyAiPromptButton", () => {
const prompt = writeText.mock.calls[0]?.[0] as string;
expect(prompt.startsWith("Please create this Homarr Custom JSX v2 widget:\n\nCreate a Pokédex")).toBe(true);
expect(prompt).toContain("# Homarr Custom Widget offline authoring bundle");
expect(prompt).toContain("--- BEGIN SKILL FILE: SKILL.md ---");
expect(prompt).toContain("--- BEGIN SKILL FILE: references/widget-schema.json ---");
expect(prompt).toContain("--- BEGIN CANONICAL COMPONENT CATALOG ---");
expect(prompt).toContain("--- BEGIN CANONICAL RUNTIME EXAMPLES ---");
expect(prompt.length).toBeGreaterThan(500_000);
expect(prompt.length).toBeLessThanOrEqual(1_700_000);
expect(prompt.endsWith(`${CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL}\nCharacters: ${prompt.length}`)).toBe(true);
expect(showSuccessNotification).toHaveBeenCalledWith({
title: "action.copyAiPrompt",
message: `notification.aiPromptCopiedWithCount:${prompt.length}`,
});
});
test("places the AI authoring card before General information", async () => {
const source = await readFile(
resolve(process.cwd(), "apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx"),
"utf8",
);
expect(source.indexOf("<CustomWidgetAiCard")).toBeLessThan(source.indexOf('<EditorSection id="general"'));
});
});
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { getCustomWidgetPreviewOptionIssues, loadPreviewQueries } from "./_custom-widget-form-utils";
import { getChangedSecrets, getCustomWidgetPreviewOptionIssues, loadPreviewQueries } from "./_custom-widget-form-utils";
const previewQuery = vi.hoisted(() => vi.fn());
@@ -64,4 +64,23 @@ describe("Custom Widget workbench preview options", () => {
{ path: "configuration.environmentId", message: "Expected number" },
]);
});
it("normalizes only changed secrets for save and preview", () => {
expect(
getChangedSecrets({
name: "Widget",
description: "",
iconUrl: "",
sources: "[]",
requests: "[]",
optionsSchema: "{}",
defaultOptions: "{}",
template: "<Text />",
secrets: [
{ sourceId: "default", kind: "apiKey", value: "", hasValue: true },
{ sourceId: "other", kind: "password", value: "replacement" },
],
}),
).toEqual([{ sourceId: "other", kind: "password", value: "replacement" }]);
});
});
@@ -24,6 +24,16 @@ export function buildDefinition(values: CustomWidgetFormValues) {
});
}
export function getChangedSecrets(values: CustomWidgetFormValues) {
return values.secrets
.filter((secret) => secret.value.trim())
.map(({ sourceId, kind, value }) => ({
sourceId,
kind: kind as "apiKey" | "username" | "password",
value,
}));
}
export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrCustomWidgetV2) {
form.setValues({
...form.values,
@@ -165,6 +165,14 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
</nav>
<div className={classes.workbench} data-mobile-pane={mobilePane}>
<Stack gap="lg" className={classes.configuration}>
<CustomWidgetAiCard
candidate={candidate.success ? candidate.data : null}
request={request}
onRequestChange={setRequest}
documentationUrl={documentationUrl}
onDocumentationUrlChange={setDocumentationUrl}
onPaste={() => void pasteAiResponse()}
/>
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
<Textarea label={t("field.description")} autosize minRows={2} {...form.getInputProps("description")} />
@@ -224,14 +232,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
maxLength={50_000}
/>
</EditorSection>
<CustomWidgetAiCard
candidate={candidate.success ? candidate.data : null}
request={request}
onRequestChange={setRequest}
documentationUrl={documentationUrl}
onDocumentationUrlChange={setDocumentationUrl}
onPaste={() => void pasteAiResponse()}
/>
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
<SaveActions
dirty={form.isDirty()}
@@ -18,6 +18,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import {
applyDefinition,
buildDefinition,
getChangedSecrets,
getCustomWidgetPreviewOptionIssues,
isRecord,
loadPreviewQueries,
@@ -63,13 +64,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
);
return;
}
const changedSecrets = values.secrets
.filter((secret) => secret.value.trim())
.map(({ sourceId, kind, value }) => ({
sourceId,
kind: kind as "apiKey" | "username" | "password",
value,
}));
const changedSecrets = getChangedSecrets(values);
try {
if (input.mode === "create") {
const result = await createMutation.mutateAsync({ ...definition.data, secrets: changedSecrets });
@@ -135,13 +130,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
definition: input.candidate.data,
definitionId: input.definitionId,
options: input.optionsSnapshot,
secrets: input.form.values.secrets
.filter((secret) => secret.value.trim())
.map(({ sourceId, kind, value }) => ({
sourceId,
kind: kind as "apiKey" | "username" | "password",
value,
})),
secrets: getChangedSecrets(input.form.values),
});
const snapshot = await loadPreviewQueries(input.candidate.data, created.previewSession.id, input.optionsSnapshot);
const failed = Object.values(snapshot.status).filter((status) => isRecord(status) && status.ok === false).length;
@@ -13,5 +13,6 @@ export function GET() {
"homarr://custom-widgets/components",
"homarr://custom-widgets/skill",
],
httpResources: ["/api/custom-widgets/schema", "/api/custom-widgets/components", "/api/custom-widgets/skill"],
});
}
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
export const dynamic = "force-static";
export function GET() {
return NextResponse.json(getCustomWidgetJsonSchema());
}
@@ -22,7 +22,7 @@ import { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-pro
import {
getCustomWidgetComponent,
getCustomWidgetComponentCatalog,
getCustomWidgetSkill,
getCustomWidgetOfflineBundle,
} from "@homarr/custom-widgets/authoring-resources";
import { customJsxExamples, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
import { db } from "@homarr/db";
@@ -252,8 +252,7 @@ const mcpHandler = createMcpHandler(
if (uri === "homarr://custom-widgets/schema") text = JSON.stringify(getCustomWidgetJsonSchema());
else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog());
else if (uri === "homarr://custom-widgets/skill") {
const skill = getCustomWidgetSkill();
text = `${skill.skillMd}\n\nInstall from skills.sh: ${skill.skillsShUrl}\nSource: ${skill.sourceUrl}\nCommand: ${skill.installCommand}\n`;
text = getCustomWidgetOfflineBundle();
mimeType = "text/markdown";
} else if (uri.startsWith("homarr://custom-widgets/components/")) {
const name = decodeURIComponent(uri.slice("homarr://custom-widgets/components/".length));
@@ -10,6 +10,7 @@ import {
Code,
Group,
Image,
Loader,
Modal,
Pagination,
Select,
@@ -33,6 +34,8 @@ import {
} from "@tabler/icons-react";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import type { WorkshopReport, WorkshopSubmissionDetail, WorkshopUser } from "@homarr/workshop";
import { validateWorkshopWidget, workshopExportFilename } from "@homarr/workshop";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -49,7 +52,7 @@ function downloadWorkshopSubmission(submission: WorkshopSubmissionDetail) {
URL.revokeObjectURL(url);
}
export function WorkshopBrowser({ onInstall }: { onInstall?(submission: WorkshopSubmissionDetail): Promise<void> }) {
export function WorkshopBrowser({ onInstall }: { onInstall?(widget: HomarrCustomWidgetV2): Promise<void> }) {
const t = useScopedI18n("workshop");
const client = useMemo(createWorkshopClient, []);
const queryClient = useQueryClient();
@@ -85,6 +88,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
() => (detail.data ? validateWorkshopWidget(detail.data.content) : null),
[detail.data],
);
const detailCompatible = detailValidation?.success === true && detail.data?.widgetSchema === CUSTOM_WIDGET_SCHEMA;
const signIn = () => {
setLoginPending(true);
setLoginError(null);
@@ -99,7 +103,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
.finally(() => setLoginPending(false));
};
const install = useMutation({
mutationFn: async (submission: WorkshopSubmissionDetail) => onInstall?.(submission),
mutationFn: async (widget: HomarrCustomWidgetV2) => onInstall?.(widget),
onSuccess: () => setSelectedId(null),
});
const vote = useMutation({
@@ -167,6 +171,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
<TextInput
label={t("search")}
leftSection={<IconSearch size={16} />}
rightSection={list.isFetching ? <Loader size={16} /> : undefined}
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
@@ -194,7 +199,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
<Skeleton h={280} />
</SimpleGrid>
) : list.isError ? (
<Alert color="red">{t("unavailable")}</Alert>
<Alert color="red">{list.error instanceof Error ? list.error.message : t("unavailable")}</Alert>
) : list.data?.items.length ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
{list.data.items.map((item) => (
@@ -223,6 +228,9 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
<IconThumbUp size={13} /> {item.upvotes} · <IconThumbDown size={13} /> {item.downvotes}
</Text>
</Group>
<Badge color={item.widgetSchema === CUSTOM_WIDGET_SCHEMA ? "green" : "red"} variant="light">
{item.widgetSchema}
</Badge>
<Text fw={700} lineClamp={1}>
{item.title}
</Text>
@@ -254,7 +262,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
{detail.isPending ? (
<Skeleton h={400} />
) : detail.isError || !detail.data ? (
<Alert color="red">{t("loadError")}</Alert>
<Alert color="red">{detail.error instanceof Error ? detail.error.message : t("loadError")}</Alert>
) : (
<Stack>
<Text>{detail.data.description}</Text>
@@ -273,12 +281,17 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
))}
</SimpleGrid>
)}
<CapabilitySummary content={detail.data.content} />
{detailValidation?.success && <CapabilitySummary widget={detailValidation.data} />}
{detailValidation && !detailValidation.success && (
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
{t("installErrorDescription")} {detailValidation.error}
</Alert>
)}
{detailValidation?.success && !detailCompatible && (
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
{t("installErrorDescription")}
</Alert>
)}
<Code block mah={360} style={{ overflow: "auto", whiteSpace: "pre" }}>
{detail.data.content}
</Code>
@@ -294,7 +307,8 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
<Button
variant="subtle"
leftSection={<IconThumbUp size={16} />}
disabled={!user}
loading={vote.isPending && vote.variables?.value === 1}
disabled={!user || vote.isPending}
onClick={() => vote.mutate({ submission: detail.data.id, value: 1 })}
>
{detail.data.upvotes}
@@ -302,7 +316,8 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
<Button
variant="subtle"
leftSection={<IconThumbDown size={16} />}
disabled={!user}
loading={vote.isPending && vote.variables?.value === -1}
disabled={!user || vote.isPending}
onClick={() => vote.mutate({ submission: detail.data.id, value: -1 })}
>
{detail.data.downvotes}
@@ -320,8 +335,8 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
{onInstall && (
<Button
loading={install.isPending}
disabled={!detailValidation?.success}
onClick={() => install.mutate(detail.data)}
disabled={!detailCompatible}
onClick={() => detailValidation?.success && install.mutate(detailValidation.data)}
>
{t("install")}
</Button>
@@ -332,7 +347,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
{t("signInHint")}
</Text>
)}
{vote.error && <Alert color="red">{t("voteError")}</Alert>}
{vote.error && <Alert color="red">{vote.error.message || t("voteError")}</Alert>}
{install.error && <Alert color="red">{install.error.message}</Alert>}
</Stack>
)}
@@ -367,10 +382,8 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
);
}
function CapabilitySummary({ content }: { content: string }) {
function CapabilitySummary({ widget }: { widget: HomarrCustomWidgetV2 }) {
const t = useScopedI18n("workshop");
const parsed = validateWorkshopWidget(content);
if (!parsed.success) return null;
return (
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<Card withBorder p="sm">
@@ -378,7 +391,7 @@ function CapabilitySummary({ content }: { content: string }) {
{t("sources")}
</Text>
<Stack gap={6}>
{parsed.data.sources.map((source) => (
{widget.sources.map((source) => (
<Box key={source.id}>
<Text size="sm" fw={500}>
{source.name}
@@ -402,13 +415,13 @@ function CapabilitySummary({ content }: { content: string }) {
<Text fw={600} size="sm" mb="xs">
{t("requests")}
</Text>
{parsed.data.requests.length === 0 ? (
{widget.requests.length === 0 ? (
<Text size="xs" c="dimmed">
{t("noRequests")}
</Text>
) : (
<Stack gap={6}>
{parsed.data.requests.map((request) => (
{widget.requests.map((request) => (
<Group key={request.id} justify="space-between" wrap="nowrap">
<Text size="xs" ff="monospace" truncate>
{request.id}
@@ -1,10 +1,13 @@
import { WorkshopClient, WORKSHOP_API_URL } from "@homarr/workshop";
export function createWorkshopClient() {
const runtimeUrl =
typeof document === "undefined"
? undefined
: document.querySelector<HTMLMetaElement>('meta[name="homarr-workshop-api-url"]')?.content;
let browserClient: WorkshopClient | undefined;
return new WorkshopClient(runtimeUrl || WORKSHOP_API_URL);
export function createWorkshopClient() {
if (typeof document === "undefined") return new WorkshopClient(WORKSHOP_API_URL);
if (browserClient) return browserClient;
const runtimeUrl = document.querySelector<HTMLMetaElement>('meta[name="homarr-workshop-api-url"]')?.content;
browserClient = new WorkshopClient(runtimeUrl || WORKSHOP_API_URL);
return browserClient;
}
@@ -5,9 +5,7 @@ import { Button, Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconBuildingStore } from "@tabler/icons-react";
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
@@ -25,23 +23,9 @@ export function WorkshopInstallButton() {
</Button>
<Modal opened={opened} onClose={controls.close} title={t("installDialog")} size="90%">
<WorkshopBrowser
onInstall={async (submission) => {
try {
const parsed = customWidgetImportSchema.safeParse(JSON.parse(submission.content) as unknown);
if (!parsed.success) throw new Error(t("installErrorDescription"));
setPendingWidget(parsed.data);
reviewControls.open();
} catch (error) {
showErrorNotification({
title: t("installError"),
message:
error instanceof SyntaxError
? t("installErrorDescription")
: error instanceof Error
? error.message
: t("installErrorDescription"),
});
}
onInstall={async (widget) => {
setPendingWidget(widget);
reviewControls.open();
}}
/>
</Modal>
@@ -4,38 +4,19 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { WorkshopSubmissionDetail } from "@homarr/workshop";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { WorkshopBrowser } from "./workshop-browser";
export function WorkshopInstaller() {
const t = useScopedI18n("workshop");
const router = useRouter();
const [pendingWidget, setPendingWidget] = useState<HomarrCustomWidgetV2 | null>(null);
const [reviewOpened, reviewControls] = useDisclosure(false);
const install = async (submission: WorkshopSubmissionDetail) => {
try {
const parsed = customWidgetImportSchema.safeParse(JSON.parse(submission.content) as unknown);
if (!parsed.success) throw new Error(t("installErrorDescription"));
setPendingWidget(parsed.data);
reviewControls.open();
} catch (error) {
showErrorNotification({
title: t("installError"),
message:
error instanceof SyntaxError
? t("installErrorDescription")
: error instanceof Error
? error.message
: t("installErrorDescription"),
});
}
const install = async (widget: HomarrCustomWidgetV2) => {
setPendingWidget(widget);
reviewControls.open();
};
return (
+1 -2
View File
@@ -31,9 +31,8 @@ ENV PORT=8090
RUN addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
COPY --from=website /app/apps/docs/build /pb_public
COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint
COPY apps/workshop/pb_hooks /pb_hooks
COPY apps/workshop/pb_migrations /pb_migrations
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data && chown -R pocketbase:pocketbase /pb_data /pb_hooks /pb_migrations /pb_public
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data && chown -R pocketbase:pocketbase /pb_data /pb_migrations /pb_public
USER pocketbase
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -q --spider "http://127.0.0.1:${PORT}/api/health" || exit 1
+11 -4
View File
@@ -3,7 +3,7 @@
Workshop is a small PocketBase service that serves the public documentation site and stores one Custom JSX v2 widget per submission.
The runtime image extends `ghcr.io/muchobien/pocketbase:0.39`; Homarr adds only the built documentation, PocketBase
collections, one small user-role guard, and its explicit serve arguments.
collections, API rules, and its explicit serve arguments.
It intentionally has no approval queue, user bans, comments, email notifications, audit log, reusable widget connections,
or remote control over installed widgets. Submissions publish immediately. Authenticated users can vote and report;
@@ -20,8 +20,9 @@ container. With the image directly, set both values: `docker run -e PORT=3003 -p
Open `http://127.0.0.1:18090/_/`, create the first PocketBase superuser, and configure GitHub OAuth on the `users` collection.
The `users.isAdmin` field defaults to `false` and cannot be changed by regular users. Appoint administrators from the
PocketBase dashboard with a superuser account.
The `users.isAdmin` field defaults to `false`. Collection rules allow account creation only through OAuth without that
field and reject regular-user updates to it. Appoint administrators from the PocketBase dashboard with a superuser
account.
Users must sign in again after the role changes. Setting the value back to `false` removes Workshop administrator access.
@@ -39,8 +40,13 @@ Run the disposable service integration test from the repository root:
pnpm test:workshop
```
The test uses a separate Compose project and deletes its named volume on exit. It never writes fixture users or admin
roles into the normal `homarr-workshop_pb_data` volume.
The Workshop does not maintain a second Custom Widget schema. Its frontend validates submissions with
`customWidgetImportSchema` from `@homarr/custom-widgets`, and Homarr validates canonical content again before installation.
The validated `$schema` is also stored as `widgetSchema` for listing compatibility badges; it must match the downloaded
manifest before Homarr enables installation.
PocketBase API rules enforce submission ownership, one vote/report per user and submission, and administrator-only
moderation. Reports are dismissed by deleting them; PocketBase cascade deletion removes related votes and reports when a
submission is deleted.
@@ -53,7 +59,8 @@ Build the documentation and serve it from PocketBase using the same production i
pnpm docker:docs
```
Open `http://127.0.0.1:3003`. Set `DOCS_EXPOSE_PORT` to use another host port. The preview does not mount or persist PocketBase data.
Open `http://127.0.0.1:3003`. Set `DOCS_EXPOSE_PORT` to use another host port. PocketBase data persists in the
`homarr-workshop_pb_data` named volume.
Point a local Homarr checkout at it with:
-1
View File
@@ -14,7 +14,6 @@ services:
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:${PB_EXPOSE_PORT:-8090}"
volumes:
- pb_data:/pb_data
- ./pb_hooks:/pb_hooks:ro
- ./pb_migrations:/pb_migrations:ro
security_opt:
- no-new-privileges:true
-1
View File
@@ -5,7 +5,6 @@ exec pocketbase serve \
--http="0.0.0.0:${PORT:-8090}" \
--origins="${PB_ALLOWED_ORIGINS:-*}" \
--dir=/pb_data \
--hooksDir=/pb_hooks \
--migrationsDir=/pb_migrations \
--publicDir=/pb_public \
--indexFallback=true
-6
View File
@@ -1,6 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
onRecordCreate((e) => {
e.record.set("isAdmin", false);
e.next();
}, "users");
@@ -17,6 +17,7 @@ migrate(
const adminRule = "@request.auth.isAdmin = true";
users.listRule = adminRule;
users.viewRule = `id = @request.auth.id || ${adminRule}`;
users.createRule = '@request.context = "oauth2" && @request.body.isAdmin:isset = false';
users.updateRule = "id = @request.auth.id && @request.body.isAdmin:isset = false";
users.deleteRule = null;
app.save(users);
@@ -32,6 +33,7 @@ migrate(
fields: [
{ type: "text", name: "title", required: true, min: 3, max: 100 },
{ type: "text", name: "description", max: 2000 },
{ type: "text", name: "widgetSchema", required: true, min: 1, max: 100 },
{ type: "text", name: "content", required: true, max: 1000000 },
{
type: "file",
@@ -126,7 +128,7 @@ migrate(
listRule: "",
viewRule: "",
viewQuery: `
SELECT s.id, s.title, s.description, s.screenshots, s.author, u.displayName AS authorName,
SELECT s.id, s.title, s.description, s.widgetSchema, s.screenshots, s.author, u.displayName AS authorName,
s.created, s.updated,
COALESCE(SUM(CASE WHEN v.value = 1 THEN 1 ELSE 0 END), 0) AS upvotes,
COALESCE(SUM(CASE WHEN v.value = -1 THEN 1 ELSE 0 END), 0) AS downvotes,
+9 -9
View File
@@ -1,32 +1,32 @@
#!/bin/sh
set -eu
WORKSHOP_TEST_DATA_DIR=$(mktemp -d)
WORKSHOP_TEST_PORT=${WORKSHOP_TEST_PORT:-18090}
WORKSHOP_TEST_PROJECT="homarr-workshop-test-$$"
cleanup() {
WORKSHOP_DATA_DIR="$WORKSHOP_TEST_DATA_DIR" PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" \
docker compose -f apps/workshop/docker-compose.yml down --remove-orphans >/dev/null 2>&1 || true
rm -rf "$WORKSHOP_TEST_DATA_DIR"
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml down --volumes --remove-orphans >/dev/null 2>&1 || true
}
trap cleanup EXIT
WORKSHOP_DATA_DIR="$WORKSHOP_TEST_DATA_DIR" PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" \
docker compose -f apps/workshop/docker-compose.yml up --build -d
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml up --build -d
for attempt in $(seq 1 60); do
if curl --fail --silent "http://127.0.0.1:$WORKSHOP_TEST_PORT/api/health" >/dev/null; then
break
fi
if [ "$attempt" -eq 60 ]; then
WORKSHOP_DATA_DIR="$WORKSHOP_TEST_DATA_DIR" PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" \
docker compose -f apps/workshop/docker-compose.yml logs --no-color workshop
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml logs --no-color workshop
exit 1
fi
sleep 1
done
docker exec homarr-workshop-workshop-1 pocketbase superuser create \
docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml exec -T workshop \
pocketbase superuser create \
workshop-test@example.invalid 'WorkshopLocalTest123!' --dir=/pb_data
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" node apps/workshop/tests/workshop.integration.mjs
+20 -5
View File
@@ -37,6 +37,9 @@ for (const removed of ["comments", "workshop_admin_actions", "workshop_admins"])
// Production uses GitHub OAuth. Password auth is enabled only in this disposable database.
const usersCollection = await request("/api/collections/users", { headers: rootHeaders });
if (usersCollection.createRule !== '@request.context = "oauth2" && @request.body.isAdmin:isset = false') {
throw new Error("User creation must be limited to OAuth without administrator fields");
}
await request("/api/collections/users", {
method: "PATCH",
headers: rootHeaders,
@@ -67,8 +70,7 @@ const signIn = async (email, password) => {
};
const authorPassword = "WorkshopAuthor123!";
const author = await createUser("widget-author@example.invalid", "Widget Author", authorPassword, { isAdmin: true });
if (author.isAdmin !== false) throw new Error("New users must not be able to start as Workshop administrators");
const author = await createUser("widget-author@example.invalid", "Widget Author", authorPassword);
const authorSession = await signIn(author.email, authorPassword);
await expectStatus(
`/api/collections/users/records/${author.id}`,
@@ -104,11 +106,16 @@ const submission = await request("/api/collections/submissions/records", {
body: JSON.stringify({
title: "Workshop runtime probe",
description: "PocketBase integration test",
widgetSchema: widget.$schema,
content: JSON.stringify(widget),
author: author.id,
}),
});
if (submission.author !== author.id || submission.title !== "Workshop runtime probe") {
if (
submission.author !== author.id ||
submission.title !== "Workshop runtime probe" ||
submission.widgetSchema !== widget.$schema
) {
throw new Error("Submission publication failed");
}
@@ -117,7 +124,8 @@ const updatedSubmission = await request(`/api/collections/submissions/records/${
headers: authorSession.headers,
body: JSON.stringify({ title: "Updated runtime probe" }),
});
if (updatedSubmission.title !== "Updated runtime probe") throw new Error("Authors must be able to edit their submission");
if (updatedSubmission.title !== "Updated runtime probe")
throw new Error("Authors must be able to edit their submission");
await expectStatus(
`/api/collections/submissions/records/${submission.id}`,
@@ -161,7 +169,13 @@ await expectStatus(
);
const listing = await request(`/api/collections/workshop_listings/records/${submission.id}`);
if (listing.score !== 1 || listing.upvotes !== 1 || listing.downvotes !== 0 || listing.authorName !== "Widget Author") {
if (
listing.score !== 1 ||
listing.upvotes !== 1 ||
listing.downvotes !== 0 ||
listing.authorName !== "Widget Author" ||
listing.widgetSchema !== widget.$schema
) {
throw new Error("Workshop listing data is incorrect");
}
@@ -222,6 +236,7 @@ const visitorSubmission = await request("/api/collections/submissions/records",
body: JSON.stringify({
title: "Administrator deletion probe",
content: JSON.stringify(widget),
widgetSchema: widget.$schema,
author: visitor.id,
}),
});
+101
View File
@@ -0,0 +1,101 @@
import { chromium, expect } from "@playwright/test";
import { describe, test } from "vitest";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
import { startMockApiContainerAsync } from "./shared/mock-api-container";
import { seedAdminUserAsync } from "./shared/seed-admin-user";
const adminCredentials = { username: "admin", password: "Comp(exP4sswOrd" };
const requests = JSON.stringify(
[
{
id: "status",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/status",
parameters: {},
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
},
],
null,
2,
);
const initialTemplate =
"<Stack><Title order={4}>{data.status?.title}</Title><Badge>{data.status?.status}</Badge></Stack>";
const updatedTemplate = "<Stack><Text fw={700}>{data.status?.title}</Text><Text>{data.status?.value}</Text></Stack>";
describe("Custom JSX v2 workbench", () => {
test("creates, previews, edits, and immediately adds a widget to a board", async () => {
const mockApi = await startMockApiContainerAsync({ title: "E2E Widget", status: "online", value: 42 });
const { db, localMountPath } = await createSqliteDbFileAsync();
await seedAdminUserAsync(db, adminCredentials);
const homarrContainer = await createHomarrContainer({
environment: { AUTH_PROVIDERS: "credentials" },
mounts: { "/appdata": localMountPath },
}).start();
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
const browser = await chromium.launch();
const page = await browser.newPage();
try {
await page.goto(`${baseUrl}/auth/login`);
await page.getByLabel("Username").fill(adminCredentials.username);
await page.locator("#password").fill(adminCredentials.password);
await page.locator("button[type='submit']").click();
await page.waitForURL(baseUrl, { timeout: 15_000 });
await page.goto(`${baseUrl}/manage/custom-widgets/new`);
await page.getByRole("textbox", { name: "Name" }).fill("E2E Custom JSX v2");
await page.getByRole("textbox", { name: "Base URL" }).fill(mockApi.url);
await page.getByRole("combobox", { name: "Network scope" }).click();
await page.getByRole("option", { name: "private" }).click();
await page.locator("#requests-editor-root").fill(requests);
await page.locator("#jsx-editor-root").fill(initialTemplate);
await page.getByRole("button", { name: "Test and preview" }).last().click();
await expect(page.getByRole("complementary", { name: "Widget preview" }).getByText("E2E Widget")).toBeVisible({
timeout: 15_000,
});
await page.getByRole("button", { name: "Create widget" }).last().click();
await expect(page.getByText('Widget "E2E Custom JSX v2" created successfully.')).toBeVisible({ timeout: 15_000 });
await page.waitForURL("**/manage/custom-widgets/edit/**", { timeout: 15_000 });
await page.locator("#jsx-editor-root").fill(updatedTemplate);
await page.getByRole("button", { name: "Save changes" }).last().click();
await expect(page.getByText('Widget "E2E Custom JSX v2" updated successfully.')).toBeVisible({ timeout: 15_000 });
await page.goto(baseUrl);
await page
.getByRole("button", { name: /edit mode|pencil/iu })
.first()
.click();
await page
.getByRole("button", { name: /Add widget|Add item/iu })
.first()
.click();
await page
.getByRole("button", { name: /Custom API|Custom Widget/iu })
.first()
.click();
const addDialog = page.getByRole("dialog").last();
await addDialog.getByText("E2E Custom JSX v2").first().click();
await expect(page.getByText("Widget definition not found")).not.toBeVisible();
await page
.getByRole("button", { name: /edit mode|pencil/iu })
.first()
.click();
await expect(page.getByText("E2E Widget")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("42")).toBeVisible({ timeout: 15_000 });
} finally {
await browser.close();
await homarrContainer.stop();
await mockApi.stop();
}
}, 180_000);
});
@@ -13,6 +13,7 @@ import {
} from "@homarr/custom-widgets/core";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
import { insertCustomWidgetDefinition } from "./definition-insert";
import { managementQueryProcedures } from "./management-queries";
import { metadataProcedures } from "./metadata-procedures";
import { previewActionProcedures } from "./preview-action-procedures";
@@ -48,35 +49,7 @@ export const customWidgetRouter = createTRPCRouter({
}
const definition = customWidgetDefinitionSchema.parse(candidate);
assertSecretSources(definition.sources, secrets);
const id = createId();
const definitionRow = {
id,
...serializeCustomWidgetDefinition(definition),
creatorId: ctx.session.user.id,
};
const updatedAt = new Date();
const secretRows = secrets.map((secret) => ({
definitionId: id,
sourceId: secret.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
updatedAt,
}));
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
await transaction.insert(schema.customWidgetDefinitions).values(definitionRow);
if (secretRows.length > 0) await transaction.insert(schema.customWidgetSecrets).values(secretRows);
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction.insert(customWidgetDefinitions).values(definitionRow).run();
if (secretRows.length > 0) transaction.insert(customWidgetSecrets).values(secretRows).run();
});
},
});
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets);
logger.info("Created custom widget definition", { id, name: definition.name });
return { id };
}),
@@ -0,0 +1,43 @@
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import type { Database } from "@homarr/db";
import { handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import type { CustomWidgetCreateInput, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { serializeCustomWidgetDefinition } from "./stored-definition";
export async function insertCustomWidgetDefinition(
db: Database,
definition: HomarrCustomWidgetV2,
creatorId: string,
secrets: CustomWidgetCreateInput["secrets"],
) {
const id = createId();
const definitionRow = { id, ...serializeCustomWidgetDefinition(definition), creatorId };
const updatedAt = new Date();
const secretRows = secrets.map((secret) => ({
definitionId: id,
sourceId: secret.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
updatedAt,
}));
await handleTransactionsAsync(db, {
async handleAsync(database, schema) {
await database.transaction(async (transaction) => {
await transaction.insert(schema.customWidgetDefinitions).values(definitionRow);
if (secretRows.length > 0) await transaction.insert(schema.customWidgetSecrets).values(secretRows);
});
},
handleSync(database) {
database.transaction((transaction) => {
transaction.insert(customWidgetDefinitions).values(definitionRow).run();
if (secretRows.length > 0) transaction.insert(customWidgetSecrets).values(secretRows).run();
});
},
});
return id;
}
@@ -22,6 +22,7 @@ export const metadataProcedures = {
"homarr://custom-widgets/components",
"homarr://custom-widgets/skill",
],
httpResources: ["/api/custom-widgets/schema", "/api/custom-widgets/components", "/api/custom-widgets/skill"],
})),
getSkill: publicProcedure
@@ -1,16 +1,15 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import { customWidgetImportSchema, customWidgetSecretsInputSchema } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
import { insertCustomWidgetDefinition } from "./definition-insert";
import { assertSecretSources } from "./secret-policy";
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage");
const logger = createLogger({ module: "custom-widget" });
@@ -34,35 +33,7 @@ export const transferProcedures = {
});
}
assertSecretSources(input.widget.sources, input.secrets);
const id = createId();
const definitionRow = {
id,
...serializeCustomWidgetDefinition(input.widget),
creatorId: ctx.session.user.id,
};
const updatedAt = new Date();
const secretRows = input.secrets.map((secret) => ({
definitionId: id,
sourceId: secret.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
updatedAt,
}));
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
await transaction.insert(schema.customWidgetDefinitions).values(definitionRow);
if (secretRows.length > 0) await transaction.insert(schema.customWidgetSecrets).values(secretRows);
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction.insert(customWidgetDefinitions).values(definitionRow).run();
if (secretRows.length > 0) transaction.insert(customWidgetSecrets).values(secretRows).run();
});
},
});
const id = await insertCustomWidgetDefinition(ctx.db, input.widget, ctx.session.user.id, input.secrets);
logger.info("Imported custom widget definition", { id, name: input.widget.name });
return { id };
}),
@@ -0,0 +1,202 @@
import { stringify as stringifySuperJson } from "superjson";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { eq } from "@homarr/db";
import { boardUserPermissions, boards, customWidgetDefinitions, items, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
const mocks = vi.hoisted(() => ({
executeRequest: vi.fn(async () => ({ ok: true, status: 200, statusText: "OK", data: { value: 42 } })),
}));
vi.mock("../../custom-widget/request-executor", () => ({
executeCustomWidgetRequest: mocks.executeRequest,
invalidateCustomWidgetResponseCache: vi.fn(),
}));
vi.mock("../../custom-widget/request-limits", () => ({
acquireCustomWidgetRequestLimit: vi.fn(async () => async () => undefined),
}));
import { serializeCustomWidgetDefinition } from "../../custom-widget/stored-definition";
import { customApiRouter } from "../../widgets/custom-api";
const definition: HomarrCustomWidgetV2 = {
$schema: "homarr-custom-widget-v2",
name: "Router test widget",
sources: [
{
id: "default",
name: "API",
baseUrl: "https://example.com",
networkScope: "public",
auth: { type: "none" },
},
],
requests: [
{
id: "load-status",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/status",
parameters: {},
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
},
{
id: "status",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/status/{name}",
parameters: { name: "string" },
auth: "inherit",
minimumBoardPermission: "view",
trigger: "manual",
},
{
id: "toggle",
sourceId: "default",
kind: "action",
method: "POST",
pathTemplate: "/toggle",
parameters: { enabled: "boolean" },
bodyTemplate: { enabled: { $param: "enabled" } },
auth: "inherit",
minimumBoardPermission: "modify",
trigger: "manual",
},
{
id: "delete",
sourceId: "default",
kind: "action",
method: "DELETE",
pathTemplate: "/resource",
parameters: {},
auth: "inherit",
minimumBoardPermission: "full",
trigger: "manual",
},
],
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
defaultOptions: {},
template: "<Text>{data['load-status']?.value}</Text>",
};
const createSession = (userId: string): Session => ({
user: { id: userId, permissions: [], colorScheme: "light" },
expires: new Date(Date.now() + 60_000).toISOString(),
});
async function setup(isPublic = true) {
const db = createDb();
const ownerId = createId();
const boardId = createId();
const itemId = createId();
const definitionId = createId();
await db.insert(users).values({ id: ownerId });
await db.insert(boards).values({ id: boardId, name: createId(), creatorId: ownerId, isPublic });
await db.insert(customWidgetDefinitions).values({
id: definitionId,
...serializeCustomWidgetDefinition(definition),
creatorId: ownerId,
});
await db.insert(items).values({
id: itemId,
boardId,
kind: "customApi",
options: stringifySuperJson({ definitionId, configuration: {}, configurationVersion: 1 }),
});
return { db, ownerId, boardId, itemId, definitionId };
}
describe("Custom JSX v2 board router", () => {
beforeEach(() => mocks.executeRequest.mockClear());
test("allows public queries only through the definition placed on an accessible board", async () => {
const { db, itemId } = await setup();
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
await expect(caller.queryRequest({ itemId, requestId: "status", params: { name: "homarr" } })).resolves.toEqual(
expect.objectContaining({ ok: true, data: { value: 42 } }),
);
await expect(caller.queryRequest({ itemId, requestId: "missing", params: {} })).rejects.toMatchObject({
code: "NOT_FOUND",
});
expect(mocks.executeRequest).toHaveBeenCalledOnce();
});
test("hides private boards and disabled definitions before executing a request", async () => {
const privateSetup = await setup(false);
const anonymous = customApiRouter.createCaller({
db: privateSetup.db,
deviceType: undefined,
session: null,
});
await expect(
anonymous.queryRequest({ itemId: privateSetup.itemId, requestId: "status", params: { name: "homarr" } }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
const enabledSetup = await setup();
await enabledSetup.db
.update(customWidgetDefinitions)
.set({ enabled: false })
.where(eq(customWidgetDefinitions.id, enabledSetup.definitionId));
const publicCaller = customApiRouter.createCaller({ db: enabledSetup.db, deviceType: undefined, session: null });
await expect(publicCaller.getData({ itemId: enabledSetup.itemId })).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
test("rejects request-kind and parameter mismatches", async () => {
const { db, itemId, ownerId } = await setup();
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
await expect(caller.queryRequest({ itemId, requestId: "toggle", params: { enabled: true } })).rejects.toMatchObject(
{ code: "NOT_FOUND" },
);
await expect(caller.queryRequest({ itemId, requestId: "status", params: { name: 42 } })).rejects.toThrow();
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
test("enforces modify permission and renders an authorized action body", async () => {
const { db, itemId, boardId } = await setup(false);
const editorId = createId();
await db.insert(users).values({ id: editorId });
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(editorId) });
await expect(
caller.executeAction({ itemId, requestId: "toggle", params: { enabled: true } }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
await db.insert(boardUserPermissions).values({ boardId, userId: editorId, permission: "modify" });
await expect(caller.executeAction({ itemId, requestId: "toggle", params: { enabled: true } })).resolves.toEqual(
expect.objectContaining({ ok: true }),
);
expect(mocks.executeRequest).toHaveBeenCalledWith(
expect.objectContaining({ method: "POST", body: '{"enabled":true}' }),
);
});
test("requires confirmation and full permission for DELETE actions", async () => {
const { db, itemId, boardId, ownerId } = await setup(false);
const owner = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
await expect(owner.executeAction({ itemId, requestId: "delete", params: {} })).rejects.toMatchObject({
code: "PRECONDITION_FAILED",
});
const editorId = createId();
await db.insert(users).values({ id: editorId });
await db.insert(boardUserPermissions).values({ boardId, userId: editorId, permission: "modify" });
const editor = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(editorId) });
await expect(
editor.executeAction({ itemId, requestId: "delete", params: {}, confirmed: true }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
await expect(owner.executeAction({ itemId, requestId: "delete", params: {}, confirmed: true })).resolves.toEqual(
expect.objectContaining({ ok: true }),
);
});
});
+1
View File
@@ -15,6 +15,7 @@
"./authoring-prompt": "./src/core/ai-prompt.ts",
"./authoring-resources": "./src/core/authoring-resources.ts",
"./catalog": "./src/core/catalog.ts",
"./catalog-example": "./src/core/component-example.ts",
"./core": "./src/core/index.ts",
"./embedded-authoring-prompt": "./src/core/embedded-ai-prompt.ts",
"./jsx": "./src/jsx/index.ts",
@@ -93,11 +93,11 @@ const componentRegistry = await readFile(
join(repositoryRoot, "packages/custom-widgets/src/core/component-registry.ts"),
"utf8",
);
if (!/from\s+["'].\/component-runtime\.generated\.json["']/u.test(componentRegistry)) {
failures.push("The Custom JSX runtime registry must use the compact generated component index");
if (!/from\s+["'].\/component-catalog["']/u.test(componentRegistry)) {
failures.push("The Custom JSX runtime registry must use the canonical authoring catalog");
}
if (/from\s+["'].\/component-catalog["']/u.test(componentRegistry)) {
failures.push("The Custom JSX runtime registry must not bundle the full authoring catalog");
if (/component-runtime\.generated\.json/u.test(componentRegistry)) {
failures.push("The Custom JSX runtime registry must not use a second generated component catalog");
}
if (failures.length > 0) {
@@ -6,13 +6,7 @@ import type {
} from "../src/core/component-catalog-types";
import type { CustomJsxComponentPackage } from "../src/core/component-types";
export const GENERATED_COMPONENT_REFERENCE_PATHS = [
"components.md",
"mantine-core.md",
"mantine-dates.md",
"mantine-charts.md",
"homarr-components.md",
] as const;
export const GENERATED_COMPONENT_REFERENCE_PATHS = ["components.md", "homarr-components.md"] as const;
export type GeneratedComponentReferencePath = (typeof GENERATED_COMPONENT_REFERENCE_PATHS)[number];
@@ -39,7 +33,7 @@ const packageReferences = [
},
] as const satisfies readonly {
packageName: CustomJsxComponentPackage;
path: Exclude<GeneratedComponentReferencePath, "components.md">;
path: string;
title: string;
}[];
@@ -50,10 +44,7 @@ export function renderCustomWidgetComponentReferences(
const references = Object.fromEntries([
["components.md", renderCatalogOverview(catalog)],
...packageReferences.map(({ packageName, path, title }) => [
path,
renderPackageReference(catalog, packageName, title),
]),
["homarr-components.md", renderPackageReference(catalog, "@homarr/widgets", "Homarr components")],
]) as Record<GeneratedComponentReferencePath, string>;
return references;
@@ -73,7 +64,7 @@ function renderCatalogOverview(catalog: Readonly<CustomJsxAuthoringCatalog>): st
"",
generatedNotice(catalog),
"",
"This is the complete offline Custom JSX component contract for this Homarr release. When connected to Homarr, prefer the live catalog because the installed release may differ. The catalog is descriptive, not a prop allowlist: ordinary serializable props pass through unless the capability boundary explicitly blocks them.",
"This is the complete Custom JSX component boundary for this Homarr release. The machine-readable `component-catalog.json` contains exact generated component, prop, binding, and safety metadata; these Markdown pages provide the human-readable inventory and upstream Mantine indexes. Ordinary serializable props pass through unless the capability boundary explicitly blocks them.",
"",
"## Package references",
"",
@@ -87,7 +78,7 @@ function renderCatalogOverview(catalog: Readonly<CustomJsxAuthoringCatalog>): st
"## Authoring boundary",
"",
"- Use the exact component names below. Compound names such as `Tabs.List`, `Card.Section`, and `Radio.Group` are distinct catalog entries.",
"- The effective prop API is the shared prop table plus the component-specific table in the package reference. A component-specific record overrides the shared record with the same name.",
"- Use the official Mantine LLM links for component props and examples. Homarr's live catalog is authoritative for availability, bindings, and blocked capabilities.",
'- Use `bind="name"` only when a component record declares bind metadata. Read the temporary in-memory value from `inputs.name`; it is never persisted.',
"- Ordinary authored callbacks remain blocked. Restricted collection callbacks, directly invoked zero-argument derived-value IIFEs, and `RecursiveList`'s child template may use the immutable local `const` grammar in [JSX runtime](runtime.md#immutable-local-bindings).",
`- Use [\`RecursiveList\`](homarr-components.md#${getComponentReferenceAnchor("RecursiveList")}) for custom arbitrary-depth trees. Do not author recursion or pass a renderer callback to Mantine Tree.`,
@@ -101,12 +92,6 @@ function renderCatalogOverview(catalog: Readonly<CustomJsxAuthoringCatalog>): st
"",
...renderInventoryByPackage(catalog, true),
"",
"## Shared prop API",
"",
"These props are available to enabled components unless a component-specific record overrides the same name.",
"",
renderPropTable(catalog, catalog.globalProps),
"",
"## Blocked capabilities",
"",
"| Kind | Name | Reason |",
@@ -134,7 +119,7 @@ function renderPackageReference(
"",
generatedNotice(catalog),
"",
`Package: \`${packageName}\`. Effective props combine the [shared prop API](components.md#shared-prop-api) with each component's specific props below. Component-specific records override shared records with the same name.`,
`Package: \`${packageName}\`. The component-specific props below supplement ordinary safe serializable Mantine props and style props. Component-specific records override ordinary behavior with the same name.`,
"",
...packageGuidance(packageName),
"",
@@ -223,10 +208,13 @@ function renderInventoryByPackage(catalog: Readonly<CustomJsxAuthoringCatalog>,
"",
components.length > 0
? components
.map(
(component) =>
`[\`${escapeMarkdownLinkLabel(component.name)}\`](${path}#${getComponentReferenceAnchor(component.name)})${component.deniedReason ? ` — ${component.deniedReason}` : ""}`,
)
.map((component) => {
const target =
component.package === "@homarr/widgets"
? `${path}#${getComponentReferenceAnchor(component.name)}`
: component.documentationUrl;
return `[\`${escapeMarkdownLinkLabel(component.name)}\`](${target})${component.deniedReason ? ` — ${component.deniedReason}` : ""}`;
})
.join(", ")
: "_None._",
"",
@@ -18,7 +18,6 @@ import type {
CustomJsxLiteralValue,
CustomJsxPropDescriptor,
CustomJsxPropSource,
CustomJsxRuntimeCatalog,
} from "../src/core/component-catalog-types";
import { getCatalogBlockedComponentProps, getCatalogDeniedComponentReason } from "../src/core/component-catalog-policy";
import { commonSafeProps } from "../src/core/component-props";
@@ -152,24 +151,7 @@ async function generateCatalog() {
components,
};
const runtimeCatalog: CustomJsxRuntimeCatalog = {
schemaVersion: catalog.schemaVersion,
globalProps: catalog.globalProps.map(({ name }) => name),
components: catalog.components.map((component) => ({
name: component.name,
package: component.package,
safety: component.safety,
props: component.props.map(({ name }) => name),
blockedProps: component.blockedProps,
...(component.bind ? { bind: component.bind } : {}),
...(component.deniedReason ? { deniedReason: component.deniedReason } : {}),
})),
};
await Promise.all([
writeFile(resolve(packageRoot, "src/core/component-catalog.generated.json"), `${JSON.stringify(catalog)}\n`),
writeFile(resolve(packageRoot, "src/core/component-runtime.generated.json"), `${JSON.stringify(runtimeCatalog)}\n`),
]);
await writeFile(resolve(packageRoot, "src/core/component-catalog.generated.json"), `${JSON.stringify(catalog)}\n`);
}
function inspectMantineComponent(component: CatalogSourceComponent): InspectedComponent {
@@ -246,17 +228,19 @@ function buildComponentApi(
const inspected = inspectedComponents.get(component.name);
const componentProps = new Map<string, UninternedPropDescriptor>();
for (const prop of inspected?.props ?? []) {
if (!blockedPropNames.has(prop.name) && !prop.global && !globalNames.has(prop.name)) {
const { global: _global, ...descriptor } = prop;
componentProps.set(prop.name, { ...descriptor, source: "component" });
}
if (blockedPropNames.has(prop.name)) continue;
const { global: _global, ...descriptor } = prop;
const componentDescriptor = { ...descriptor, source: "component" as const };
const globalProp = globalNames.has(prop.name) ? globalPropsByName.get(prop.name) : undefined;
if (globalProp && samePropMetadata(globalProp, componentDescriptor)) continue;
componentProps.set(prop.name, componentDescriptor);
}
if (component.safety !== "denied" && component.package === "@homarr/widgets") {
const homarrProps = HOMARR_COMPONENT_PROPS[component.name];
if (!homarrProps) throw new Error(`Homarr component '${component.name}' requires explicit catalog prop metadata`);
for (const [propName, metadata] of Object.entries(homarrProps)) {
const globalProp = globalPropsByName.get(propName);
if (globalProp && !metadata.required && samePropMetadata(globalProp, metadata)) continue;
if (globalProp && samePropMetadata(globalProp, metadata)) continue;
if (isBlockedCustomJsxProp(propName)) continue;
componentProps.set(propName, { name: propName, source: "component", ...metadata });
}
@@ -339,8 +323,8 @@ function mergeGlobalProp(name: string, candidates: UninternedPropDescriptor[]):
function samePropMetadata(globalProp: UninternedPropDescriptor, componentProp: HomarrPropMetadata): boolean {
return (
globalProp.type === componentProp.type &&
(componentProp.literalValues === undefined ||
JSON.stringify(globalProp.literalValues ?? []) === JSON.stringify(componentProp.literalValues))
globalProp.required === componentProp.required &&
JSON.stringify(globalProp.literalValues ?? []) === JSON.stringify(componentProp.literalValues ?? [])
);
}
@@ -458,7 +442,7 @@ function inferFallbackType(name: string): string {
if (/^(?:count|index|max|min|offset|order|page|pageSize|size|span|step|total|value)$/u.test(name)) return "number";
if (/^(?:colors|values|sections|data)$/u.test(name)) return "unknown[]";
if (/^(?:children|description|fallback|icon|label|leftSection|rightSection|title)$/u.test(name)) return "ReactNode";
if (/^(?:params|onParams|offParams|style|styles)$/u.test(name)) return "Record<string, unknown>";
if (/^(?:params|enabledParams|disabledParams|style|styles)$/u.test(name)) return "Record<string, unknown>";
return "string | number | boolean";
}
@@ -696,8 +680,8 @@ const HOMARR_COMPONENT_PROPS: Readonly<Record<string, Readonly<Record<string, Ho
},
ToggleSwitch: {
requestId: optionalProp("string", undefined, "Identifier of the named Custom Widget action."),
onParams: optionalProp("Record<string, string | number | boolean>"),
offParams: optionalProp("Record<string, string | number | boolean>"),
enabledParams: optionalProp("Record<string, string | number | boolean>"),
disabledParams: optionalProp("Record<string, string | number | boolean>"),
initialValue: optionalProp("boolean | string"),
label: optionalProp("string"),
color: optionalProp("string"),
@@ -1,12 +1,19 @@
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { customJsxAuthoringCatalog } from "../src/core/component-catalog";
import { customJsxExamples } from "../src/core/examples";
import { getCustomWidgetJsonSchema } from "../src/core/schema";
export const CUSTOM_WIDGET_SKILL_SOURCE_PATHS = [
"SKILL.md",
"references/schema.md",
"references/widget-schema.json",
"references/runtime.md",
"references/security.md",
"references/components.md",
"references/component-catalog.json",
"references/examples.json",
"references/mantine-core.md",
"references/mantine-dates.md",
"references/mantine-charts.md",
@@ -18,6 +25,16 @@ const repositoryRoot = resolve(packageRoot, "../..");
const skillRoot = resolve(repositoryRoot, ".agents/skills/homarr-custom-widget");
const outputPath = resolve(packageRoot, "src/core/skill-content.generated.json");
await writeFile(
resolve(skillRoot, "references/widget-schema.json"),
`${JSON.stringify(getCustomWidgetJsonSchema(), null, 2)}\n`,
);
await writeFile(
resolve(skillRoot, "references/component-catalog.json"),
`${JSON.stringify(customJsxAuthoringCatalog)}\n`,
);
await writeFile(resolve(skillRoot, "references/examples.json"), `${JSON.stringify(customJsxExamples, null, 2)}\n`);
const files = await Promise.all(
CUSTOM_WIDGET_SKILL_SOURCE_PATHS.map(async (path) => ({
path,
@@ -1,22 +1,23 @@
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
import { customJsxAuthoringCatalog } from "./component-catalog";
export const CUSTOM_WIDGET_MANTINE_VERSION = "9.4.1";
export const CUSTOM_WIDGET_MANTINE_VERSION = customJsxAuthoringCatalog.mantineVersion;
export const CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL = "--- END HOMARR CUSTOM WIDGET OFFLINE BUNDLE ---";
const CUSTOM_WIDGET_AI_PROMPT_LIMIT = 8_000;
const FINAL_OUTPUT_INSTRUCTION =
export const CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION =
"Create the requested widget now and return exactly one complete `json` fenced block followed by one complete `jsx` fenced block. Homarr will validate the result after it is pasted into the workbench.";
const AUTHORING_PROMPT = `You create Homarr Custom JSX v2 widgets. Return exactly two fenced blocks: a json block containing one complete widget object with template set to "__HOMARR_TEMPLATE__", then a jsx block containing the readable template.
Use $schema "homarr-custom-widget-v2". A widget contains metadata, inline API sources, named requests, an options schema, defaults, and one JSX template. Never include credentials. Use one source named "default" unless the widget genuinely combines APIs.
This Homarr release uses Mantine ${CUSTOM_WIDGET_MANTINE_VERSION}. Use only the Homarr runtime components and Mantine capabilities described in this prompt. The embedded-skill variant includes the exact release-matched component reference files.
This Homarr release uses Mantine ${CUSTOM_WIDGET_MANTINE_VERSION}. Use only the Homarr runtime components and Mantine capabilities described in this prompt. The embedded-skill variant includes the complete release-matched skill, widget schema, component catalog, and canonical examples for self-contained offline authoring.
Queries and actions may use GET, POST, PUT, PATCH, or DELETE. Set kind to query for reads and action for user-triggered changes. Actions never use trigger "load". Declare every path, query, or body parameter. Use {name} in paths and {"$param":"name"} in query/body templates. Every declared parameter needs an explicit value source: SubFetch, ActionButton, and ToggleSwitch supply manual request values through their params prop; every load-query parameter must have an optionsBinding entry containing {"$option":"optionName"} or a primitive literal. Never infer a binding from matching names. Use load-triggered query results through data.<requestId>.
Source authentication is exactly one of {"type":"none"}, {"type":"bearer"}, {"type":"basic"}, {"type":"apiKeyHeader","headerName":"X-API-Key"}, or {"type":"apiKeyQuery","parameterName":"api_key"}. Credentials are configured separately by the user.
Templates can read data, status, options, and inputs. Bind supported Mantine controls with bind="search" and read the temporary value as inputs.search. Bound inputs live only in memory and reset when the widget reloads; saved configuration belongs in options. Restricted callback blocks may declare immutable local const values followed by one final return. Use RecursiveList for arbitrary-depth trees instead of authored recursion. Use Mantine Core, Dates, and Charts components. Do not use imports, hooks, refs, raw event callback props, component/renderRoot, arbitrary portals, raw HTML, fetch, eval, npm packages, or credentials. Design for compact and wide dashboard sizes with useful loading, empty, and error states.
Templates can read data, status, options, and inputs. Each status.<requestId> is an object shaped { loading: boolean, ok?: boolean, status?: number, statusText?: string, error?: string }; never compare it with the strings "loading", "success", or "error". Use status.list?.loading while loading, status.list?.ok === false for failure, and status.list?.ok === true for success. Bind supported Mantine controls with bind="search" and read the temporary value as inputs.search. Bound inputs live only in memory and reset when the widget reloads; saved configuration belongs in options. Restricted callback blocks may declare immutable local const values followed by one final return. Use RecursiveList for arbitrary-depth trees instead of authored recursion. Use Mantine Core, Dates, and Charts components. Do not use imports, hooks, refs, raw event callback props, component/renderRoot, arbitrary portals, raw HTML, fetch, eval, npm packages, or credentials. Design for compact and wide dashboard sizes with useful loading, empty, and error states.
Options use a restricted object JSON Schema. x-homarr controls include text, textarea, number, switch, select, multi-select, slider, date, time, color, icon, url, duration, timeZone, and json. Dynamic selects use x-homarr.optionsSource with requestId, optional itemsPath for wrapped arrays, valuePath, and labelPath.
@@ -37,7 +38,7 @@ export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX
6. Use customWidget_secretSet only for a credential the user supplied and only with dedicated permission. Otherwise call customWidget_secretRequestUser and poll its completion status; never receive or repeat the plaintext.
7. Call customWidget_create only after validation, request tests, action simulation, and visual inspection pass.
Use inline sources, named queries/actions, restricted JSON Schema options, temporary declarative bind controls exposed through inputs, and safe Mantine JSX. Every load-query parameter needs an explicit optionsBinding option reference or literal; manual queries and actions receive all parameters from the invoking component's params prop. Never infer sources from matching names. Use kind, not method, to distinguish reads from user-triggered changes. Never include credentials, imports, hooks, refs, callbacks, browser requests, arbitrary JavaScript, npm packages, polymorphic roots, or arbitrary portals. Make the widget responsive, accessible, loading-aware, empty-aware, and error-aware. The live schema and component resources are authoritative for the installed Homarr release.`;
Use inline sources, named queries/actions, restricted JSON Schema options, temporary declarative bind controls exposed through inputs, and safe Mantine JSX. Every load-query parameter needs an explicit optionsBinding option reference or literal; manual queries and actions receive all parameters from the invoking component's params prop. Never infer sources from matching names. Each status.<requestId> is an object with loading, ok, status, statusText, and error fields; never compare it with "loading", "success", or "error". Use kind, not method, to distinguish reads from user-triggered changes. Never include credentials, imports, hooks, refs, callbacks, browser requests, arbitrary JavaScript, npm packages, polymorphic roots, or arbitrary portals. Make the widget responsive, accessible, loading-aware, empty-aware, and error-aware. The live schema and component resources are authoritative for the installed Homarr release.`;
export function buildCustomWidgetMcpPrompt(request?: string | null, documentationUrl?: string | null) {
const sections = [CUSTOM_WIDGET_MCP_AUTHORING_PROMPT];
@@ -60,7 +61,7 @@ export function buildCustomWidgetAiPrompt(
const sections = [`Please create this Homarr Custom JSX v2 widget:\n\n${requestedWidget}`];
if (documentationUrl) sections.push(`API documentation: ${truncatePromptText(redactUrl(documentationUrl), 500)}`);
sections.push(AUTHORING_PROMPT);
sections.push(FINAL_OUTPUT_INSTRUCTION);
sections.push(CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION);
const optionalSections = [
...(rawResponse ? [{ label: "Sample API response", content: redactResponse(rawResponse) }] : []),
@@ -1,6 +1,11 @@
import { customJsxAuthoringCatalog, getCustomJsxComponentProps } from "./component-catalog";
import { customJsxExamples } from "./examples";
import { CUSTOM_WIDGET_SKILL_MD, CUSTOM_WIDGET_SKILL_VERSION } from "./embedded-ai-prompt";
import {
CUSTOM_WIDGET_OFFLINE_BUNDLE,
CUSTOM_WIDGET_SKILL_FILES,
CUSTOM_WIDGET_SKILL_MD,
CUSTOM_WIDGET_SKILL_VERSION,
} from "./embedded-ai-prompt";
import { customJsxTablerIconNames } from "./tabler-icons";
export const CUSTOM_WIDGET_SKILLS_SH_URL = "https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget";
@@ -14,12 +19,19 @@ export function getCustomWidgetSkill() {
name: "homarr-custom-widget",
version: CUSTOM_WIDGET_SKILL_VERSION,
skillMd: CUSTOM_WIDGET_SKILL_MD,
files: CUSTOM_WIDGET_SKILL_FILES,
componentCatalog: customJsxAuthoringCatalog,
examples: customJsxExamples,
skillsShUrl: CUSTOM_WIDGET_SKILLS_SH_URL,
sourceUrl: CUSTOM_WIDGET_SKILL_SOURCE_URL,
installCommand: CUSTOM_WIDGET_SKILL_INSTALL_COMMAND,
};
}
export function getCustomWidgetOfflineBundle() {
return CUSTOM_WIDGET_OFFLINE_BUNDLE;
}
export function getCustomWidgetComponentCatalog() {
return customJsxAuthoringCatalog;
}
@@ -60,19 +60,3 @@ export interface CustomJsxAuthoringCatalog {
blockedCapabilities: CustomJsxBlockedCapability[];
components: CustomJsxComponentApi[];
}
export interface CustomJsxRuntimeComponentApi {
name: string;
package: CustomJsxComponentPackage;
safety: CustomJsxComponentSafety;
props: string[];
blockedProps: CustomJsxComponentBlockedProp[];
bind?: CustomJsxBindingDescriptor;
deniedReason?: string;
}
export interface CustomJsxRuntimeCatalog {
schemaVersion: typeof CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION;
globalProps: string[];
components: CustomJsxRuntimeComponentApi[];
}
@@ -1 +1 @@
{"schemaVersion":1,"mantineVersion":"9.4.1","customWidgetVersion":"2.0.0","types":["\"\" | \"anonymous\" | \"use-credentials\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"auto\" | \"fixed\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"auto\" | \"all\" | \"fill\" | \"stroke\" | \"painted\" | \"visible\" | \"visibleFill\" | \"visiblePainted\" | \"visibleStroke\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"block\" | \"inline\" | \"both\" | \"horizontal\" | \"vertical\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"contain\" | \"cover\" | \"fill\" | \"scale-down\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"auto-fit\" | \"auto-fill\"","\"block\" | \"grid\"","\"both\" | \"rightSection\" | \"clear\"","\"bottom\" | \"top\"","\"bottom\" | \"top\" | \"baseline\" | \"middle\"","\"bottom\" | \"top\" | \"middle\"","\"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\" | \"bottom-center\" | \"top-center\" | \"middle-center\" | \"middle-end\" | \"middle-start\"","\"button\" | \"submit\" | \"reset\"","\"center\" | \"bottom\"","\"center\" | \"left\" | \"right\"","\"center\" | \"side\" | \"merge\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"responsive\"","\"div\" | \"label\"","\"end\" | \"start\"","\"fixed\" | \"absolute\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\" | \"click-hover\"","\"inherit\" | \"auto\" | \"baseline\" | \"before-edge\" | \"text-before-edge\" | \"middle\" | \"central\" | \"after-edge\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\"","\"inherit\" | \"auto\" | \"sRGB\" | \"linearRGB\"","\"inherit\" | \"auto\" | \"text-before-edge\" | \"middle\" | \"central\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\" | \"use-script\" | \"no-change\" | \"reset-size\"","\"inherit\" | \"end\" | \"start\" | \"middle\"","\"inherit\" | \"nonzero\" | \"evenodd\"","\"inherit\" | \"round\" | \"butt\" | \"square\"","\"inherit\" | \"round\" | \"miter\" | \"bevel\"","\"initial\" | \"medial\" | \"terminal\" | \"isolated\"","\"inside\" | \"outside\"","\"left\" | \"right\"","\"left\" | \"right\" | \"bottom\" | \"top\" | \"left-end\" | \"left-start\" | \"right-end\" | \"right-start\" | \"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\"","\"left\" | \"right\" | \"inside\"","\"link\" | \"none\" | \"copy\" | \"execute\" | \"move\" | \"popup\"","\"month\" | \"year\" | \"decade\"","\"none\" | \"all\" | \"rows\" | \"groups\" | \"columns\"","\"none\" | \"all\" | \"with-indicators\"","\"none\" | \"ascending\" | \"descending\" | \"other\"","\"none\" | \"inline\" | \"list\" | \"both\"","\"none\" | \"strict\" | \"blur\"","\"none\" | \"sum\"","\"none\" | \"thousand\" | \"lakh\" | \"wan\"","\"none\" | \"x\" | \"y\" | \"xy\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"right-to-left\" | \"left-to-right\"","\"search\" | \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"send\"","\"search\" | \"text\" | \"none\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"","\"step\" | \"bump\" | \"linear\" | \"natural\" | \"monotone\" | \"stepBefore\" | \"stepAfter\"","\"stepInactive\" | \"stepProgress\" | \"stepCompleted\"","\"sum\" | \"replace\"","\"td\" | \"th\"","\"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"yes\" | \"no\"","'Text' | 'Title' | 'Badge' | 'Code' | 'Image'","'auto' | 'manual'","'json' | 'text'","'xs' | 'sm' | 'md' | 'lg' | 'xl'","(\"input\" | \"label\" | \"description\" | \"error\")[]","(\"next\" | \"previous\" | \"level\")[]","(ActionIconProps & React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement>)","(CartesianChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })","(CloseButtonProps & ElementProps<\"button\"> & DataAttributes)","(CloseButtonProps & React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement>)","(ElementProps<\"button\"> & DataAttributes)","(InputDescriptionProps & DataAttributes)","(InputErrorProps & DataAttributes)","(InputLabelProps & DataAttributes)","(InputSuccessProps & DataAttributes)","(OverlayProps & ElementProps<\"div\">)","(PolarChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })","(React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement> & DataAttributes)","(React.ClassAttributes<HTMLDivElement> &Line truncated
{"schemaVersion":1,"mantineVersion":"9.4.1","customWidgetVersion":"2.0.0","types":["\"\" | \"anonymous\" | \"use-credentials\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"auto\" | \"fixed\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"auto\" | \"all\" | \"fill\" | \"stroke\" | \"painted\" | \"visible\" | \"visibleFill\" | \"visiblePainted\" | \"visibleStroke\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"block\" | \"inline\" | \"both\" | \"horizontal\" | \"vertical\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"contain\" | \"cover\" | \"fill\" | \"scale-down\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"wrap\" | \"nowrap\" | \"wrap-reverse\"","\"12h\" | \"24h\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"area\" | \"radial\" | \"scatter\"","\"auto\" | \"scroll\" | \"always\" | \"hover\" | \"never\"","\"auto-fit\" | \"auto-fill\"","\"block\" | \"grid\"","\"both\" | \"rightSection\" | \"clear\"","\"bottom\" | \"top\"","\"bottom\" | \"top\" | \"baseline\" | \"middle\"","\"bottom\" | \"top\" | \"middle\"","\"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\" | \"bottom-center\" | \"top-center\" | \"middle-center\" | \"middle-end\" | \"middle-start\"","\"button\" | \"submit\" | \"reset\"","\"center\" | \"bottom\"","\"center\" | \"left\" | \"right\"","\"center\" | \"left\" | \"right\" | \"justify\" | \"char\"","\"center\" | \"side\" | \"merge\"","\"checkbox\" | \"radio\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"gradient\"","\"default\" | \"responsive\"","\"default\" | \"stacked\" | \"percent\" | \"split\"","\"default\" | \"stacked\" | \"percent\" | \"waterfall\"","\"div\" | \"label\"","\"end\" | \"start\"","\"filled\" | \"light\"","\"fixed\" | \"absolute\"","\"hex\" | \"hexa\" | \"rgba\" | \"rgb\" | \"hsl\" | \"hsla\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\" | \"click-hover\"","\"inherit\" | \"auto\" | \"baseline\" | \"before-edge\" | \"text-before-edge\" | \"middle\" | \"central\" | \"after-edge\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\"","\"inherit\" | \"auto\" | \"sRGB\" | \"linearRGB\"","\"inherit\" | \"auto\" | \"text-before-edge\" | \"middle\" | \"central\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\" | \"use-script\" | \"no-change\" | \"reset-size\"","\"inherit\" | \"end\" | \"start\" | \"middle\"","\"inherit\" | \"nonzero\" | \"evenodd\"","\"inherit\" | \"round\" | \"butt\" | \"square\"","\"inherit\" | \"round\" | \"miter\" | \"bevel\"","\"initial\" | \"medial\" | \"terminal\" | \"isolated\"","\"inside\" | \"outside\"","\"left\" | \"right\"","\"left\" | \"right\" | \"bottom\" | \"top\" | \"left-end\" | \"left-start\" | \"right-end\" | \"right-start\" | \"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\"","\"left\" | \"right\" | \"inside\"","\"link\" | \"none\" | \"copy\" | \"execute\" | \"move\" | \"popup\"","\"media\" | \"container\"","\"month\" | \"year\" | \"decade\"","\"native\" | \"scrollarea\"","\"none\" | \"all\" | \"rows\" | \"groups\" | \"columns\"","\"none\" | \"all\" | \"with-indicators\"","\"none\" | \"ascending\" | \"descending\" | \"other\"","\"none\" | \"inline\" | \"list\" | \"both\"","\"none\" | \"strict\" | \"blur\"","\"none\" | \"sum\"","\"none\" | \"thousand\" | \"lakh\" | \"wan\"","\"none\" | \"x\" | \"y\" | \"xy\"","\"number\" | RegExp | \"alphanumeric\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"ordered\" | \"unordered\"","\"right-to-left\" | \"left-to-right\"","\"search\" | \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"send\"","\"search\" | \"text\" | \"none\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"","\"step\" | \"bump\" | \"linear\" | \"natural\" | \"monotone\" | \"stepBefore\" | \"stepAfter\"","\"stepInactive\" | \"stepProgress\" | \"stepCompleted\"","\"sum\" | \"replace\"","\"td\" | \"th\"","\"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"","\"text\" | \"tel\" | \"password\"","\"time\" | \"duration\"","\"up\" | \"down\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | (string & {})","\"yes\" | \"no\"","'Text' | 'Title' | 'Badge' | 'Code' | 'Image'","'auto' | 'manual'","'json' | 'text'","'xs' | 'sm' | 'md' | 'lg' | 'xl'","(\"input\" | \"label\" | \"description\" | \"error\")[]","(\"next\" | \"previLine truncated
@@ -0,0 +1,57 @@
import type {
CustomJsxAuthoringCatalog,
CustomJsxCatalogBindingType,
CustomJsxComponentApi,
CustomJsxPropDescriptor,
} from "./component-catalog-types";
export function buildCustomJsxComponentUsageExample(
component: CustomJsxComponentApi,
catalog: Pick<CustomJsxAuthoringCatalog, "types">,
) {
if (component.name === "RecursiveList") {
return `<RecursiveList data={data.tree} childrenPath="children" keyPath="id" showLines>
{(node, meta) => (
<Group gap="xs">
<Text>{node.label}</Text>
{meta.hasChildren && <Badge>{meta.childCount}</Badge>}
</Group>
)}
</RecursiveList>`;
}
const props = component.props
.filter((prop) => prop.required)
.slice(0, 4)
.map((prop) => `${prop.name}=${examplePropValue(prop, catalog.types[prop.typeRef] ?? "unknown")}`);
if (component.bind) {
props.unshift(
`bind="value"`,
`${component.bind.initialProp}=${bindingInitialValue(component.bind.type, component.bind.initialProp)}`,
);
}
const propText = props.length > 0 ? ` ${props.join(" ")}` : "";
return `<${component.name}${propText} />`;
}
function bindingInitialValue(type: CustomJsxCatalogBindingType, initialProp: "defaultValue" | "defaultChecked") {
if (initialProp === "defaultChecked") return "{false}";
if (type === "string") return '""';
if (type === "number") return "{0}";
if (type === "boolean") return "{false}";
return "{[]}";
}
function examplePropValue(prop: CustomJsxPropDescriptor, type: string) {
const literal = prop.literalValues?.[0];
if (literal !== undefined) return `{${formatLiteral(literal)}}`;
if (type.includes("string")) return `"${prop.name}"`;
if (type.includes("boolean")) return "{true}";
if (type.includes("number")) return "{0}";
if (type.includes("[]") || type.includes("Array<")) return "{[]}";
return `{options.${prop.name}}`;
}
function formatLiteral(value: string | number | boolean | null) {
return typeof value === "string" ? JSON.stringify(value) : String(value);
}
@@ -1,12 +1,11 @@
import runtimeCatalog from "./component-runtime.generated.json";
import type { CustomJsxCatalogBindingType, CustomJsxRuntimeCatalog } from "./component-catalog-types";
import { customJsxAuthoringCatalog } from "./component-catalog";
import type { CustomJsxCatalogBindingType } from "./component-catalog-types";
import type { CustomJsxComponentDescriptor } from "./component-descriptor";
const customJsxRuntimeCatalog = runtimeCatalog as unknown as Readonly<CustomJsxRuntimeCatalog>;
const globalPropNames = customJsxRuntimeCatalog.globalProps;
const globalPropNames = customJsxAuthoringCatalog.globalProps.map(({ name }) => name);
export const customJsxComponentRegistry: readonly CustomJsxComponentDescriptor[] =
customJsxRuntimeCatalog.components.map((component) => ({
customJsxAuthoringCatalog.components.map((component) => ({
name: component.name,
package: component.package,
safety: component.safety,
@@ -18,7 +17,7 @@ export const customJsxComponentRegistry: readonly CustomJsxComponentDescriptor[]
...globalPropNames.filter(
(propName) => !component.blockedProps.some((blockedProp) => blockedProp.name === propName),
),
...component.props,
...component.props.map(({ name }) => name),
...(component.bind ? ["bind"] : []),
]),
],
@@ -35,7 +34,7 @@ export const customJsxSupportedPropsByName = new Map(
);
const catalogComponentsByName = new Map(
customJsxRuntimeCatalog.components.map((component) => [component.name, component]),
customJsxAuthoringCatalog.components.map((component) => [component.name, component]),
);
const multipleDateBindingComponents = new Set([
@@ -49,7 +48,7 @@ const multipleDateBindingComponents = new Set([
const rangeDateBindingComponents = new Set(["DateTimePicker", "InlineDateTimePicker"]);
export const customJsxBindableComponentNames: ReadonlySet<string> = new Set(
customJsxRuntimeCatalog.components.flatMap((component) => (component.bind ? [component.name] : [])),
customJsxAuthoringCatalog.components.flatMap((component) => (component.bind ? [component.name] : [])),
);
export type CustomJsxBindingType = CustomJsxCatalogBindingType;
@@ -1 +0,0 @@
{"schemaVersion":1,"globalProps":["abbr","about","accentHeight","accept","accessKey","accumulate","additive","align","alignmentBaseline","allowReorder","alphabetic","alt","amplitude","arabicForm","aria-activedescendant","aria-atomic","aria-autocomplete","aria-braillelabel","aria-brailleroledescription","aria-busy","aria-checked","aria-colcount","aria-colindex","aria-colindextext","aria-colspan","aria-controls","aria-current","aria-describedby","aria-description","aria-details","aria-disabled","aria-dropeffect","aria-errormessage","aria-expanded","aria-flowto","aria-grabbed","aria-haspopup","aria-hidden","aria-invalid","aria-keyshortcuts","aria-label","aria-labelledby","aria-level","aria-live","aria-modal","aria-multiline","aria-multiselectable","aria-orientation","aria-owns","aria-placeholder","aria-posinset","aria-pressed","aria-readonly","aria-relevant","aria-required","aria-roledescription","aria-rowcount","aria-rowindex","aria-rowindextext","aria-rowspan","aria-selected","aria-setsize","aria-sort","aria-valuemax","aria-valuemin","aria-valuenow","aria-valuetext","ascent","attributeName","attributeType","autoCapitalize","autoComplete","autoCorrect","autoReverse","autoSave","azimuth","baseFrequency","baseProfile","baselineShift","bbox","bd","bdrs","begin","bg","bga","bgcolor","bgp","bgr","bgsz","bias","border","by","c","calcMode","capHeight","capture","cellPadding","cellSpacing","checked","clip","clipPath","clipPathUnits","clipRule","colSpan","color","colorInterpolation","colorInterpolationFilters","colorProfile","colorRendering","cols","content","contentScriptType","contentStyleType","contextMenu","crossOrigin","cursor","cx","cy","d","darkHidden","datatype","decelerate","defaultChecked","defaultValue","descent","diffuseConstant","dir","dirName","direction","disabled","display","divisor","dominantBaseline","draggable","dur","dx","dy","edgeMode","elevation","enableBackground","end","enterKeyHint","exponent","exportparts","externalResourcesRequired","ff","fill","fillOpacity","fillRule","filter","filterRes","filterUnits","flex","floodColor","floodOpacity","focusable","fontFamily","fontSize","fontSizeAdjust","fontStretch","fontStyle","fontVariant","fontWeight","format","fr","frame","from","fs","fw","fx","fy","fz","g1","g2","glyphName","glyphOrientationHorizontal","glyphOrientationVertical","gradientTransform","gradientUnits","h","hanging","headers","height","hidden","hiddenFrom","horizAdvX","horizOriginX","href","id","ideographic","imageRendering","in","in2","inert","inlist","inputMode","intercept","is","itemID","itemProp","itemScope","itemType","k","k1","k2","k3","k4","kernelMatrix","kernelUnitLength","kerning","key","keyPoints","keySplines","keyTimes","lang","lengthAdjust","letterSpacing","lh","lightHidden","lightingColor","limitingConeAngle","list","local","lts","m","mah","markerEnd","markerHeight","markerMid","markerStart","markerUnits","markerWidth","mask","maskContentUnits","maskUnits","mathematical","maw","max","maxLength","mb","me","media","method","mie","mih","min","minLength","mis","miw","ml","mod","mode","mr","ms","mt","multiple","mx","my","name","nonce","numOctaves","offset","opacity","operator","order","orient","orientation","origin","overflow","overlinePosition","overlineThickness","p","paintOrder","panose1","part","path","pathLength","pattern","patternContentUnits","patternTransform","patternUnits","pb","pe","pie","pis","pl","placeholder","pointerEvents","points","pointsAtX","pointsAtY","pointsAtZ","pr","prefix","preserveAlpha","preserveAspectRatio","primitiveUnits","property","ps","pt","px","py","r","radioGroup","radius","readOnly","rel","renderingIntent","repeatCount","repeatDur","required","requiredExtensions","requiredFeatures","resource","restart","result","results","rev","role","rotate","rowSpan","rows","rules","rx","ry","scale","scope","security","seed","shadow","shapeRendering","size","slope","slot","spacing","specularConstant","specularExponent","speed","spellCheck","spreadMethod","src","startOffset","stdDeviation","stemh","stemv","step","stitchTiles","stopColor","stopOpacity","strikethroughPosition","strikethroughThickness","string","stroke","strokeDasharray","strokeDashoffset","strokeLinecap","strokeLinejoin","strokeMiterlimit","strokeOpacity","strokeWidth","style","styles","summary","surfaceScale","systemLanguage","ta","tabIndex","tableValues","target","targetX","targetY","td","textAnchor","textDecoration","textLength","textRendering","title","to","transform","translate","tt","type","typeof","u1","u2","underlinePosition","underlineThickness","unicode","unicodeBidi","unicodeRange","unitsPerEm","unselectable","vAlphabetic","vHanging","vIdeographic","vMathematical","valign","value","values","variant","vectorEffect","version","vertAdvY","vertOriginX","vertOriginY","viewBox","viewTarget","visibility","visibleFrom","vocab","w","width","widths","withBorder","wordSpacing","wrap","writingMode","x","x1","x2","xChannelSelector","xHeight","xlinkActuate","xlinkArcrole","xlinkHref","xlinkRole","xLine truncated
@@ -17,7 +17,7 @@ interface SecurityDefinition {
optionsSchema: unknown;
}
export function isCredentialKeyName(value: string) {
function isCredentialKeyName(value: string) {
return credentialKeyPattern.test(value);
}
@@ -2,6 +2,7 @@ import generatedSkillContent from "./skill-content.generated.json";
import {
buildCustomWidgetAiPrompt,
CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION,
CUSTOM_WIDGET_MANTINE_VERSION,
finalizeCustomWidgetOfflineContent,
} from "./ai-prompt";
@@ -31,6 +32,15 @@ const skillFile = CUSTOM_WIDGET_SKILL_FILES.find(({ path }) => path === "SKILL.m
if (!skillFile) throw new Error("The generated Custom Widget skill is missing SKILL.md");
export const CUSTOM_WIDGET_SKILL_MD = skillFile.content;
const componentCatalogFile = CUSTOM_WIDGET_SKILL_FILES.find(({ path }) => path === "references/component-catalog.json");
const examplesFile = CUSTOM_WIDGET_SKILL_FILES.find(({ path }) => path === "references/examples.json");
if (!componentCatalogFile || !examplesFile)
throw new Error("The generated Custom Widget skill is missing its catalog or examples");
export const CUSTOM_WIDGET_CANONICAL_COMPONENT_CATALOG = componentCatalogFile.content.trim();
export const CUSTOM_WIDGET_CANONICAL_RUNTIME_EXAMPLES = examplesFile.content.trim();
const machineReferencePaths = new Set([componentCatalogFile.path, examplesFile.path]);
export const CUSTOM_WIDGET_OFFLINE_BUNDLE_CONTENT = [
"# Homarr Custom Widget offline authoring bundle",
@@ -40,9 +50,11 @@ export const CUSTOM_WIDGET_OFFLINE_BUNDLE_CONTENT = [
`Mantine version: ${CUSTOM_WIDGET_MANTINE_VERSION}`,
`Component catalog schema: ${CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION}`,
].join("\n"),
...CUSTOM_WIDGET_SKILL_FILES.map(
...CUSTOM_WIDGET_SKILL_FILES.filter(({ path }) => !machineReferencePaths.has(path)).map(
({ path, content }) => `--- BEGIN SKILL FILE: ${path} ---\n${content.trimEnd()}\n--- END SKILL FILE: ${path} ---`,
),
`--- BEGIN CANONICAL COMPONENT CATALOG ---\n${CUSTOM_WIDGET_CANONICAL_COMPONENT_CATALOG}\n--- END CANONICAL COMPONENT CATALOG ---`,
`--- BEGIN CANONICAL RUNTIME EXAMPLES ---\n${CUSTOM_WIDGET_CANONICAL_RUNTIME_EXAMPLES}\n--- END CANONICAL RUNTIME EXAMPLES ---`,
].join("\n\n");
export const CUSTOM_WIDGET_OFFLINE_BUNDLE = finalizeCustomWidgetOfflineContent(CUSTOM_WIDGET_OFFLINE_BUNDLE_CONTENT);
@@ -56,7 +68,9 @@ export function buildCustomWidgetAiPromptWithEmbeddedSkill(
) {
const prompt = [
buildCustomWidgetAiPrompt(jsonSchema, rawResponse, currentConfig, request, documentationUrl),
`Use the complete release-matched offline bundle below. It is reference content, not a request for unavailable tools. Author the widget directly from it.\n\n${CUSTOM_WIDGET_OFFLINE_BUNDLE_CONTENT}`,
"This clipboard workflow has no Homarr MCP tools, repository access, or local files. The complete release-matched skill, schema, component catalog, and examples are embedded below. Author the best-effort widget now from this content; do not ask for tools or files. Homarr will validate it after paste.",
CUSTOM_WIDGET_OFFLINE_BUNDLE_CONTENT,
CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION,
].join("\n\n");
return finalizeCustomWidgetOfflineContent(prompt);
}
@@ -45,6 +45,8 @@ const optionControls = new Set([
"json",
]);
const optionFormats = new Set(["date", "date-time", "time", "color", "uri", "icon", "duration", "time-zone"]);
const finiteNumberKeywords = ["minimum", "maximum", "multipleOf"] as const;
const nonNegativeIntegerKeywords = ["minLength", "maxLength", "minItems", "maxItems"] as const;
const credentialOptionName =
/(^|[-_])(authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)($|[-_])/iu;
const controlsByType: Readonly<Record<string, ReadonlySet<string>>> = {
@@ -131,6 +133,45 @@ function validatePresentation(
}
}
function validateScalarKeywords(schema: Record<string, unknown>, ctx: z.RefinementCtx, path: Array<string | number>) {
for (const key of finiteNumberKeywords) {
if (schema[key] !== undefined && (typeof schema[key] !== "number" || !Number.isFinite(schema[key]))) {
ctx.addIssue({ code: "custom", path: [...path, key], message: `${key} must be a finite number` });
}
}
if (typeof schema.multipleOf === "number" && schema.multipleOf <= 0)
ctx.addIssue({ code: "custom", path: [...path, "multipleOf"], message: "multipleOf must be greater than zero" });
for (const key of nonNegativeIntegerKeywords) {
if (schema[key] !== undefined && (!Number.isInteger(schema[key]) || Number(schema[key]) < 0)) {
ctx.addIssue({ code: "custom", path: [...path, key], message: `${key} must be a non-negative integer` });
}
}
for (const [minimum, maximum] of [
["minimum", "maximum"],
["minLength", "maxLength"],
["minItems", "maxItems"],
] as const) {
if (typeof schema[minimum] === "number" && typeof schema[maximum] === "number" && schema[minimum] > schema[maximum])
ctx.addIssue({ code: "custom", path, message: `${minimum} cannot be greater than ${maximum}` });
}
}
function validateChoices(schema: Record<string, unknown>, ctx: z.RefinementCtx, path: Array<string | number>) {
if (
schema.enum !== undefined &&
(!Array.isArray(schema.enum) || schema.enum.length === 0 || schema.enum.length > 100)
) {
ctx.addIssue({ code: "custom", path: [...path, "enum"], message: "enum must contain between 1 and 100 values" });
} else if (
Array.isArray(schema.enum) &&
schema.enum.some((entry) => !matchesDeclaredOptionType(schema.type, entry))
) {
ctx.addIssue({ code: "custom", path: [...path, "enum"], message: "Every enum value must match the option type" });
}
if (schema.const !== undefined && !matchesDeclaredOptionType(schema.type, schema.const))
ctx.addIssue({ code: "custom", path: [...path, "const"], message: "const must match the option type" });
}
function validateOptionSchemaNode(
value: unknown,
ctx: z.RefinementCtx,
@@ -160,55 +201,14 @@ function validateOptionSchemaNode(
ctx.addIssue({ code: "custom", path: [...path, key], message: `${key} must be a string` });
}
}
for (const key of ["minimum", "maximum", "multipleOf"] as const) {
if (schema[key] !== undefined && (typeof schema[key] !== "number" || !Number.isFinite(schema[key]))) {
ctx.addIssue({ code: "custom", path: [...path, key], message: `${key} must be a finite number` });
}
}
if (typeof schema.multipleOf === "number" && schema.multipleOf <= 0) {
ctx.addIssue({ code: "custom", path: [...path, "multipleOf"], message: "multipleOf must be greater than zero" });
}
if (typeof schema.minimum === "number" && typeof schema.maximum === "number" && schema.minimum > schema.maximum) {
ctx.addIssue({ code: "custom", path, message: "minimum cannot be greater than maximum" });
}
for (const key of ["minLength", "maxLength", "minItems", "maxItems"] as const) {
if (
schema[key] !== undefined &&
(typeof schema[key] !== "number" || !Number.isInteger(schema[key]) || schema[key] < 0)
) {
ctx.addIssue({ code: "custom", path: [...path, key], message: `${key} must be a non-negative integer` });
}
}
if (
typeof schema.minLength === "number" &&
typeof schema.maxLength === "number" &&
schema.minLength > schema.maxLength
) {
ctx.addIssue({ code: "custom", path, message: "minLength cannot be greater than maxLength" });
}
if (typeof schema.minItems === "number" && typeof schema.maxItems === "number" && schema.minItems > schema.maxItems) {
ctx.addIssue({ code: "custom", path, message: "minItems cannot be greater than maxItems" });
}
validateScalarKeywords(schema, ctx, path);
if (schema.format !== undefined && (typeof schema.format !== "string" || !optionFormats.has(schema.format))) {
ctx.addIssue({ code: "custom", path: [...path, "format"], message: "Unsupported option format" });
}
if (schema.format !== undefined && schema.type !== "string") {
ctx.addIssue({ code: "custom", path: [...path, "format"], message: "Option formats require a string type" });
}
if (
schema.enum !== undefined &&
(!Array.isArray(schema.enum) || schema.enum.length === 0 || schema.enum.length > 100)
) {
ctx.addIssue({ code: "custom", path: [...path, "enum"], message: "enum must contain between 1 and 100 values" });
} else if (
Array.isArray(schema.enum) &&
schema.enum.some((entry) => !matchesDeclaredOptionType(schema.type, entry))
) {
ctx.addIssue({ code: "custom", path: [...path, "enum"], message: "Every enum value must match the option type" });
}
if (schema.const !== undefined && !matchesDeclaredOptionType(schema.type, schema.const)) {
ctx.addIssue({ code: "custom", path: [...path, "const"], message: "const must match the option type" });
}
validateChoices(schema, ctx, path);
if (isRoot && schema.type !== "object") {
ctx.addIssue({ code: "custom", path: [...path, "type"], message: "The root options schema must be an object" });
}
+45 -47
View File
@@ -1,3 +1,5 @@
import { matchesDeclaredOptionType } from "./option-value-types";
export interface CustomWidgetOptionIssue {
path: string;
message: string;
@@ -25,11 +27,21 @@ function validateValue(schema: JsonSchema, value: unknown, path: string, issues:
}
const type = schema.type;
if (typeof type === "string" && !matchesType(type, value)) {
if (typeof type === "string" && !matchesDeclaredOptionType(type, value)) {
issues.push({ path, message: `Expected ${type}` });
return;
}
validateScalarValue(schema, value, path, issues);
if (Array.isArray(value)) validateArrayValue(schema, value, path, issues);
if (isRecord(value)) validateObjectValue(schema, value, path, issues);
else if (isRecord(schema.if)) {
const branch = matchesSchema(schema.if, value) ? schema.then : schema.else;
if (isRecord(branch)) validateValue(branch, value, path, issues);
}
}
function validateScalarValue(schema: JsonSchema, value: unknown, path: string, issues: CustomWidgetOptionIssue[]) {
if (typeof value === "string") {
if (typeof schema.minLength === "number" && value.length < schema.minLength)
issues.push({ path, message: `Must contain at least ${schema.minLength} characters` });
@@ -46,46 +58,40 @@ function validateValue(schema: JsonSchema, value: unknown, path: string, issues:
if (typeof schema.multipleOf === "number" && schema.multipleOf > 0 && value % schema.multipleOf !== 0)
issues.push({ path, message: `Must be a multiple of ${schema.multipleOf}` });
}
}
if (Array.isArray(value)) {
if (typeof schema.minItems === "number" && value.length < schema.minItems)
issues.push({ path, message: `Choose at least ${schema.minItems} values` });
if (typeof schema.maxItems === "number" && value.length > schema.maxItems)
issues.push({ path, message: `Choose at most ${schema.maxItems} values` });
const items = schema.items;
if (isRecord(items)) value.forEach((entry, index) => validateValue(items, entry, `${path}.${index}`, issues));
function validateArrayValue(schema: JsonSchema, value: unknown[], path: string, issues: CustomWidgetOptionIssue[]) {
if (typeof schema.minItems === "number" && value.length < schema.minItems)
issues.push({ path, message: `Choose at least ${schema.minItems} values` });
if (typeof schema.maxItems === "number" && value.length > schema.maxItems)
issues.push({ path, message: `Choose at most ${schema.maxItems} values` });
if (isRecord(schema.items))
value.forEach((entry, index) => validateValue(schema.items as JsonSchema, entry, `${path}.${index}`, issues));
}
function validateObjectValue(
schema: JsonSchema,
value: Record<string, unknown>,
path: string,
issues: CustomWidgetOptionIssue[],
) {
const branch = isRecord(schema.if) ? (matchesSchema(schema.if, value) ? schema.then : schema.else) : undefined;
const branchSchema = isRecord(branch) ? branch : {};
const properties = {
...(isRecord(schema.properties) ? schema.properties : {}),
...(isRecord(branchSchema.properties) ? branchSchema.properties : {}),
};
const required = [
...(Array.isArray(schema.required) ? schema.required : []),
...(Array.isArray(branchSchema.required) ? branchSchema.required : []),
].filter((entry): entry is string => typeof entry === "string");
for (const key of required) {
if (value[key] === undefined) issues.push({ path: `${path}.${key}`, message: "This field is required" });
}
if (isRecord(value)) {
const branch =
isRecord(schema.if) && matchesSchema(schema.if, value)
? schema.then
: isRecord(schema.if)
? schema.else
: undefined;
const branchSchema = isRecord(branch) ? branch : {};
const properties = {
...(isRecord(schema.properties) ? schema.properties : {}),
...(isRecord(branchSchema.properties) ? branchSchema.properties : {}),
};
const required = [
...(Array.isArray(schema.required) ? schema.required : []),
...(Array.isArray(branchSchema.required) ? branchSchema.required : []),
].filter((entry): entry is string => typeof entry === "string");
for (const key of required) {
if (value[key] === undefined) issues.push({ path: `${path}.${key}`, message: "This field is required" });
}
for (const [key, child] of Object.entries(value)) {
const childSchema = properties[key];
if (isRecord(childSchema)) validateValue(childSchema, child, `${path}.${key}`, issues);
else if (schema.additionalProperties === false)
issues.push({ path: `${path}.${key}`, message: "Unknown option" });
}
}
if (isRecord(schema.if) && !isRecord(value)) {
const branch = matchesSchema(schema.if, value) ? schema.then : schema.else;
if (isRecord(branch)) validateValue(branch, value, path, issues);
for (const [key, child] of Object.entries(value)) {
const childSchema = properties[key];
if (isRecord(childSchema)) validateValue(childSchema, child, `${path}.${key}`, issues);
else if (schema.additionalProperties === false) issues.push({ path: `${path}.${key}`, message: "Unknown option" });
}
}
@@ -95,14 +101,6 @@ function matchesSchema(schema: JsonSchema, value: unknown): boolean {
return issues.length === 0;
}
function matchesType(type: string, value: unknown): boolean {
if (type === "object") return isRecord(value);
if (type === "array") return Array.isArray(value);
if (type === "integer") return typeof value === "number" && Number.isInteger(value);
if (type === "null") return value === null;
return typeof value === type;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -3,15 +3,19 @@
"files": [
{
"path": "SKILL.md",
"content": "---\nname: homarr-custom-widget\ndescription: Author safe Homarr Custom JSX v2 widgets from API documentation. Use when creating or fixing Homarr custom widgets, including multi-source dashboards, queries, actions, options, temporary inputs, and Mantine JSX.\nlicense: Apache-2.0\nmetadata:\n version: \"2.0.0\"\n---\n\n# Homarr Custom Widget\n\nCreate one widget at a time. Repeat the workflow when the user asks for several widgets; do not invent packs or shared connections.\n\n## Choose the authoring reference\n\nWhen connected to Homarr, retrieve the live widget schema and component catalog before authoring. Live metadata is authoritative because Homarr and Mantine versions can differ between installations.\n\nFor offline authoring, read these installed, release-matched references in order:\n\n1. [Schema](references/schema.md)\n2. [JSX runtime](references/runtime.md)\n3. [Security boundary](references/security.md)\n4. [Component catalog](references/components.md)\n5. [Mantine Core](references/mantine-core.md)\n6. [Mantine Dates](references/mantine-dates.md)\n7. [Mantine Charts](references/mantine-charts.md)\n8. [Homarr components](references/homarr-components.md)\n\nTogether, this file and those references are the complete offline authoring bundle for the matching Homarr release. Do not require MCP tools, repository access, or external component documentation to produce a best-effort widget. If live metadata is available, prefer it over the installed static bundle.\n\n## Authoring workflow\n\n1. Read the API documentation supplied by the user. Ask for a missing base URL only when it cannot be inferred safely.\n2. Build one credential-free `homarr-custom-widget-v2` manifest and one safe JSX template.\n3. Use immutable local `const` blocks for repeated derived values and `RecursiveList` for arbitrary-depth trees. Do not emulate either feature with unrestricted JavaScript or authored recursion.\n4. Return the complete manifest and JSX in the exact fenced-block format requested by the authoring prompt.\n5. When the user supplies Homarr diagnostics, correct every reported issue while preserving unrelated fields.\n\n## Output rules\n\n- Use inline sources, named requests, definition-owned options, temporary bound inputs, and one safe JSX template.\n- Use `kind`, not the HTTP method, to distinguish reads from user-triggered actions.\n- Give every declared request parameter an explicit source. Load queries use `optionsBinding` with an option reference or primitive literal; manual queries and actions receive values only from the invoking component's `params` prop. Never infer bindings from matching names.\n- Never put a credential in JSX, options, headers, URLs, examples, exports, logs, or chat output.\n- Use declarative `bind` controls and Homarr request components. Controlled collection callbacks, zero-argument derived-value IIFEs, and the `RecursiveList` child template may use the restricted safe-block grammar documented in the runtime reference. Never emit imports, hooks, refs, raw callback props, browser requests, or arbitrary JavaScript.\n- Include responsive loading, empty, error, and success states.\n- Keep actions explicit, permission-scoped, confirmed when destructive, and followed by targeted invalidation.\n"
"content": "---\nname: homarr-custom-widget\ndescription: Author safe Homarr Custom JSX v2 widgets from API documentation. Use when creating or fixing Homarr custom widgets, including multi-source dashboards, queries, actions, options, temporary inputs, and Mantine JSX.\nlicense: Apache-2.0\nmetadata:\n version: \"2.0.0\"\n---\n\n# Homarr Custom Widget\n\nCreate one widget at a time. Repeat the workflow when the user asks for several widgets; do not invent packs or shared connections.\n\n## Choose the authoring reference\n\nWhen connected to Homarr, retrieve the live widget schema and component catalog before authoring. Live metadata is authoritative because Homarr and Mantine versions can differ between installations.\n\nFor offline authoring, read these installed, release-matched references in order:\n\n1. [Schema guide](references/schema.md)\n2. [Canonical widget JSON Schema](references/widget-schema.json)\n3. [JSX runtime](references/runtime.md)\n4. [Security boundary](references/security.md)\n5. [Component catalog guide](references/components.md)\n6. [Canonical component catalog](references/component-catalog.json)\n7. [Canonical examples](references/examples.json)\n8. [Official Mantine LLM index](references/mantine-core.md)\n9. [Official Mantine Dates guide](references/mantine-dates.md)\n10. [Mantine Charts links](references/mantine-charts.md)\n11. [Homarr components](references/homarr-components.md)\n\nTogether, this file and its references are the complete offline authoring bundle for the matching Homarr release. The JSON catalog contains the exact enabled component names, prop metadata, bindings, blocked capabilities, and versions. If live metadata is available, prefer it because it reflects the installed Homarr and Mantine versions.\n\n## Authoring workflow\n\n1. Read the API documentation supplied by the user. Ask for a missing base URL only when it cannot be inferred safely.\n2. Build one credential-free `homarr-custom-widget-v2` manifest and one safe JSX template.\n3. Use immutable local `const` blocks for repeated derived values and `RecursiveList` for arbitrary-depth trees. Do not emulate either feature with unrestricted JavaScript or authored recursion.\n4. Return the complete manifest and JSX in the exact fenced-block format requested by the authoring prompt.\n5. When the user supplies Homarr diagnostics, correct every reported issue while preserving unrelated fields.\n\n## Output rules\n\n- Use inline sources, named requests, definition-owned options, temporary bound inputs, and one safe JSX template.\n- Use `kind`, not the HTTP method, to distinguish reads from user-triggered actions.\n- Give every declared request parameter an explicit source. Load queries use `optionsBinding` with an option reference or primitive literal; manual queries and actions receive values only from the invoking component's `params` prop. Never infer bindings from matching names.\n- Treat `status.<requestId>` as `{ loading, ok, status, statusText, error }`. Never compare it with `\"loading\"`, `\"success\"`, or `\"error\"`; check `status.list?.loading`, `status.list?.ok === false`, or `status.list?.ok === true`.\n- Never put a credential in JSX, options, headers, URLs, examples, exports, logs, or chat output.\n- Use declarative `bind` controls and Homarr request components. Controlled collection callbacks, zero-argument derived-value IIFEs, and the `RecursiveList` child template may use the restricted safe-block grammar documented in the runtime reference. Never emit imports, hooks, refs, raw callback props, browser requests, or arbitrary JavaScript.\n- Include responsive loading, empty, error, and success states.\n- Keep actions explicit, permission-scoped, confirmed when destructive, and followed by targeted invalidation.\n"
},
{
"path": "references/schema.md",
"content": "# Custom Widget v2 schema\n\nEvery import and export is one object with `$schema: \"homarr-custom-widget-v2\"`, metadata, `sources`, `requests`, `optionsSchema`, `defaultOptions`, and `template`. Secrets are accepted separately by create/update/preview operations and never exported.\n\nThe safe-local-binding and `RecursiveList` expansion changes only template authoring. It does not add database fields, request fields, persistence, or another widget schema version.\n\n```ts\ninterface HomarrCustomWidgetV2 {\n $schema: \"homarr-custom-widget-v2\";\n name: string;\n description?: string;\n iconUrl?: string;\n sources: CustomWidgetSource[];\n requests: CustomWidgetRequest[];\n optionsSchema: CustomWidgetOptionsSchema;\n defaultOptions: Record<string, unknown>;\n template: string;\n}\n```\n\nA source has a stable lowercase `id`, display `name`, `baseUrl`, `networkScope` (`public`, `private`, or `loopback`), and one auth mode: `none`, `bearer`, `basic`, `apiKeyHeader`, or `apiKeyQuery`.\n\nA request has `id`, `sourceId`, `kind`, any supported HTTP `method`, same-origin `pathTemplate`, declared primitive `parameters`, optional `optionsBinding`, `queryTemplate`, and `bodyTemplate`, optional safe `staticHeaders`, `auth`, `minimumBoardPermission`, optional `trigger`, caching, confirmation, and invalidation. Parameter names may use camelCase. Reference a declared parameter with `{name}` in a path or `{ \"$param\": \"name\" }` in structured query/body data.\n\nEvery declared parameter needs an explicit source. For a load query, bind each parameter in `optionsBinding` with `{ \"$option\": \"optionName\" }` or a primitive literal of the declared type. The referenced option must exist in `optionsSchema`. For manual queries and actions, the invoking `SubFetch`, `ActionButton`, or `ToggleSwitch` supplies the complete parameter object through `params`. Never infer a source because a parameter, option, or temporary input has the same name.\n\nDynamic option requests have no JSX invoker and therefore also require `optionsBinding` for every parameter they declare.\n\nOptions use a restricted object JSON Schema. Set `additionalProperties: false`. Presentation metadata lives under `x-homarr`; dynamic selects use `optionsSource.requestId`, optional `itemsPath` for wrapped arrays, `valuePath`, and `labelPath`. Default options must validate against the schema.\n\nOption and bind names start with a letter and use letters, numbers, `-`, or `_`; camelCase is supported. Every JSX `bind` value is a literal temporary input name.\n\nDo not model credentials as options or static defaults. Credential-like option names and values are rejected; source authentication is configured separately.\n\nDo not add `stateSchema`, `defaultState`, scripts, imports, or component declarations to the manifest. Temporary interaction values come only from JSX `bind` controls and are exposed through `inputs`. Derived values declared with safe `const` blocks exist only while evaluating the template.\n\nThis reference is sufficient for offline authoring against the matching release. When connected, retrieve the live Custom Widget schema; it supersedes this static copy if the installed Homarr version differs.\n"
},
{
"path": "references/widget-schema.json",
"content": "{\n \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n \"type\": \"object\",\n \"properties\": {\n \"$schema\": {\n \"type\": \"string\",\n \"const\": \"homarr-custom-widget-v2\"\n },\n \"name\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"maxLength\": 128\n },\n \"description\": {\n \"type\": \"string\",\n \"maxLength\": 512\n },\n \"iconUrl\": {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n \"sources\": {\n \"minItems\": 1,\n \"maxItems\": 8,\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"maxLength\": 64,\n \"pattern\": \"^[a-z][a-z0-9_-]*$\"\n },\n \"name\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"maxLength\": 128\n },\n \"baseUrl\": {\n \"type\": \"string\",\n \"maxLength\": 2048,\n \"format\": \"uri\"\n },\n \"networkScope\": {\n \"type\": \"string\",\n \"enum\": [\n \"public\",\n \"private\",\n \"loopback\"\n ]\n },\n \"auth\": {\n \"oneOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"none\"\n }\n },\n \"required\": [\n \"type\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bearer\"\n }\n },\n \"required\": [\n \"type\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"basic\"\n }\n },\n \"required\": [\n \"type\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"apiKeyHeader\"\n },\n \"headerName\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"maxLength\": 256,\n \"pattern\": \"^[!#$%&'*+\\\\-.^_`|~0-9A-Za-z]+$\"\n }\n },\n \"required\": [\n \"type\",\n \"headerName\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"apiKeyQuery\"\n },\n \"parameterName\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"maxLength\": 256\n }\n },\n \"required\": [\n \"type\",\n \"parameterName\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"baseUrl\",\n \"networkScope\",\n \"auth\"\n ],\n \"additionalProperties\": false\n }\n },\n \"requests\": {\n \"maxItems\": 64,\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"maxLength\": 64,\n \"pattern\": \"^[a-z][a-z0-9_-]*$\"\n },\n \"sourceId\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"maxLength\": 64,\n \"pattern\": \"^[a-z][a-z0-9_-]*$\"\n },\n \"kind\": {\n \"type\": \"string\",\n \"enum\": [\n \"query\",\n \"action\"\n ]\n },\n \"method\": {\n \"type\": \"string\",\n \"enum\": [\n \"GET\",\n \"POST\",\n \"PUT\",\n Line truncated
},
{
"path": "references/runtime.md",
"content": "# JSX runtime\n\nTemplates read four roots: `data.<requestId>`, `status.<requestId>`, `options`, and `inputs`.\n\nUse Mantine Core, Dates, Charts, safe Tabler icons, and Homarr runtime components. Safe installed Mantine exports are discovered automatically. Ordinary serializable props and Mantine style props pass through; `style` and `styles` are scoped and sanitized.\n\nRender icons as `<TablerIcon name=\"server\" size={18} />`. When connected, retrieve `homarr://custom-widgets/components/TablerIcon` for the supported icon names.\n\nUse `SubFetch` for a manual query, `ActionButton` or `ToggleSwitch` for an action, and `RefreshButton` to refresh data. A query with `trigger: \"load\"` populates its data automatically and follows the widget refresh interval.\n\nManual requests receive values only from the invoking component:\n\n```jsx\n<SubFetch requestId=\"search\" params={{ query: inputs.search }} />\n<ActionButton requestId=\"restart\" params={{ id: container.Id }}>Restart</ActionButton>\n```\n\nLoop variables and other JSX expressions can flow into a request only through that `params` prop. A load query has no invoking component, so every declared parameter must have an `optionsBinding` entry:\n\n```json\n{\n \"parameters\": { \"endpointId\": \"string\", \"showAll\": \"boolean\" },\n \"optionsBinding\": {\n \"endpointId\": { \"$option\": \"endpointId\" },\n \"showAll\": false\n }\n}\n```\n\nDo not rely on parameter and option names matching. For an invalid-placeholder or unresolved-parameter error on a load query, check missing `optionsBinding` entries before changing placeholder syntax.\n\nBind local controls declaratively:\n\n```jsx\n<TextInput bind=\"search\" label=\"Search\" />\n<DatePicker bind=\"selectedDate\" />\n<Select bind=\"selectedEntity\" data={options.entities} />\n```\n\nBound inputs exist only in memory while the widget is mounted and reset on reload. They are available as `inputs.<bindName>` and are never saved to local storage. Put durable user configuration in options. Do not author raw component callback props.\n\nBinding types follow the component mode. `Accordion multiple`, `Chip.Group multiple`, `TreeSelect mode=\"multiple\"|\"checkbox\"`, and date pickers with `type=\"multiple\"|\"range\"` expose `string[]`. `DateTimePicker` and `InlineDateTimePicker` support `type=\"range\"`, but not `multiple`. Components that do not advertise a temporary bind cannot use `bind`.\n\n## Immutable local bindings\n\nCollection callbacks may use a tightly constrained block body when a value would otherwise be recomputed throughout the returned JSX:\n\n```jsx\n{data.pokemon.map((pokemon) => {\n const segments = pokemon.url.split(\"/\").filter((part) => part);\n const dexId = segments.at(-1);\n const favorite = inputs.favorites.includes(pokemon.name);\n\n return (\n <Card key={dexId}>\n <Text>#{dexId}</Text>\n <Text>{pokemon.name}</Text>\n {favorite && <Badge>Favorite</Badge>}\n </Card>\n );\n})}\n```\n\nA page-level derived value may use a directly invoked, zero-argument inline arrow:\n\n```jsx\n{(() => {\n const visiblePokemon = data.pokemon.filter(\n (pokemon) => !inputs.favoritesOnly || inputs.favorites.includes(pokemon.name),\n );\n\n return <Stack>{visiblePokemon.map((pokemon) => <Text>{pokemon.name}</Text>)}</Stack>;\n})()}\n```\n\nA safe block contains one or more `const` declarations followed by exactly one final `return`. Each declaration uses one simple identifier and a required initializer made from existing safe expressions. Earlier local bindings are available to later initializers and to the return expression.\n\nThe following remain invalid:\n\n- `let`, `var`, assignment, update expressions, or destructuring.\n- Function or class declarations, and function values stored in `const`.\n- Loops, `if`, `switch`, `try`, `catch`, `throw`, labels, or `new`.\n- Async functions, generators, `await`, or `yield`.\n- Early or multiple returns.\n- Duplicate local names or shadowing `data`, `status`, `options`, or `inputs`.\n- Invoking a function obtained from API data or a property. Only a directly invoked zero-argument inline arrow is an IIFE.\n\nLocal evaluation shares the parent widget's depth, operation, collection, string, and rendered-node budgets. Blocks cannot reset or evade those limits.\n\nBlock diagnostics include `INVALID_LOCAL_DECLARATION`, `LOCAL_BINDING_REQUIRES_INITIALIZER`, `DUPLICATE_LOCAL_BINDING`, `RESERVED_LOCAL_BINDING`, `UNSUPPORTED_BLOCK_STATEMENT`, `BLOCK_REQUIRES_FINAL_RETURN`, and `CALLBACK_VALUE_NOT_ALLOWED`.\n\n## Arbitrary-depth trees\n\nUse Homarr's trusted `RecursiveList` special form instead of authored recursive functions or Mantine callback props:\n\n```jsx\n<RecursiveList\n data={data.evolution.chain}\n childrenPath=\"evolves_to\"\n keyPath=\"species.name\"\n maxDepth={16}\n defaultExpandedDepth={4}\n showLines\n>\n {(node, meta) => {\n const segments = node.species.url.split(\"/\").filter((part) Line truncated
"content": "# JSX runtime\n\nTemplates read four roots: `data.<requestId>`, `status.<requestId>`, `options`, and `inputs`.\n\nEach request status is an object:\n\n```ts\nstatus.<requestId> = {\n loading: boolean;\n ok?: boolean;\n status?: number;\n statusText?: string;\n error?: string;\n};\n```\n\nUse `status.list?.loading` for loading, `status.list?.ok === false` for failure, and `status.list?.ok === true` for success. Do not compare `status.list` with the strings `\"loading\"`, `\"success\"`, or `\"error\"`; those comparisons are always false and are rejected by validation.\n\nUse Mantine Core, Dates, Charts, safe Tabler icons, and Homarr runtime components. Safe installed Mantine exports are discovered automatically. Ordinary serializable props and Mantine style props pass through; `style` and `styles` are scoped and sanitized.\n\nRender icons as `<TablerIcon name=\"server\" size={18} />`. When connected, retrieve `homarr://custom-widgets/components/TablerIcon` for the supported icon names.\n\nUse `SubFetch` for a manual query, `ActionButton` or `ToggleSwitch` for an action, and `RefreshButton` to refresh data. A query with `trigger: \"load\"` populates its data automatically and follows the widget refresh interval.\n\nManual requests receive values only from the invoking component:\n\n```jsx\n<SubFetch requestId=\"search\" params={{ query: inputs.search }} />\n<ActionButton requestId=\"restart\" params={{ id: container.Id }}>Restart</ActionButton>\n```\n\nLoop variables and other JSX expressions can flow into a request only through that `params` prop. A load query has no invoking component, so every declared parameter must have an `optionsBinding` entry:\n\n```json\n{\n \"parameters\": { \"endpointId\": \"string\", \"showAll\": \"boolean\" },\n \"optionsBinding\": {\n \"endpointId\": { \"$option\": \"endpointId\" },\n \"showAll\": false\n }\n}\n```\n\nDo not rely on parameter and option names matching. For an invalid-placeholder or unresolved-parameter error on a load query, check missing `optionsBinding` entries before changing placeholder syntax.\n\nBind local controls declaratively:\n\n```jsx\n<TextInput bind=\"search\" label=\"Search\" />\n<DatePicker bind=\"selectedDate\" />\n<Select bind=\"selectedEntity\" data={options.entities} />\n```\n\nBound inputs exist only in memory while the widget is mounted and reset on reload. They are available as `inputs.<bindName>` and are never saved to local storage. Put durable user configuration in options. Do not author raw component callback props.\n\nBinding types follow the component mode. `Accordion multiple`, `Chip.Group multiple`, `TreeSelect mode=\"multiple\"|\"checkbox\"`, and date pickers with `type=\"multiple\"|\"range\"` expose `string[]`. `DateTimePicker` and `InlineDateTimePicker` support `type=\"range\"`, but not `multiple`. Components that do not advertise a temporary bind cannot use `bind`.\n\n## Immutable local bindings\n\nCollection callbacks may use a tightly constrained block body when a value would otherwise be recomputed throughout the returned JSX:\n\n```jsx\n{data.pokemon.map((pokemon) => {\n const segments = pokemon.url.split(\"/\").filter((part) => part);\n const dexId = segments.at(-1);\n const favorite = inputs.favorites.includes(pokemon.name);\n\n return (\n <Card key={dexId}>\n <Text>#{dexId}</Text>\n <Text>{pokemon.name}</Text>\n {favorite && <Badge>Favorite</Badge>}\n </Card>\n );\n})}\n```\n\nA page-level derived value may use a directly invoked, zero-argument inline arrow:\n\n```jsx\n{(() => {\n const visiblePokemon = data.pokemon.filter(\n (pokemon) => !inputs.favoritesOnly || inputs.favorites.includes(pokemon.name),\n );\n\n return <Stack>{visiblePokemon.map((pokemon) => <Text>{pokemon.name}</Text>)}</Stack>;\n})()}\n```\n\nA safe block contains one or more `const` declarations followed by exactly one final `return`. Each declaration uses one simple identifier and a required initializer made from existing safe expressions. Earlier local bindings are available to later initializers and to the return expression.\n\nThe following remain invalid:\n\n- `let`, `var`, assignment, update expressions, or destructuring.\n- Function or class declarations, and function values stored in `const`.\n- Loops, `if`, `switch`, `try`, `catch`, `throw`, labels, or `new`.\n- Async functions, generators, `await`, or `yield`.\n- Early or multiple returns.\n- Duplicate local names or shadowing `data`, `status`, `options`, or `inputs`.\n- Invoking a function obtained from API data or a property. Only a directly invoked zero-argument inline arrow is an IIFE.\n\nLocal evaluation shares the parent widget's depth, operation, collection, string, and rendered-node budgets. Blocks cannot reset or evade those limits.\n\nBlock diagnostics include `INVALID_LOCAL_DECLARATION`, `LOCAL_BINDING_REQUIRES_INITIALIZER`, `DUPLICATE_LOCAL_BINDING`, `RESERVED_LOCAL_BINDING`, `UNSUPPORTED_BLOCK_STATEMENT`, `BLOCK_REQULine truncated
},
{
"path": "references/security.md",
@@ -19,23 +23,31 @@
},
{
"path": "references/components.md",
"content": "# Component catalog\n\n> Generated from `component-catalog-v1.json`. Catalog schema v1 · Custom Widget 2.0.0 · Mantine 9.4.1. Do not edit this file by hand.\n\nThis is the complete offline Custom JSX component contract for this Homarr release. When connected to Homarr, prefer the live catalog because the installed release may differ. The catalog is descriptive, not a prop allowlist: ordinary serializable props pass through unless the capability boundary explicitly blocks them.\n\n## Package references\n\n- [Mantine Core](mantine-core.md) — 265 enabled, 133 denied\n- [Mantine Dates](mantine-dates.md) — 29 enabled, 3 denied\n- [Mantine Charts](mantine-charts.md) — 18 enabled, 1 denied\n- [Homarr components](homarr-components.md) — 13 enabled, 0 denied\n\n## Authoring boundary\n\n- Use the exact component names below. Compound names such as `Tabs.List`, `Card.Section`, and `Radio.Group` are distinct catalog entries.\n- The effective prop API is the shared prop table plus the component-specific table in the package reference. A component-specific record overrides the shared record with the same name.\n- Use `bind=\"name\"` only when a component record declares bind metadata. Read the temporary in-memory value from `inputs.name`; it is never persisted.\n- Ordinary authored callbacks remain blocked. Restricted collection callbacks, directly invoked zero-argument derived-value IIFEs, and `RecursiveList`'s child template may use the immutable local `const` grammar in [JSX runtime](runtime.md#immutable-local-bindings).\n- Use [`RecursiveList`](homarr-components.md#component-recursivelist) for custom arbitrary-depth trees. Do not author recursion or pass a renderer callback to Mantine Tree.\n- Unknown ordinary Mantine props are advisory and are not silently discarded. Every capability listed in the blocked table is rejected.\n\n## Complete enabled inventory\n\n### Mantine Core\n\n[`Accordion`](mantine-core.md#component-accordion), [`Accordion.Chevron`](mantine-core.md#component-accordion-2e-chevron), [`Accordion.Control`](mantine-core.md#component-accordion-2e-control), [`Accordion.Item`](mantine-core.md#component-accordion-2e-item), [`Accordion.Panel`](mantine-core.md#component-accordion-2e-panel), [`AccordionChevron`](mantine-core.md#component-accordionchevron), [`AccordionControl`](mantine-core.md#component-accordioncontrol), [`AccordionItem`](mantine-core.md#component-accordionitem), [`AccordionPanel`](mantine-core.md#component-accordionpanel), [`ActionIcon`](mantine-core.md#component-actionicon), [`ActionIcon.Group`](mantine-core.md#component-actionicon-2e-group), [`ActionIcon.GroupSection`](mantine-core.md#component-actionicon-2e-groupsection), [`ActionIconGroup`](mantine-core.md#component-actionicongroup), [`ActionIconGroupSection`](mantine-core.md#component-actionicongroupsection), [`Alert`](mantine-core.md#component-alert), [`AlphaSlider`](mantine-core.md#component-alphaslider), [`Anchor`](mantine-core.md#component-anchor), [`AngleSlider`](mantine-core.md#component-angleslider), [`AspectRatio`](mantine-core.md#component-aspectratio), [`Autocomplete`](mantine-core.md#component-autocomplete), [`Avatar`](mantine-core.md#component-avatar), [`Avatar.Group`](mantine-core.md#component-avatar-2e-group), [`AvatarGroup`](mantine-core.md#component-avatargroup), [`BackgroundImage`](mantine-core.md#component-backgroundimage), [`Badge`](mantine-core.md#component-badge), [`Blockquote`](mantine-core.md#component-blockquote), [`Box`](mantine-core.md#component-box), [`Breadcrumbs`](mantine-core.md#component-breadcrumbs), [`Burger`](mantine-core.md#component-burger), [`Button`](mantine-core.md#component-button), [`Button.Group`](mantine-core.md#component-button-2e-group), [`Button.GroupSection`](mantine-core.md#component-button-2e-groupsection), [`ButtonGroup`](mantine-core.md#component-buttongroup), [`ButtonGroupSection`](mantine-core.md#component-buttongroupsection), [`Card`](mantine-core.md#component-card), [`Card.Section`](mantine-core.md#component-card-2e-section), [`CardSection`](mantine-core.md#component-cardsection), [`Center`](mantine-core.md#component-center), [`CheckIcon`](mantine-core.md#component-checkicon), [`Checkbox`](mantine-core.md#component-checkbox), [`Checkbox.Card`](mantine-core.md#component-checkbox-2e-card), [`Checkbox.Group`](mantine-core.md#component-checkbox-2e-group), [`Checkbox.Indicator`](mantine-core.md#component-checkbox-2e-indicator), [`CheckboxCard`](mantine-core.md#component-checkboxcard), [`CheckboxGroup`](mantine-core.md#component-checkboxgroup), [`CheckboxIndicator`](mantine-core.md#component-checkboxindicator), [`Chip`](mantine-core.md#component-chip), [`Chip.Group`](mantine-core.md#component-chip-2e-group), [`ChipGroup`](mantine-core.md#component-chipgroup), [`CloseButton`](mantine-core.md#component-closebutton), [`CloseIcon`](mantine-core.md#component-closeicon), [`Code`](mantine-core.md#component-code), [`Collapse`](mantine-core.md#component-collapse), [`CoLine truncated
"content": "# Component catalog\n\n> Generated from `component-catalog-v1.json`. Catalog schema v1 · Custom Widget 2.0.0 · Mantine 9.4.1. Do not edit this file by hand.\n\nThis is the complete Custom JSX component boundary for this Homarr release. The machine-readable `component-catalog.json` contains exact generated component, prop, binding, and safety metadata; these Markdown pages provide the human-readable inventory and upstream Mantine indexes. Ordinary serializable props pass through unless the capability boundary explicitly blocks them.\n\n## Package references\n\n- [Mantine Core](mantine-core.md) — 265 enabled, 133 denied\n- [Mantine Dates](mantine-dates.md) — 29 enabled, 3 denied\n- [Mantine Charts](mantine-charts.md) — 18 enabled, 1 denied\n- [Homarr components](homarr-components.md) — 13 enabled, 0 denied\n\n## Authoring boundary\n\n- Use the exact component names below. Compound names such as `Tabs.List`, `Card.Section`, and `Radio.Group` are distinct catalog entries.\n- Use the official Mantine LLM links for component props and examples. Homarr's live catalog is authoritative for availability, bindings, and blocked capabilities.\n- Use `bind=\"name\"` only when a component record declares bind metadata. Read the temporary in-memory value from `inputs.name`; it is never persisted.\n- Ordinary authored callbacks remain blocked. Restricted collection callbacks, directly invoked zero-argument derived-value IIFEs, and `RecursiveList`'s child template may use the immutable local `const` grammar in [JSX runtime](runtime.md#immutable-local-bindings).\n- Use [`RecursiveList`](homarr-components.md#component-recursivelist) for custom arbitrary-depth trees. Do not author recursion or pass a renderer callback to Mantine Tree.\n- Unknown ordinary Mantine props are advisory and are not silently discarded. Every capability listed in the blocked table is rejected.\n\n## Complete enabled inventory\n\n### Mantine Core\n\n[`Accordion`](https://mantine.dev/core/accordion/), [`Accordion.Chevron`](https://mantine.dev/core/accordion/), [`Accordion.Control`](https://mantine.dev/core/accordion/), [`Accordion.Item`](https://mantine.dev/core/accordion/), [`Accordion.Panel`](https://mantine.dev/core/accordion/), [`AccordionChevron`](https://mantine.dev/core/accordion-chevron/), [`AccordionControl`](https://mantine.dev/core/accordion-control/), [`AccordionItem`](https://mantine.dev/core/accordion-item/), [`AccordionPanel`](https://mantine.dev/core/accordion-panel/), [`ActionIcon`](https://mantine.dev/core/action-icon/), [`ActionIcon.Group`](https://mantine.dev/core/action-icon/), [`ActionIcon.GroupSection`](https://mantine.dev/core/action-icon/), [`ActionIconGroup`](https://mantine.dev/core/action-icon-group/), [`ActionIconGroupSection`](https://mantine.dev/core/action-icon-group-section/), [`Alert`](https://mantine.dev/core/alert/), [`AlphaSlider`](https://mantine.dev/core/alpha-slider/), [`Anchor`](https://mantine.dev/core/anchor/), [`AngleSlider`](https://mantine.dev/core/angle-slider/), [`AspectRatio`](https://mantine.dev/core/aspect-ratio/), [`Autocomplete`](https://mantine.dev/core/autocomplete/), [`Avatar`](https://mantine.dev/core/avatar/), [`Avatar.Group`](https://mantine.dev/core/avatar/), [`AvatarGroup`](https://mantine.dev/core/avatar-group/), [`BackgroundImage`](https://mantine.dev/core/background-image/), [`Badge`](https://mantine.dev/core/badge/), [`Blockquote`](https://mantine.dev/core/blockquote/), [`Box`](https://mantine.dev/core/box/), [`Breadcrumbs`](https://mantine.dev/core/breadcrumbs/), [`Burger`](https://mantine.dev/core/burger/), [`Button`](https://mantine.dev/core/button/), [`Button.Group`](https://mantine.dev/core/button/), [`Button.GroupSection`](https://mantine.dev/core/button/), [`ButtonGroup`](https://mantine.dev/core/button-group/), [`ButtonGroupSection`](https://mantine.dev/core/button-group-section/), [`Card`](https://mantine.dev/core/card/), [`Card.Section`](https://mantine.dev/core/card/), [`CardSection`](https://mantine.dev/core/card-section/), [`Center`](https://mantine.dev/core/center/), [`CheckIcon`](https://mantine.dev/core/check-icon/), [`Checkbox`](https://mantine.dev/core/checkbox/), [`Checkbox.Card`](https://mantine.dev/core/checkbox/), [`Checkbox.Group`](https://mantine.dev/core/checkbox/), [`Checkbox.Indicator`](https://mantine.dev/core/checkbox/), [`CheckboxCard`](https://mantine.dev/core/checkbox-card/), [`CheckboxGroup`](https://mantine.dev/core/checkbox-group/), [`CheckboxIndicator`](https://mantine.dev/core/checkbox-indicator/), [`Chip`](https://mantine.dev/core/chip/), [`Chip.Group`](https://mantine.dev/core/chip/), [`ChipGroup`](https://mantine.dev/core/chip-group/), [`CloseButton`](https://mantine.dev/core/close-button/), [`CloseIcon`](https://mantine.dev/core/close-icon/), [`Code`](https://mantine.dev/core/code/), [`Collapse`](https://mantine.dev/core/collapse/), [`ColorInput`](https://mantine.dev/core/color-input/), [`ColorPicker`](https://mantine.devLine truncated
},
{
"path": "references/component-catalog.json",
"content": "{\"schemaVersion\":1,\"mantineVersion\":\"9.4.1\",\"customWidgetVersion\":\"2.0.0\",\"types\":[\"\\\"\\\" | \\\"anonymous\\\" | \\\"use-credentials\\\"\",\"\\\"-moz-initial\\\" | \\\"inherit\\\" | \\\"initial\\\" | \\\"revert\\\" | \\\"revert-layer\\\" | \\\"unset\\\" | \\\"auto\\\" | \\\"fixed\\\"\",\"\\\"-moz-initial\\\" | \\\"inherit\\\" | \\\"initial\\\" | \\\"revert\\\" | \\\"revert-layer\\\" | \\\"unset\\\" | \\\"none\\\" | \\\"auto\\\" | \\\"all\\\" | \\\"fill\\\" | \\\"stroke\\\" | \\\"painted\\\" | \\\"visible\\\" | \\\"visibleFill\\\" | \\\"visiblePainted\\\" | \\\"visibleStroke\\\"\",\"\\\"-moz-initial\\\" | \\\"inherit\\\" | \\\"initial\\\" | \\\"revert\\\" | \\\"revert-layer\\\" | \\\"unset\\\" | \\\"none\\\" | \\\"block\\\" | \\\"inline\\\" | \\\"both\\\" | \\\"horizontal\\\" | \\\"vertical\\\"\",\"\\\"-moz-initial\\\" | \\\"inherit\\\" | \\\"initial\\\" | \\\"revert\\\" | \\\"revert-layer\\\" | \\\"unset\\\" | \\\"none\\\" | \\\"contain\\\" | \\\"cover\\\" | \\\"fill\\\" | \\\"scale-down\\\"\",\"\\\"-moz-initial\\\" | \\\"inherit\\\" | \\\"initial\\\" | \\\"revert\\\" | \\\"revert-layer\\\" | \\\"unset\\\" | \\\"wrap\\\" | \\\"nowrap\\\" | \\\"wrap-reverse\\\"\",\"\\\"12h\\\" | \\\"24h\\\"\",\"\\\"activity\\\" | \\\"display-none\\\"\",\"\\\"all\\\" | \\\"parent\\\" | \\\"child\\\"\",\"\\\"all\\\" | \\\"segment\\\"\",\"\\\"always\\\" | \\\"hover\\\" | \\\"not-hover\\\" | \\\"never\\\"\",\"\\\"area\\\" | \\\"radial\\\" | \\\"scatter\\\"\",\"\\\"auto\\\" | \\\"scroll\\\" | \\\"always\\\" | \\\"hover\\\" | \\\"never\\\"\",\"\\\"auto-fit\\\" | \\\"auto-fill\\\"\",\"\\\"block\\\" | \\\"grid\\\"\",\"\\\"both\\\" | \\\"rightSection\\\" | \\\"clear\\\"\",\"\\\"bottom\\\" | \\\"top\\\"\",\"\\\"bottom\\\" | \\\"top\\\" | \\\"baseline\\\" | \\\"middle\\\"\",\"\\\"bottom\\\" | \\\"top\\\" | \\\"middle\\\"\",\"\\\"bottom-end\\\" | \\\"bottom-start\\\" | \\\"top-end\\\" | \\\"top-start\\\" | \\\"bottom-center\\\" | \\\"top-center\\\" | \\\"middle-center\\\" | \\\"middle-end\\\" | \\\"middle-start\\\"\",\"\\\"button\\\" | \\\"submit\\\" | \\\"reset\\\"\",\"\\\"center\\\" | \\\"bottom\\\"\",\"\\\"center\\\" | \\\"left\\\" | \\\"right\\\"\",\"\\\"center\\\" | \\\"left\\\" | \\\"right\\\" | \\\"justify\\\" | \\\"char\\\"\",\"\\\"center\\\" | \\\"side\\\" | \\\"merge\\\"\",\"\\\"checkbox\\\" | \\\"radio\\\"\",\"\\\"dashed\\\" | \\\"dotted\\\" | \\\"solid\\\"\",\"\\\"default\\\" | \\\"gradient\\\"\",\"\\\"default\\\" | \\\"responsive\\\"\",\"\\\"default\\\" | \\\"stacked\\\" | \\\"percent\\\" | \\\"split\\\"\",\"\\\"default\\\" | \\\"stacked\\\" | \\\"percent\\\" | \\\"waterfall\\\"\",\"\\\"div\\\" | \\\"label\\\"\",\"\\\"end\\\" | \\\"start\\\"\",\"\\\"filled\\\" | \\\"light\\\"\",\"\\\"fixed\\\" | \\\"absolute\\\"\",\"\\\"hex\\\" | \\\"hexa\\\" | \\\"rgba\\\" | \\\"rgb\\\" | \\\"hsl\\\" | \\\"hsla\\\"\",\"\\\"horizontal\\\" | \\\"vertical\\\"\",\"\\\"hover\\\" | \\\"click\\\" | \\\"click-hover\\\"\",\"\\\"inherit\\\" | \\\"auto\\\" | \\\"baseline\\\" | \\\"before-edge\\\" | \\\"text-before-edge\\\" | \\\"middle\\\" | \\\"central\\\" | \\\"after-edge\\\" | \\\"text-after-edge\\\" | \\\"ideographic\\\" | \\\"alphabetic\\\" | \\\"hanging\\\" | \\\"mathematical\\\"\",\"\\\"inherit\\\" | \\\"auto\\\" | \\\"sRGB\\\" | \\\"linearRGB\\\"\",\"\\\"inherit\\\" | \\\"auto\\\" | \\\"text-before-edge\\\" | \\\"middle\\\" | \\\"central\\\" | \\\"text-after-edge\\\" | \\\"ideographic\\\" | \\\"alphabetic\\\" | \\\"hanging\\\" | \\\"mathematical\\\" | \\\"use-script\\\" | \\\"no-change\\\" | \\\"reset-size\\\"\",\"\\\"inherit\\\" | \\\"end\\\" | \\\"start\\\" | \\\"middle\\\"\",\"\\\"inherit\\\" | \\\"nonzero\\\" | \\\"evenodd\\\"\",\"\\\"inherit\\\" | \\\"round\\\" | \\\"butt\\\" | \\\"square\\\"\",\"\\\"inherit\\\" | \\\"round\\\" | \\\"miter\\\" | \\\"bevel\\\"\",\"\\\"initial\\\" | \\\"medial\\\" | \\\"terminal\\\" | \\\"isolated\\\"\",\"\\\"inside\\\" | \\\"outside\\\"\",\"\\\"left\\\" | \\\"right\\\"\",\"\\\"left\\\" | \\\"right\\\" | \\\"bottom\\\" | \\\"top\\\" | \\\"left-end\\\" | \\\"left-start\\\" | \\\"right-end\\\" | \\\"right-start\\\" | \\\"bottom-end\\\" | \\\"bottom-start\\\" | \\\"top-end\\\" | \\\"top-start\\\"\",\"\\\"left\\\" | \\\"right\\\" | \\\"inside\\\"\",\"\\\"link\\\" | \\\"none\\\" | \\\"copy\\\" | \\\"execute\\\" | \\\"move\\\" | \\\"popup\\\"\",\"\\\"media\\\" | \\\"container\\\"\",\"\\\"month\\\" | \\\"year\\\" | \\\"decade\\\"\",\"\\\"native\\\" | \\\"scrollarea\\\"\",\"\\\"none\\\" | \\\"all\\\" | \\\"rows\\\" | \\\"groups\\\" | \\\"columns\\\"\",\"\\\"none\\\" | \\\"all\\\" | \\\"with-indicators\\\"\",\"\\\"none\\\" | \\\"ascending\\\" | \\\"descending\\\" | \\\"other\\\"\",\"\\\"none\\\" | \\\"inline\\\" | \\\"list\\\" | \\\"both\\\"\",\"\\\"none\\\" | \\\"strict\\\" | \\\"blur\\\"\",\"\\\"none\\\" | \\\"sum\\\"\",\"\\\"none\\\" | \\\"thousand\\\" | \\\"lakh\\\" | \\\"wan\\\"\",\"\\\"none\\\" | \\\"x\\\" | \\\"y\\\" | \\\"xy\\\"\",\"\\\"number\\\" | RegExp | \\\"alphanumeriLine truncated
},
{
"path": "references/examples.json",
"content": "[\n {\n \"id\": \"service-status\",\n \"title\": \"Service status\",\n \"description\": \"A compact status widget backed by one load query.\",\n \"widget\": {\n \"$schema\": \"homarr-custom-widget-v2\",\n \"name\": \"Service status\",\n \"sources\": [\n {\n \"id\": \"default\",\n \"name\": \"Service API\",\n \"baseUrl\": \"https://example.com\",\n \"networkScope\": \"public\",\n \"auth\": {\n \"type\": \"none\"\n }\n }\n ],\n \"requests\": [\n {\n \"id\": \"status\",\n \"sourceId\": \"default\",\n \"kind\": \"query\",\n \"method\": \"GET\",\n \"pathTemplate\": \"/api/status\",\n \"parameters\": {},\n \"auth\": \"inherit\",\n \"minimumBoardPermission\": \"view\",\n \"trigger\": \"load\"\n }\n ],\n \"optionsSchema\": {\n \"type\": \"object\",\n \"properties\": {},\n \"additionalProperties\": false\n },\n \"defaultOptions\": {},\n \"template\": \"<Stack gap=\\\"xs\\\" p=\\\"sm\\\">\\n <Group justify=\\\"space-between\\\">\\n <Text fw={600}>{data.status.name ?? \\\"Service\\\"}</Text>\\n <Badge color={data.status.online ? \\\"green\\\" : \\\"red\\\"}>{data.status.online ? \\\"Online\\\" : \\\"Offline\\\"}</Badge>\\n </Group>\\n <Text size=\\\"sm\\\" c=\\\"dimmed\\\">{data.status.message ?? \\\"No additional details\\\"}</Text>\\n </Stack>\"\n }\n },\n {\n \"id\": \"pokemon-evolution-tree\",\n \"title\": \"Pokémon evolution tree\",\n \"description\": \"A trusted arbitrary-depth tree with one immutable derived-value block per node.\",\n \"widget\": {\n \"$schema\": \"homarr-custom-widget-v2\",\n \"name\": \"Pokémon evolution tree\",\n \"sources\": [\n {\n \"id\": \"default\",\n \"name\": \"PokeAPI\",\n \"baseUrl\": \"https://pokeapi.co\",\n \"networkScope\": \"public\",\n \"auth\": {\n \"type\": \"none\"\n }\n }\n ],\n \"requests\": [\n {\n \"id\": \"evolution\",\n \"sourceId\": \"default\",\n \"kind\": \"query\",\n \"method\": \"GET\",\n \"pathTemplate\": \"/api/v2/evolution-chain/1\",\n \"parameters\": {},\n \"auth\": \"inherit\",\n \"minimumBoardPermission\": \"view\",\n \"trigger\": \"load\"\n }\n ],\n \"optionsSchema\": {\n \"type\": \"object\",\n \"properties\": {},\n \"additionalProperties\": false\n },\n \"defaultOptions\": {},\n \"template\": \"<Stack gap=\\\"sm\\\" p=\\\"sm\\\">\\n <Title order={3}>Evolution chain</Title>\\n {status.evolution?.loading ? <Skeleton height={160} radius=\\\"md\\\" /> : status.evolution?.error ? <Alert color=\\\"red\\\">{status.evolution.error}</Alert> : data.evolution?.chain ?\\n <RecursiveList data={data.evolution.chain} childrenPath=\\\"evolves_to\\\" keyPath=\\\"species.name\\\" defaultExpandedDepth={8} showLines>\\n {(node, meta) => {\\n const segments = node.species.url.split(\\\"/\\\").filter((part) => part);\\n const dexId = segments.at(-1);\\n return <Group gap=\\\"xs\\\"><Badge variant=\\\"light\\\">#{dexId}</Badge><Text tt=\\\"capitalize\\\" fw={600}>{node.species.name}</Text>{meta.hasChildren && <Text size=\\\"xs\\\" c=\\\"dimmed\\\">{meta.childCount} next</Text>}</Group>;\\n }}\\n </RecursiveList> : <Text c=\\\"dimmed\\\">No evolution chain returned.</Text>}\\n</Stack>\"\n }\n }\n]\n"
},
{
"path": "references/mantine-core.md",
"content": "# Mantine Core\n\n> Generated from `component-catalog-v1.json`. Catalog schema v1 · Custom Widget 2.0.0 · Mantine 9.4.1. Do not edit this file by hand.\n\nPackage: `@mantine/core`. Effective props combine the [shared prop API](components.md#shared-prop-api) with each component's specific props below. Component-specific records override shared records with the same name.\n\n## Package guidance\n\nUse declarative props and `bind` adapters instead of event callbacks. Homarr scopes supported overlays to the widget root. Use Homarr's `RecursiveList` when a tree needs a custom arbitrary-depth node template.\n\n## Enabled inventory\n\n<code>Accordion</code>, <code>Accordion.Chevron</code>, <code>Accordion.Control</code>, <code>Accordion.Item</code>, <code>Accordion.Panel</code>, <code>AccordionChevron</code>, <code>AccordionControl</code>, <code>AccordionItem</code>, <code>AccordionPanel</code>, <code>ActionIcon</code>, <code>ActionIcon.Group</code>, <code>ActionIcon.GroupSection</code>, <code>ActionIconGroup</code>, <code>ActionIconGroupSection</code>, <code>Alert</code>, <code>AlphaSlider</code>, <code>Anchor</code>, <code>AngleSlider</code>, <code>AspectRatio</code>, <code>Autocomplete</code>, <code>Avatar</code>, <code>Avatar.Group</code>, <code>AvatarGroup</code>, <code>BackgroundImage</code>, <code>Badge</code>, <code>Blockquote</code>, <code>Box</code>, <code>Breadcrumbs</code>, <code>Burger</code>, <code>Button</code>, <code>Button.Group</code>, <code>Button.GroupSection</code>, <code>ButtonGroup</code>, <code>ButtonGroupSection</code>, <code>Card</code>, <code>Card.Section</code>, <code>CardSection</code>, <code>Center</code>, <code>CheckIcon</code>, <code>Checkbox</code>, <code>Checkbox.Card</code>, <code>Checkbox.Group</code>, <code>Checkbox.Indicator</code>, <code>CheckboxCard</code>, <code>CheckboxGroup</code>, <code>CheckboxIndicator</code>, <code>Chip</code>, <code>Chip.Group</code>, <code>ChipGroup</code>, <code>CloseButton</code>, <code>CloseIcon</code>, <code>Code</code>, <code>Collapse</code>, <code>ColorInput</code>, <code>ColorPicker</code>, <code>ColorSwatch</code>, <code>Container</code>, <code>CopyButton</code>, <code>DataList</code>, <code>DataList.Item</code>, <code>DataList.ItemLabel</code>, <code>DataList.ItemValue</code>, <code>DataListItem</code>, <code>DataListItemLabel</code>, <code>DataListItemValue</code>, <code>Divider</code>, <code>EmptyState</code>, <code>EmptyState.Actions</code>, <code>EmptyState.Description</code>, <code>EmptyState.Indicator</code>, <code>EmptyState.Title</code>, <code>EmptyStateActions</code>, <code>EmptyStateDescription</code>, <code>EmptyStateIndicator</code>, <code>EmptyStateTitle</code>, <code>Fieldset</code>, <code>FlatTreeNode</code>, <code>Flex</code>, <code>FloatingArrow</code>, <code>FloatingIndicator</code>, <code>Grid</code>, <code>Grid.Col</code>, <code>GridCol</code>, <code>Group</code>, <code>Highlight</code>, <code>HoverCard</code>, <code>HoverCard.Dropdown</code>, <code>HoverCard.Group</code>, <code>HoverCard.Target</code>, <code>HoverCardDropdown</code>, <code>HoverCardGroup</code>, <code>HoverCardTarget</code>, <code>HueSlider</code>, <code>Image</code>, <code>Indicator</code>, <code>JsonInput</code>, <code>Kbd</code>, <code>List</code>, <code>List.Item</code>, <code>ListItem</code>, <code>Loader</code>, <code>LoadingOverlay</code>, <code>Mark</code>, <code>Marquee</code>, <code>MaskInput</code>, <code>Menu</code>, <code>Menu.CheckboxGroup</code>, <code>Menu.CheckboxItem</code>, <code>Menu.ContextMenu</code>, <code>Menu.Divider</code>, <code>Menu.Dropdown</code>, <code>Menu.Item</code>, <code>Menu.Label</code>, <code>Menu.RadioGroup</code>, <code>Menu.RadioItem</code>, <code>Menu.Search</code>, <code>Menu.Sub</code>, <code>Menu.Target</code>, <code>MenuCheckboxGroup</code>, <code>MenuCheckboxItem</code>, <code>MenuContextMenu</code>, <code>MenuDivider</code>, <code>MenuDropdown</code>, <code>MenuItem</code>, <code>MenuLabel</code>, <code>MenuRadioGroup</code>, <code>MenuRadioItem</code>, <code>MenuSearch</code>, <code>MenuSub</code>, <code>MenuSub.Dropdown</code>, <code>MenuSub.Item</code>, <code>MenuSub.Target</code>, <code>MenuSubDropdown</code>, <code>MenuSubItem</code>, <code>MenuSubTarget</code>, <code>MenuTarget</code>, <code>MultiSelect</code>, <code>NativeScrollArea</code>, <code>NativeSelect</code>, <code>NavLink</code>, <code>Notification</code>, <code>NumberFormatter</code>, <code>NumberInput</code>, <code>OverflowList</code>, <code>Overlay</code>, <code>Pagination</code>, <code>Pagination.Control</code>, <code>Pagination.Dots</code>, <code>Pagination.First</code>, <code>Pagination.Items</code>, <code>Pagination.Label</code>, <code>Pagination.Last</code>, <code>Pagination.Next</code>, <code>Pagination.Previous</code>, <code>Pagination.Root</code>, <code>PaginationControl</code>, <code>PaginationDots</code>, <code>PaginationFirst</code>, <code>PaginationItems</code>, <code>PaginationLabel</code>, <code>PaginaLine truncated
"content": "# Mantine UI - LLM Documentation\n\nThis index lists Mantine documentation pages formatted for LLMs.\nEach link points to a standalone Markdown file under the /llms path.\n\nFor a single consolidated file with all content, use:\n- https://mantine.dev/llms-full.txt\n\n## FAQ\n\n- [Are Mantine components accessible?](https://mantine.dev/llms/q-are-mantine-components-accessible.md): Learn about Mantine components accessibility features\n- [Browser zooms in when input is focused. What should I do?](https://mantine.dev/llms/q-browser-zooms-on-focus.md): Use meta tag to disable browser scaling or increase input size\n- [Can I have color schemes other than light and dark?](https://mantine.dev/llms/q-light-dark-is-not-enough.md): Learn about the difference between color scheme and theme\n- [Can I have different primary color for light and dark color schemes?](https://mantine.dev/llms/q-primary-virtual-color.md): Learn how to use virtual color with primary color in theme object\n- [Can I remove MultiSelect placeholder when the component has selected values?](https://mantine.dev/llms/q-multiselect-value-placeholder.md): Learn why MultiSelect placeholder is not removed when values are selected and how to remove it with CSS\n- [Can I use an array of strings as a list in use-form?](https://mantine.dev/llms/q-list-of-strings-in-use-form.md): Learn about use-form lists limitations\n- [Can I use Mantine components as server components?](https://mantine.dev/llms/q-server-components.md): Learn about use client directive and server components usage\n- [Can I use Mantine with Astro?](https://mantine.dev/llms/q-can-i-use-mantine-with-astro.md): No, Astro does not support React context\n- [Can I use Mantine with Create React App (CRA)?](https://mantine.dev/llms/q-can-i-use-mantine-with-cra.md): Learn how to use Mantine without postcss-preset-mantine or how to eject CRA and customize webpack.config.js and use postcss-preset-mantine\n- [Can I use Mantine with Emotion/styled-components/tailwindcss?](https://mantine.dev/llms/q-third-party-styles.md): Learn about limitations of third-party styles\n- [Can I use Mantine with Vue/Svelte/Angular/etc.?](https://mantine.dev/llms/q-vue-svelte-angular.md): No, Mantine is a React library and does not support other frameworks/libraries\n- [Can I use nested inline styles with Mantine components?](https://mantine.dev/llms/q-nested-inline-styles.md): Nested styles are supported only in CSS files\n- [Can I use PostCSS function in inline styles?](https://mantine.dev/llms/q-postcss-fns-inline.md): Learn where PostCSS functions can be used in Mantine\n- [Can I use private CSS variables to style components?](https://mantine.dev/llms/q-private-css-variables.md): No, it is not safe and will not work with future versions of Mantine.\n- [Can I use SegmentedControl with empty value?](https://mantine.dev/llms/q-segmented-control-no-value.md): SegmentedControl cannot be used without a value\n- [How can I add dynamic CSS styles?](https://mantine.dev/llms/q-dynamic-css-styles.md): Use data attributes, CSS variables or inline styles\n- [How can I add fuzzy search to Select component?](https://mantine.dev/llms/q-select-fuzzy.md): Learn how to integrate third-party fuzzy search libraries with Mantine Select component\n- [How can I add hover styles to an element?](https://mantine.dev/llms/q-how-to-add-hover-styles.md): Learn how to add hover classes to an element with CSS modules, &:hover or @mixin hover\n- [How can I apply styles to all Mantine components?](https://mantine.dev/llms/q-apply-styles-to-all.md): Learn how to use attribute selector to apply styles to all Mantine components\n- [How can I change body background color?](https://mantine.dev/llms/q-body-background.md): Use CSS to change body background color\n- [How can I change component color prop value depending on the color scheme?](https://mantine.dev/llms/q-color-scheme-color.md): Learn how to use CSS variables resolver to change color value depending on the color scheme\n- [How can I change input focus styles?](https://mantine.dev/llms/q-input-focus-styles.md): Learn how to use Styles API with Mantine inputs\n- [How can I change Tabs border color?](https://mantine.dev/llms/q-tabs-border-color.md): Learn how to use Styles API with Tabs component\n- [How can I contribute to the library?](https://mantine.dev/llms/q-how-can-i-contribute.md): There are multiple ways to contribute even without writing code\n- [How can I disable all inputs/input groups inside a form?](https://mantine.dev/llms/q-disable-all-inputs-in-form.md): Learn how to disable all inputs/input groups inside a form with the unstyled Fieldset component\n- [How can I display different elements in light and dark color schemes?](https://mantine.dev/llms/q-light-dark-elements.md): Learn how to hide/show elements based on color scheme\n- [How can I focus the first input with error with use-form?](https://mantine.dev/llms/q-focus-first-input-with-error.md): Learn how to handlLine truncated
},
{
"path": "references/mantine-dates.md",
"content": "# Mantine Dates\n\n> Generated from `component-catalog-v1.json`. Catalog schema v1 · Custom Widget 2.0.0 · Mantine 9.4.1. Do not edit this file by hand.\n\nPackage: `@mantine/dates`. Effective props combine the [shared prop API](components.md#shared-prop-api) with each component's specific props below. Component-specific records override shared records with the same name.\n\n## Package guidance\n\nDate bindings are temporary serialized inputs. Persisted locale, time-zone, and date choices belong in widget options. Include a textual date or agenda when a visual date control carries meaning.\n\n## Enabled inventory\n\n<code>Calendar</code>, <code>CalendarHeader</code>, <code>DateInput</code>, <code>DatePicker</code>, <code>DatePickerInput</code>, <code>DateTimePicker</code>, <code>Day</code>, <code>DecadeLevel</code>, <code>DecadeLevelGroup</code>, <code>InlineDateTimePicker</code>, <code>LevelsGroup</code>, <code>MiniCalendar</code>, <code>Month</code>, <code>MonthLevel</code>, <code>MonthLevelGroup</code>, <code>MonthPicker</code>, <code>MonthPickerInput</code>, <code>MonthsList</code>, <code>PickerControl</code>, <code>TimeGrid</code>, <code>TimeInput</code>, <code>TimePicker</code>, <code>TimeValue</code>, <code>WeekdaysRow</code>, <code>YearLevel</code>, <code>YearLevelGroup</code>, <code>YearPicker</code>, <code>YearPickerInput</code>, <code>YearsList</code>\n\n## Denied inventory\n\n<code>DatesProvider</code>, <code>HiddenDatesInput</code>, <code>PickerInputBase</code>\n\n## Component records\n\n<a id=\"component-calendar\"></a>\n### `Calendar`\n\n- Safety: `wrapped`\n- Category: `dates`\n- Documentation: [upstream component API](https://mantine.dev/dates/calendar/)\n- Temporary bind: not available\n- Subcomponents: none\n- Component-specific blocked props: none\n- Accessibility:\n - Include a textual date when the visual calendar carries meaning.\n\n#### Component-specific props\n\n| Prop | Required | Type | Literal values | Description |\n| --- | --- | --- | --- | --- |\n| <code>ariaLabels</code> | no | <code>CalendarAriaLabels</code> | — | `aria-label` attributes for controls on different levels |\n| <code>attributes</code> | no | <code>{ month?: Record&lt;string, any&gt;; weekday?: Record&lt;string, any&gt;; weekdaysRow?: Record&lt;string, any&gt;; monthRow?: Record&lt;string, any&gt;; ... 17 more ...; yearsListRow?: Record&lt;...&gt;; }</code> | — | — |\n| <code>columnsToScroll</code> | no | <code>number</code> | — | Number of columns to scroll with next/prev buttons, same as `numberOfColumns` if not set explicitly |\n| <code>date</code> | no | <code>string &#124; Date</code> | — | Displayed date in controlled mode |\n| <code>defaultDate</code> | no | <code>string &#124; Date</code> | — | Initial displayed date in uncontrolled mode |\n| <code>defaultLevel</code> | no | <code>\"month\" &#124; \"year\" &#124; \"decade\"</code> | <code>\"decade\"</code>, <code>\"month\"</code>, <code>\"year\"</code> | Initial displayed level in uncontrolled mode |\n| <code>enableKeyboardNavigation</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Enable enhanced keyboard navigation (Ctrl/Cmd + Arrow keys for year navigation, Ctrl/Cmd + Shift + Arrow keys for decade navigation, Y key to open year view) |\n| <code>firstDayOfWeek</code> | no | <code>0 &#124; 2 &#124; 3 &#124; 4 &#124; 5 &#124; 6 &#124; 1</code> | <code>0</code>, <code>1</code>, <code>2</code>, <code>3</code>, <code>4</code>, <code>5</code>, <code>6</code> | Number 0-6, where 0 – Sunday and 6 – Saturday. |\n| <code>fullWidth</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether the list should take the full width of its container |\n| <code>headerControlsOrder</code> | no | <code>(\"next\" &#124; \"previous\" &#124; \"level\")[]</code> | — | Controls order |\n| <code>hideOutsideDates</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether outside dates should be hidden |\n| <code>hideWeekdays</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether weekdays row should be hidden |\n| <code>highlightToday</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | Determines whether today should be highlighted with a border |\n| <code>level</code> | no | <code>\"month\" &#124; \"year\" &#124; \"decade\"</code> | <code>\"decade\"</code>, <code>\"month\"</code>, <code>\"year\"</code> | Current displayed level displayed in controlled mode |\n| <code>locale</code> | no | <code>string</code> | — | Dayjs locale, defaults to value defined in DatesProvider |\n| <code>maxDate</code> | no | <code>string &#124; Date</code> | — | Maximum possible date in `YYYY-MM-DD` format or Date object |\n| <code>maxLevel</code> | no | <code>\"month\" &#124; \"year\" &#124; \"decade\"</code> | <code>\"decade\"</code>, <code>\"mLine truncated
"content": "# GettingStartedDates\nPackage: @mantine/dates\nImport: import { GettingStartedDates } from '@mantine/dates';\n\n## Installation\n\n```bash\nyarn add @mantine/dates dayjs\n```\n\n```bash\nnpm install @mantine/dates dayjs\n```\n\nAfter installation import package styles at the root of your application:\n\n```tsx\nimport '@mantine/core/styles.css';\n// ‼️ import dates styles after core package styles\nimport '@mantine/dates/styles.css';\n```\n\n## Do not forget to import styles\n\nFollowed the installation instructions above but something is not working\n(calendars and date pickers have no styles and look broken)?\nYou've fallen into the trap of not importing dates styles!\nTo fix this issue, import dates styles at the root of your application:\n\n```tsx\nimport '@mantine/dates/styles.css';\n```\n\n## Usage\n\nAfter installing the `@mantine/dates` package and importing styles, you can use all components from it:\n\n\n\n## Date values as strings\n\n`@mantine/dates` components work with date strings: `YYYY-MM-DD` or `YYYY-MM-DD HH:mm:ss` depending on the component. Those strings do not include any timezone-specific information.\n\n## dayjs\n\n`@mantine/dates` components use [dayjs](https://day.js.org/) under the hood for date manipulations and formatting.\ndayjs is a required dependency – you cannot change it to another date library. If you want to use a different\ndate library in your application, you will need to install it separately.\n\n## DatesProvider\n\nThe `DatesProvider` component lets you set various settings that are shared across all\ncomponents exported from the `@mantine/dates` package. `DatesProvider` supports the following settings:\n\n* `locale` – dayjs locale. Note that you also need to import the corresponding locale module from dayjs. The default value is `en`.\n* `firstDayOfWeek` – a number from 0 to 6, where 0 is Sunday and 6 is Saturday. The default value is 1 – Monday.\n* `weekendDays` – an array of numbers from 0 to 6, where 0 is Sunday and 6 is Saturday. The default value is `[0, 6]` – Saturday and Sunday.\n* `consistentWeeks` – boolean. If `true`, every month will have 6 weeks. The default value is `false`.\n\n```tsx\nimport 'dayjs/locale/ru';\nimport { DatesProvider, MonthPickerInput, DatePickerInput } from '@mantine/dates';\n\nfunction Demo() {\n return (\n <DatesProvider settings={{ locale: 'ru', firstDayOfWeek: 0, weekendDays: [0] }}>\n <MonthPickerInput label=\"Pick month\" placeholder=\"Pick month\" />\n <DatePickerInput mt=\"md\" label=\"Pick date\" placeholder=\"Pick date\" />\n </DatesProvider>\n );\n}\n```\n\n\n## Consistent weeks\n\nIf you want to avoid layout shifts, set `consistentWeeks: true` in the `DatesProvider` settings.\nThis will ensure that every month has 6 weeks, even if outside days are not in the same month.\n\n```tsx\nimport { DatePicker, DatesProvider } from '@mantine/dates';\n\nfunction Demo() {\n return (\n <DatesProvider settings={{ consistentWeeks: true }}>\n <DatePicker />\n </DatesProvider>\n );\n}\n```\n\n\n## Custom parse format\n\nSome components like [DateInput](https://mantine.dev/llms/dates-date-input.md) require the [custom parse format](https://day.js.org/docs/en/plugin/custom-parse-format)\ndayjs plugin. You need to extend dayjs with this plugin before using components that require it. Note that\nthis is usually done once in your application root file, so you don't need to do it every time you use the component.\n\n```tsx\nimport dayjs from 'dayjs';\nimport customParseFormat from 'dayjs/plugin/customParseFormat';\n\ndayjs.extend(customParseFormat);\n```\n\n## Localization and server components\n\nTo add localization, you must import `import 'dayjs/locale/x';` in your application (`x` is the locale name)\nand set `locale` either on `DatesProvider` or on each component individually.\n\nExample of setting the locale on DatesProvider:\n\n```tsx\nimport 'dayjs/locale/ru';\n\nimport { DatesProvider } from '@mantine/dates';\n\nfunction Demo() {\n return (\n <DatesProvider settings={{ locale: 'ru' }}>\n {/* Your app */}\n </DatesProvider>\n );\n}\n```\n\nThe code above works in all environments, except Next.js app router.\nIf you are using Next.js app router, you must add `'use client';` to the\ntop of the file where you are importing `dayjs/locale/x` – locale data\nis required both on client and server.\n\n```tsx\n'use client';\n\nimport 'dayjs/locale/ru';\n\nimport { DatesProvider } from '@mantine/dates';\n\nfunction Demo() {\n return (\n <DatesProvider settings={{ locale: 'ru' }}>\n {/* Your app */}\n </DatesProvider>\n );\n}\n```\n"
},
{
"path": "references/mantine-charts.md",
"content": "# Mantine Charts\n\n> Generated from `component-catalog-v1.json`. Catalog schema v1 · Custom Widget 2.0.0 · Mantine 9.4.1. Do not edit this file by hand.\n\nPackage: `@mantine/charts`. Effective props combine the [shared prop API](components.md#shared-prop-api) with each component's specific props below. Component-specific records override shared records with the same name.\n\n## Package guidance\n\nPass sanitized JSON data through serializable props. Use [immutable local `const` blocks](runtime.md#immutable-local-bindings) for repeated derived chart values; authored formatters and renderer callbacks remain blocked. Include a textual summary and do not rely on color alone.\n\n## Enabled inventory\n\n<code>AreaChart</code>, <code>BarChart</code>, <code>BarsList</code>, <code>BubbleChart</code>, <code>ChartLegend</code>, <code>ChartTooltip</code>, <code>CompositeChart</code>, <code>DonutChart</code>, <code>FunnelChart</code>, <code>Heatmap</code>, <code>LineChart</code>, <code>PieChart</code>, <code>RadarChart</code>, <code>RadialBarChart</code>, <code>SankeyChart</code>, <code>ScatterChart</code>, <code>Sparkline</code>, <code>Treemap</code>\n\n## Denied inventory\n\n<code>AreaGradient</code>\n\n## Component records\n\n<a id=\"component-areachart\"></a>\n### `AreaChart`\n\n- Safety: `wrapped`\n- Category: `charts`\n- Documentation: [upstream component API](https://mantine.dev/charts/area-chart/)\n- Temporary bind: not available\n- Subcomponents: none\n- Component-specific blocked props: none\n- Accessibility:\n - Include a nearby text summary of the chart data.\n\n#### Component-specific props\n\n| Prop | Required | Type | Literal values | Description |\n| --- | --- | --- | --- | --- |\n| <code>activeDotProps</code> | no | <code>MantineChartDotProps</code> | — | Props passed down to all active dots. Ignored if `withDots={false}` is set. |\n| <code>areaChartProps</code> | no | <code>(CartesianChartProps&lt;unknown&gt; &amp; { ref?: React.Ref&lt;SVGSVGElement&gt;; })</code> | — | Props passed down to recharts `AreaChart` component |\n| <code>attributes</code> | no | <code>{ area?: Record&lt;string, any&gt;; legend?: Record&lt;string, any&gt;; grid?: Record&lt;string, any&gt;; root?: Record&lt;string, any&gt;; ... 14 more ...; tooltipBody?: Record&lt;...&gt;; }</code> | — | — |\n| <code>connectNulls</code> | no | <code>false &#124; true</code> | <code>false</code>, <code>true</code> | If set, points with `null` values are connected |\n| <code>curveType</code> | no | <code>\"step\" &#124; \"bump\" &#124; \"linear\" &#124; \"natural\" &#124; \"monotone\" &#124; \"stepBefore\" &#124; \"stepAfter\"</code> | <code>\"bump\"</code>, <code>\"linear\"</code>, <code>\"monotone\"</code>, <code>\"natural\"</code>, <code>\"step\"</code>, <code>\"stepAfter\"</code>, <code>\"stepBefore\"</code> | Type of the curve |\n| <code>data</code> | yes | <code>ChartData</code> | — | Data used to display chart |\n| <code>dataKey</code> | yes | <code>string</code> | — | Key of the `data` object for x-axis values |\n| <code>dotProps</code> | no | <code>MantineChartDotProps</code> | — | Props passed down to all dots. Ignored if `withDots={false}` is set. |\n| <code>gridAxis</code> | no | <code>\"none\" &#124; \"x\" &#124; \"y\" &#124; \"xy\"</code> | <code>\"none\"</code>, <code>\"x\"</code>, <code>\"xy\"</code>, <code>\"y\"</code> | Specifies which lines should be displayed in the grid, `'x'` by default |\n| <code>gridColor</code> | no | <code>DefaultMantineColor</code> | <code>\"blue\"</code>, <code>\"cyan\"</code>, <code>\"dark\"</code>, <code>\"grape\"</code>, <code>\"gray\"</code>, <code>\"green\"</code>, <code>\"indigo\"</code>, <code>\"lime\"</code>, <code>\"orange\"</code>, <code>\"pink\"</code>, <code>\"red\"</code>, <code>\"teal\"</code>, <code>\"violet\"</code>, <code>\"yellow\"</code> | Color of the grid and cursor lines, by default depends on color scheme |\n| <code>gridProps</code> | no | <code>Omit&lt;CartesianGridProps, \"ref\"&gt;</code> | — | Props passed down to the `CartesianGrid` component |\n| <code>legendProps</code> | no | <code>Omit&lt;LegendProps, \"ref\"&gt;</code> | — | Props passed down to the `Legend` component |\n| <code>referenceLines</code> | no | <code>ChartReferenceLineProps[]</code> | — | Reference lines that should be displayed on the chart |\n| <code>rightYAxisLabel</code> | no | <code>string</code> | — | A label to display next to the right y-axis |\n| <code>rightYAxisProps</code> | no | <code>Omit&lt;YAxisProps, \"ref\"&gt;</code> | — | Props passed down to the `YAxis` recharts component rendered on the right side |\n| <code>series</code> | yes | <code>AreaChartSeries[]</code> | — | An array of objects with `name` and `color` keys. Determines which data should be consumed from the `data` array. |\n| <code>splitColors</code> | no | <code>[DefaultMantineColor, DefaultMantineColor]</code> | — | A tuple of colors used when `type=\"split\"` is set, ignoredLine truncated
"content": "# Mantine Charts\n\nUse the official Mantine LLM index in [Mantine Core](mantine-core.md#charts) to discover chart documentation.\n\nThe upstream source is https://mantine.dev/llms.txt. Follow its `## Charts` links for current component documentation. Homarr's live component catalog remains authoritative for the installed component names and blocked capabilities.\n"
},
{
"path": "references/homarr-components.md",
"content": "# Homarr components\n\n> Generated from `component-catalog-v1.json`. Catalog schema v1 · Custom Widget 2.0.0 · Mantine 9.4.1. Do not edit this file by hand.\n\nPackage: `@homarr/widgets`. Effective props combine the [shared prop API](components.md#shared-prop-api) with each component's specific props below. Component-specific records override shared records with the same name.\n\n## Package guidance\n\nHomarr components own controlled request, action, pagination, disclosure, icon, and recursive traversal behavior. Manual request parameters come from the invoking component's `params` prop. `RecursiveList` is the only supported arbitrary-depth authored renderer and its child template may use the restricted safe-block grammar.\n\n## Enabled inventory\n\n<code>ActionButton</code>, <code>Collapsible</code>, <code>PaginatedList</code>, <code>RecursiveList</code>, <code>RefreshButton</code>, <code>StatBar</code>, <code>SubData</code>, <code>SubFetch</code>, <code>TabPanel</code>, <code>TablerIcon</code>, <code>TabsContainer</code>, <code>ToggleSwitch</code>, <code>TypeBadge</code>\n\n## Denied inventory\n\n_None._\n\n## Component records\n\n<a id=\"component-actionbutton\"></a>\n### `ActionButton`\n\n- Safety: `wrapped`\n- Category: `network`\n- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)\n- Temporary bind: not available\n- Subcomponents: none\n- Component-specific blocked props: none\n- Accessibility:\n - Action labels must describe their effect; destructive actions require confirmation.\n\nRuns a named user-triggered action.\n\n#### Component-specific props\n\n| Prop | Required | Type | Literal values | Description |\n| --- | --- | --- | --- | --- |\n| <code>confirmMessage</code> | no | <code>string</code> | — | — |\n| <code>disabled</code> | no | <code>boolean &#124; string</code> | — | — |\n| <code>errorMessage</code> | no | <code>string</code> | — | — |\n| <code>fullWidth</code> | no | <code>boolean &#124; string</code> | — | — |\n| <code>icon</code> | no | <code>string</code> | — | — |\n| <code>label</code> | no | <code>string</code> | — | — |\n| <code>params</code> | no | <code>Record&lt;string, string &#124; number &#124; boolean&gt;</code> | — | — |\n| <code>requestId</code> | no | <code>string</code> | — | Identifier of the named Custom Widget action. |\n| <code>size</code> | no | <code>string</code> | — | — |\n| <code>successMessage</code> | no | <code>string</code> | — | — |\n\n<a id=\"component-collapsible\"></a>\n### `Collapsible`\n\n- Safety: `allowed`\n- Category: `interaction`\n- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)\n- Temporary bind: not available\n- Subcomponents: none\n- Component-specific blocked props: none\n- Accessibility:\n - Provide a visible label or aria-label.\n\nShows and hides a bounded content section.\n\n#### Component-specific props\n\n| Prop | Required | Type | Literal values | Description |\n| --- | --- | --- | --- | --- |\n| <code>defaultOpen</code> | no | <code>boolean</code> | — | — |\n| <code>title</code> | yes | <code>string</code> | — | — |\n\n<a id=\"component-paginatedlist\"></a>\n### `PaginatedList`\n\n- Safety: `allowed`\n- Category: `interaction`\n- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)\n- Temporary bind: not available\n- Subcomponents: none\n- Component-specific blocked props: none\n- Accessibility:\n - Provide a visible label or aria-label.\n\nRenders a bounded page of collection items.\n\n#### Component-specific props\n\n| Prop | Required | Type | Literal values | Description |\n| --- | --- | --- | --- | --- |\n| <code>pageSize</code> | no | <code>number</code> | — | — |\n\n<a id=\"component-recursivelist\"></a>\n### `RecursiveList`\n\n- Safety: `wrapped`\n- Category: `content`\n- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)\n- Temporary bind: not available\n- Subcomponents: none\n- Component-specific blocked props: none\n- Accessibility:\n - Provide meaningful text alternatives for non-text content.\n\nRenders an arbitrary-depth tree through a bounded, Homarr-owned traversal.\n\n#### Component-specific props\n\n| Prop | Required | Type | Literal values | Description |\n| --- | --- | --- | --- | --- |\n| <code>childrenPath</code> | yes | <code>string</code> | — | — |\n| <code>data</code> | yes | <code>unknown</code> | — | — |\n| <code>defaultExpandedDepth</code> | no | <code>number</code> | — | — |\n| <code>gap</code> | no | <code>MantineSpacing</code> | — | — |\n| <code>indent</code> | no | <code>MantineSpacing</code> | — | — |\n| <code>keyPath</code> | yes | <code>string</code> | — | — |\n| <code>maxDepth</code> | no | <code>number</code> | — | — |\n| <code>maxNodes</code> | no | <code>number</code> | — | — |\n| <code>showLines</code> | no | <code>boolean</cLine truncated
"content": "# Homarr components\n\n> Generated from `component-catalog-v1.json`. Catalog schema v1 · Custom Widget 2.0.0 · Mantine 9.4.1. Do not edit this file by hand.\n\nPackage: `@homarr/widgets`. The component-specific props below supplement ordinary safe serializable Mantine props and style props. Component-specific records override ordinary behavior with the same name.\n\n## Package guidance\n\nHomarr components own controlled request, action, pagination, disclosure, icon, and recursive traversal behavior. Manual request parameters come from the invoking component's `params` prop. `RecursiveList` is the only supported arbitrary-depth authored renderer and its child template may use the restricted safe-block grammar.\n\n## Enabled inventory\n\n<code>ActionButton</code>, <code>Collapsible</code>, <code>PaginatedList</code>, <code>RecursiveList</code>, <code>RefreshButton</code>, <code>StatBar</code>, <code>SubData</code>, <code>SubFetch</code>, <code>TabPanel</code>, <code>TablerIcon</code>, <code>TabsContainer</code>, <code>ToggleSwitch</code>, <code>TypeBadge</code>\n\n## Denied inventory\n\n_None._\n\n## Component records\n\n<a id=\"component-actionbutton\"></a>\n### `ActionButton`\n\n- Safety: `wrapped`\n- Category: `network`\n- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)\n- Temporary bind: not available\n- Subcomponents: none\n- Component-specific blocked props: none\n- Accessibility:\n - Action labels must describe their effect; destructive actions require confirmation.\n\nRuns a named user-triggered action.\n\n#### Component-specific props\n\n| Prop | Required | Type | Literal values | Description |\n| --- | --- | --- | --- | --- |\n| <code>color</code> | no | <code>string</code> | — | — |\n| <code>confirmMessage</code> | no | <code>string</code> | — | — |\n| <code>disabled</code> | no | <code>boolean &#124; string</code> | — | — |\n| <code>errorMessage</code> | no | <code>string</code> | — | — |\n| <code>fullWidth</code> | no | <code>boolean &#124; string</code> | — | — |\n| <code>icon</code> | no | <code>string</code> | — | — |\n| <code>label</code> | no | <code>string</code> | — | — |\n| <code>params</code> | no | <code>Record&lt;string, string &#124; number &#124; boolean&gt;</code> | — | — |\n| <code>requestId</code> | no | <code>string</code> | — | Identifier of the named Custom Widget action. |\n| <code>size</code> | no | <code>string</code> | — | — |\n| <code>successMessage</code> | no | <code>string</code> | — | — |\n| <code>variant</code> | no | <code>string</code> | — | — |\n\n<a id=\"component-collapsible\"></a>\n### `Collapsible`\n\n- Safety: `allowed`\n- Category: `interaction`\n- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)\n- Temporary bind: not available\n- Subcomponents: none\n- Component-specific blocked props: none\n- Accessibility:\n - Provide a visible label or aria-label.\n\nShows and hides a bounded content section.\n\n#### Component-specific props\n\n| Prop | Required | Type | Literal values | Description |\n| --- | --- | --- | --- | --- |\n| <code>defaultOpen</code> | no | <code>boolean</code> | — | — |\n| <code>title</code> | yes | <code>string</code> | — | — |\n\n<a id=\"component-paginatedlist\"></a>\n### `PaginatedList`\n\n- Safety: `allowed`\n- Category: `interaction`\n- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)\n- Temporary bind: not available\n- Subcomponents: none\n- Component-specific blocked props: none\n- Accessibility:\n - Provide a visible label or aria-label.\n\nRenders a bounded page of collection items.\n\n#### Component-specific props\n\n| Prop | Required | Type | Literal values | Description |\n| --- | --- | --- | --- | --- |\n| <code>pageSize</code> | no | <code>number</code> | — | — |\n\n<a id=\"component-recursivelist\"></a>\n### `RecursiveList`\n\n- Safety: `wrapped`\n- Category: `content`\n- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)\n- Temporary bind: not available\n- Subcomponents: none\n- Component-specific blocked props: none\n- Accessibility:\n - Provide meaningful text alternatives for non-text content.\n\nRenders an arbitrary-depth tree through a bounded, Homarr-owned traversal.\n\n#### Component-specific props\n\n| Prop | Required | Type | Literal values | Description |\n| --- | --- | --- | --- | --- |\n| <code>childrenPath</code> | yes | <code>string</code> | — | — |\n| <code>data</code> | yes | <code>unknown</code> | — | — |\n| <code>defaultExpandedDepth</code> | no | <code>number</code> | — | — |\n| <code>gap</code> | no | <code>MantineSpacing</code> | — | — |\n| <code>indent</code> | no | <code>MantineSpacing</code> | — | — |\n| <code>keyPath</code> | yes | <code>string</code> | — | — |\n| <code>maxDepth</code> | no | <code>number</code> | — | — |\nLine truncated
}
]
}
@@ -0,0 +1,36 @@
export interface CustomJsxTemplateDiagnostic {
severity: "error" | "warning";
message: string;
index: number;
line: number;
column: number;
}
export function createCustomJsxParseDiagnostic(template: string, error: unknown): CustomJsxTemplateDiagnostic {
const parseError = error as Error & { pos?: number; loc?: { line?: number; column?: number } };
const parseIndex = Math.max(0, (parseError.pos ?? 2) - 2);
const missingInitializer = [...template.matchAll(/(?:\bconst|,)\s+([A-Za-z_$][\w$]*)\s*;/gu)].find((match) => {
const semicolon = (match.index ?? 0) + match[0].length - 1;
return Math.abs(semicolon - parseIndex) <= 1;
});
const duplicateBinding = /Identifier '([^']+)' has already been declared/iu.exec(parseError.message);
const duplicateArgument = /Argument name clash/iu.test(parseError.message);
const message = missingInitializer
? `LOCAL_BINDING_REQUIRES_INITIALIZER: '${missingInitializer[1]}' requires an initializer`
: duplicateBinding
? `DUPLICATE_LOCAL_BINDING: '${duplicateBinding[1]}' is already declared`
: duplicateArgument
? "DUPLICATE_LOCAL_BINDING: Callback parameter names must be unique"
: parseError.message;
return {
severity: "error",
message,
index: parseIndex,
line: parseError.loc?.line ?? 1,
column: Math.max(
1,
(parseError.loc?.column ?? 0) + (parseError.loc?.line === 1 || parseError.loc?.line === undefined ? -1 : 1),
),
};
}
@@ -118,13 +118,19 @@ function analyzeAttributeValue(
}
if (
attributeName === "maxDepth" &&
(typeof literalValue.value !== "number" || literalValue.value < 1 || literalValue.value > 32)
(typeof literalValue.value !== "number" ||
!Number.isInteger(literalValue.value) ||
literalValue.value < 1 ||
literalValue.value > 32)
) {
context.add(attribute, "INVALID_PROP_VALUE: 'maxDepth' must be between 1 and 32");
}
if (
attributeName === "maxNodes" &&
(typeof literalValue.value !== "number" || literalValue.value < 1 || literalValue.value > 2_000)
(typeof literalValue.value !== "number" ||
!Number.isInteger(literalValue.value) ||
literalValue.value < 1 ||
literalValue.value > 2_000)
) {
context.add(attribute, "INVALID_PROP_VALUE: 'maxNodes' must be between 1 and 2000");
}
@@ -102,6 +102,30 @@ export function isStaticallyCallableBinding(node: AstNode): boolean {
return Boolean(propertyName && callableRootMembers[String(object.name)]?.has(propertyName));
}
export function canStaticallyProduceCallable(node: AstNode): boolean {
if (isStaticallyCallableBinding(node) || ["ArrowFunctionExpression", "FunctionExpression"].includes(node.type)) {
return true;
}
if (node.type === "ConditionalExpression") {
const consequent = nodeOf(node.consequent);
const alternate = nodeOf(node.alternate);
return Boolean(
(consequent && canStaticallyProduceCallable(consequent)) ||
(alternate && canStaticallyProduceCallable(alternate)),
);
}
if (node.type === "LogicalExpression") {
const left = nodeOf(node.left);
const right = nodeOf(node.right);
return Boolean((left && canStaticallyProduceCallable(left)) || (right && canStaticallyProduceCallable(right)));
}
if (node.type === "ChainExpression") {
const expression = nodeOf(node.expression);
return Boolean(expression && canStaticallyProduceCallable(expression));
}
return false;
}
function editDistance(left: string, right: string): number {
const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
+57 -38
View File
@@ -9,23 +9,54 @@ import {
} from "./policy";
import type { AstNode } from "./analyzer-ast";
import { nodeOf, nodesOf, staticPropertyName } from "./analyzer-ast";
import { createCustomJsxParseDiagnostic } from "./analyzer-diagnostics";
import type { CustomJsxTemplateDiagnostic } from "./analyzer-diagnostics";
import { analyzeCustomJsxElement } from "./analyzer-jsx";
import { isStaticallyCallableBinding, RESERVED_LOCAL_BINDINGS, ROOT_BINDINGS } from "./analyzer-language";
import {
canStaticallyProduceCallable,
isStaticallyCallableBinding,
RESERVED_LOCAL_BINDINGS,
ROOT_BINDINGS,
} from "./analyzer-language";
import {
CUSTOM_JSX_BINARY_OPERATORS,
CUSTOM_JSX_CALLBACK_METHODS,
CUSTOM_JSX_SAFE_VALUE_METHODS,
} from "./safe-language-policy";
export interface CustomJsxTemplateDiagnostic {
severity: "error" | "warning";
message: string;
index: number;
line: number;
column: number;
}
export type { CustomJsxTemplateDiagnostic } from "./analyzer-diagnostics";
const JsxParser = Parser.extend(jsx());
const requestStatusLabels = new Set(["loading", "success", "error"]);
function directRequestStatusId(node: AstNode | null): string | null {
if (node?.type !== "MemberExpression") return null;
const object = nodeOf(node.object);
if (object?.type !== "Identifier" || object.name !== "status") return null;
const property = nodeOf(node.property);
return node.computed
? (staticPropertyName(property) ?? null)
: String(property?.name ?? property?.value ?? "") || null;
}
function invalidRequestStatusComparison(node: AstNode) {
if (!["===", "==", "!==", "!="].includes(String(node.operator))) return null;
const left = nodeOf(node.left);
const right = nodeOf(node.right);
const leftStatus = directRequestStatusId(left);
const rightStatus = directRequestStatusId(right);
const leftLabel = left?.type === "Literal" && typeof left.value === "string" ? left.value : null;
const rightLabel = right?.type === "Literal" && typeof right.value === "string" ? right.value : null;
const requestId =
leftStatus && rightLabel && requestStatusLabels.has(rightLabel)
? leftStatus
: rightStatus && leftLabel && requestStatusLabels.has(leftLabel)
? rightStatus
: null;
return requestId
? `INVALID_STATUS_COMPARISON: status.${requestId} is an object. Use status.${requestId}?.loading, status.${requestId}?.ok === true, or status.${requestId}?.ok === false`
: null;
}
export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDiagnostic[] {
const diagnostics: CustomJsxTemplateDiagnostic[] = [];
@@ -85,7 +116,9 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
const body = nodeOf(node.body);
if (!body) return;
if (body.type === "BlockStatement") visitSafeBlock(body, depth + 1, callbackBindings);
else visit(body, depth + 1, callbackBindings);
else if (canStaticallyProduceCallable(body)) {
add(body, "CALLBACK_VALUE_NOT_ALLOWED: Authored callbacks cannot return callable values");
} else visit(body, depth + 1, callbackBindings);
};
const visitSafeBlock = (block: AstNode, depth: number, bindings: ReadonlySet<string>) => {
@@ -110,7 +143,9 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
return;
}
const argument = nodeOf(statement.argument);
if (argument) visit(argument, depth + 1, scopedBindings);
if (argument && canStaticallyProduceCallable(argument)) {
add(argument, "CALLBACK_VALUE_NOT_ALLOWED: Authored callbacks cannot return callable values");
} else if (argument) visit(argument, depth + 1, scopedBindings);
return;
}
if (statement.type !== "VariableDeclaration") {
@@ -146,11 +181,7 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
add(declaration, `LOCAL_BINDING_REQUIRES_INITIALIZER: '${name}' requires an initializer`);
continue;
}
if (
initializer.type === "ArrowFunctionExpression" ||
initializer.type === "FunctionExpression" ||
isStaticallyCallableBinding(initializer)
) {
if (canStaticallyProduceCallable(initializer)) {
add(initializer, "CALLBACK_VALUE_NOT_ALLOWED: Functions cannot be stored in local bindings");
continue;
}
@@ -240,6 +271,10 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
if (node.type === "BinaryExpression" && !CUSTOM_JSX_BINARY_OPERATORS.has(String(node.operator))) {
add(node, `Binary operator '${String(node.operator)}' is not supported`);
}
if (node.type === "BinaryExpression") {
const statusDiagnostic = invalidRequestStatusComparison(node);
if (statusDiagnostic) add(node, statusDiagnostic);
}
const left = nodeOf(node.left);
const right = nodeOf(node.right);
if (left) visit(left, depth + 1, bindings);
@@ -268,6 +303,9 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
case "CallExpression": {
const callee = nodeOf(node.callee);
const arguments_ = nodesOf(node.arguments);
if (node.optional) {
add(node, "CALL_TARGET_NOT_ALLOWED: Optional calls are not supported");
}
if (callee?.type === "ArrowFunctionExpression") {
if (nodesOf(callee.params).length > 0) {
add(callee, "CALLBACK_VALUE_NOT_ALLOWED: Inline derived-value functions cannot declare parameters");
@@ -275,6 +313,9 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
if (arguments_.length > 0) {
add(node, "CALLBACK_VALUE_NOT_ALLOWED: Inline derived-value functions must be called without arguments");
}
if (nodeOf(callee.body)?.type !== "BlockStatement") {
add(callee, "BLOCK_REQUIRES_FINAL_RETURN: Inline derived-value functions require a safe const block");
}
visitArrow(callee, depth + 1, bindings);
arguments_.forEach((argument) => visit(argument, depth + 1, bindings));
return;
@@ -352,29 +393,7 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
}) as unknown as AstNode;
visit(program, 0, ROOT_BINDINGS);
} catch (error) {
const parseError = error as Error & { pos?: number; loc?: { line?: number; column?: number } };
const parseIndex = Math.max(0, (parseError.pos ?? 2) - 2);
const missingInitializer = [...template.matchAll(/(?:\bconst|,)\s+([A-Za-z_$][\w$]*)\s*;/gu)].find((match) => {
const semicolon = (match.index ?? 0) + match[0].length - 1;
return Math.abs(semicolon - parseIndex) <= 1;
});
const duplicateCandidate = /Identifier '([^']+)' has already been declared/iu.exec(parseError.message);
const duplicateBinding = duplicateCandidate;
const message = missingInitializer
? `LOCAL_BINDING_REQUIRES_INITIALIZER: '${missingInitializer[1]}' requires an initializer`
: duplicateBinding
? `DUPLICATE_LOCAL_BINDING: '${duplicateBinding[1]}' is already declared`
: parseError.message;
diagnostics.push({
severity: "error",
message,
index: Math.max(0, (parseError.pos ?? 2) - 2),
line: parseError.loc?.line ?? 1,
column: Math.max(
1,
(parseError.loc?.column ?? 0) + (parseError.loc?.line === 1 || parseError.loc?.line === undefined ? -1 : 1),
),
});
diagnostics.push(createCustomJsxParseDiagnostic(template, error));
}
return diagnostics;
@@ -7,6 +7,7 @@ import {
SafeJsxError,
} from "./interpreter-foundation";
import type { AstNode, Budget, InterpreterCallback } from "./interpreter-foundation";
import { RESERVED_LOCAL_BINDINGS } from "./analyzer-language";
import { isBlockedCustomJsxLexicalBinding } from "./policy";
interface InterpreterCallbackContext {
@@ -14,20 +15,20 @@ interface InterpreterCallbackContext {
evaluate(node: AstNode, environment: Environment, depth: number): unknown;
}
const reservedLocalBindings = new Set(["data", "status", "options", "inputs"]);
export function createInterpreterCallback(node: AstNode, environment: Environment): InterpreterCallback {
if (node.async || node.generator) {
throw new SafeJsxError("CALLBACK_VALUE_NOT_ALLOWED: Async and generator callbacks are not supported");
}
const parameterNames = new Set<string>();
const params = asNodeArray(node.params, "callback parameters").map((param) => {
if (param.type !== "Identifier") throw new SafeJsxError("Callback parameters must be identifiers");
if (param.type !== "Identifier") {
throw new SafeJsxError("INVALID_LOCAL_DECLARATION: Callback parameters must be identifiers");
}
const name = String(param.name);
if (isBlockedCustomJsxLexicalBinding(name)) {
throw new SafeJsxError(`INVALID_LOCAL_DECLARATION: '${name}' is not a safe callback parameter name`);
}
if (reservedLocalBindings.has(name)) {
if (RESERVED_LOCAL_BINDINGS.has(name)) {
throw new SafeJsxError(`RESERVED_LOCAL_BINDING: '${name}' cannot be shadowed`);
}
if (parameterNames.has(name)) {
@@ -55,9 +56,11 @@ export function runInterpreterCallback(
values[name] = args[index];
});
const environment = new Environment(values, callback.environment);
return callback.body.type === "BlockStatement"
? executeSafeBlock(callback.body, environment, depth + 1, context)
: context.evaluate(callback.body, environment, depth + 1);
const result =
callback.body.type === "BlockStatement"
? executeSafeBlock(callback.body, environment, depth + 1, context)
: context.evaluate(callback.body, environment, depth + 1);
return ensureNonCallableResult(result);
}
function executeSafeBlock(
@@ -91,7 +94,9 @@ function executeSafeBlock(
if (!isFinal) {
throw new SafeJsxError("BLOCK_REQUIRES_FINAL_RETURN: Return is only allowed as the final statement");
}
return context.evaluate(asNode(statement.argument, "safe block return value"), scopedEnvironment, depth + 1);
return ensureNonCallableResult(
context.evaluate(asNode(statement.argument, "safe block return value"), scopedEnvironment, depth + 1),
);
}
if (statement.type !== "VariableDeclaration") {
throw new SafeJsxError(`UNSUPPORTED_BLOCK_STATEMENT: '${statement.type}' is not allowed in a safe block`);
@@ -109,7 +114,7 @@ function executeSafeBlock(
if (isBlockedCustomJsxLexicalBinding(name)) {
throw new SafeJsxError(`INVALID_LOCAL_DECLARATION: '${name}' is not a safe local binding name`);
}
if (reservedLocalBindings.has(name)) {
if (RESERVED_LOCAL_BINDINGS.has(name)) {
throw new SafeJsxError(`RESERVED_LOCAL_BINDING: '${name}' cannot be shadowed`);
}
if (localNames.has(name)) {
@@ -129,3 +134,10 @@ function executeSafeBlock(
}
throw new SafeJsxError("BLOCK_REQUIRES_FINAL_RETURN: Safe block did not return a value");
}
function ensureNonCallableResult(value: unknown): unknown {
if (typeof value === "function" || isInterpreterCallback(value)) {
throw new SafeJsxError("CALLBACK_VALUE_NOT_ALLOWED: Authored callbacks cannot return callable values");
}
return value;
}
@@ -20,7 +20,12 @@ export function parseCustomJsxTemplate(template: string): AstNode {
allowReturnOutsideFunction: false,
}) as unknown as AstNode;
} catch (error) {
throw new SafeJsxError(error instanceof Error ? error.message : "Unable to parse JSX template");
const message = error instanceof Error ? error.message : "Unable to parse JSX template";
throw new SafeJsxError(
/Argument name clash/iu.test(message)
? "DUPLICATE_LOCAL_BINDING: Callback parameter names must be unique"
: message,
);
}
const body = asNodeArray(program.body, "program body");
if (body.length !== 1 || body[0]?.type !== "ExpressionStatement") {
@@ -264,6 +264,11 @@ class Interpreter {
const callee = asNode(node.callee, "call target");
const argumentNodes = asNodeArray(node.arguments, "call arguments");
if (callee.type === "ArrowFunctionExpression") {
if (callee.body && asNode(callee.body, "inline derived-value body").type !== "BlockStatement") {
throw new SafeJsxError(
"BLOCK_REQUIRES_FINAL_RETURN: Inline derived-value functions require a safe const block",
);
}
if (asNodeArray(callee.params, "inline derived-value parameters").length > 0) {
throw new SafeJsxError("CALLBACK_VALUE_NOT_ALLOWED: Inline derived-value functions cannot declare parameters");
}
@@ -59,7 +59,7 @@ export function buildTrustedRecursiveList(options: RecursiveListBuildOptions): T
return [diagnosticNode("diagnostic:root-data", "RecursiveList data must be an object or an array")];
}
const willExceedNodeLimit = exceedsRecursiveListNodeLimit(roots, childrenPath, maxDepth, maxNodes);
const willExceedNodeLimit = exceedsRecursiveListNodeLimit(roots, childrenPath, maxDepth, maxNodes, options.budget);
let renderedRowCount = 0;
let diagnosticCount = 0;
let limitSentinelRendered = false;
@@ -1,4 +1,5 @@
import { SafeJsxError } from "./interpreter-foundation";
import type { Budget } from "./interpreter-foundation";
import { CUSTOM_JSX_BLOCKED_PROPERTIES, normalizeCustomJsxProperty } from "./policy";
import { ownProperty } from "./safe-properties";
@@ -12,25 +13,27 @@ export function exceedsRecursiveListNodeLimit(
childrenPath: readonly string[],
maxDepth: number,
maxNodes: number,
budget: Budget,
) {
let rows = 0;
const charge = () => {
const charge = (depth: number) => {
budget.operation(depth + 1);
rows += 1;
return rows > maxNodes;
};
const visit = (values: readonly unknown[], depth: number, ancestors: ReadonlySet<object>): boolean => {
for (const value of values) {
if (charge()) return true;
if (charge(depth)) return true;
if (!isObject(value) || ancestors.has(value)) continue;
const rawChildren = readRestrictedPath(value, childrenPath);
if (rawChildren !== undefined && rawChildren !== null && !Array.isArray(rawChildren)) {
if (charge()) return true;
if (charge(depth + 1)) return true;
continue;
}
if (!Array.isArray(rawChildren) || rawChildren.length === 0) continue;
if (depth + 1 >= maxDepth) {
if (charge()) return true;
if (charge(depth + 1)) return true;
continue;
}
if (visit(rawChildren, depth + 1, new Set(ancestors).add(value))) return true;
@@ -74,7 +77,7 @@ export function clampWithWarning(
const validNumber = typeof value === "number" && Number.isFinite(value);
const number = validNumber ? Math.trunc(value) : fallback;
const clamped = Math.min(maximum, Math.max(minimum, number));
if (value !== undefined && (!validNumber || number !== clamped)) {
if (value !== undefined && (!validNumber || value !== number || number !== clamped)) {
warnings.add(`RECURSIVE_LIST_LIMIT_CLAMPED: ${name} was clamped to ${clamped}`);
}
return clamped;
@@ -18,7 +18,7 @@ export type {
export function TrustedRecursiveList({ ...props }: TrustedRecursiveListProps) {
return (
<RecursiveListErrorBoundary resetKey={props.nodes}>
<RecursiveListErrorBoundary resetKey={props.nodes} code="RECURSIVE_LIST_RENDER_ERROR">
<TrustedRecursiveListInner {...props} />
</RecursiveListErrorBoundary>
);
@@ -51,16 +51,13 @@ function TrustedRecursiveListInner({
}
class RecursiveListErrorBoundary extends Component<
{ children: ReactNode; resetKey: readonly TreeNodeData[] },
{ error: Error | null; resetKey: readonly TreeNodeData[] }
{ children: ReactNode; resetKey: unknown; code: string },
{ error: Error | null; resetKey: unknown }
> {
public state = { error: null, resetKey: this.props.resetKey } as {
error: Error | null;
resetKey: readonly TreeNodeData[];
};
public state = { error: null, resetKey: this.props.resetKey } as { error: Error | null; resetKey: unknown };
public static getDerivedStateFromProps(
props: Readonly<{ children: ReactNode; resetKey: readonly TreeNodeData[] }>,
state: Readonly<{ error: Error | null; resetKey: readonly TreeNodeData[] }>,
props: Readonly<{ children: ReactNode; resetKey: unknown; code: string }>,
state: Readonly<{ error: Error | null; resetKey: unknown }>,
) {
if (props.resetKey === state.resetKey) return null;
return { error: null, resetKey: props.resetKey };
@@ -72,7 +69,7 @@ class RecursiveListErrorBoundary extends Component<
public render() {
return this.state.error ? (
<Text size="xs" c="red">
RECURSIVE_LIST_RENDER_ERROR: {this.state.error.message}
{this.props.code}: {this.state.error.message}
</Text>
) : (
this.props.children
@@ -88,7 +85,7 @@ function TrustedRecursiveListNode({
gap,
}: RenderTreeNodePayload & { gap: MantineSpacing }) {
return (
<Group {...elementProps} gap={gap} wrap="nowrap" py={2}>
<Group {...elementProps} aria-expanded={hasChildren ? expanded : undefined} gap={gap} wrap="nowrap" py={2}>
<Box w={16} h={16} style={{ flex: "0 0 16px" }} aria-hidden>
{hasChildren && (
<IconChevronRight
@@ -97,7 +94,11 @@ function TrustedRecursiveListNode({
/>
)}
</Box>
<Box style={{ minWidth: 0, flex: 1 }}>{node.label}</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<RecursiveListErrorBoundary resetKey={node.label} code="RECURSIVE_LIST_BRANCH_RENDER_ERROR">
{node.label}
</RecursiveListErrorBoundary>
</Box>
</Group>
);
}
@@ -145,8 +145,8 @@ export function ActionButton(props: ActionButtonProps) {
export interface ToggleSwitchProps {
requestId?: string;
onParams?: CustomJsxRuntimeParams;
offParams?: CustomJsxRuntimeParams;
enabledParams?: CustomJsxRuntimeParams;
disabledParams?: CustomJsxRuntimeParams;
initialValue?: boolean | string;
label?: string;
color?: string;
@@ -160,8 +160,8 @@ export function ToggleSwitch(props: ToggleSwitchProps) {
const initial = parseBool(props.initialValue);
const [checked, setChecked] = useState(initial);
const locked = useRef(false);
const onParams = normalizeParams(props.onParams);
const offParams = normalizeParams(props.offParams);
const enabledParams = normalizeParams(props.enabledParams);
const disabledParams = normalizeParams(props.disabledParams);
const capability = runtime.requestCapabilities.find(
(candidate) => candidate.id === props.requestId && candidate.kind === "action",
);
@@ -169,7 +169,7 @@ export function ToggleSwitch(props: ToggleSwitchProps) {
if (!locked.current) setChecked(initial);
}, [initial]);
const change = async (next: boolean) => {
const params = next ? onParams : offParams;
const params = next ? enabledParams : disabledParams;
if (!props.requestId || !params || locked.current) return;
const confirmation = capability?.confirmation;
const confirmMessage =
@@ -231,8 +231,8 @@ export function ToggleSwitch(props: ToggleSwitchProps) {
mutation.isPending ||
runtime.isEditMode ||
(!runtime.itemId && !runtime.previewSessionId) ||
!onParams ||
!offParams
!enabledParams ||
!disabledParams
}
/>
);
@@ -42,7 +42,7 @@ describe("buildCustomWidgetAiPrompt", () => {
expect(CUSTOM_WIDGET_MANTINE_VERSION).toBe(mantinePackage.version);
expect(source).not.toContain("skill-content.generated.json");
expect(source).not.toContain("component-catalog.generated.json");
expect(source).not.toContain('from "./component-catalog"');
expect(source).toContain('from "./component-catalog"');
expect(source).not.toContain('from "./authoring-resources"');
});
@@ -11,6 +11,8 @@ import {
import { CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL } from "../core/ai-prompt";
import { customJsxAuthoringCatalog } from "../core/component-catalog";
import {
CUSTOM_WIDGET_CANONICAL_RUNTIME_EXAMPLES,
CUSTOM_WIDGET_CANONICAL_COMPONENT_CATALOG,
CUSTOM_WIDGET_OFFLINE_BUNDLE,
CUSTOM_WIDGET_SKILL_FILES,
CUSTOM_WIDGET_SKILL_MD,
@@ -19,13 +21,16 @@ import {
const repositoryRoot = resolve(import.meta.dirname, "../../../..");
const skillRoot = resolve(repositoryRoot, ".agents/skills/homarr-custom-widget");
const generatedPath = resolve(repositoryRoot, "packages/custom-widgets/src/core/skill-content.generated.json");
const MAX_OFFLINE_BUNDLE_CHARACTERS = 900_000;
const MAX_OFFLINE_BUNDLE_CHARACTERS = 1_600_000;
const expectedPaths = [
"SKILL.md",
"references/schema.md",
"references/widget-schema.json",
"references/runtime.md",
"references/security.md",
"references/components.md",
"references/component-catalog.json",
"references/examples.json",
"references/mantine-core.md",
"references/mantine-dates.md",
"references/mantine-charts.md",
@@ -49,12 +54,24 @@ describe("custom widget Agent Skill", () => {
});
test("builds a complete self-verifying offline bundle", () => {
for (const file of CUSTOM_WIDGET_SKILL_FILES) {
for (const file of CUSTOM_WIDGET_SKILL_FILES.filter(
({ path }) => !["references/component-catalog.json", "references/examples.json"].includes(path),
)) {
expect(CUSTOM_WIDGET_OFFLINE_BUNDLE).toContain(`--- BEGIN SKILL FILE: ${file.path} ---`);
expect(CUSTOM_WIDGET_OFFLINE_BUNDLE).toContain(file.content.trimEnd());
}
expect(CUSTOM_WIDGET_OFFLINE_BUNDLE).not.toContain("--- BEGIN COMPACT COMPONENT CATALOG ---");
expect(CUSTOM_WIDGET_OFFLINE_BUNDLE).not.toContain("--- BEGIN CANONICAL RUNTIME EXAMPLES ---");
expect(CUSTOM_WIDGET_OFFLINE_BUNDLE).toContain(
`--- BEGIN CANONICAL COMPONENT CATALOG ---\n${CUSTOM_WIDGET_CANONICAL_COMPONENT_CATALOG}\n--- END CANONICAL COMPONENT CATALOG ---`,
);
expect(CUSTOM_WIDGET_OFFLINE_BUNDLE).toContain(
`--- BEGIN CANONICAL RUNTIME EXAMPLES ---\n${CUSTOM_WIDGET_CANONICAL_RUNTIME_EXAMPLES}\n--- END CANONICAL RUNTIME EXAMPLES ---`,
);
const firstComponent = customJsxAuthoringCatalog.components[0];
const lastComponent = customJsxAuthoringCatalog.components.at(-1);
expect(firstComponent).toBeDefined();
expect(lastComponent).toBeDefined();
expect(CUSTOM_WIDGET_CANONICAL_COMPONENT_CATALOG).toContain(JSON.stringify(firstComponent));
expect(CUSTOM_WIDGET_CANONICAL_COMPONENT_CATALOG).toContain(JSON.stringify(lastComponent));
expect(CUSTOM_WIDGET_OFFLINE_BUNDLE).toContain("Custom Widget authoring version: 2.0.0");
expect(CUSTOM_WIDGET_OFFLINE_BUNDLE).toContain("Mantine version: 9.4.1");
expect(
@@ -72,6 +89,9 @@ describe("custom widget Agent Skill", () => {
expect(skill.installCommand).toBe(
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget",
);
expect(skill.files).toBe(CUSTOM_WIDGET_SKILL_FILES);
expect(skill.componentCatalog).toBe(customJsxAuthoringCatalog);
expect(skill.examples).toEqual(expect.arrayContaining([expect.objectContaining({ id: "pokemon-evolution-tree" })]));
expect(skill).not.toHaveProperty("sha256");
expect(skill).not.toHaveProperty("downloadUrl");
});
@@ -16,13 +16,11 @@ import { customJsxComponentRegistry } from "../core/component-registry";
const repositoryRoot = resolve(import.meta.dirname, "../../../..");
const catalogPath = resolve(repositoryRoot, "packages/custom-widgets/src/core/component-catalog.generated.json");
const runtimeCatalogPath = resolve(repositoryRoot, "packages/custom-widgets/src/core/component-runtime.generated.json");
const publicCatalogPath = resolve(repositoryRoot, "apps/docs/static/custom-widgets/component-catalog-v1.json");
// Keep a reviewed ceiling close to the generated artifact so accidental metadata
// duplication must receive explicit review.
const MAX_CATALOG_BYTES = 680 * 1024;
const MAX_RUNTIME_CATALOG_BYTES = 120 * 1024;
const MAX_CATALOG_BYTES = 800 * 1024;
describe("Custom JSX authoring catalog", () => {
test("is versioned against the installed authoring surface", async () => {
@@ -82,22 +80,6 @@ describe("Custom JSX authoring catalog", () => {
expect(catalogNames).toEqual(runtimeNames);
});
test("derives a compact runtime index from the canonical catalog", async () => {
const runtimeCatalog = JSON.parse(await readFile(runtimeCatalogPath, "utf8")) as {
globalProps: string[];
components: Array<{ name: string; props: string[]; blockedProps: Array<{ name: string }> }>;
};
expect(runtimeCatalog.globalProps).toEqual(customJsxAuthoringCatalog.globalProps.map(({ name }) => name));
expect(runtimeCatalog.components).toHaveLength(customJsxAuthoringCatalog.components.length);
for (const [index, component] of runtimeCatalog.components.entries()) {
const canonical = customJsxAuthoringCatalog.components[index];
expect(component.name).toBe(canonical?.name);
expect(component.props).toEqual(canonical?.props.map(({ name }) => name));
expect(component.blockedProps).toEqual(canonical?.blockedProps);
}
expect((await readFile(runtimeCatalogPath)).byteLength).toBeLessThanOrEqual(MAX_RUNTIME_CATALOG_BYTES);
});
test("includes prop APIs, bindings, accessibility, and denied reasons", () => {
const textInput = customJsxAuthoringCatalog.components.find(({ name }) => name === "TextInput");
const label = textInput?.props.find(({ name }) => name === "label");
@@ -36,27 +36,17 @@ describe("generated Custom Widget component references", () => {
expect(generated["components.md"]).toContain(`Custom Widget ${customJsxAuthoringCatalog.customWidgetVersion}`);
expect(generated["components.md"]).toContain(`Mantine ${customJsxAuthoringCatalog.mantineVersion}`);
for (const component of customJsxAuthoringCatalog.components) {
expect(combined, component.name).toContain(`<a id="${getComponentReferenceAnchor(component.name)}"></a>`);
}
for (const component of customJsxAuthoringCatalog.components)
expect(combined, component.name).toContain(component.name);
});
test("contains the first and last canonical catalog records with their real prop APIs", () => {
test("keeps detailed records only for Homarr-owned runtime components", () => {
const generated = renderCustomWidgetComponentReferences(customJsxAuthoringCatalog);
const first = customJsxAuthoringCatalog.components[0];
const last = customJsxAuthoringCatalog.components.at(-1);
expect(first).toBeDefined();
expect(last).toBeDefined();
if (!first || !last) return;
const packagePath = {
"@mantine/core": "mantine-core.md",
"@mantine/dates": "mantine-dates.md",
"@mantine/charts": "mantine-charts.md",
"@homarr/widgets": "homarr-components.md",
} as const;
for (const component of [first, last]) {
const reference = generated[packagePath[component.package]];
const homarrComponents = customJsxAuthoringCatalog.components.filter(
(component) => component.package === "@homarr/widgets",
);
for (const component of homarrComponents) {
const reference = generated["homarr-components.md"];
expect(reference).toContain(`<a id="${getComponentReferenceAnchor(component.name)}"></a>`);
expect(reference).toContain(`### \`${component.name}\``);
for (const prop of component.props) {
@@ -70,6 +60,9 @@ describe("generated Custom Widget component references", () => {
expect(GENERATED_COMPONENT_REFERENCE_PATHS).not.toContain("schema.md");
expect(GENERATED_COMPONENT_REFERENCE_PATHS).not.toContain("runtime.md");
expect(GENERATED_COMPONENT_REFERENCE_PATHS).not.toContain("security.md");
expect(GENERATED_COMPONENT_REFERENCE_PATHS).not.toContain("mantine-core.md");
expect(GENERATED_COMPONENT_REFERENCE_PATHS).not.toContain("mantine-dates.md");
expect(GENERATED_COMPONENT_REFERENCE_PATHS).not.toContain("mantine-charts.md");
});
});
@@ -4,7 +4,12 @@ import { resolve } from "node:path";
import { describe, expect, test } from "vitest";
import { CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL } from "../core/ai-prompt";
import { buildCustomWidgetAiPromptWithEmbeddedSkill, CUSTOM_WIDGET_SKILL_FILES } from "../core/embedded-ai-prompt";
import { CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION } from "../core/ai-prompt";
import {
buildCustomWidgetAiPromptWithEmbeddedSkill,
CUSTOM_WIDGET_OFFLINE_BUNDLE_CONTENT,
CUSTOM_WIDGET_SKILL_FILES,
} from "../core/embedded-ai-prompt";
describe("buildCustomWidgetAiPromptWithEmbeddedSkill", () => {
test("stays behind dedicated package exports instead of the core barrel", async () => {
@@ -25,7 +30,7 @@ describe("buildCustomWidgetAiPromptWithEmbeddedSkill", () => {
expect(coreIndexSource).not.toContain('export * from "./embedded-ai-prompt"');
});
test("embeds every generated skill file without duplicating bulk resources", () => {
test("embeds the complete release-matched offline authoring bundle", () => {
const prompt = buildCustomWidgetAiPromptWithEmbeddedSkill(
undefined,
undefined,
@@ -34,20 +39,24 @@ describe("buildCustomWidgetAiPromptWithEmbeddedSkill", () => {
);
expect(prompt.startsWith("Please create this Homarr Custom JSX v2 widget:\n\nCreate a status widget")).toBe(true);
expect(prompt).toContain("Use the complete release-matched offline bundle below.");
expect(prompt).toContain("# Homarr Custom Widget");
expect(prompt).toContain("## Authoring workflow");
for (const file of CUSTOM_WIDGET_SKILL_FILES) {
expect(prompt).toContain("This clipboard workflow has no Homarr MCP tools, repository access, or local files.");
expect(prompt).toContain(CUSTOM_WIDGET_OFFLINE_BUNDLE_CONTENT);
for (const file of CUSTOM_WIDGET_SKILL_FILES.filter(
({ path }) => !["references/component-catalog.json", "references/examples.json"].includes(path),
)) {
expect(prompt).toContain(`--- BEGIN SKILL FILE: ${file.path} ---`);
expect(prompt).toContain(file.content.trimEnd());
}
expect(prompt).not.toContain("--- BEGIN COMPACT COMPONENT CATALOG ---");
expect(prompt).not.toContain("--- BEGIN CANONICAL RUNTIME EXAMPLES ---");
expect(prompt).toContain('"name":"ActionButton"');
expect(prompt).toContain('"name":"SubFetch"');
expect(prompt).toContain("RecursiveList");
expect(prompt).toContain("const segments =");
expect(prompt).toContain("{(() => {");
expect(prompt.length).toBeGreaterThan(8_000);
expect(prompt.length).toBeLessThan(900_000);
expect(prompt).toContain("--- BEGIN CANONICAL COMPONENT CATALOG ---");
expect(prompt).toContain("--- BEGIN CANONICAL RUNTIME EXAMPLES ---");
expect(prompt.lastIndexOf(CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION)).toBeGreaterThan(
prompt.indexOf("# Homarr Custom Widget offline authoring bundle"),
);
expect(prompt.length).toBeGreaterThan(500_000);
expect(prompt.length).toBeLessThanOrEqual(1_700_000);
expect(prompt.endsWith(`${CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL}\nCharacters: ${prompt.length}`)).toBe(true);
});
@@ -58,10 +67,11 @@ describe("buildCustomWidgetAiPromptWithEmbeddedSkill", () => {
{ template: "<Text>Large</Text>".repeat(2_000) },
"Repair this widget",
);
const beforeBundle = prompt.slice(0, prompt.indexOf("Use the complete release-matched offline bundle below."));
const beforeBundle = prompt.slice(0, prompt.indexOf("This clipboard workflow has no Homarr MCP tools"));
expect(beforeBundle.match(/```/gu)?.length ?? 0).toBe(4);
expect(beforeBundle).toContain("Homarr will validate the result after it is pasted into the workbench.");
expect(prompt).toContain("--- BEGIN SKILL FILE: SKILL.md ---");
expect(prompt).toContain("# Homarr Custom Widget offline authoring bundle");
expect(prompt.length).toBeLessThanOrEqual(1_700_000);
});
});
@@ -11,6 +11,27 @@ const components = { Text: () => null };
const bindings = { data: Object.create(null) as Record<string, unknown> };
describe("shared Custom JSX policy", () => {
test.each(['status.list === "loading"', '"success" === status.list', 'status.list !== "error"'])(
"rejects string request-status comparisons in %s",
(expression) => {
expect(validateCustomJsxTemplate(`<Text>{${expression}}</Text>`)).toEqual(
expect.arrayContaining([
expect.objectContaining({
severity: "error",
message: expect.stringContaining("INVALID_STATUS_COMPARISON: status.list is an object"),
}),
]),
);
},
);
test("accepts request-status object fields", () => {
const diagnostics = validateCustomJsxTemplate(
"<Text>{status.list?.loading ? 'Loading' : status.list?.ok === false ? status.list.error : 'Ready'}</Text>",
);
expect(diagnostics.filter(({ severity }) => severity === "error")).toEqual([]);
});
test("accepts canonical Mantine compound component names", () => {
const diagnostics = validateCustomJsxTemplate(
'<Radio.Group value="one"><Radio.Card value="one"><Radio.Indicator /></Radio.Card></Radio.Group>',
@@ -288,6 +309,52 @@ describe("shared Custom JSX policy", () => {
expect(renderToStaticMarkup(rendered.node)).not.toContain("Mew");
});
test.each([
["callback return", "{data.items.map((item) => Number)}"],
["safe-block return", "{data.items.map((item) => { const value = item; return Math.round; })}"],
["conditional local", "{data.items.map((item) => { const value = true ? Math.round : item; return value; })}"],
["IIFE return", "{(() => { const value = 1; return Number; })()}"],
])("rejects callable values escaping an authored %s", (_name, template) => {
expect(validateCustomJsxTemplate(template)).toEqual(
expect.arrayContaining([
expect.objectContaining({ message: expect.stringContaining("CALLBACK_VALUE_NOT_ALLOWED") }),
]),
);
expect(() => renderSafeJsx({ template, components, bindings: createCustomJsxBindings({ items: [1] }) })).toThrow(
"CALLBACK_VALUE_NOT_ALLOWED",
);
});
test("keeps direct documented safe helper callbacks available", () => {
const template = '<Text>{data.items.map(Number).join(",")}</Text>';
expect(validateCustomJsxTemplate(template).filter(({ severity }) => severity === "error")).toEqual([]);
expect(() =>
renderSafeJsx({ template, components, bindings: createCustomJsxBindings({ items: ["1", "2"] }) }),
).not.toThrow();
});
test.each([
["expression body", "{(() => <Text>value</Text>)()}", "BLOCK_REQUIRES_FINAL_RETURN"],
["optional call", "{(() => { const value = 1; return <Text>{value}</Text>; })?.()}", "CALL_TARGET_NOT_ALLOWED"],
])("rejects unsupported derived-value IIFE form: %s", (_name, template, code) => {
expect(validateCustomJsxTemplate(template)).toEqual(
expect.arrayContaining([expect.objectContaining({ message: expect.stringContaining(code) })]),
);
expect(() => renderSafeJsx({ template, components, bindings })).toThrow();
});
test("normalizes duplicate callback parameter parse diagnostics", () => {
const template = "{data.items.map((item, item) => item)}";
expect(validateCustomJsxTemplate(template)).toEqual(
expect.arrayContaining([
expect.objectContaining({ message: expect.stringContaining("DUPLICATE_LOCAL_BINDING") }),
]),
);
expect(() => renderSafeJsx({ template, components, bindings: { data: { items: [1] } } })).toThrow(
"DUPLICATE_LOCAL_BINDING",
);
});
test.each([
[
"let mutation",
@@ -32,6 +32,9 @@ describe("RecursiveList", () => {
const html = renderToStaticMarkup(createElement(MantineProvider, null, rendered.node));
expect(html).toContain("0: LEVEL-0");
expect(html).toContain("7: LEVEL-7");
expect(html).toContain('role="tree"');
expect(html).toContain('role="treeitem"');
expect(html).toContain('aria-expanded="true"');
});
test("contains malformed children, cycles, depth limits, and node limits", () => {
@@ -158,6 +161,39 @@ describe("RecursiveList", () => {
).toEqual(
expect.arrayContaining([expect.objectContaining({ message: expect.stringContaining("INVALID_PROP_VALUE") })]),
);
expect(
validateCustomJsxTemplate(
'<RecursiveList data={data.tree} childrenPath="children" keyPath="name" maxNodes={1.5}>{(node) => node.name}</RecursiveList>',
),
).toEqual(
expect.arrayContaining([expect.objectContaining({ message: expect.stringContaining("INVALID_PROP_VALUE") })]),
);
});
test("uses namespaced primitive keys and stable path fallbacks", () => {
const warnings = new Set<string>();
const nodes = buildTrustedRecursiveList({
data: [{ id: 1 }, { id: "1" }, { id: true }, { id: "duplicate" }, { id: "duplicate" }, {}],
childrenPath: "children",
keyPath: "id",
warnings,
budget: new Budget(DEFAULT_BUDGETS),
render: () => null,
});
expect(nodes.map(({ value }) => value)).toEqual([
"key:number:1",
"key:string:1",
"key:boolean:true",
"key:string:duplicate",
"path:4",
"path:5",
]);
expect([...warnings]).toEqual(
expect.arrayContaining([
expect.stringContaining("RECURSIVE_LIST_DUPLICATE_KEY"),
expect.stringContaining("RECURSIVE_LIST_MISSING_KEY"),
]),
);
});
test("contains ordinary branch errors even when their text resembles a budget failure", () => {
@@ -203,7 +239,7 @@ describe("RecursiveList", () => {
);
});
test("resets its contained render error when a corrected node model arrives", async () => {
test("contains a branch render error and resets it when a corrected node model arrives", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
@@ -226,11 +262,17 @@ describe("RecursiveList", () => {
await act(async () => {
root.render(
<MantineProvider>
<TrustedRecursiveList nodes={[{ value: "broken", label: <BrokenLabel /> }]} />
<TrustedRecursiveList
nodes={[
{ value: "broken", label: <BrokenLabel /> },
{ value: "healthy", label: "Healthy sibling" },
]}
/>
</MantineProvider>,
);
});
expect(host.textContent).toContain("RECURSIVE_LIST_RENDER_ERROR");
expect(host.textContent).toContain("RECURSIVE_LIST_BRANCH_RENDER_ERROR");
expect(host.textContent).toContain("Healthy sibling");
await act(async () => {
root.render(
@@ -326,7 +326,10 @@ describe("Custom Widget runtime ports", () => {
const port = createPort({
executeAction: vi.fn(async () => ({ ok: false, status: 500, data: null, error: "Nope" })),
});
await render(<ToggleSwitch requestId="toggle" onParams={{ enabled: true }} offParams={{ enabled: false }} />, port);
await render(
<ToggleSwitch requestId="toggle" enabledParams={{ enabled: true }} disabledParams={{ enabled: false }} />,
port,
);
const input = host.querySelector("input") as HTMLInputElement;
await act(async () => {
input.click();
@@ -340,7 +343,7 @@ describe("Custom Widget runtime ports", () => {
it("confirms a DELETE toggle before executing it", async () => {
const port = createPort();
await render(
<ToggleSwitch requestId="delete-toggle" onParams={{ enabled: true }} offParams={{ enabled: false }} />,
<ToggleSwitch requestId="delete-toggle" enabledParams={{ enabled: true }} disabledParams={{ enabled: false }} />,
port,
[
{
@@ -19,7 +19,11 @@ import {
getCustomWidgetOptionsJsonSchema,
getCustomWidgetRequestsJsonSchema,
} from "../core/schema-references";
import { getCustomJsxComponentPropCompletions, getCustomJsxLocalConstCompletions } from "../workbench/code-language";
import {
customJsxSafeBlockCompletions,
getCustomJsxComponentPropCompletions,
getCustomJsxLocalConstCompletions,
} from "../workbench/code-language";
describe("Custom Widget workbench contracts", () => {
it("always provides a valid starter template", () => {
@@ -108,6 +112,17 @@ describe("Custom Widget workbench contracts", () => {
expect(labels).not.toContain("future");
});
it("completes callback parameters and publishes safe-block snippets", () => {
const source = `{data.items.map((item, index, collection) => {
const value = item;
return <Text>{value}</Text>;
})}`;
const cursor = source.indexOf("return <Text>");
const labels = getCustomJsxLocalConstCompletions(source, cursor).map(({ label }) => label);
expect(labels).toEqual(expect.arrayContaining(["item", "index", "collection", "value"]));
expect(customJsxSafeBlockCompletions.map(({ label }) => label)).toEqual(["map block", "derived value IIFE"]);
});
it("uses canonical component prop types for contextual JSX completions", () => {
const textInput = getCustomJsxComponentPropCompletions("TextInput");
expect(textInput).toEqual(
@@ -130,7 +130,11 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
/>
{props.reference && (
<Collapse expanded={referenceOpened}>
<CustomWidgetSchemaReference reference={props.reference} messages={props.messages} />
<CustomWidgetSchemaReference
id={`${props.id}-schema-reference`}
reference={props.reference}
messages={props.messages}
/>
</Collapse>
)}
<div className={classes.viewport}>
@@ -201,9 +205,11 @@ export function ReadOnlyCustomWidgetCode(props: Omit<CustomWidgetCodeEditorProps
}
export function CustomWidgetSchemaReference({
id,
reference,
messages,
}: {
id: string;
reference: CustomWidgetSchemaReferenceData;
messages: CustomWidgetEditorMessages;
}) {
@@ -219,7 +225,7 @@ export function CustomWidgetSchemaReference({
{Object.entries(values).map(([key, value]) => (
<Tabs.Panel key={key} value={key} pt="xs">
<ReadOnlyCustomWidgetCode
id={`schema-reference-${key}`}
id={`${id}-${key}`}
label={labels[key as keyof typeof labels]}
language="json"
value={JSON.stringify(value, null, 2)}
@@ -9,6 +9,7 @@ import {
getCustomJsxComponentProps,
} from "../core/component-catalog";
import { customJsxTablerIconNames } from "../core/tabler-icons";
import { ROOT_BINDINGS } from "../jsx/analyzer-language";
const enabledCatalogComponents = customJsxAuthoringCatalog.components.filter(
(component) => component.safety !== "denied",
@@ -40,7 +41,7 @@ const iconCompletions: Completion[] = customJsxTablerIconNames.map((name) => ({
detail: "Safe Tabler icon",
}));
const safeBlockCompletions: Completion[] = [
export const customJsxSafeBlockCompletions: Completion[] = [
snippetCompletion("map((item) => {\n\tconst value = ${};\n\treturn ${};\n})", {
label: "map block",
detail: "Safe immutable collection callback",
@@ -53,6 +54,12 @@ const safeBlockCompletions: Completion[] = [
}),
];
const rootBindingCompletions: Completion[] = [...ROOT_BINDINGS].toSorted().map((label) => ({
label,
type: ["data", "status", "options", "inputs"].includes(label) ? "variable" : "function",
detail: "Safe Custom JSX binding",
}));
export function getCustomJsxLocalConstCompletions(source: string, cursor: number): Completion[] {
const wrapped = `<>${source}</>`;
const target = cursor + 2;
@@ -63,6 +70,18 @@ export function getCustomJsxLocalConstCompletions(source: string, cursor: number
activeNode = parent;
}
const completions: Completion[] = [];
let arrow = activeNode.parent;
while (arrow && arrow.name !== "ArrowFunction") arrow = arrow.parent;
arrow
?.getChild("ParamList")
?.getChildren("VariableDefinition")
.forEach((definition) => {
completions.push({
label: wrapped.slice(definition.from, definition.to),
type: "variable",
detail: "Safe callback parameter",
});
});
for (let statement = activeNode.firstChild; statement; statement = statement.nextSibling) {
if (statement.name !== "VariableDeclaration" || statement.from >= target) continue;
if (statement.firstChild?.name !== "const") continue;
@@ -97,7 +116,13 @@ export const createCustomJsxCompletionSource =
: componentCompletions;
return {
from: word?.from ?? context.pos,
options: [...localCompletions, ...safeBlockCompletions, ...contextualCompletions, ...additional],
options: [
...localCompletions,
...customJsxSafeBlockCompletions,
...rootBindingCompletions,
...contextualCompletions,
...additional,
],
};
};
-2
View File
@@ -4090,8 +4090,6 @@
"configuration": {
"needsAttention": "Configuration needs attention: {message}",
"advancedOptions": "Advanced options",
"advancedJson": "Advanced JSON",
"jsonObjectRequired": "Configuration must be a JSON object",
"invalidJson": "Invalid JSON"
}
},
-2
View File
@@ -29,7 +29,6 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@codemirror/lang-json": "catalog:",
"@dnd-kit/core": "catalog:",
"@dnd-kit/sortable": "catalog:",
"@homarr/api": "workspace:^0.1.0",
@@ -76,7 +75,6 @@
"@tiptap/extension-underline": "catalog:",
"@tiptap/react": "catalog:",
"@tiptap/starter-kit": "catalog:",
"@uiw/react-codemirror": "catalog:",
"acorn": "catalog:",
"acorn-jsx": "catalog:",
"clsx": "catalog:",
@@ -1,8 +1,6 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useMemo, useState } from "react";
import { json } from "@codemirror/lang-json";
import { useEffect, useId, useMemo, useState } from "react";
import {
Accordion,
Alert,
@@ -17,23 +15,20 @@ import {
Text,
Textarea,
TextInput,
useComputedColorScheme,
} from "@mantine/core";
import { DateInput, TimeInput } from "@mantine/dates";
import { IconAlertTriangle, IconBraces } from "@tabler/icons-react";
import { IconAlertTriangle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useOptionalBoard } from "@homarr/boards/context";
import { resolveCustomWidgetOptionsBinding, validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
import { IconPicker } from "@homarr/forms-collection";
import { useScopedI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useFormContext } from "./form";
const CodeMirror = dynamic(() => import("@uiw/react-codemirror").then((module) => module.default), { ssr: false });
const jsonExtensions = [json()];
type Schema = Record<string, unknown>;
export const WidgetCustomWidgetConfigurationInput = ({
@@ -111,7 +106,6 @@ export const WidgetCustomWidgetConfigurationInput = ({
</Accordion.Item>
</Accordion>
)}
<AdvancedJson value={configuration} onChange={(value) => form.setFieldValue(`options.${property}`, value)} />
</Stack>
);
};
@@ -193,119 +187,158 @@ function OptionField({
/>
);
}
if (control === "switch") return <Switch label={label} description={description} required={required} {...input} />;
if (control === "number")
return <NumberInput label={label} description={description} required={required} {...input} />;
if (control === "duration")
return <NumberInput label={label} description={description} required={required} suffix=" s" min={0} {...input} />;
if (control === "slider") {
return (
<Stack gap={4}>
<Text size="sm" fw={500}>
{label}
{required ? " *" : ""}
</Text>
{description && (
<Text size="xs" c="dimmed">
{description}
</Text>
)}
<Slider
min={typeof schema.minimum === "number" ? schema.minimum : 0}
max={typeof schema.maximum === "number" ? schema.maximum : 100}
step={typeof schema.multipleOf === "number" ? schema.multipleOf : 1}
{...input}
/>
</Stack>
);
}
if (control === "select") {
const numeric = schema.type === "number" || schema.type === "integer";
return (
<Select
label={label}
description={description}
placeholder={placeholder}
data={choices}
searchable
required={required}
value={input.value === undefined || input.value === null ? null : String(input.value)}
onBlur={input.onBlur}
onChange={(value) => form.setFieldValue(path, numeric && value !== null ? Number(value) : value)}
/>
);
}
if (control === "multi-select") {
const itemSchema = isRecord(schema.items) ? schema.items : {};
const numeric = itemSchema.type === "number" || itemSchema.type === "integer";
return (
<MultiSelect
label={label}
description={description}
placeholder={placeholder}
data={choices}
searchable
required={required}
value={Array.isArray(input.value) ? input.value.map(String) : []}
onBlur={input.onBlur}
onChange={(value) => form.setFieldValue(path, numeric ? value.map(Number) : value)}
/>
);
}
if (control === "date")
return (
<DateInput label={label} description={description} required={required} valueFormat="YYYY-MM-DD" {...input} />
);
if (control === "time") return <TimeInput label={label} description={description} required={required} {...input} />;
if (control === "color") return <ColorInput label={label} description={description} required={required} {...input} />;
if (control === "icon") {
return (
<Stack gap={4}>
<IconPicker label={label} withAsterisk={required} {...input} />
{description && (
<Text size="xs" c="dimmed">
{description}
</Text>
)}
</Stack>
);
}
if (control === "timeZone") {
return (
<Select
label={label}
description={description}
data={Intl.supportedValuesOf("timeZone")}
searchable
required={required}
{...input}
/>
);
}
if (control === "textarea")
return (
<Textarea
label={label}
description={description}
placeholder={placeholder}
required={required}
autosize
minRows={3}
{...input}
/>
);
return (
<TextInput
<StaticOptionControl
control={control}
schema={schema}
path={path}
label={label}
description={description}
placeholder={placeholder}
required={required}
type={control === "url" ? "url" : "text"}
{...input}
choices={choices}
/>
);
}
function StaticOptionControl({
control,
schema,
path,
label,
description,
placeholder,
required,
choices,
}: {
control: string;
schema: Schema;
path: string;
label: string;
description?: string;
placeholder?: string;
required: boolean;
choices: Array<{ value: string; label: string }>;
}) {
const form = useFormContext();
const input = form.getInputProps(path, { type: control === "switch" ? "checkbox" : "input" });
switch (control) {
case "switch":
return <Switch label={label} description={description} required={required} {...input} />;
case "number":
return <NumberInput label={label} description={description} required={required} {...input} />;
case "duration":
return <NumberInput label={label} description={description} required={required} suffix=" s" min={0} {...input} />;
case "slider":
return (
<Stack gap={4}>
<Text size="sm" fw={500}>
{label}
{required ? " *" : ""}
</Text>
{description && (
<Text size="xs" c="dimmed">
{description}
</Text>
)}
<Slider
min={typeof schema.minimum === "number" ? schema.minimum : 0}
max={typeof schema.maximum === "number" ? schema.maximum : 100}
step={typeof schema.multipleOf === "number" ? schema.multipleOf : 1}
{...input}
/>
</Stack>
);
case "select": {
const numeric = schema.type === "number" || schema.type === "integer";
return (
<Select
label={label}
description={description}
placeholder={placeholder}
data={choices}
searchable
required={required}
value={input.value === undefined || input.value === null ? null : String(input.value)}
onBlur={input.onBlur}
onChange={(value) => form.setFieldValue(path, numeric && value !== null ? Number(value) : value)}
/>
);
}
case "multi-select": {
const itemSchema = isRecord(schema.items) ? schema.items : {};
const numeric = itemSchema.type === "number" || itemSchema.type === "integer";
return (
<MultiSelect
label={label}
description={description}
placeholder={placeholder}
data={choices}
searchable
required={required}
value={Array.isArray(input.value) ? input.value.map(String) : []}
onBlur={input.onBlur}
onChange={(value) => form.setFieldValue(path, numeric ? value.map(Number) : value)}
/>
);
}
case "date":
return (
<DateInput label={label} description={description} required={required} valueFormat="YYYY-MM-DD" {...input} />
);
case "time":
return <TimeInput label={label} description={description} required={required} {...input} />;
case "color":
return <ColorInput label={label} description={description} required={required} {...input} />;
case "icon":
return (
<Stack gap={4}>
<IconPicker label={label} withAsterisk={required} {...input} />
{description && (
<Text size="xs" c="dimmed">
{description}
</Text>
)}
</Stack>
);
case "timeZone":
return (
<Select
label={label}
description={description}
data={Intl.supportedValuesOf("timeZone")}
searchable
required={required}
{...input}
/>
);
case "textarea":
return (
<Textarea
label={label}
description={description}
placeholder={placeholder}
required={required}
autosize
minRows={3}
{...input}
/>
);
default:
return (
<TextInput
label={label}
description={description}
placeholder={placeholder}
required={required}
type={control === "url" ? "url" : "text"}
{...input}
/>
);
}
}
function DynamicOptionsSelect({
label,
description,
@@ -391,48 +424,6 @@ function DynamicOptionsSelect({
);
}
function AdvancedJson({
value,
onChange,
}: {
value: Record<string, unknown>;
onChange(value: Record<string, unknown>): void;
}) {
const labels = useScopedI18n("widget.customApi.configuration");
const [draft, setDraft] = useState(() => JSON.stringify(value, null, 2));
const [error, setError] = useState<string | null>(null);
useEffect(() => setDraft(JSON.stringify(value, null, 2)), [value]);
return (
<Accordion variant="contained">
<Accordion.Item value="json">
<Accordion.Control icon={<IconBraces size={16} />}>{labels("advancedJson")}</Accordion.Control>
<Accordion.Panel>
<ConfigurationJsonEditor
value={draft}
height="220px"
onChange={(next) => {
setDraft(next);
try {
const parsed = JSON.parse(next) as unknown;
if (!isRecord(parsed)) throw new Error(labels("jsonObjectRequired"));
setError(null);
onChange(parsed);
} catch (cause) {
setError(cause instanceof Error ? cause.message : labels("invalidJson"));
}
}}
/>
{error && (
<Text c="red" size="xs" mt="xs">
{error}
</Text>
)}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}
function JsonValueInput({
label,
description,
@@ -451,51 +442,84 @@ function JsonValueInput({
const [error, setError] = useState<string | null>(null);
useEffect(() => setDraft(JSON.stringify(value ?? null, null, 2)), [value]);
return (
<Stack gap={4}>
<Text size="sm" fw={500}>
{label}
{required ? " *" : ""}
</Text>
{description && (
<Text size="xs" c="dimmed">
{description}
</Text>
)}
<ConfigurationJsonEditor
value={draft}
height="160px"
onChange={(next) => {
setDraft(next);
try {
const parsed = JSON.parse(next) as unknown;
setError(null);
onChange(parsed);
} catch (cause) {
setError(cause instanceof Error ? cause.message : labels("invalidJson"));
}
}}
/>
{error && (
<Text c="red" size="xs">
{error}
</Text>
)}
</Stack>
<ConfigurationJsonEditor
label={label}
description={description}
required={required}
error={error}
value={draft}
height="160px"
onChange={(next) => {
setDraft(next);
try {
const parsed = JSON.parse(next) as unknown;
setError(null);
onChange(parsed);
} catch (cause) {
setError(cause instanceof Error ? cause.message : labels("invalidJson"));
}
}}
/>
);
}
function ConfigurationJsonEditor({
label,
description,
required,
error,
value,
height,
onChange,
}: {
label: string;
description?: string;
required?: boolean;
error?: string | null;
value: string;
height: string;
onChange(value: string): void;
}) {
const colorScheme = useComputedColorScheme("light");
const t = useScopedI18n("customWidget.editor");
const id = useId();
return (
<CodeMirror value={value} extensions={jsonExtensions} height={height} theme={colorScheme} onChange={onChange} />
<CustomWidgetCodeEditor
id={id}
label={label}
description={description}
required={required}
error={error}
value={value}
language="json"
height={height}
onChange={onChange}
messages={{
languageJsx: t("language.jsx"),
languageJson: t("language.json"),
undo: t("action.undo"),
redo: t("action.redo"),
components: t("action.components"),
componentSearch: t("componentReference.search"),
componentEmpty: t("componentReference.empty"),
componentCount: (count) => t("componentReference.count", { count }),
insertStarter: t("action.insertStarter"),
format: t("action.format"),
copy: t("action.copy"),
copied: t("action.copied"),
schema: t("action.schema"),
schemaTab: t("reference.schema"),
minimalTab: t("reference.minimal"),
fullTab: t("reference.full"),
errors: (count) => t("status.errors", { count }),
warnings: (count) => t("status.warnings", { count }),
ready: t("status.ready"),
position: (cursor) => t("status.position", cursor),
characters: (count, limit) =>
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
diagnosticsTitle: t("diagnostics.title"),
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
}}
/>
);
}
-31
View File
@@ -1,31 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { WorkshopClient } from "./client";
describe("Workshop deletion", () => {
const cases: Array<{
label: string;
action: (client: WorkshopClient) => Promise<void>;
collection: string;
id: string;
}> = [
{
label: "submission",
action: (client) => client.delete("submission-id"),
collection: "submissions",
id: "submission-id",
},
{ label: "report", action: (client) => client.dismissReport("report-id"), collection: "reports", id: "report-id" },
];
test.each(cases)("deletes $label records through PocketBase", async ({ action, collection: collectionName, id }) => {
const client = new WorkshopClient("https://workshop.example.com");
const deleteRecord = vi.fn().mockResolvedValue(true);
const collection = vi.spyOn(client.pocketBase, "collection").mockReturnValue({ delete: deleteRecord } as never);
await action(client);
expect(collection).toHaveBeenCalledWith(collectionName);
expect(deleteRecord).toHaveBeenCalledWith(id);
});
});
+61 -74
View File
@@ -1,5 +1,7 @@
import PocketBase, { ClientResponseError } from "pocketbase";
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import type {
WorkshopReport,
WorkshopSubmissionDetail,
@@ -11,7 +13,6 @@ import type {
import {
WORKSHOP_API_URL,
WORKSHOP_REQUEST_TIMEOUT_MS,
WorkshopError,
workshopReportSchema,
workshopSubmissionDetailSchema,
workshopSubmissionSummarySchema,
@@ -21,8 +22,6 @@ import {
workshopVoteSchema,
} from "./schema";
const WORKSHOP_OAUTH_TIMEOUT_MS = 2 * 60_000;
export interface WorkshopListOptions {
page?: number;
perPage?: number;
@@ -47,32 +46,34 @@ const requestSignal = (signal?: AbortSignal) =>
: [AbortSignal.timeout(WORKSHOP_REQUEST_TIMEOUT_MS)],
);
function workshopError(error: unknown, fallback: string) {
if (error instanceof WorkshopError) return error;
if (error instanceof ClientResponseError) {
const code =
error.status === 401
? "authentication_required"
: error.status === 403
? "forbidden"
: error.status === 404
? "not_found"
: error.status === 409
? "conflict"
: error.status === 429
? "rate_limited"
: error.status >= 500
? "unavailable"
: "unknown";
return new WorkshopError(code, error.data?.message || error.message || fallback, error.status);
}
if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError"))
return new WorkshopError("unavailable", `${fallback}. The request timed out.`);
return new WorkshopError("unknown", error instanceof Error ? error.message : fallback);
const OAUTH_TIMEOUT_MS = 2 * 60 * 1000;
function oauthText(...values: unknown[]) {
return values.find((value): value is string => typeof value === "string" && value.trim().length > 0)?.trim();
}
function firstNonEmptyString(...values: unknown[]) {
return values.find((value): value is string => typeof value === "string" && value.trim().length > 0)?.trim();
function githubProfile(meta: Record<string, unknown> | undefined, record: Record<string, unknown>) {
const rawUser =
meta?.rawUser && typeof meta.rawUser === "object" ? (meta.rawUser as Record<string, unknown>) : undefined;
const displayName = oauthText(meta?.name, meta?.username, rawUser?.name, rawUser?.login);
const avatarUrl = oauthText(meta?.avatarUrl, meta?.avatarURL, rawUser?.avatar_url);
return {
displayName: (displayName ?? `GitHub user ${String(record.id ?? "").slice(0, 8)}`).slice(0, 100),
avatarUrl: avatarUrl?.startsWith("https://") ? avatarUrl : undefined,
};
}
function workshopError(error: unknown, fallback: string) {
if (error instanceof ClientResponseError) {
const sdkMessage = error.data?.message || error.message;
const originalMessage =
error.originalError instanceof Error ? error.originalError.message : String(error.originalError?.message ?? "");
const message = !sdkMessage || sdkMessage === "Something went wrong." ? originalMessage || fallback : sdkMessage;
return new Error(message, { cause: error });
}
if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError"))
return new Error(`${fallback}. The request timed out.`, { cause: error });
return error instanceof Error ? error : new Error(fallback);
}
export class WorkshopClient {
@@ -102,83 +103,67 @@ export class WorkshopClient {
try {
await this.pocketBase.collection("users").authRefresh();
return this.currentUser;
} catch {
this.pocketBase.authStore.clear();
return null;
} catch (error) {
if (error instanceof ClientResponseError && (error.status === 401 || error.status === 403)) {
this.pocketBase.authStore.clear();
return null;
}
return this.currentUser;
}
}
public async signInWithGitHub() {
if (typeof window === "undefined" || typeof window.open !== "function") {
throw new WorkshopError("unavailable", "GitHub sign-in requires a browser popup");
throw new Error("GitHub sign-in requires a browser popup");
}
const popup = window.open(
"about:blank",
"homarr_workshop_oauth",
"width=1024,height=768,resizable=yes,scrollbars=yes,menubar=no",
);
if (!popup) throw new WorkshopError("unavailable", "GitHub sign-in popup was blocked by the browser");
const popup = window.open("about:blank", "homarr_workshop_oauth", "width=1024,height=768,resizable,menubar=no");
if (!popup) throw new Error("GitHub sign-in popup was blocked. Allow popups for Homarr and try again.");
const requestKey = `workshop_oauth_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const requestKey = `workshop-oauth-${Date.now()}-${Math.random().toString(36).slice(2)}`;
let cancellationError: Error | null = null;
let closePoll: ReturnType<typeof setInterval> | undefined;
let timeout: ReturnType<typeof setTimeout> | undefined;
const popupFailure = new Promise<never>((_resolve, reject) => {
const cancellation = new Promise<never>((_resolve, reject) => {
closePoll = setInterval(() => {
if (popup.closed) reject(new WorkshopError("unknown", "GitHub sign-in was cancelled"));
if (!popup.closed) return;
cancellationError = new Error("GitHub sign-in was cancelled.");
this.pocketBase.cancelRequest(requestKey);
reject(cancellationError);
}, 250);
timeout = setTimeout(
() => reject(new WorkshopError("unavailable", "GitHub sign-in timed out")),
WORKSHOP_OAUTH_TIMEOUT_MS,
);
timeout = setTimeout(() => {
cancellationError = new Error("GitHub sign-in timed out. Try again.");
this.pocketBase.cancelRequest(requestKey);
popup.close();
reject(cancellationError);
}, OAUTH_TIMEOUT_MS);
});
try {
const auth = await Promise.race([
this.pocketBase.collection("users").authWithOAuth2({
provider: "github",
createData: { displayName: "Community member" },
createData: { displayName: "GitHub user" },
requestKey,
urlCallback: (url) => {
if (popup.closed) throw new WorkshopError("unknown", "GitHub sign-in was cancelled");
if (popup.closed) throw new Error("GitHub sign-in was cancelled.");
popup.location.href = url;
},
}),
popupFailure,
cancellation,
]);
const rawUser =
auth.meta?.rawUser && typeof auth.meta.rawUser === "object"
? (auth.meta.rawUser as Record<string, unknown>)
: undefined;
const displayName = firstNonEmptyString(
auth.meta?.name,
auth.meta?.username,
rawUser?.name,
rawUser?.login,
auth.record.name,
auth.record.username,
);
const avatarUrl = firstNonEmptyString(
auth.meta?.avatarUrl,
auth.meta?.avatar,
rawUser?.avatar_url,
auth.record.avatarUrl,
auth.record.avatar,
);
if (displayName || avatarUrl) {
await this.pocketBase.collection("users").update(auth.record.id, {
...(displayName ? { displayName } : {}),
...(avatarUrl ? { avatarUrl } : {}),
});
}
return this.refreshAuth();
const profile = githubProfile(auth.meta, auth.record);
const updated = await this.pocketBase.collection("users").update(auth.record.id, profile);
this.pocketBase.authStore.save(auth.token, updated);
return this.currentUser;
} catch (error) {
if (cancellationError) throw cancellationError;
throw workshopError(error, "GitHub sign-in failed");
} finally {
if (closePoll) clearInterval(closePoll);
if (timeout) clearTimeout(timeout);
this.pocketBase.cancelRequest(requestKey);
if (!popup.closed) popup.close();
popup.close();
}
}
@@ -244,6 +229,7 @@ export class WorkshopClient {
workshopScreenshotsSchema.parse(screenshots);
const data = new FormData();
Object.entries(parsed).forEach(([key, value]) => data.set(key, value));
data.set("widgetSchema", CUSTOM_WIDGET_SCHEMA);
data.set("author", this.currentUser?.id ?? "");
screenshots.forEach((file) => data.append("screenshots", file));
try {
@@ -259,6 +245,7 @@ export class WorkshopClient {
workshopScreenshotsSchema.parse(screenshots);
const data = new FormData();
Object.entries(parsed).forEach(([key, value]) => data.set(key, value));
data.set("widgetSchema", CUSTOM_WIDGET_SCHEMA);
screenshots.forEach((file) => data.append("screenshots", file));
try {
await this.pocketBase.collection("submissions").update(id, data);
+1 -61
View File
@@ -34,6 +34,7 @@ const workshopSubmissionBaseSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string().default(""),
widgetSchema: z.string(),
screenshots: workshopFileListSchema,
author: z.string(),
authorName: z.string(),
@@ -106,55 +107,12 @@ export function validateWorkshopWidget(content: string): WorkshopValidationResul
.join("\n");
return { success: false, error: error || "Invalid widget" };
}
if (containsCredentialLikeValue(result.data)) {
return { success: false, error: "Widget exports must not contain credentials or credential-like static values" };
}
return { success: true, data: result.data };
} catch {
return { success: false, error: "Widget content is not valid JSON" };
}
}
function containsCredentialLikeValue(widget: z.infer<typeof customWidgetImportSchema>) {
const sensitiveName =
/(^|[-_])(authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)($|[-_])/iu;
return (
credentialTextPattern.test(widget.template) ||
widget.sources.some((source) => {
const url = new URL(source.baseUrl);
return Boolean(url.username || url.password || url.search || url.hash);
}) ||
(widget.iconUrl
? [...new URL(widget.iconUrl).searchParams].some(
([name, value]) => sensitiveName.test(name) && value.trim().length > 0,
)
: false) ||
widget.requests.some(
(request) =>
Object.entries(request.staticHeaders ?? {}).some(
([name, value]) =>
(sensitiveName.test(name) && value.trim().length > 0) || /^\s*bearer\s+\S{8,}\s*$/iu.test(value),
) ||
containsCredential(request.optionsBinding, "", sensitiveName) ||
containsCredential(request.bodyTemplate, "", sensitiveName) ||
containsCredential(request.queryTemplate, "", sensitiveName),
) ||
containsCredential(widget.defaultOptions, "", sensitiveName)
);
}
const credentialTextPattern =
/(?:\bauthorization\s*[:=]\s*["']?bearer\s+[A-Za-z0-9._~+/%-]{8,}|\b(?:api[ _-]?key|access[ _-]?token|refresh[ _-]?token|client[ _-]?secret|token|password|passwd|secret)\s*[:=]\s*["'][^"']{4,}["'])/iu;
function containsCredential(value: unknown, key: string, pattern: RegExp): boolean {
if (pattern.test(key) && typeof value === "string" && value.trim().length > 0) return true;
if (Array.isArray(value)) return value.some((entry) => containsCredential(entry, "", pattern));
if (value === null || typeof value !== "object") return false;
const entries = Object.entries(value);
if (entries.length === 1 && entries[0]?.[0] === "$param" && typeof entries[0][1] === "string") return false;
return entries.some(([childKey, child]) => containsCredential(child, childKey, pattern));
}
export function workshopExportFilename(title: string) {
const safe =
title
@@ -164,21 +122,3 @@ export function workshopExportFilename(title: string) {
.replaceAll(/^-|-$/g, "") || "homarr-widget";
return `${safe}.json`;
}
export class WorkshopError extends Error {
public constructor(
public readonly code:
| "authentication_required"
| "forbidden"
| "not_found"
| "conflict"
| "rate_limited"
| "unavailable"
| "unknown",
message: string,
public readonly status?: number,
) {
super(message);
this.name = "WorkshopError";
}
}
+3 -58
View File
@@ -291,9 +291,6 @@ catalogs:
'@types/xml2js':
specifier: ^0.4.14
version: 0.4.14
'@uiw/react-codemirror':
specifier: ^4.25.11
version: 4.25.11
'@vitejs/plugin-react':
specifier: ^6.0.3
version: 6.0.3
@@ -947,6 +944,9 @@ importers:
'@mantine/dropzone':
specifier: 'catalog:'
version: 9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/form':
specifier: 'catalog:'
version: 9.4.1(react@19.2.7)
'@mantine/hooks':
specifier: 'catalog:'
version: 9.4.1(react@19.2.7)
@@ -2663,9 +2663,6 @@ importers:
packages/widgets:
dependencies:
'@codemirror/lang-json':
specifier: 'catalog:'
version: 6.0.2
'@dnd-kit/core':
specifier: 'catalog:'
version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -2804,9 +2801,6 @@ importers:
'@tiptap/starter-kit':
specifier: 'catalog:'
version: 3.27.3
'@uiw/react-codemirror':
specifier: 'catalog:'
version: 4.25.11(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.1)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.0)(codemirror@6.0.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
acorn:
specifier: 'catalog:'
version: 8.16.0
@@ -8188,28 +8182,6 @@ packages:
resolution: {integrity: sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==}
hasBin: true
'@uiw/codemirror-extensions-basic-setup@4.25.11':
resolution: {integrity: sha512-otyFa+n9IOYtEjaKOxPedHkj15fTPUF21wdR9pv0GpZPfuGl27cvmcv6+tognbRu9VvEcsHKE+ESoszeo3KfTw==}
peerDependencies:
'@codemirror/autocomplete': '>=6.0.0'
'@codemirror/commands': '>=6.0.0'
'@codemirror/language': '>=6.0.0'
'@codemirror/lint': '>=6.0.0'
'@codemirror/search': '>=6.0.0'
'@codemirror/state': '>=6.0.0'
'@codemirror/view': '>=6.0.0'
'@uiw/react-codemirror@4.25.11':
resolution: {integrity: sha512-DYVFAKLX+F/4JS9N/7xexh+TICrlncwkX9HKKInrP1bwO0tSfc3k0GB6oawTYhelVKh20cX3TuRx+NJSkVXuMw==}
peerDependencies:
'@babel/runtime': '>=7.29.7'
'@codemirror/state': '>=6.0.0'
'@codemirror/theme-one-dark': '>=6.0.0'
'@codemirror/view': '>=6.0.0'
codemirror: '>=6.0.0'
react: '>=17.0.0'
react-dom: '>=17.0.0'
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
@@ -21864,33 +21836,6 @@ snapshots:
dependencies:
'@typescript/old': typescript@6.0.3
'@uiw/codemirror-extensions-basic-setup@4.25.11(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.1)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/commands': 6.10.3
'@codemirror/language': 6.12.3
'@codemirror/lint': 6.9.6
'@codemirror/search': 6.7.1
'@codemirror/state': 6.6.0
'@codemirror/view': 6.43.0
'@uiw/react-codemirror@4.25.11(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.1)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.0)(codemirror@6.0.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@babel/runtime': 7.29.7
'@codemirror/commands': 6.10.3
'@codemirror/state': 6.6.0
'@codemirror/theme-one-dark': 6.1.3
'@codemirror/view': 6.43.0
'@uiw/codemirror-extensions-basic-setup': 4.25.11(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.1)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)
codemirror: 6.0.2
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
transitivePeerDependencies:
- '@codemirror/autocomplete'
- '@codemirror/language'
- '@codemirror/lint'
- '@codemirror/search'
'@ungap/structured-clone@1.3.0': {}
'@unhead/vue@2.1.15(vue@3.5.35(@typescript/typescript6@6.0.2))':