Lesson 44 of 45 · Web pages

Whole pages: six projects

Elements, styles and events are easier to learn one at a time and easier to use together. This lesson builds six whole pages, each the size of something you might really publish. For every project you get the goal, the code running in the preview, the decisions that matter and a few ideas for taking it further.

How to build a page

Every project below was built in the same order, and the order is a good habit. First the content: write the headings and the text in plain elements, with no styling, and check that the page makes sense. Second the structure: put the parts in landmarks such as header, main, section and footer (page structure), because they give the page its outline. Third the look: colours, spacing and layout (styles, layout, responsive pages). Last the behaviour: state, handlers and the dom helpers (state, the DOM and timers). A page that works at each step is easy to repair. A page that was styled before it had content is not.

Two habits keep the code short. Values used in many places, such as a brand colour, go into a constant above the page. A piece that appears more than once, such as a price card or a statistic, becomes a component. All the previews are live: click, type and resize your browser window to see how the pages react.

Project 1: a landing page

Goal. One page that sells one thing: a header with navigation, a hero with a single call to action, three reasons to buy, three plans and a footer. It has to look right on a phone without a separate mobile version.

web-project-landing.salam
const BRAND := "Kettle"
const ACCENT := "#b45309"

component Plan(name, price, note):
    section:
        class = "plan" background = white border = "1px solid #e7e5e4" border radius = 16 padding = 24 text align = center
        transition = "transform 0.2s, box-shadow 0.2s"
        hover transform = "translateY(-4px)"
        hover box shadow = "0 12px 28px rgba(0,0,0,0.12)"
        heading: size = 3 content = name margin = "0 0 6px 0" end
        paragraph: content = price font size = "2.2rem" font weight = bold color = ACCENT margin = 0 end
        paragraph: content = note color = "#78716c" margin = "4px 0 16px 0" end
        content: end
        button: content = "Choose " + name padding = "10px 22px" border = 0 border radius = 999 background = "#292524" color = white cursor = pointer hover background = ACCENT end
    end
end

page:
    title = BRAND + " coffee club"
    global: selector = "body" font family = "system-ui, sans-serif" margin = 0 color = "#292524" background = "#fafaf9" line height = 1.6 end
    header:
        display = flex justify content = space-between align items = center padding = "16px 6vw"
        strong: content = BRAND font size = "1.4rem" color = ACCENT end
        nav: display = flex gap = 22
            link: url = "#features" content = "Why us" color = "#44403c" text decoration = none hover color = ACCENT end
            link: url = "#plans" content = "Plans" color = "#44403c" text decoration = none hover color = ACCENT end
        end
    end
    main:
        section:
            padding = "72px 6vw" text align = center background = "linear-gradient(135deg, #fef3c7, #fed7aa)"
            heading: size = 1 content = "Fresh coffee, every Monday" font size = "clamp(2rem, 6vw, 3.4rem)" margin = "0 0 12px 0" end
            paragraph: content = "Beans roasted this week, ground the way you like, at your door before breakfast." max width = "34rem" margin = "0 auto 24px auto" font size = "1.15rem" end
            button: content = "Start my first box" padding = "14px 30px" border = 0 border radius = 999 background = ACCENT color = white font size = "1.05rem" cursor = pointer hover background = "#92400e" end
        end
        section:
            id = "features" padding = "56px 6vw"
            heading: size = 2 content = "Why people stay" text align = center margin = "0 0 28px 0" end
            box: display = grid grid template columns = "repeat(auto-fit, minmax(200px, 1fr))" gap = 24
                box: heading: size = 3 content = "Roasted to order" end paragraph: content = "Nothing sits on a shelf. Every bag is roasted after you order it." end end
                box: heading: size = 3 content = "Skip or pause" end paragraph: content = "Going away? Pause in two clicks. No fees, no calls." end end
                box: heading: size = 3 content = "Fair prices" end paragraph: content = "We buy from farms we know and tell you what each one earned." end end
            end
        end
        section:
            id = "plans" padding = "56px 6vw" background = "#f5f5f4"
            heading: size = 2 content = "Pick a plan" text align = center margin = "0 0 28px 0" end
            box: display = grid grid template columns = "repeat(auto-fit, minmax(220px, 1fr))" gap = 24
                Plan: name = "Solo" price = "$12" note = "one bag a month" paragraph: content = "250 g, any roast" end end
                Plan: name = "Duo" price = "$21" note = "two bags a month" paragraph: content = "2 x 250 g, mixed" end end
                Plan: name = "Office" price = "$58" note = "six bags a month" paragraph: content = "6 x 250 g, grinder tips" end end
            end
        end
    end
    footer: padding = "28px 6vw" text align = center color = "#78716c" content = "Kettle coffee club. Made with care in a small kitchen." end
