platform-specialist

Make web apps native. LINE, Mobile, Desktop - same quality, platform-optimized.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "platform-specialist" with this command: npx skills add wasintoh/toh-framework/wasintoh-toh-framework-platform-specialist

Platform Specialist

Make web apps native. LINE, Mobile, Desktop - same quality, platform-optimized.

<core_principle>

The Platform Promise

Same beautiful UI → Platform-specific magic → Native-feeling experience

We adapt, not rebuild. Core logic stays the same. </core_principle>

<line_mini_app>

LINE Mini App (LIFF)

What is LIFF?

LINE Front-end Framework - run web apps inside LINE app with access to LINE APIs.

Setup

npm install @line/liff

// src/lib/liff.ts import liff from '@line/liff'

const LIFF_ID = process.env.NEXT_PUBLIC_LIFF_ID!

export async function initializeLiff() { try { await liff.init({ liffId: LIFF_ID }) return true } catch (error) { console.error('LIFF init failed:', error) return false } }

export function isInLiff(): boolean { return liff.isInClient() }

export function isLoggedIn(): boolean { return liff.isLoggedIn() }

export async function login() { if (!liff.isLoggedIn()) { liff.login() } }

export async function logout() { if (liff.isLoggedIn()) { liff.logout() } }

export async function getProfile() { if (!liff.isLoggedIn()) return null return await liff.getProfile() }

export async function getAccessToken() { return liff.getAccessToken() }

// Send message to LINE chat export async function sendMessage(text: string) { if (!liff.isInClient()) return

await liff.sendMessages([ { type: 'text', text } ]) }

// Share to LINE export async function shareMessage(text: string) { if (!liff.isApiAvailable('shareTargetPicker')) return

await liff.shareTargetPicker([ { type: 'text', text } ]) }

// Close LIFF window export function closeLiff() { if (liff.isInClient()) { liff.closeWindow() } }

LIFF Provider

// src/providers/liff-provider.tsx 'use client'

import { createContext, useContext, useEffect, useState } from 'react' import { initializeLiff, getProfile, isLoggedIn, isInLiff } from '@/lib/liff'

interface LiffProfile { userId: string displayName: string pictureUrl?: string statusMessage?: string }

interface LiffContextType { isReady: boolean isInLiff: boolean isLoggedIn: boolean profile: LiffProfile | null error: string | null }

const LiffContext = createContext<LiffContextType>({ isReady: false, isInLiff: false, isLoggedIn: false, profile: null, error: null, })

export function LiffProvider({ children }: { children: React.ReactNode }) { const [state, setState] = useState<LiffContextType>({ isReady: false, isInLiff: false, isLoggedIn: false, profile: null, error: null, })

useEffect(() => { async function init() { const success = await initializeLiff()

  if (!success) {
    setState(prev => ({ ...prev, isReady: true, error: 'LIFF init failed' }))
    return
  }

  const inLiff = isInLiff()
  const loggedIn = isLoggedIn()
  let profile = null

  if (loggedIn) {
    profile = await getProfile()
  }

  setState({
    isReady: true,
    isInLiff: inLiff,
    isLoggedIn: loggedIn,
    profile,
    error: null,
  })
}

init()

}, [])

return ( <LiffContext.Provider value={state}> {children} </LiffContext.Provider> ) }

export const useLiff = () => useContext(LiffContext)

Connect LIFF to Supabase Auth

// src/lib/liff-auth.ts import { supabase } from './supabase' import { getAccessToken, getProfile } from './liff'

export async function signInWithLiff() { const accessToken = getAccessToken() const profile = await getProfile()

if (!accessToken || !profile) { throw new Error('LIFF not logged in') }

// Create or sign in user via Supabase Edge Function const { data, error } = await supabase.functions.invoke('liff-auth', { body: { accessToken, userId: profile.userId, displayName: profile.displayName, pictureUrl: profile.pictureUrl, } })

if (error) throw error

// Set Supabase session await supabase.auth.setSession({ access_token: data.access_token, refresh_token: data.refresh_token, })

return data.user }

LINE-Specific UI Components

// LINE-style button export function LineButton({ onClick, children }: { onClick: () => void children: React.ReactNode }) { return ( <button onClick={onClick} className="w-full bg-[#06C755] hover:bg-[#05B34D] text-white font-medium py-3 px-4 rounded-lg transition-colors" > {children} </button> ) }

