Search by

Renderers for Storyfeed activity feeds: Blade, Vue/Inertia and React/Inertia component kits.

Package info

github.com/storyfeed/ui

Language:JavaScript

pkg:composer/storyfeed/ui

Fund package maintenance!

storyfeed

Statistics

Installs: 5 867

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 8

v0.10.0 2026-10-14 23:26 UTC

README

GitHub Tests Action Status

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\* in storyfeed/storyfeed 0.11, stored as Storyfeed/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.