end

Walkthrough. The page is a stack of full width bands inside main, and each band is a section that sets its own background and its own padding. 6vw of padding left and right keeps the text away from the edges on every screen size, and the heading uses clamp(2rem, 6vw, 3.4rem) so it grows with the window but never gets too small or too big.

The columns need no media query. repeat(auto-fit, minmax(220px, 1fr)) says: make as many columns as fit, each at least 220 pixels wide, and share the rest equally. Three cards sit side by side on a laptop, and the same rule stacks them on a phone. The price cards are one component, Plan, with three parameters. The content: end line inside it is a slot: whatever you write between Plan: and its end lands there, so each plan can list different details. Its look on hover, a small lift and a shadow, is written once with the hover prefix and a transition that makes the change smooth. BRAND and ACCENT are constants, so changing the colour of the whole page is a one line edit.

Change it next. Make the middle plan stand out with a darker border and a small badge. Add a media rule that stacks the header on very narrow screens. Turn the call to action into a link to a sign up page, and add a form such as the one in project 3.

Project 2: a blog post with a sidebar

Goal. A comfortable reading page: a header, one article with a picture, a quotation and a sidebar, which drops below the article when the screen is narrow.

web-project-blog.salam
component Tag(name):
    link: url = "#" content = name display = inline-block padding = "3px 12px" margin = "0 6px 6px 0" border radius = 999 background = "#e0f2f1" color = "#115e59" text decoration = none font size = "0.9rem" hover background = "#99f6e4" end
end

page:
    title = "Garden Notes: why mulch matters"
    global: selector = "nav a" text decoration = none end
    global: selector = "body" font family = "Georgia, serif" margin = 0 color = "#1c1917" background = "#fffdf8" line height = 1.75 end
    header:
        padding = "18px 5vw" border bottom = "1px solid #e7e5e4" display = flex justify content = space-between align items = baseline flex wrap = wrap gap = 8
        strong: content = "Garden Notes" font size = "1.5rem" color = "#0f766e" end
        nav: display = flex gap = 18 font family = "system-ui, sans-serif" font size = "0.95rem"
            link: url = "#" content = "Home" color = "#44403c" end
            link: url = "#" content = "Archive" color = "#44403c" end
            link: url = "#" content = "About" color = "#44403c" end
        end
    end
    box:
        display = grid grid template columns = "minmax(0, 1fr) 17rem" gap = 40 padding = "36px 5vw" max width = "70rem" margin = "0 auto"
        media: condition = "(max-width: 760px)" grid template columns = "1fr" gap = 24 end
        main:
            article:
                header:
                    heading: size = 1 content = "Why mulch matters" font size = "2.4rem" line height = 1.2 margin = "0 0 8px 0" end
                    paragraph: color = "#78716c" margin = "0 0 24px 0" font family = "system-ui, sans-serif" font size = "0.9rem"
                        content = "By Sara Nikoo, "
                        time: datetime = "2026-10-26" content = "26 October 2026" end
                        span: content = ", 4 minute read" end
                    end
                end
                figure:
                    margin = "0 0 24px 0"
                    box: height = 220 border radius = 14 background = "linear-gradient(160deg, #86efac, #166534)" end
                    figure caption: content = "A thick straw layer, one week after planting." color = "#78716c" font size = "0.85rem" margin top = 8 end
                end
                paragraph: content = "Bare soil loses water fast. A layer of straw or leaves keeps the sun off it, and the beds stay damp for days after rain." end
                heading: size = 2 content = "Fewer weeds" margin = "28px 0 8px 0" end
                paragraph: content = "Seeds in the dark do not sprout. Spread the mulch five centimetres deep and most weeds never start." end
                block quote: margin = "24px 0" padding = "4px 20px" border inline start = "4px solid #14b8a6" color = "#134e4a" font style = italic content = "The best tool in my garden is a wheelbarrow of old straw." end
                paragraph: content = "Keep it a hand away from the stems, so they can breathe." end
            end
        end
        aside:
            font family = "system-ui, sans-serif"
            heading: size = 2 content = "About the author" font size = "1.1rem" margin = "0 0 6px 0" end
            paragraph: content = "Sara grows vegetables on a small balcony and writes about what works." font size = "0.95rem" color = "#57534e" end
            heading: size = 2 content = "Topics" font size = "1.1rem" margin = "22px 0 8px 0" end
            box: Tag: name = "Soil" end Tag: name = "Water" end Tag: name = "Weeds" end Tag: name = "Compost" end end
        end
    end
    footer: padding = "24px 5vw" border top = "1px solid #e7e5e4" color = "#78716c" font family = "system-ui, sans-serif" font size = "0.9rem" content = "Garden Notes. Written slowly." end