// LINE profile card export function LineProfileCard({ profile }: { profile: LiffProfile }) { return ( <div className="flex items-center gap-3 p-4 bg-white rounded-lg shadow-sm"> <img src={profile.pictureUrl || '/default-avatar.png'} alt={profile.displayName} className="w-12 h-12 rounded-full" /> <div> <p className="font-medium">{profile.displayName}</p> {profile.statusMessage && ( <p className="text-sm text-slate-500">{profile.statusMessage}</p> )} </div> </div> ) }

LIFF Deployment Checklist

  • Create LIFF app in LINE Developers Console

  • Set LIFF endpoint URL (your deployed URL)

  • Configure LIFF scope (profile, openid, etc.)

  • Set NEXT_PUBLIC_LIFF_ID in environment

  • Test in LINE app (not browser) </line_mini_app>

<expo_react_native>

Expo (React Native)

Project Setup

Create new Expo project

npx create-expo-app my-app --template tabs

Or with our stack

npx create-expo-app my-app cd my-app npx expo install nativewind npx expo install react-native-reanimated npm install zustand @supabase/supabase-js

NativeWind Setup (Tailwind for RN)

// tailwind.config.js module.exports = { content: [ "./app//*.{js,jsx,ts,tsx}", "./components//*.{js,jsx,ts,tsx}" ], presets: [require("nativewind/preset")], theme: { extend: {}, }, plugins: [], }

// babel.config.js module.exports = function (api) { api.cache(true); return { presets: [ ["babel-preset-expo", { jsxImportSource: "nativewind" }], "nativewind/babel", ], }; };

File Structure

my-app/ ├── app/ # Expo Router (file-based routing) │ ├── (tabs)/ # Tab navigation group │ │ ├── index.tsx # Home tab │ │ ├── explore.tsx # Explore tab │ │ └── _layout.tsx # Tab layout │ ├── _layout.tsx # Root layout │ └── +not-found.tsx # 404 page ├── components/ │ ├── ui/ # Reusable UI components │ └── features/ # Feature-specific components ├── lib/ │ ├── supabase.ts # Supabase client │ └── utils.ts # Utilities ├── stores/ # Zustand stores └── types/ # TypeScript types

Common Components Translation

Web (shadcn) → React Native

// Web: Button <Button variant="default">Click me</Button>

// React Native equivalent import { Pressable, Text } from 'react-native'

export function Button({ children, onPress, variant = 'default' }) { return ( <Pressable onPress={onPress} className={px-4 py-3 rounded-lg ${ variant === 'default' ? 'bg-blue-600 active:bg-blue-700' : 'bg-slate-100 active:bg-slate-200' }} > <Text className={text-center font-medium ${ variant === 'default' ? 'text-white' : 'text-slate-900' }}> {children} </Text> </Pressable> ) }

// Web: Card <Card><CardContent>...</CardContent></Card>

// React Native equivalent import { View } from 'react-native'

export function Card({ children, className = '' }) { return ( <View className={bg-white rounded-xl shadow-sm p-4 ${className}}> {children} </View> ) }

// Web: Input <Input placeholder="Enter text" />

// React Native equivalent import { TextInput } from 'react-native'

export function Input({ placeholder, value, onChangeText, ...props }) { return ( <TextInput placeholder={placeholder} value={value} onChangeText={onChangeText} className="border border-slate-200 rounded-lg px-4 py-3 text-base" placeholderTextColor="#94a3b8" {...props} /> ) }

Navigation (Expo Router)

// app/(tabs)/_layout.tsx import { Tabs } from 'expo-router' import { Home, Search, User } from 'lucide-react-native'

export default function TabLayout() { return ( <Tabs screenOptions={{ tabBarActiveTintColor: '#2563eb', headerShown: false, }}> <Tabs.Screen name="index" options={{ title: 'Home', tabBarIcon: ({ color, size }) => ( <Home size={size} color={color} /> ), }} /> <Tabs.Screen name="explore" options={{ title: 'Search', tabBarIcon: ({ color, size }) => ( <Search size={size} color={color} /> ), }} /> <Tabs.Screen name="profile" options={{ title: 'Profile', tabBarIcon: ({ color, size }) => ( <User size={size} color={color} /> ), }} /> </Tabs> ) }

Supabase in Expo

// lib/supabase.ts import 'react-native-url-polyfill/auto' import AsyncStorage from '@react-native-async-storage/async-storage' import { createClient } from '@supabase/supabase-js'

const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL! const supabaseKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!

