storyfeed / ui
Renderers for Storyfeed activity feeds: Blade, Vue/Inertia and React/Inertia component kits.
Fund package maintenance!
Requires
- php: ^8.4
- illuminate/support: ^12.0||^13.0
- illuminate/view: ^12.0||^13.0
- league/commonmark: ^2.7
- storyfeed/storyfeed: ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^0.21 || 0.17.x-dev || 0.18.x-dev || 0.19.x-dev || 0.20.x-dev || 0.21.x-dev
- symfony/html-sanitizer: ^7.3 || ^8.0
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0
- pestphp/pest: ^4.0 || ^5.0
- phpstan/phpstan: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v0.10.0
- v0.9.8
- v0.9.7
- v0.9.6
- v0.9.5
- v0.9.4
- v0.9.3
- v0.9.2
- v0.9.1
- v0.9.0
- v0.8.0
- v0.7.0
- v0.6.0
- v0.5.0
- v0.4.5
- v0.4.4
- v0.4.3
- v0.4.2
- v0.4.1
- v0.4.0
- v0.3.0
- v0.2.0
- v0.1.1
- v0.1.0
- dev-feat/evidence-disclosure
- dev-release/v0.9.7
- dev-release/v0.9.6
- dev-release/v0.9.5
- dev-release/v0.9.4
- dev-release/v0.9.3
- dev-release/v0.9.2
- dev-explore/actor-strip
- dev-release/v0.9.1
- dev-release/v0.9.0
- dev-explore/strip-policy
- dev-release/v0.8.0
- dev-fix/quieter-dot
- dev-chore/core-0.19
- dev-strip-reads-sample-featured
This package is auto-updated.
Last update: 2026-10-19 00:26:09 UTC
README
Storyfeed UI is a free, MIT-licensed set of renderers for Storyfeed activity feeds: components that take a page of the feed and draw it. Blade, Vue/Inertia and React/Inertia, with Livewire following.
It works the way Laravel's pagination does. Core hands you the data, a
FeedPage whose items read as Storyfeed\Support\FeedItem, and this package
renders it with default Blade views you can publish and restyle.
The data forms moved to core. v0.1.1's
Storyfeed\Ui\Data\*classes are core's body types,Storyfeed\Body\*instoryfeed/storyfeed0.11, stored asStoryfeed/Body/Excerpt. A form's name must not contain the library that defined it, so the vocabulary was always core's. This package is the renderers. See the changelog for the upgrade.
Early development. Storyfeed is pre-1.0. Minor releases can change the API without a deprecation cycle. Commit your application's Composer lockfile to keep installations reproducible.
Installation
Requires PHP 8.4 or later in the PHP 8 series. The current test harness uses Laravel 12; CI runs PHP 8.4 and 8.5 on Ubuntu and Windows with lowest and stable dependencies.
composer require storyfeed/ui
The service provider registers itself through package discovery. This release
requires Storyfeed core ^0.13. For an Inertia app, install the Vue or React kit
with php artisan storyfeed:ui vue or php artisan storyfeed:ui react (below).
The Blade, Vue and React kits use Tailwind CSS v4, the
Typography plugin and
Laravel starter-kit colour tokens. No package stylesheet is needed. Install the
plugin, then register it and the package's views in your application's
resources/css/app.css file:
npm install -D @tailwindcss/typography
@plugin "@tailwindcss/typography"; @source "../../vendor/storyfeed/ui/resources/views";
Text-shaped bodies (rich Prose, ItemList, Table and Code) render inside Typography's
prose, coloured from your tokens and sized by the feed (see
Set the feed's size), so they follow dark mode with the rest of the feed.
Compile your application's CSS with npm run build. Your layout must load
the compiled CSS, for example with @vite('resources/css/app.css').
Vue (Inertia)
Copy the Vue 3 kit into your app:
php artisan storyfeed:ui vue npm install lucide-vue-next micromark micromark-extension-gfm-autolink-literal micromark-extension-gfm-strikethrough micromark-extension-gfm-table micromark-extension-gfm-task-list-item sanitize-html npm install -D @tailwindcss/typography
The default destination is resources/js/components/storyfeed/; change it with
--path=resources/js/my-feed. These are TypeScript <script setup> components
for Tailwind v4. They expect the Laravel Vue starter-kit colour tokens,
including background, foreground, muted, muted-foreground, card,
primary, primary-foreground, border and ring. Tokens supply dark mode.
The Vue kit needs the Typography plugin and no separate stylesheet.
Register the plugin and, if the copied directory is not already scanned, the
kit, once in resources/css/app.css (paths are relative to that CSS file):
@plugin "@tailwindcss/typography"; @source "../js/components/storyfeed";
<script setup lang="ts"> import FeedStream from '@/components/storyfeed/FeedStream.vue'; import type { FeedNode } from '@/components/storyfeed/types'; defineProps<{ items: FeedNode[] }>(); </script> <template> <FeedStream :items="items" /> </template>
Copied files belong to your app and may be edited. Re-running writes missing
files and skips identical ones. Files that differ are kept and reported;
php artisan storyfeed:ui vue --diff prints unified diffs to reconcile by hand.
Use --force to replace differing files. Diffs are generated by the command;
no external diff tool is required. The summary reports written, unchanged and
differing files. See the kit README for slots, rail options,
Component bodies and Inertia links.
React (Inertia)
Copy the React 19 kit, including its shared TypeScript core:
php artisan storyfeed:ui react npm install react@^19 react-dom@^19 lucide-react micromark micromark-extension-gfm-autolink-literal micromark-extension-gfm-strikethrough micromark-extension-gfm-table micromark-extension-gfm-task-list-item sanitize-html npm install -D @types/react @types/react-dom @types/sanitize-html @tailwindcss/typography
The default destination, --path, --diff and --force semantics are the same
as Vue's. Both commands include a self-contained shared/ directory. Add
@plugin "@tailwindcss/typography"; and, if needed,
@source "../js/components/storyfeed"; in resources/css/app.css.
React uses the same Tailwind v4 starter-kit tokens and Typography plugin as Vue and Blade.
import { Link } from '@inertiajs/react'; import { FeedProvider, FeedStream } from '@/components/storyfeed'; import type { FeedPagePayload, FeedPayload } from '@/components/storyfeed'; export default function History({ feed }: { feed: FeedPagePayload | FeedPayload }) { return <FeedProvider FEED_LINK={Link}> <FeedStream page={feed} onLoadMore={() => { /* load and append the next page in your app */ }} /> </FeedProvider>; }
Function components work with Inertia 2/3's React adapter and the Laravel React
starter kit. FeedProvider supplies the FEED_LINK, FEED_BODIES, FEED_COMPONENTS, FEED_NOW,
FEED_FILE_LABELLER and FEED_MEDIA_OBJECT_PLACEMENT seams, and the
headlineText option (Headline text). body, annotations
and time render props replace Vue slots. Groups use native <details>.
The initial SSR/hydration render uses stable ISO dates; after mount, labels
use the browser's local calendar and live or pinned clock. See the
React kit README for the complete provider,
pagination, body and SSR contracts.
Import directly from vendor (Vue or React)
Instead of copying a kit, point a Vite alias at the package's JS directory.
For example, add this alias to your vite.config.ts (alongside existing aliases):
import { fileURLToPath } from 'node:url'; // Merge into defineConfig's resolve.alias: const alias = { '@storyfeed': fileURLToPath(new URL('./vendor/storyfeed/ui/resources/js', import.meta.url)), };
Import Vue components from @storyfeed/vue/FeedStream.vue, or React exports
from @storyfeed/react. Shared helpers live at @storyfeed/shared/body.
Install the same npm dependencies as for the copied kit. In
resources/css/app.css, use this scan instead of the copied-directory scan:
@plugin "@tailwindcss/typography"; @source "../../vendor/storyfeed/ui/resources/js";
Scan the parent resources/js, not only resources/js/vue or
resources/js/react: both kits use literal utilities from shared/,
including the image-size map. If you also render Blade views, keep its
@source "../../vendor/storyfeed/ui/resources/views"; line too.
Usage
Pass a page of the feed to a view:
use Illuminate\Http\Request; use Storyfeed\Facades\Storyfeed; Route::get('/', fn (Request $request) => view('feed', [ 'page' => Storyfeed::feed()->cursor($request->query('cursor'))->get(), ]));
Render the page with the feed component:
<x-storyfeed::feed :page="$page" />
Attributes on the tag, such as class, land on the feed's root element. The
empty slot replaces the words shown for an empty page:
<x-storyfeed::feed :page="$page"> <x-slot:empty>Nothing has happened yet.</x-slot:empty> </x-storyfeed::feed>
Components
<x-storyfeed::feed> is built from smaller anonymous components, and each one
can be used on its own:
| Component | Draws |
|---|---|
<x-storyfeed::feed :page> |
the page, then a link to older activity |
<x-storyfeed::item :item> |
one item: an activity or a group |
<x-storyfeed::activity :activity> |
an activity row |
<x-storyfeed::group :group> |
a group row, its members behind a disclosure |
<x-storyfeed::headline :headline> |
a headline, each entity linked |
<x-storyfeed::glyph :glyph :intent> |
the icon disc |
<x-storyfeed::time :at> |
when it happened |
<x-storyfeed::media :image> |
a picture |
<x-storyfeed::body :body> |
one body, by its type |
<x-storyfeed::pager :cursor> |
the link to the next page |
Each of core's body types has a component in components/body: key-value,
excerpt, prose, file-attachment (including stored File), item-list,
image, component, media-object, table, call-to-action, code and callout. A body
type with no component draws its $fallback line, or nothing.
Code draws escaped source as <pre><code> inside Typography's prose, with
an optional title above it. An optional language adds language-<name> to
<code> for your application's syntax highlighter; the kit adds no highlighter.
Whitespace is preserved and long blocks scroll at --sf-prose-max-h, like code
inside rich Prose. The surface uses your muted and foreground tokens in both themes.
Callout draws plain-text content in a frame, with an optional title above
it. intent selects neutral note (the default), sky info, emerald
success, amber warning or red danger, with light and dark treatments.
Unknown intents draw as note. All three kits escape the title and content;
neither body treats its text as HTML or Markdown. Existing Tailwind scans cover
both bodies; no stylesheet or allow-list exception is needed.
A CallToAction draws its heading and text in a card with its one action as a
button, or the button alone when it has neither. Blade draws a plain link with
the link's safe attributes; the Vue and React kits also pass modal to a host
FEED_LINK such as Inertia's Link. An action without an href goes to the
body's own entity.
Prose displays plain text and unknown media types as escaped text. It renders Markdown as GitHub-flavoured Markdown (tables, strikethrough, autolinks and task lists) with raw HTML and unsafe links disabled, and sanitizes rich HTML at render time using Symfony's HTML Sanitizer. A task list's checkboxes are the only inputs kept, and they stay disabled. Verbatim content is always escaped and preserves its source whitespace.
Render Markdown in a Prose body
use Illuminate\Support\Str; use Storyfeed\Body\Prose; // Each kit renders the Markdown when it displays the feed. Prose::markdown($source); // Your application renders it once, on the server. Prose::html(Str::markdown($source));
Prose::markdown keeps the Markdown source in the payload, so a consumer that
does not display HTML still receives it. Prose::html stores the HTML that
Laravel's GitHub-flavoured converter returns, or any converter and extensions
your application chooses; every kit displays that same HTML, sanitized.
Styling
Blade uses the same starter-kit tokens as Vue. A starter-kit app already has
these. For another Tailwind v4 app, add this minimal theme to app.css after
@import "tailwindcss"; change the values to your application's palette:
@custom-variant dark (&:where(.dark, .dark *)); @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-border: var(--border); --color-ring: var(--ring); } :root { --background: #fff; --foreground: #1f2933; --card: #fafafa; --muted: #f6f6f7; --muted-foreground: #6b7785; --primary: #1f2933; --primary-foreground: #fff; --border: #e2e5e9; --ring: #1f2933; } .dark { --background: #1b1b1f; --foreground: #dfdfd6; --card: #26262b; --muted: #202127; --muted-foreground: #98989f; --primary: #dfdfd6; --primary-foreground: #1b1b1f; --border: #3c3f44; --ring: #dfdfd6; }
Add or remove the dark class on your layout to choose the theme. The kit is
all Tailwind utilities; the avatar's declared colour and a picture's aspect
ratio are data-driven inline styles. Verbatim prose keeps a dark code surface
in both themes. Rich prose, lists and tables use Typography's prose with its colours
mapped to your tokens, and quotations carry their own utility styles.
Tune the body cards
Card-shaped bodies (MediaObject, Prose, ItemList, Table, KeyValue and
CallToAction) draw on a light fill with a hairline edge, both mixed from your
tokens. In light mode the fill is 55% of card, so the page shows through; in
dark mode it is a 3% lift of foreground. The edge is 8% of foreground in
both. Set --sf-well (the fill) and --sf-well-border (the edge) on the feed
or any element around it to change them:
.activity { --sf-well: var(--color-card); --sf-well-border: var(--color-border); } .dark .activity { --sf-well: var(--color-muted); }
A value applies in both themes, so set it again under .dark for a different
dark one. The kit draws no blur behind cards, which would cost every card on a
long feed its own compositing layer.
Set the feed's size
The feed is sized in rem on Tailwind's scale, so it follows the reader's
browser text size. Body text starts at 1rem. Set --sf-font-size on the feed
root, or on any element around it, to scale text, spacing, avatars, badges and
the rail together:
<x-storyfeed::feed :page="$page" class="[--sf-font-size:0.875rem]" />
<FeedStream :items="items" class="[--sf-font-size:1.125rem]" />
Give it in rem or px. Inside the feed, Tailwind's --spacing, --text-xs,
--text-sm and --text-base derive from it, so your own slot content on those
utilities scales with the feed too. Every row sets its own size from it, so a
row drawn outside a feed (in a Filament panel, say) never inherits the host
page's base size.
A row has one type scale, every step relative to --sf-font-size:
| Element | Size | Weight / colour |
|---|---|---|
| headline | 1em | normal; entity names medium |
| meta line | 0.875em | muted |
| body content: Prose, KeyValue values, Table cells, MediaObject text, Excerpt | 0.875em | normal |
| body labels: KeyValue keys, Table headers | 0.875em | muted |
| body titles: KeyValue, Table and ItemList titles, MediaObject and CallToAction subjects | 0.875em | semibold |
| footnotes, captions, file kinds and sizes | 0.75em | muted |
Headings inside a body never outgrow it: h1–h6 in rich Prose draw at the
body size, semibold (h1, h2) or medium (h3 and below). KeyValue rows share
one key column, at most 40% of the card, so values form one left edge; on a
narrow card each key sits over its value. Verbatim Prose is a dark block with
light text in every theme, from --sf-code-bg and --sf-code-fg (set them to
match your brand); inline and fenced code inside rich Prose draw on the muted
colour.
Flowing text is never capped: rich and plain Prose, lists, tables and
key-value facts show in full and flow with the page. Only contained blocks,
code (pre) inside rich Prose and verbatim Prose, scroll inside their box
when long, at --sf-prose-max-h (24rem by default, scaled with
--sf-font-size), a CSS variable your app can change.
A body can set its own maximum height with core 0.17's maxHeight()
(Table::make()->maxHeight('16rem'), Prose::verbatim($log)->maxHeight('none')),
which the kits read from the body's $meta as maxHeight. A length caps the
whole body, which then scrolls inside its wrapper; none draws it in full,
its code and verbatim blocks included. Text-shaped bodies inherit the feed's size
rather than taking a prose-sm or prose-lg modifier, so their tables, lists
and headings scale with it as one unit.
Icon intents are application-defined strings exposed through data-sf-intent.
To assign colours to your intent values, add the corresponding Tailwind
utilities to the published components/glyph.blade.php view.
Rails, dividers and group state
<x-storyfeed::feed :page="$page" rail="actor" :dividers="[$timelineId => 'Timeline']" />
rail accepts actor (face + glyph badge), activity (glyph + face badge),
actor-only and activity-only. The default matches Vue: actor-only, and
activity-only for group children. A missing primary falls back to the other
subject, then a blank disc. Several actors draw as a diagonal pair inside one
disc's square: the first actor in front at the bottom-right, with the glyph
badge, and the second behind at the top-left, each 2/3 of --sf-disc with
one-letter initials and a ring in the page colour; the glyph badge on a pair
is smaller (11/14 of --sf-badge, 11/32 of the default disc) to balance
the smaller faces, centred where it sits by default like every other badge. Below a 1.5rem (24px) disc only the
front face shows. The count stays in the headline, and several actors never
take a face badge. Avatars draw
media.icon, else media.initials on a media.color disc (black or white
text, whichever contrasts more). The older data.initials and
data.avatar_color apply when media declares neither, then Vue's
deterministic type/id palette. Tombstones suppress former pictures and
colours. The default image fallback uses an inline error handler; apps with
strict script CSP can supply an avatar renderer with their own fallback.
An image avatar has a transparent fill and a neutral hairline border. Without
an image, or when loading fails, the disc shows its brand colour and initials.
Tombstones stay muted. The arbitrary per-entity colour is bound only as
--sf-avatar-color; Tailwind utilities control the disc’s appearance.
Day dividers are on by default; :grouped="false" hides them. Per-item
dividers are keyed by public item id and work in either mode.
Both draw as a branch: a curve off the rail into the label.
divider-style="dot" draws a dot on the rail instead, for either. timezone controls the display
zone for days, the timestamp ladder, its absolute hover title and time ranges.
Timestamps are rendered on the server; the host owns any live refresh.
An activity recorded with a time range (->startsAt() / ->endsAt(), core
0.17) shows it on the meta line after the time, in all three kits:
"30 Sep – 9 Oct 2026", collapsed to "1 – 9 Oct 2026" within a month and
"9 Oct 2026" within a day, and "from 9 Oct 2026" or "until 31 Oct 2026" when
one end is open. An activity without a range is unchanged; groups carry none.
The words are the storyfeed-ui::meta.from and meta.until lines in Blade and
messages.rangeFrom / rangeUntil in the JavaScript kits. React reads the
range in UTC until the page hydrates, then in the browser's zone.
interactive and collapsed control groups independently. Native details
works without JavaScript and supports keyboard disclosure. An unspecified
state opens unnamed groups, or all groups when interactive is false.
:collapsed="true" overrides that default; payload expanded still opens a
group. With :interactive="false", no toggle is rendered and only the chosen
server state is drawn. Groups show honest truncated-member counts and up to
three sampled faces. Below the headline, a group draws a strip: one tile per member activity, newest first, each the
preview of what that member's row shows: the picture it draws (its featured
entity's Image body), else a quiet name tile with that entity's label, which
the member's headline names. Never initials, and a declared icon counts only
when an Image body draws it. Every tile is the same rounded square, spaced,
never overlapped, and links to its entity; a deleted entity is unlinked. Up to four tiles; past that, three
and a "+N" tile counting the members not shown. On a group that can open, the "+N" tile opens and
closes it like "Show all N". A strip of identical tiles is
skipped. Expanding the group hides it; the head row stays put.
A group with several actors also draws an actor strip: every actor's face,
newest first, including the ones the headline names, as one row of
overlapping 24px avatars. Eight faces, then a "+N" chip counting the actors
not shown (core's distinct.actors); with eight or fewer, all of them and no
chip. The faces come from the members, so each is a member row's face once
the group opens. Like the feature strip, the chip opens the group and
expanding hides the strip. actor-strip is standalone too, with faces from
ActorStrip::of().
page takes whatever core's reads return: core 0.13–0.18's FeedPage, or
from storyfeed/storyfeed#95 the collection from get() and the paginators
from cursorPaginate(), simplePaginate() and members(). A raw JSON page
works too, with the nodes under data (#95) or items (before it):
:page="$payload", or :items="$nodes" and :next-cursor="$cursor".
The footer slot replaces the pager (for example, with a Livewire load-more
control). divider, avatar, rail and media-strip are standalone
components; media-strip accepts tiles (from Strip::of(), or your own
{image, href} pictures), overflow, and a renderer callback.
Component bodies and host seams
Register an app Blade component in a service provider:
use Storyfeed\Ui\Support\BodyComponents; app(BodyComponents::class)->register('App/Message', 'feed.message');
Storyfeed/Body/Component with name: "App/Message" renders
<x-feed.message> using its props as typed component props. Stored names
cannot choose arbitrary views: unregistered names draw nothing. The mapped
component must exist. Regular custom body types remain supported as below.
Forms are read from activity data, the object's body slot and object
data, with data walking bounded to four levels like Vue; other roles' bodies
are left to the app. Empty and unknown forms produce no wrapper.
Standalone activity/group components accept their default body slot, time
and annotations slots and a removed text prop. A row shows a picture only
when one of its bodies asks for one. An Image body draws its own picture
(Image::make('https://…/day-3.jpg'), stored as src) at its declared size,
or else the entity's media slot it names, built-in or custom
(Image::make($this->getFeedMedia('sparkline')), read from
media.slots.sparkline). One naming the icon slot
(Image::make($this->feedMediaIcon())) draws as a small thumbnail beside the
row's other bodies; the others draw full width.
The thumbnail keeps the object's URL and scalar link attributes, excluding
href, event handlers and invalid names; it also passes through the media
renderer. A missing URL or tombstone leaves it unlinked, and a form renderer
that draws the body itself keeps it in place. Static groups (interactive=false) retain collapsed members with
hidden print:block, while interactive groups keep native details print rules.
For a whole feed, renderers propagates trusted
application callbacks to every row and group child:
| Key | Callback receives | Returns |
|---|---|---|
time |
FeedItem |
timestamp/permalink HTML (including any refresh attributes) |
glyph |
token, disc or badge |
icon SVG/HTML inside the kit's disc |
avatar |
Entity, md, sm or badge |
complete avatar HTML |
body, annotations |
FeedItem |
app content HTML |
removed |
FeedItem |
optional escaped removal text |
fileLabel |
{name, mediaType} array |
optional file-kind label; null uses the built-in MIME map |
form |
body array, owning Entity or null |
body HTML; null uses the built-in renderer |
mediaTiles, mediaOverflow |
group FeedItem |
replacement sample tiles or overflow count |
media |
{image, href, attributes} tile array, Tailwind class string |
complete picture/tile HTML, including an optional lightbox |
Callbacks are trusted application code and their HTML is not sanitized; stored
payloads never supply callbacks. Body text itself is escaped or sanitized.
Filament can use these seams for its icons, timestamp refresh and lightbox
(the media callback reaches Image and MediaObject bodies as well as sample
tiles; form can override an entire body). Standalone MediaObject also accepts
image-placement="beside|below"; beside is the default in both kits. Beside
the text, a card picture keeps its shape: an icon is a 4rem square; a picture with a declared width and height is 4rem tall at its own ratio, clamped between 1:1 and 2:1 (cropped only beyond that); one without shows whole at 6rem wide, up to 8rem tall. On a narrow card the picture stacks above the text.
FileAttachment always shows its supplied name and formats decimal byte sizes
(21 MB, 76 KB). Built-in MIME labels include PDF, images, Word and CSV
(Spreadsheet (CSV)). The fileLabel callback overrides those labels, with
null falling back to the MIME map and then the supplied MIME string. The
standalone file component accepts labeller; <x-storyfeed::body> accepts
file-labeller. No extension-based guessing or payload changes occur.
Links. Core 0.17 gives an entity one link, {href, modal, attributes},
in place of url, modal and attributes, and gives links inside bodies the
same shape. All three kits read both shapes, so they work with core 0.13 to
0.17. A body link without an href goes to its entity's own link, and its
modal and attributes add to the entity's. Attributes never include href
or event handlers. modal reaches a host link component (Inertia's Link) in
Vue and React; Blade draws plain links.
Feeds, items and groups accept child-rail="activity-only" independently of
rail="actor", so expanded members can use glyph discs while their parent
shows an actor and activity badge. Without a child override, children inherit
the parent posture and dense rows suppress its badge.
Groups use explicit pinned singular slots and their own headline/template. Summary rendering has been removed from all three kits, mirroring core. Unknown extra payload keys are ignored. Sample photograph strips read Image bodies of the group's objects only, never its actors, deduplicate image sources, cap at three and hide when children are shown. The Filament inventory lists the rendering boundary and the integration features that stay in the plugin.
Custom body types
An app or package draws its own body type, such as Acme/Shipment, by giving
each kit a renderer for that exact type. A renderer receives the body's payload
and the entity that carries it. A renderer registered for one of core's types
replaces the kit's own. A body whose type has no renderer draws its $fallback
line, the one plain-text line core lets any body carry, as muted, escaped text;
without one it draws nothing.
Blade maps the type segment by segment to a view under the storyfeed
namespace: Acme/Shipment draws components/body/acme/shipment.blade.php,
which receives the body as $body and its entity as $entity. An app adds it
in resources/views/vendor/storyfeed. A package adds its views to the
namespace from its service provider:
$this->loadViewsFrom(__DIR__.'/../resources/views', 'storyfeed');
{{-- resources/views/vendor/storyfeed/components/body/acme/shipment.blade.php --}} @props(['body', 'entity' => null]) <p>{{ $body['carrier'] }} · {{ $body['tracking'] }}</p>
Vue reads the FEED_BODIES injection key. Install renderers with the
feedBodies() plugin, which merges each map into those already installed, or
provide() a map for one part of the page. A renderer receives payload,
entityLabel, entityUrl and entityMedia props:
import { feedBodies } from '@/components/storyfeed/body'; import Shipment from '@/components/Shipment.vue'; createApp(App).use(feedBodies({ 'Acme/Shipment': Shipment }));
React reads FEED_BODIES from FeedProvider; nested providers merge their
maps. A renderer receives BodyProps:
import { FeedProvider, type BodyProps } from '@/components/storyfeed'; const Shipment = ({ payload }: BodyProps) => <p>{payload.carrier} · {payload.tracking}</p>; <FeedProvider FEED_BODIES={{ 'Acme/Shipment': Shipment }}>…</FeedProvider>
The viewer reads as "You"
Name who is reading the page, and every headline that names them says "You" instead: "You placed Order #1042", "Order #7 was assigned to you", "Kim, Ravi and you reviewed Order #9". The payload is the same for every reader; only the render changes.
<x-storyfeed::feed :page="$page" :viewer="auth()->user()" />
app.provide(FEED_VIEWER, { type: 'user', id: user.id }); // Vue <FeedProvider FEED_VIEWER={`user:${user.id}`}> // React
The viewer is a model (Blade), { type, id } or 'type:id', matched against
each entity's morph alias and id. "You" opens a sentence, "you" sits anywhere
else. Templates written in the past tense read correctly; a present-tense
template such as :actor is reading :object reads "You is reading", so
reword it. "You" is drawn as the actor it is, with the actor's weight and
colour, and no link: the reader needs no link to themselves.
Headline text
A headline's own words, its template text and the pre-rendered headline
fallback, are drawn as escaped plain text. A headline-text seam lets an app
draw those words itself. It never receives entity names, lists, counts or
"You", which keep their own links and viewer handling. With nothing set, every
kit's output is unchanged.
Each kit ships one formatter, inlineCode. It escapes the text, then draws
each pair of single backticks as a <code> styled with Tailwind utilities, so
"Released `v2.3` to production" shows v2.3 as code. An unmatched
backtick stays literal, and nothing else is treated as Markdown.
use Storyfeed\Ui\Support\HeadlineText; // In a service provider's boot(); register(null) restores escaped text. app(HeadlineText::class)->register(HeadlineText::inlineCode(...));
import { FEED_HEADLINE_TEXT } from '@/components/storyfeed/keys'; import { inlineCode } from '@/components/storyfeed/inlineCode'; app.provide(FEED_HEADLINE_TEXT, inlineCode); // Vue: (text: string) => VNodeChild
import { FeedProvider, inlineCode } from '@/components/storyfeed'; <FeedProvider headlineText={inlineCode}>…</FeedProvider> // React: (text: string) => ReactNode
Blade passes the registered callback to core's headline rendering, which
takes it from Storyfeed core 0.21; with an older core, Blade headlines stay
escaped plain text. A Blade callback receives a text segment and returns
trusted HTML that is not sanitized, so it must escape the text itself, as
inlineCode does. Vue and React callbacks return nodes, which the framework
escapes.
Customising the Views
Publish the views to change the markup:
php artisan vendor:publish --tag=storyfeed-views
They land in resources/views/vendor/storyfeed, and a view there replaces
the package's. You only need to keep the files you change.
Icons. The default Blade kit ships only its generic activity fallback. The payload's glyph is a token, such as shopping-bag, and the kit
ships no icon set. Draw a token by adding
resources/views/vendor/storyfeed/icons/shopping-bag.blade.php. A token with
no view draws icons/activity.
Your own body types. A body type draws the component named after it:
Acme/Attachment draws
resources/views/vendor/storyfeed/components/body/acme/attachment.blade.php,
which receives the body as $body and its entity as $entity. See
Custom body types for the Vue and React kits.
Words. Headline words such as "Someone" and "a removed order" are core's
translation lines (php artisan vendor:publish --tag=storyfeed-translations).
The kit's own words, such as "Older activity" and "Show all :count", are plain
__() strings: translate them in your lang/{locale}.json.
Licence
MIT. See LICENSE.md.
The paperclip icon is from Heroicons, copyright Tailwind Labs, Inc., used under the MIT licence. The full notice is included in licenses/heroicons.txt. The attachment row layout is original work.
Image sizes
Size limits use Tailwind utilities; the existing kit @source scans are sufficient.
No extra stylesheet import is needed.
<x-storyfeed::feed image-size="card" />, Vue <FeedStream image-size="card" />,
and React <FeedStream imageSize="card" /> set the default for that feed.
Image::size('inline') on a body wins; explicit full overrides a feed default.
Without either choice the picture renders as before. Unknown payload sizes inherit.
| Size | Use | Max width | Max height |
|---|---|---|---|
| thumbnail | Small identifying picture | 6rem | 6rem |
| inline | Picture supporting an update | 16rem | 12rem |
| card | Project or link-card picture | 24rem | 18rem |
| feature | Photo central to the update | 36rem | 27rem |
| full | Current column-width behaviour | 100% | none |
Retune --sf-image-size-card: 20rem and --sf-image-size-card-height: 15rem
on a feed or host. Pictures preserve aspect ratio and never crop. The limits
apply to the image, leaving captions readable at the normal text width.
The literal class maps live in resources/views/image-sizes.php (Blade) and
shared/body.ts (Vue/React). The Blade map is inside the existing vendor-view
scan; the copied shared map is inside the existing kit scan. Defaults are
var() fallbacks in those utilities, so tokens set above the feed inherit.
For example, use [--sf-image-size-card:20rem] [--sf-image-size-card-height:15rem]
on the feed’s wrapper.
An app can also retune the tokens in its own Tailwind theme:
@theme { --sf-image-size-inline: 20rem; --sf-image-size-inline-height: 15rem; }
With the kit’s literal utilities scanned, Tailwind emits these referenced
variables to :root. The image harness verifies the resulting 320×240 limit
in all three kits. @theme static also works and emits the tokens even when
unused; that form is verified by the same harness.
Custom Image renderers
Use FeedMedia (<x-storyfeed::media> in Blade) for a linked picture. It
consumes the inherited --sf-image-width and --sf-image-height without an
injection or extra stylesheet. Pass the body as payload so its own
$meta.size wins over the feed, including explicit full; absent or unknown
sizes inherit. Its frame shrinks to the picture, with no fill beyond it.
Outside a sized feed, media retains its 22rem maximum width. Pictures keep
their intrinsic ratio and scale down, never up; declared width/height
attributes reserve space while loading. Captions stay in your renderer,
outside the media frame, so their text can use the normal column width.
Blade: replace resources/views/vendor/storyfeed/components/body/image.blade.php:
@props(['body', 'entity' => null]) @php $picture = \Storyfeed\Ui\Support\Bodies::picture($body, $entity); $link = \Storyfeed\Ui\Support\Links::entity($entity); @endphp @if ($picture) <x-storyfeed::media :image="$picture" :payload="$body" :href="$link['href'] ?? null" :link-attributes="$link['attributes'] ?? []" /> @endif
Vue: register this component with
app.use(feedBodies({ 'Storyfeed/Body/Image': LinkedImage })):
<script setup lang="ts"> import { computed } from 'vue'; import FeedMedia from '@/components/storyfeed/FeedMedia.vue'; import { pictureOf } from '@/components/storyfeed/shared/body'; const props = defineProps<{ payload: Record<string, any>; entityMedia?: Record<string, any> | null; entityUrl?: string | null; }>(); const picture = computed(() => pictureOf(props.payload, props.entityMedia)); </script> <template> <FeedMedia v-if="picture" :image="picture" :payload="payload" :href="entityUrl" /> </template>
React: register the renderer through FeedProvider:
import { FeedMedia, FeedProvider, type BodyProps } from '@/components/storyfeed'; import { pictureOf } from '@/components/storyfeed/shared/body'; function LinkedImage({ payload, entityMedia, entityUrl }: BodyProps) { const picture = pictureOf(payload, entityMedia); return picture ? <FeedMedia image={picture} payload={payload} href={entityUrl} /> : null; } <FeedProvider FEED_BODIES={{ 'Storyfeed/Body/Image': LinkedImage }}>…</FeedProvider>
For vendor-alias imports, replace @/components/storyfeed in these JS
examples with @storyfeed/vue or @storyfeed/react, and import pictureOf
from @storyfeed/shared/body. A host FEED_MEDIA component or Blade media
callback receives the same size and frame classes; apply them to a root
containing the <img> as a direct child, or consume the size variables in
your own markup if it wraps the image differently.