end

Walkthrough. Reading is the whole point, so the text rules come first: a serif font, a line height of 1.75 and a column that never gets wider than the page allows. The two column layout is a grid with minmax(0, 1fr) 17rem: the sidebar is a fixed 17rem and the article takes the rest. The 0 in minmax matters, because without it a long word or a wide picture can force the column wider than the screen. The box has a max width and margin = "0 auto", which centres it on a big monitor.

The responsive part is the media: child of the grid. Below 760 pixels it replaces the two columns with one, and since the sidebar comes second in the source, it lands under the article. Order in the source is order on a phone, so write the parts in the order a phone reader should meet them. The meta line uses time with a machine readable datetime, the picture sits in a figure with a figure caption, and the quotation is a block quote whose bar is border inline start. Start and end are logical sides: in a right to left page the bar moves to the right edge by itself. Tag is a small component for the topic pills.

Change it next. Add a table of contents as a nav with links to the headings (give the headings ids). Add a dark version with a style element and prefers-color-scheme. Print it: a media = "print" rule that hides the sidebar.

Project 3: a sign up form with live checks

Goal. A form that tells people what is wrong while they type, not after they press the button: a green or red border on every field, a message under it, a strength bar for the password, and a Join button that stays off until everything is right.

web-project-signup.salam
import dom

mut shown := false

func judge(field: str, good: bool, note: str):
    box_el := dom.ById(field)
    dom.RemoveClass(box_el, "good")
    dom.RemoveClass(box_el, "bad")
    if dom.GetValue(box_el).len() > 0:
        if good:
            dom.AddClass(box_el, "good")
        else:
            dom.AddClass(box_el, "bad")
        end
    end
    if good:
        dom.SetText(dom.ById(field + "-note"), "")
    else:
        dom.SetText(dom.ById(field + "-note"), note)
    end
end

func check:
    word := dom.GetValue(dom.ById("pw"))
    mut score := 0
    if word.len() >= 8:
        score += 1
    end
    if word.lower() != word:
        score += 1
    end
    repeat 10 in d:
        if word.includes("" + d):
            score = score + 1
            break
        end
    end
    mail := dom.GetValue(dom.ById("mail"))
    name_ok := dom.GetValue(dom.ById("name")).len() >= 2
    mail_ok := mail.includes("@") and mail.includes(".") and mail.len() >= 6
    pw_ok := score == 3
    again_ok := word.len() > 0 and dom.GetValue(dom.ById("again")) == word
    judge("name", name_ok, "Please tell us your name.")
    judge("mail", mail_ok, "That does not look like an email address.")
    judge("pw", pw_ok, "Use 8 characters, a capital letter and a digit.")
    judge("again", again_ok, "The two passwords differ.")
    dom.SetStyle(dom.ById("meter"), "width", "" + score * 33 + "%")
    dom.SetBool(dom.ById("join"), "disabled", not (name_ok and mail_ok and pw_ok and again_ok))
end

func reveal:
    shown = not shown
    if shown:
        dom.SetAttr(dom.ById("pw"), "type", "text")
    else:
        dom.SetAttr(dom.ById("pw"), "type", "password")
    end
end

func welcome(e: i64):
    dom.PreventDefault(e)
    dom.SetText(dom.ById("done"), "Welcome, " + dom.GetValue(dom.ById("name")) + ". Check your inbox.")