export const supabase = createClient(supabaseUrl, supabaseKey, { auth: { storage: AsyncStorage, autoRefreshToken: true, persistSession: true, detectSessionInUrl: false, }, })

Expo Deployment

Development build

npx expo start

Build for testing

eas build --profile development --platform ios eas build --profile development --platform android

Production build

eas build --profile production --platform all

Submit to stores

eas submit --platform ios eas submit --platform android

</expo_react_native>

<tauri_desktop>

Tauri (Desktop App)

Why Tauri?

  • Reuse Next.js/React web code

  • Native performance (Rust backend)

  • Small bundle size (~10MB vs Electron's 100MB+)

  • Cross-platform (macOS, Windows, Linux)

Setup (Add to existing Next.js)

Install Tauri CLI

npm install -D @tauri-apps/cli

Initialize Tauri in existing project

npx tauri init

Configuration

// src-tauri/tauri.conf.json { "build": { "beforeBuildCommand": "npm run build", "beforeDevCommand": "npm run dev", "devPath": "http://localhost:3000", "distDir": "../out" }, "package": { "productName": "My App", "version": "1.0.0" }, "tauri": { "bundle": { "active": true, "icon": [ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" ], "identifier": "com.myapp.app", "targets": "all" }, "windows": [ { "title": "My App", "width": 1200, "height": 800, "resizable": true, "fullscreen": false } ] } }

Next.js Config for Tauri

// next.config.js /** @type {import('next').NextConfig} */ const nextConfig = { output: 'export', // Static export for Tauri images: { unoptimized: true // Required for static export } }

module.exports = nextConfig

Tauri Commands (Rust ↔ JavaScript)

// src-tauri/src/main.rs #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

#[tauri::command] fn greet(name: &str) -> String { format!("Hello, {}!", name) }

#[tauri::command] async fn read_file(path: String) -> Result<String, String> { std::fs::read_to_string(path) .map_err(|e| e.to_string()) }

fn main() { tauri::Builder::default() .invoke_handler(tauri::generate_handler![greet, read_file]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }

// Call from React import { invoke } from '@tauri-apps/api/tauri'

async function greetUser() { const message = await invoke('greet', { name: 'Wasin' }) console.log(message) // "Hello, Wasin!" }

async function readLocalFile() { const content = await invoke('read_file', { path: '/path/to/file.txt' }) console.log(content) }

Desktop-Specific Features

// Window controls import { appWindow } from '@tauri-apps/api/window'

await appWindow.minimize() await appWindow.maximize() await appWindow.close()

// System tray import { TrayIcon } from '@tauri-apps/api/tray'

// File dialogs import { open, save } from '@tauri-apps/api/dialog'

const selected = await open({ multiple: false, filters: [{ name: 'Images', extensions: ['png', 'jpg'] }] })

// Notifications import { sendNotification } from '@tauri-apps/api/notification'

await sendNotification({ title: 'My App', body: 'Operation completed!' })

Build & Distribute

Development

npm run tauri dev

Build for current platform

npm run tauri build

Build for all platforms (requires cross-compilation setup)

npm run tauri build -- --target universal-apple-darwin # macOS npm run tauri build -- --target x86_64-pc-windows-msvc # Windows npm run tauri build -- --target x86_64-unknown-linux-gnu # Linux

</tauri_desktop>

<platform_decision_tree>

When to Use Which Platform

User Request │ ▼ ┌─────────────────────────────────────┐ │ Has "LINE" or "LIFF" keywords? │ │ Or targets LINE users specifically? │ └─────────────────────────────────────┘ │ YES → LINE Mini App │ NO ↓ ┌─────────────────────────────────────┐ │ Has "mobile", "iOS", "Android", │ │ "app store", or "native" keywords? │ └─────────────────────────────────────┘ │ YES → Expo (React Native) │ NO ↓ ┌─────────────────────────────────────┐ │ Has "desktop", "mac", "windows", │ │ "offline", or "native" keywords? │ └─────────────────────────────────────┘ │ YES → Tauri │ NO ↓ ┌─────────────────────────────────────┐ │ Default: Next.js Web App │ │ (Works everywhere via browser) │ └─────────────────────────────────────┘

</platform_decision_tree>

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

premium-experience

No summary provided by upstream source.

Repository SourceNeeds Review
General

design-mastery

No summary provided by upstream source.

Repository SourceNeeds Review
General

ui-first-builder

No summary provided by upstream source.

Repository SourceNeeds Review