end

page:
    title = "Create an account"
    style: content = "input { width: 100%; box-sizing: border-box; padding: 10px 12px; border: 2px solid #cbd5e1; border-radius: 8px; font-size: 1rem; outline: none } input:focus { border-color: #6366f1 } input.good { border-color: #22c55e } input.bad { border-color: #ef4444 } label { display: block; margin: 14px 0 4px; font-weight: 600 } button:disabled { opacity: 0.5; cursor: not-allowed } .note { color: #dc2626; font-size: 0.85rem; min-height: 1.2em; margin: 4px 0 0 }" end
    global: selector = "body" font family = "system-ui, sans-serif" margin = 0 background = "#eef2ff" end
    form: id = "signup" submit = welcome max width = "24rem" margin = "28px auto" padding = 28 background = white border radius = 16 box shadow = "0 10px 30px rgba(79,70,229,0.15)"
        heading: size = 2 content = "Create an account" margin = "0 0 4px 0" end
        label: for = "name" content = "Name" end
        input: id = "name" input = check autocomplete = name end
        paragraph: id = "name-note" class = "note" end
        label: for = "mail" content = "Email" end
        input: id = "mail" type = email input = check autocomplete = email end
        paragraph: id = "mail-note" class = "note" end
        label: for = "pw" content = "Password" end
        input: id = "pw" type = password input = check autocomplete = "new-password" end
        box: height = 6 background = "#e2e8f0" border radius = 3 margin = "6px 0" box: id = "meter" height = "100%" width = "0%" background = "#6366f1" border radius = 3 transition = "width 0.2s" end end
        paragraph: id = "pw-note" class = "note" end
        button: id = "peek" type = button click = reveal content = "Show or hide password" background = none border = 0 color = "#4f46e5" cursor = pointer padding = 0 end
        label: for = "again" content = "Repeat the password" end
        input: id = "again" type = password input = check end
        paragraph: id = "again-note" class = "note" end
        button: id = "join" type = submit disabled = true content = "Join" width = "100%" padding = 12 margin = "10px 0 0 0" border = 0 border radius = 10 background = "#4f46e5" color = white font size = "1rem" cursor = pointer end
        paragraph: id = "done" color = "#15803d" font weight = 600 end
    end
end

Walkthrough. There is no handler per field. Every field has the same input = check, and check reads all four fields and decides about all of them again. That is the pattern from the lesson on state: the form is redrawn from its values every time, so it can never be half updated. judge does the drawing for one field. It clears both classes, adds good or bad only when the field is not empty (nobody wants red on a form they have not touched), and writes or clears the message. The three colours live in the CSS in the style element, next to input:focus, because they are states of an element and not decisions of the script.

The password score counts three things, length, a capital letter and a digit, and the bar shows score * 33 percent. The Show button swaps the type of the field between password and text with dom.SetAttr. The email field has type = email and autocomplete values, so phones show the right keyboard and password managers know what to fill. The form's submit handler calls dom.PreventDefault(e) and shows a greeting in place of sending anything, because the preview has no server.

Keep the names of your handler functions different from the id of every button and field in the form. A button with id = "join" and a function called join do not get along: inside a form the browser finds the button before the function, and the click does nothing. The functions here are called welcome and reveal. And remember that checks like these are help for the visitor. The server must check the same things again.

Change it next. Add a checkbox "I accept the rules" that must be ticked before the button turns on. Check the name for letters only. Replace the strength bar with a meter element. Send the form to a real address with action and method and validate it on the server (see the last section).

Project 4: a dashboard with a CSS bar chart

Goal. A page for looking at numbers: four statistic cards, a bar chart, and a table of the latest orders. No script at all.

web-project-dashboard.salam
component Stat(label, value, change, tone):
    box:
        background = white padding = 18 border radius = 14 box shadow = "0 1px 3px rgba(15,23,42,0.1)"
        paragraph: content = label margin = 0 color = "#64748b" font size = "0.85rem" end
        paragraph: content = value margin = "4px 0" font size = "1.9rem" font weight = bold end
        small: content = change color = tone font weight = 600 end
    end
end

component Bar(day, value):
    box:
        flex = 1 text align = center
        box: display = flex align items = flex-end justify content = center height = 150
            box: width = "70%" height = value + "%" content = value padding top = 4 color = white font size = "0.8rem" background = "#6366f1" border radius = "8px 8px 0 0" transition = "background 0.2s" hover background = "#4338ca" end
        end
        small: content = day color = "#475569" end
    end
end

component Pill(text, back, front):
    span: content = text padding = "2px 10px" border radius = 999 font size = "0.8rem" background = back color = front end
end

page:
    title = "Shop dashboard"
    global: selector = "body" font family = "system-ui, sans-serif" margin = 0 background = "#f1f5f9" color = "#0f172a" end
    global: selector = "td, th" padding = "10px 8px" text align = left border bottom = "1px solid #e2e8f0" end
    header: display = flex justify content = space-between align items = center padding = "16px 5vw" background = "#0f172a" color = white
        strong: content = "Hazel and Co" end
        small: content = "Week 43" color = "#94a3b8" end
    end
    main: padding = "24px 5vw" display = grid gap = 20
        box: display = grid grid template columns = "repeat(auto-fit, minmax(180px, 1fr))" gap = 16
            Stat: label = "Revenue" value = "$8,420" change = "+12% this week" tone = "#15803d" end
            Stat: label = "Orders" value = "312" change = "+5%" tone = "#15803d" end
            Stat: label = "Returns" value = "9" change = "-2 since Monday" tone = "#15803d" end
            Stat: label = "Open tickets" value = "14" change = "+6 since Monday" tone = "#b91c1c" end
        end
        box: display = grid grid template columns = "repeat(auto-fit, minmax(300px, 1fr))" gap = 16
            section: background = white padding = 18 border radius = 14
                heading: size = 2 content = "Orders per day" font size = "1.1rem" margin = "0 0 12px 0" end
                box: display = flex gap = 8
                    Bar: day = "Mon" value = 45 end
                    Bar: day = "Tue" value = 62 end
                    Bar: day = "Wed" value = 38 end
                    Bar: day = "Thu" value = 80 end
                    Bar: day = "Fri" value = 95 end
                    Bar: day = "Sat" value = 70 end
                    Bar: day = "Sun" value = 30 end
                end
            end
            section: background = white padding = 18 border radius = 14 overflow = auto
                heading: size = 2 content = "Latest orders" font size = "1.1rem" margin = "0 0 12px 0" end
                table: width = "100%" border collapse = collapse
                    header: row: header column: content = "Order" end header column: content = "Customer" end header column: content = "Status" end end end
                    main:
                        row: cell: content = "#2041" end cell: content = "Mina Rahimi" end cell: Pill: text = "Shipped" back = "#dcfce7" front = "#166534" end end end
                        row: cell: content = "#2040" end cell: content = "Tom Becker" end cell: Pill: text = "Packing" back = "#fef3c7" front = "#92400e" end end end
                        row: cell: content = "#2039" end cell: content = "Lena Novak" end cell: Pill: text = "Refunded" back = "#fee2e2" front = "#991b1b" end end end
                    end
                end
            end
        end
    end
end

Walkthrough. The chart is made of boxes. Bar is a component with a day and a value. Inside is a track of fixed height (150 pixels, a flex container whose items sit at the bottom with align items = flex-end), and in the track a box whose height = value + "%". A value of 80 gives a box 80 percent as high as the track. Putting the height in a track of known size is the trick: a percentage height needs a parent whose height is known. The number is written inside the bar, so the chart can be read with a screen reader, and the bar gets a darker colour on hover. The statistics are the component Stat, and the coloured status labels in the table are Pill, a component that is just one span.

The layout is two nested grids. The cards use auto-fit with a minimum of 180 pixels, and the lower row uses a minimum of 300, so the chart and the table sit side by side when there is room and stack when there is not. The table is a real table with a header and a main section, and its card has overflow = auto, so on a narrow screen the table scrolls inside its card instead of breaking the page. The global rule for td, th gives all cells the same padding and a thin line below. Colour carries meaning here, green for good and red for bad, so every figure also has a sign or a word: never let colour be the only message.

Change it next. Make the bars grow when the page opens, with a CSS animation (transitions and animations). Add a column of buttons that filter the table, using the pattern of project 5. Fill the numbers from the server instead of the source, as in the last section.

Project 5: a task list that remembers

Goal. A small application: add tasks, tick them, remove them, filter by All, Active and Done, count what is left, and find the list again after a reload.

web-project-todo.salam
import dom

mut texts := Vector {} as Vector<str>
mut flags := Vector {} as Vector<bool>
mut mode := 0

func choose(cond: bool, yes: str, no: str): str:
    if cond:
        ret yes
    end
    ret no
end

func keep:
    mut joined := ""
    repeat texts.len() in i:
        joined += choose(flags.get(i), "1", "0") + texts.get(i) + "\n"
    end
    dom.StoreSetStr("todos", joined)
end

func draw:
    holder := dom.ById("items")
    dom.SetHtml(holder, "")
    mut open := 0
    repeat texts.len() in i:
        if not flags.get(i):
            open += 1
        end
        if mode == 0 or (mode == 1 and not flags.get(i)) or (mode == 2 and flags.get(i)):
            row := dom.Create("li")
            tick := dom.Create("input")
            dom.SetAttr(tick, "type", "checkbox")
            dom.SetBool(tick, "checked", flags.get(i))
            dom.OnEvent(tick, "change", (): flags.set(i, not flags.get(i)) keep() draw() end)
            words := dom.Create("span")
            dom.SetText(words, texts.get(i))
            dom.SetAttr(words, "class", choose(flags.get(i), "words done", "words"))
            gone := dom.Create("button")
            dom.SetText(gone, "x")
            dom.OnEvent(gone, "click", (): texts.remove_at(i) flags.remove_at(i) keep() draw() end)
            dom.Append(row, tick)
            dom.Append(row, words)
            dom.Append(row, gone)
            dom.Append(holder, row)
        end
    end
    dom.SetText(dom.ById("left"), "" + open + " left")
    repeat 3 in k:
        dom.SetAttr(dom.ById("f" + k), "class", choose(k == mode, "pick on", "pick"))
    end
end

func show_all:
    if texts.len() == 0:
        lines := dom.StoreGetStr("todos").split("\n")
        repeat lines.len() - 1 in i:
            texts.push(lines.get(i).substr(1, lines.get(i).len()))
            flags.push(lines.get(i).substr(0, 1) == "1")
        end
    end
    mode = 0
    draw()
end

func add_item(e: i64):
    dom.PreventDefault(e)
    text := dom.GetValue(dom.ById("new"))
    if text.len() > 0:
        texts.push(text)
        flags.push(false)
        dom.SetValue(dom.ById("new"), "")
        keep()
        draw()
    end
end

page:
    title = "Tasks"
    style: content = ".pick { border: 0; background: none; padding: 4px 10px; border-radius: 6px; cursor: pointer } .pick.on { background: #e0e7ff; color: #3730a3 } .words { flex: 1; padding: 0 10px } .words.done { text-decoration: line-through; color: #94a3b8 } li { display: flex; align-items: center; padding: 10px 4px; border-bottom: 1px solid #e2e8f0 } li button { border: 0; background: none; color: #94a3b8; cursor: pointer } li button:hover { color: #dc2626 }" end
    global: selector = "body" font family = "system-ui, sans-serif" margin = 0 background = "#f8fafc" end
    main: max width = "28rem" margin = "28px auto" padding = 24 background = white border radius = 16 box shadow = "0 8px 24px rgba(15,23,42,0.08)"
        heading: size = 1 content = "Tasks" font size = "1.8rem" margin = "0 0 14px 0" end
        form: submit = add_item display = flex gap = 8
            input: id = "new" placeholder = "Add a task" flex = 1 padding = 10 border = "2px solid #e2e8f0" border radius = 8 font size = "1rem" end
            button: type = submit content = "Add" padding = "0 18px" border = 0 border radius = 8 background = "#4f46e5" color = white cursor = pointer end
        end
        list: id = "items" list style = none padding = 0 margin = "12px 0" end
        box: display = flex justify content = space-between align items = center color = "#64748b" font size = "0.9rem"
            span: id = "left" content = "0 left" end
            box:
                button: id = "f0" class = "pick on" click = show_all content = "All" end
                button: id = "f1" class = "pick" click = (): mode = 1 draw() end content = "Active" end
                button: id = "f2" class = "pick" click = (): mode = 2 draw() end content = "Done" end
            end
        end
    end
    script: content = "window.addEventListener('DOMContentLoaded', function () { show_all(); });" end
end

Walkthrough. This is the state pattern at full size. The state is three top level variables: texts and flags, two vectors that hold the text and the done mark of every task, and mode, the filter. The page keeps nothing else. draw empties the list, goes through the vectors and builds a row for every task that passes the filter, and counts what is open. Every change, adding, ticking, removing and filtering, ends with a call to draw, so there is one place where the screen is made. Rebuilding the whole list is a little wasteful and very safe. For a list of a few hundred tasks nobody can tell.

The handlers of the rows are made while the page runs, with dom.OnEvent, and each lambda remembers its own i, so the tick of the third row changes the third task. After a change the rows are made again, so those indexes are never out of date. Saving is keep: it writes one string, one line per task, with 1 or 0 in front of the text to say whether it is done. Loading is the reverse, in show_all, which is also the handler of the All button; the script element on the last line calls it when the page has been read, as in the storage section. Salam has no inline if, so a tiny function, choose, picks between two strings and keeps the other code short.

The preview runs in a frame that does not allow storage, so there the list works but does not survive a reload. On a real page it does. Task text is always put in with SetText, never SetHtml.

Change it next. Let a double click edit a task. Add a "clear done" button. Store the creation time and sort by it. Move the saving to a server, so the list follows the person to another device.

Goal. A grid of pictures. Clicking one opens it large in a window with Previous, Next and Close buttons, and the arrow keys and Escape work too. The pictures here are gradients, so the page needs no files, and the code is the same for photos.

web-project-gallery.salam
import dom

mut current := 0 as i64

func show_shot(n: i64):
    tiles := dom.QueryAll("[data-n]")
    total := dom.CountOf(tiles)
    current = (n + total) % total
    tile := dom.ItemAt(tiles, current)
    dom.SetStyle(dom.ById("big"), "background", dom.GetAttr(tile, "data-bg"))
    dom.SetText(dom.ById("cap"), dom.GetAttr(tile, "data-title") + " (" + (current + 1) + " of " + total + ")")
end

func open_shot(e: i64):
    show_shot(dom.GetAttr(dom.EventTarget(e), "data-n").to_int() as i64)
    _o := dom.Call0(dom.ById("lightbox"), "showModal")
end

func close_box:
    _c := dom.Call0(dom.ById("lightbox"), "close")
end

func box_key(e: i64):
    if dom.EventKey(e) == "ArrowRight":
        show_shot(current + 1)
    else dom.EventKey(e) == "ArrowLeft":
        show_shot(current - 1)
    end
end

func backdrop(e: i64):
    if dom.EventTarget(e) == dom.ById("lightbox"):
        close_box()
    end
end

component Shot(n, title, light, deep):
    button:
        data n = n data title = title data bg = "linear-gradient(135deg, " + light + ", " + deep + ")" click = open_shot
        background = "linear-gradient(135deg, " + light + ", " + deep + ")" content = title aspect ratio = "4 / 3" border = 0 border radius = 12
        color = white font size = "1rem" font weight = 600 text align = left padding = 12 cursor = pointer transition = "transform 0.15s"
        hover transform = "scale(1.04)"
    end
end

page:
    title = "Photo gallery"
    style: content = "dialog { border: 0; border-radius: 16px; padding: 16px; width: min(34rem, 92vw) } dialog::backdrop { background: rgba(15, 23, 42, 0.7) } .bar button { padding: 8px 16px; border: 0; border-radius: 8px; background: #e2e8f0; cursor: pointer } .bar button:hover { background: #cbd5e1 }" end
    global: selector = "body" font family = "system-ui, sans-serif" margin = 0 padding = "24px 5vw" background = "#0f172a" color = white end
    heading: size = 1 content = "Trip to the dunes" font size = "1.8rem" margin = "0 0 16px 0" end
    box: display = grid grid template columns = "repeat(auto-fill, minmax(150px, 1fr))" gap = 12
        Shot: n = 0 title = "Sunrise" light = "#f97316" deep = "#facc15" end
        Shot: n = 1 title = "Camp" light = "#7c3aed" deep = "#ec4899" end
        Shot: n = 2 title = "Dunes" light = "#d97706" deep = "#fde68a" end
        Shot: n = 3 title = "Oasis" light = "#059669" deep = "#67e8f9" end
        Shot: n = 4 title = "Stars" light = "#1e1b4b" deep = "#6366f1" end
        Shot: n = 5 title = "Road home" light = "#475569" deep = "#cbd5e1" end
    end
    dialog: id = "lightbox" key down = box_key click = backdrop color = "#0f172a"
        box: id = "big" height = 260 border radius = 12 end
        paragraph: id = "cap" margin = "12px 0" font weight = 600 end
        box: class = "bar" display = flex gap = 8
            button: id = "back" click = (): show_shot(current - 1) end content = "Previous" end
            button: id = "forth" click = (): show_shot(current + 1) end content = "Next" end
            button: id = "shut" click = close_box content = "Close" margin left = auto end
        end
    end
end

Walkthrough. The window is a dialog element. Calling showModal on it, with dom.Call0(dialog, "showModal"), puts it on top of everything, dims the page behind it, moves the keyboard focus inside and closes it on Escape. All of that comes from the browser. The dim layer is styled with dialog::backdrop in a style element. Clicking on the backdrop counts as a click on the dialog itself, so the backdrop handler closes the window when dom.EventTarget(e) is the dialog and not one of its children. (A click on the dialog's own padding counts as well, so keep the padding small.)

Each tile is a button from the component Shot, and the button carries its own data in attributes: data n, data title and data bg. One handler, open_shot, serves all six tiles. It asks the event which button was clicked and reads the attributes, and show_shot copies them into the window. Previous and Next call show_shot with a number one lower or higher, and the % in (n + total) % total wraps around from the last picture to the first. The tiles are found with QueryAll("[data-n]") and not with a class. Two elements with the same class must look the same, and every tile has its own gradient, so the compiler would refuse a shared class (error E136).

Change it next. Put real images in the tiles (links, images and media) and keep a small and a large version of each. Close the window when the user swipes down. Move the focus back to the tile that opened it.

Serving a page from a Salam program

Everything above is a fixed page: its structure is decided when you compile it, and the preview is the finished result. A real site needs pages that depend on the visitor, such as a greeting with their name or a dashboard with today's numbers. For that, the page gets a name and parameters, and it becomes a function that returns the page as HTML. A web server written in Salam calls it for each request. The first lesson of this part explains these pages in detail; here is the smallest complete program, which would serve the landing page with a name and a plan taken from the address.

web-project-serve.salam
import net.http
import net.router

page Welcome(name: str, plan: str):
    title = "Welcome, " + name
    global: selector = "body" font family = "system-ui, sans-serif" margin = 0 background = "#fafaf9" end
    main: max width = "30rem" margin = "48px auto" padding = 28 background = white border radius = 16
        heading: size = 1 content = "Welcome, " + name end
        paragraph: content = "Your " + plan + " plan starts on Monday." end
    end
end

func home(ctx: i64):
    mut name := http.Query(ctx, "name")
    mut plan := http.Query(ctx, "plan")
    if name == "":
        name = "friend"
    end
    if plan == "":
        plan = "Solo"
    end
    http.Html(ctx, Welcome(name, plan))
end

func main:
    mut r := router.NewRouter()
    router.Get(r, "/", home)
    mut s := http.NewServer(8080)
    http.Use(s, r)
    http.Run(s)
end

The program starts a server and has no output, so it is only checked here. Run it, open http://localhost:8080/?name=Ada&plan=Duo and you get the page. Text that arrives in a parameter is escaped for you, so a visitor who sends ?name=<b> sees the characters and no markup. For the server side, the net.http and net.router packages handle requests, query strings and answers, and net/http/validate checks the form data that project 3 collected in the browser. A static page like projects 1 to 6 is also complete as it is: salam page build page.salam --inline writes it to a single HTML file that any web host will serve.

Try it yourself

  • Combine two projects. Give the landing page a sign up form from project 3 under the plans, and make the Choose buttons fill in the plan name.
  • Make the blog post into a reusable page: a component Post with a title and a date, and two posts that use it. Which parts stay fixed, and which become parameters?
  • Put the dashboard on a phone: open the preview in a narrow window and fix whatever looks cramped, using only auto-fit and one media rule.
  • Translate one project into Persian, or into English if you started in Persian. Which style properties need to change for a right to left page, and which ones change by themselves?